authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-12 18:12:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 18:12:48-07:00
log2587c8060c103245b4f970b925977008f2592d1d
treec812cd1d3b9451193edde9f110f21183bcd429dd
parent2db675aeffd9cb1e8b5ad76ddd018433b45b02e9
signature Signed by PGP key B5690EEEBB952194

feat(web): switch saved environments off instead of removing them (#11478)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

40 files changed, 762 insertions(+), 173 deletions(-)

apps/desktop/src/app/DesktopConnectionCatalogStore.ts+1
......@@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn(
372372 profiles,
373373 credentials,
374374 remoteDpopTokens: [],
375 disabledEnvironmentIds: [],
375376 };
376377});
377378
apps/mobile/src/connection/storage.ts+17-1
......@@ -5,6 +5,7 @@ import {
55 putRemoteDpopTokenInCatalog,
66 registerConnectionInCatalog,
77 removeConnectionFromCatalog,
8 setConnectionEnabledInCatalog,
89 removeCatalogValue,
910 replaceCatalogValue,
1011} from "@t3tools/client-runtime/platform";
......@@ -21,7 +22,12 @@ import * as Option from "effect/Option";
2122import * as CatalogStore from "./catalog-store";
2223
2324function targetPersistenceError(
24 operation: "list-targets" | "register-connection" | "remove-connection",
25 operation:
26 | "list-targets"
27 | "list-disabled-targets"
28 | "register-connection"
29 | "remove-connection"
30 | "set-connection-enabled",
2531 error: ConnectionTransientError,
2632) {
2733 return new ConnectionPersistenceError({
......@@ -39,6 +45,10 @@ export const connectionStorageLayer = Layer.effectContext(
3945 Effect.map((document) => document.targets),
4046 Effect.mapError((error) => targetPersistenceError("list-targets", error)),
4147 ),
48 listDisabled: catalog.read.pipe(
49 Effect.map((document) => document.disabledEnvironmentIds),
50 Effect.mapError((error) => targetPersistenceError("list-disabled-targets", error)),
51 ),
4252 });
4353 const registrationStore = ConnectionRegistrationStore.of({
4454 register: (registration) =>
......@@ -49,6 +59,12 @@ export const connectionStorageLayer = Layer.effectContext(
4959 catalog
5060 .update((document) => removeConnectionFromCatalog(document, target))
5161 .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),
62 setEnabled: (environmentId, enabled) =>
63 catalog
64 .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled))
65 .pipe(
66 Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)),
67 ),
5268 });
5369 const profileStore = ProfileStore.make({
5470 get: (connectionId) =>
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+4-2
......@@ -45,7 +45,8 @@ function ConfiguredConnectOnboardingRouteScreen() {
4545 const navigation = useNavigation();
4646 const insets = useSafeAreaInsets();
4747 const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
48 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
48 const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } =
49 useRemoteConnections();
4950 const { refreshRelayEnvironments } = useConnectionController();
5051 const { connectedCloudEnvironments } = splitEnvironmentSections({
5152 connectedEnvironments,
......@@ -110,7 +111,8 @@ function ConfiguredConnectOnboardingRouteScreen() {
110111 {isSignedIn ? (
111112 <CloudEnvironmentRows
112113 connectedCloudEnvironments={connectedCloudEnvironments}
113 onReconnectEnvironment={onReconnectEnvironment}
114 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
115 onRemoveEnvironment={onRemoveEnvironmentPress}
114116 showHeader={false}
115117 />
116118 ) : (
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+26-23
......@@ -33,7 +33,9 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
3333
3434interface CloudEnvironmentRowsProps {
3535 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
36 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
36 readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
37 /** Long-press on a saved row. The callback owns the confirm. */
38 readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void;
3739 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
3840 readonly showcaseSignedIn?: boolean;
3941 /**
......@@ -97,11 +99,6 @@ function CloudEnvironmentRowsContent(
9799 [controller],
98100 );
99101
100 const handleDisconnectCloudEnvironment = useCallback(
101 (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId),
102 [controller],
103 );
104
105102 const handleToggleCloudError = useCallback((environmentId: string) => {
106103 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
107104 }, []);
......@@ -144,8 +141,10 @@ function CloudEnvironmentRowsContent(
144141 key={environment.environmentId}
145142 environment={environment}
146143 borderTop={index !== 0}
147 onConnect={() => props.onReconnectEnvironment(environment.environmentId)}
148 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
144 onSetEnabled={(enabled) =>
145 props.onSetEnvironmentEnabled(environment.environmentId, enabled)
146 }
147 onRemove={() => props.onRemoveEnvironment(environment.environmentId)}
149148 errorExpanded={expandedErrorId === environment.environmentId}
150149 onToggleError={() => handleToggleCloudError(environment.environmentId)}
151150 />
......@@ -204,38 +203,42 @@ function CloudEnvironmentRowsContent(
204203 );
205204}
206205
206/**
207 * A saved T3 Connect environment. The switch turns it on or off; off keeps the
208 * registration and cache but drops the connection and hides its errors.
209 * Long-press removes it from this device.
210 */
207211function ConnectedCloudEnvironmentRow(props: {
208212 readonly environment: ConnectedEnvironmentSummary;
209213 readonly borderTop: boolean;
210214 readonly errorExpanded: boolean;
211 readonly onConnect: () => void;
212 readonly onDisconnect: () => void;
215 readonly onSetEnabled: (enabled: boolean) => void;
216 readonly onRemove: () => void;
213217 readonly onToggleError: () => void;
214218}) {
215219 const serverConfig = useAtomValue(
216220 serverEnvironment.configValueAtom(props.environment.environmentId),
217221 );
222 const enabled = props.environment.isEnabled;
218223 return (
219 <View>
224 <Pressable
225 accessibilityHint="Long press to remove from this device"
226 onLongPress={props.onRemove}
227 >
220228 <CloudEnvironmentRowShell
221229 borderTop={props.borderTop}
222 connectionError={props.environment.connectionError}
223 connectionErrorTraceId={props.environment.connectionErrorTraceId}
224 connectionState={props.environment.connectionState}
230 connectionError={enabled ? props.environment.connectionError : null}
231 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
232 connectionState={enabled ? props.environment.connectionState : "available"}
225233 errorExpanded={props.errorExpanded}
226234 label={props.environment.environmentLabel}
227235 machine={resolveEnvironmentMachineKind(serverConfig)}
228 onValueChange={(enabled) => {
229 if (enabled) {
230 props.onConnect();
231 return;
232 }
233 props.onDisconnect();
234 }}
236 onValueChange={props.onSetEnabled}
235237 onToggleError={props.onToggleError}
236 value={props.environment.connectionState !== "available"}
238 {...(enabled ? {} : { statusText: "Off" })}
239 value={enabled}
237240 />
238 </View>
241 </Pressable>
239242 );
240243}
241244
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+18-6
......@@ -11,6 +11,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim
1111
1212import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1313import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
14import { ThemedSwitch } from "../../components/ThemedSwitch";
1415import { cn } from "../../lib/cn";
1516import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1617import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
......@@ -18,6 +19,9 @@ import { serverEnvironment } from "../../state/server";
1819import { ConnectionStatusDot } from "./ConnectionStatusDot";
1920
2021function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
22 if (!environment.isEnabled) {
23 return "Off";
24 }
2125 return connectionStatusText({
2226 phase: environment.connectionState,
2327 error: environment.connectionError,
......@@ -31,6 +35,7 @@ export function ConnectionEnvironmentRow(props: {
3135 readonly onToggle: () => void;
3236 readonly onReconnect: (environmentId: EnvironmentId) => void;
3337 readonly onRemove: (environmentId: EnvironmentId) => void;
38 readonly onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
3439 readonly onUpdate: (
3540 environmentId: EnvironmentId,
3641 updates: { readonly label: string; readonly displayUrl: string },
......@@ -41,12 +46,14 @@ export function ConnectionEnvironmentRow(props: {
4146 const serverConfig = useAtomValue(
4247 serverEnvironment.configValueAtom(props.environment.environmentId),
4348 );
49 const enabled = props.environment.isEnabled;
4450 const statusLabel = connectionStatusLabel(props.environment);
45 const statusTraceId = props.environment.connectionErrorTraceId;
46 const hasConnectionFailure = props.environment.connectionError !== null;
51 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
52 const hasConnectionFailure = enabled && props.environment.connectionError !== null;
4753 const isRetrying =
48 props.environment.connectionState === "connecting" ||
49 props.environment.connectionState === "reconnecting";
54 enabled &&
55 (props.environment.connectionState === "connecting" ||
56 props.environment.connectionState === "reconnecting");
5057 const handleSave = useCallback(async () => {
5158 const result = await props.onUpdate(props.environment.environmentId, {
5259 label: label.trim(),
......@@ -70,7 +77,7 @@ export function ConnectionEnvironmentRow(props: {
7077 onPress={props.onToggle}
7178 >
7279 <ConnectionStatusDot
73 state={props.environment.connectionState}
80 state={enabled ? props.environment.connectionState : "available"}
7481 pulse={isRetrying}
7582 size={8}
7683 />
......@@ -125,6 +132,10 @@ export function ConnectionEnvironmentRow(props: {
125132 ) : null}
126133 </View>
127134
135 <ThemedSwitch
136 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
137 value={enabled}
138 />
128139 <SymbolView
129140 name="chevron.down"
130141 size={12}
......@@ -198,7 +209,8 @@ export function ConnectionEnvironmentRow(props: {
198209 )}
199210
200211 <Pressable
201 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70"
212 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40"
213 disabled={!enabled}
202214 onPress={() => props.onReconnect(props.environment.environmentId)}
203215 >
204216 <SymbolView
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2
......@@ -17,6 +17,7 @@ export function ConnectionsRouteScreen() {
1717 connectedEnvironments,
1818 onReconnectEnvironment,
1919 onRemoveEnvironmentPress,
20 onSetEnvironmentEnabled,
2021 onUpdateEnvironment,
2122 } = useRemoteConnections();
2223 const navigation = useNavigation();
......@@ -74,6 +75,7 @@ export function ConnectionsRouteScreen() {
7475 onToggle={() => handleToggle(environment.environmentId)}
7576 onReconnect={onReconnectEnvironment}
7677 onRemove={onRemoveEnvironmentPress}
78 onSetEnabled={onSetEnvironmentEnabled}
7779 onUpdate={onUpdateEnvironment}
7880 />
7981 </View>
apps/mobile/src/features/connection/environmentSections.test.ts+1
......@@ -15,6 +15,7 @@ function connectedEnvironment(
1515 environmentLabel: input.environmentLabel ?? input.environmentId,
1616 displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`,
1717 isRelayManaged: input.isRelayManaged,
18 isEnabled: input.isEnabled ?? true,
1819 connectionState: input.connectionState ?? "connected",
1920 connectionError: input.connectionError ?? null,
2021 connectionErrorTraceId: input.connectionErrorTraceId ?? null,
apps/mobile/src/features/connection/useConnectionController.ts+10
......@@ -40,6 +40,10 @@ export function useConnectionController() {
4040 const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register");
4141 const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove");
4242 const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry");
43 const setEnvironmentEnabledMutation = useAtomCommand(
44 environmentCatalog.setEnabled,
45 "environment toggle",
46 );
4347 const refreshRelayEnvironments = useAtomCommand(
4448 relayEnvironmentDiscovery.refresh,
4549 "relay environment refresh",
......@@ -93,6 +97,11 @@ export function useConnectionController() {
9397 (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId),
9498 [retryEnvironmentMutation],
9599 );
100 const setEnvironmentEnabled = useCallback(
101 (environmentId: EnvironmentId, enabled: boolean) =>
102 setEnvironmentEnabledMutation({ environmentId, enabled }),
103 [setEnvironmentEnabledMutation],
104 );
96105 const updateEnvironment = useCallback(
97106 (
98107 environmentId: EnvironmentId,
......@@ -120,6 +129,7 @@ export function useConnectionController() {
120129 connectRelayEnvironment,
121130 removeEnvironment,
122131 retryEnvironment,
132 setEnvironmentEnabled,
123133 updateEnvironment,
124134 refreshRelayEnvironments,
125135 };
apps/mobile/src/features/home/workspace-connection-status.test.ts+1
......@@ -45,6 +45,7 @@ describe("workspace connection status", () => {
4545 environmentLabel: "Julius’s Mac mini",
4646 displayUrl: "",
4747 isRelayManaged: false,
48 isEnabled: true,
4849 connectionState: "reconnecting",
4950 connectionError: null,
5051 connectionErrorTraceId: null,
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+4-1
......@@ -28,6 +28,7 @@ export function SettingsEnvironmentsRouteScreen() {
2828 connectedEnvironments,
2929 onReconnectEnvironment,
3030 onRemoveEnvironmentPress,
31 onSetEnvironmentEnabled,
3132 onUpdateEnvironment,
3233 } = useRemoteConnections();
3334 const navigation = useNavigation();
......@@ -136,6 +137,7 @@ export function SettingsEnvironmentsRouteScreen() {
136137 onToggle={() => handleToggle(environment.environmentId)}
137138 onReconnect={onReconnectEnvironment}
138139 onRemove={onRemoveEnvironmentPress}
140 onSetEnabled={onSetEnvironmentEnabled}
139141 onUpdate={handleUpdateEnvironment}
140142 />
141143 </View>
......@@ -163,7 +165,8 @@ export function SettingsEnvironmentsRouteScreen() {
163165 user is signed out — the component gates discovery itself. */}
164166 <CloudEnvironmentRows
165167 connectedCloudEnvironments={connectedCloudEnvironments}
166 onReconnectEnvironment={onReconnectEnvironment}
168 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
169 onRemoveEnvironment={onRemoveEnvironmentPress}
167170 {...(SHOWCASE_ENABLED
168171 ? {
169172 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts+1
......@@ -17,6 +17,7 @@ function environment(
1717 environmentLabel,
1818 displayUrl,
1919 isRelayManaged: false,
20 isEnabled: true,
2021 connectionState: "connected",
2122 connectionError: null,
2223 connectionErrorTraceId: null,
apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts+1
......@@ -43,6 +43,7 @@ export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray<ConnectedEnvir
4343 environmentLabel: "Aurora GPU Pod",
4444 displayUrl: "https://aurora-gpu.t3.sh",
4545 isRelayManaged: true,
46 isEnabled: true,
4647 connectionState: "connected",
4748 connectionError: null,
4849 connectionErrorTraceId: null,
apps/mobile/src/state/remote-environment-projections.test.ts+1-1
......@@ -29,7 +29,7 @@ function presentation(
2929 serverConfig: ServerConfig | null = null,
3030): EnvironmentPresentation {
3131 return {
32 entry: { target: target(environmentId, endpoint), profile: Option.none() },
32 entry: { target: target(environmentId, endpoint), profile: Option.none(), enabled: true },
3333 connection: { phase: "connected", error: null, traceId: null },
3434 serverConfig,
3535 };
apps/mobile/src/state/remote-runtime-types.ts+2
......@@ -13,6 +13,8 @@ export interface ConnectedEnvironmentSummary {
1313 readonly environmentLabel: string;
1414 readonly displayUrl: string;
1515 readonly isRelayManaged: boolean;
16 /** False when the user switched the environment off in Settings. */
17 readonly isEnabled: boolean;
1618 readonly connectionState: EnvironmentConnectionPhase;
1719 readonly connectionError: string | null;
1820 readonly connectionErrorTraceId: string | null;
apps/mobile/src/state/use-remote-environment-registry.ts+9-2
......@@ -94,6 +94,7 @@ export function useRemoteConnectionStatus() {
9494 environmentLabel: environment.environmentLabel,
9595 displayUrl: environment.displayUrl,
9696 isRelayManaged: environment.isRelayManaged,
97 isEnabled: environment.isEnabled,
9798 connectionState: environment.connectionState,
9899 connectionError: environment.connectionError,
99100 connectionErrorTraceId: environment.connectionErrorTraceId,
......@@ -140,6 +141,11 @@ export function useRemoteConnections() {
140141 (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId),
141142 [controller],
142143 );
144 const onSetEnvironmentEnabled = useCallback(
145 (environmentId: EnvironmentId, enabled: boolean) =>
146 controller.setEnvironmentEnabled(environmentId, enabled),
147 [controller],
148 );
143149 const onUpdateEnvironment = useCallback(
144150 (
145151 environmentId: EnvironmentId,
......@@ -157,8 +163,8 @@ export function useRemoteConnections() {
157163 return;
158164 }
159165 Alert.alert(
160 "Remove environment?",
161 `Disconnect and forget ${environment.environmentLabel} on this device.`,
166 "Remove from this device?",
167 `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,
162168 [
163169 { text: "Cancel", style: "cancel" },
164170 {
......@@ -184,6 +190,7 @@ export function useRemoteConnections() {
184190 onChangeConnectionPairingUrl,
185191 onConnectPress,
186192 onReconnectEnvironment,
193 onSetEnvironmentEnabled,
187194 onUpdateEnvironment,
188195 onRemoveEnvironmentPress,
189196 };
apps/mobile/src/state/workspaceModel.test.ts+1
......@@ -36,6 +36,7 @@ function environment(
3636 wsBaseUrl: "wss://environment.example.test",
3737 }),
3838 ),
39 enabled: true,
3940 },
4041 connection: {
4142 phase,
apps/mobile/src/state/workspaceModel.ts+9-4
......@@ -10,6 +10,7 @@ export interface WorkspaceEnvironment {
1010 readonly environmentLabel: string;
1111 readonly displayUrl: string;
1212 readonly isRelayManaged: boolean;
13 readonly isEnabled: boolean;
1314 readonly connectionState: EnvironmentConnectionPhase;
1415 readonly connectionError: string | null;
1516 readonly connectionErrorTraceId: string | null;
......@@ -38,6 +39,7 @@ export function projectWorkspaceEnvironment(
3839 environmentLabel: environment.label,
3940 displayUrl: environment.displayUrl ?? "",
4041 isRelayManaged: environment.relayManaged,
42 isEnabled: environment.entry.enabled,
4143 connectionState: environment.connection.phase,
4244 connectionError: environment.connection.error,
4345 connectionErrorTraceId: environment.connection.traceId,
......@@ -78,7 +80,10 @@ export function projectWorkspaceState(input: {
7880 readonly environments: ReadonlyArray<WorkspaceEnvironment>;
7981 readonly shellSummary: EnvironmentShellSummary;
8082}): WorkspaceState {
81 const connectingEnvironments = input.environments.filter(
83 // Switched-off environments still count as saved connections, but they do
84 // not drive the overall connection state or surface their last error.
85 const activeEnvironments = input.environments.filter((environment) => environment.isEnabled);
86 const connectingEnvironments = activeEnvironments.filter(
8287 (environment) =>
8388 environment.connectionState === "connecting" ||
8489 environment.connectionState === "reconnecting",
......@@ -91,12 +96,12 @@ export function projectWorkspaceState(input: {
9196 hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell,
9297 hasReadyEnvironment:
9398 input.networkStatus !== "offline" &&
94 input.environments.some((environment) => environment.connectionState === "connected"),
99 activeEnvironments.some((environment) => environment.connectionState === "connected"),
95100 hasConnectingEnvironment: connectingEnvironments.length > 0,
96101 connectingEnvironments,
97 connectionState: overallConnectionState(input.environments, input.networkStatus),
102 connectionState: overallConnectionState(activeEnvironments, input.networkStatus),
98103 connectionError:
99 input.environments.find((environment) => environment.connectionError !== null)
104 activeEnvironments.find((environment) => environment.connectionError !== null)
100105 ?.connectionError ?? null,
101106 shellSnapshotError: input.shellSummary.firstError,
102107 latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt,
apps/web/src/components/ServerUpdateAction.tsx+34-8
......@@ -4,6 +4,7 @@ import {
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
7import { CircleArrowUpIcon } from "lucide-react";
78import { type ComponentProps, useRef, useState } from "react";
89
910import { requestConfirmDialog } from "~/confirmDialog";
......@@ -44,8 +45,10 @@ export interface ServerUpdateTarget {
4445 readonly continueThreadsAfterServerUpdate?: boolean;
4546}
4647
47type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"> & {
48type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | "className"> & {
4849 readonly label?: string;
50 /** "icon" renders a compact icon button with the label in a tooltip. */
51 readonly appearance?: "button" | "icon";
4952};
5053
5154function useServerUpdate() {
......@@ -94,6 +97,7 @@ export function ServerUpdatesAction({
9497 label = "Update all",
9598 variant = "outline",
9699 size = "xs",
100 className,
97101}: UpdateButtonProps & {
98102 readonly targets: ReadonlyArray<ServerUpdateTarget>;
99103}) {
......@@ -133,6 +137,7 @@ export function ServerUpdatesAction({
133137 <Button
134138 size={size}
135139 variant={variant}
140 className={className}
136141 disabled={isPending || eligible.length === 0}
137142 onClick={() => void handleUpdate()}
138143 >
......@@ -191,6 +196,8 @@ export function ServerUpdateAction({
191196 label = "Update",
192197 variant = "outline",
193198 size = "xs",
199 className,
200 appearance = "button",
194201}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
195202 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
196203 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
......@@ -251,18 +258,37 @@ export function ServerUpdateAction({
251258 );
252259 }
253260
254 if (selfUpdate === null) {
255 const command = manualServerUpdateCommand(targetVersion);
261 const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion) : null;
262 const actionLabel = manualCommand !== null ? "Copy update command" : label;
263 const onClick =
264 manualCommand !== null
265 ? () => copyToClipboard(manualCommand, { command: manualCommand })
266 : () => void handleUpdate();
267
268 if (appearance === "icon") {
256269 return (
257 <Button size={size} variant={variant} onClick={() => copyToClipboard(command, { command })}>
258 Copy update command
259 </Button>
270 <Tooltip>
271 <TooltipTrigger
272 render={
273 <Button
274 size="icon-xs"
275 variant="ghost"
276 className={className ?? "text-muted-foreground hover:text-foreground"}
277 aria-label={`${actionLabel} for ${serverLabel}`}
278 onClick={onClick}
279 />
280 }
281 >
282 <CircleArrowUpIcon className="size-3.5" />
283 </TooltipTrigger>
284 <TooltipPopup side="top">{actionLabel}</TooltipPopup>
285 </Tooltip>
260286 );
261287 }
262288
263289 return (
264 <Button size={size} variant={variant} onClick={() => void handleUpdate()}>
265 {label}
290 <Button size={size} variant={variant} className={className} onClick={onClick}>
291 {actionLabel}
266292 </Button>
267293 );
268294}
apps/web/src/components/settings/ConnectionsSettings.tsx+223-108
......@@ -1,5 +1,12 @@
1import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react";
1import {
2 ChevronsLeftRightEllipsisIcon,
3 EllipsisIcon,
4 PlusIcon,
5 QrCodeIcon,
6 TerminalIcon,
7} from "lucide-react";
28import { useAtomValue } from "@effect/atom-react";
9import { Atom } from "effect/unstable/reactivity";
310import {
411 type KeyboardEvent,
512 type ReactNode,
......@@ -97,6 +104,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
97104import { QRCodeSvg } from "../ui/qr-code";
98105import { Spinner } from "../ui/spinner";
99106import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
107import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
100108import { Switch } from "../ui/switch";
101109import { stackedThreadToast, toastManager } from "../ui/toast";
102110import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -145,10 +153,16 @@ import {
145153 useEnvironments,
146154 usePrimaryEnvironment,
147155} from "~/state/environments";
156import { requestConfirmDialog } from "~/confirmDialog";
148157import { useAtomCommand } from "../../state/use-atom-command";
149158import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
150159import { ConnectionStatusDot } from "../ConnectionStatusDot";
151import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction";
160import {
161 ServerUpdateAction,
162 ServerUpdateProgress,
163 ServerUpdatesAction,
164 type ServerUpdateTarget,
165} from "../ServerUpdateAction";
152166import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
153167import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
154168import {
......@@ -1399,29 +1413,31 @@ function NetworkAccessDescription({
13991413type SavedBackendListRowProps = {
14001414 environment: EnvironmentPresentation;
14011415 removingEnvironmentId: EnvironmentId | null;
1402 onConnect: (environmentId: EnvironmentId) => void;
1403 onRemove: (environmentId: EnvironmentId) => void;
1416 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
1417 onRemove: (environment: EnvironmentPresentation) => void;
14041418};
14051419
14061420function SavedBackendListRow({
14071421 environment,
14081422 removingEnvironmentId,
1409 onConnect,
1423 onSetEnabled,
14101424 onRemove,
14111425}: SavedBackendListRowProps) {
14121426 const environmentId = environment.environmentId;
1427 const enabled = environment.entry.enabled;
14131428 const connectionState = environment.connection.phase;
14141429 const isConnected = connectionState === "connected";
1415 const isConnecting = connectionState === "connecting" || connectionState === "reconnecting";
1416 const stateDotClassName =
1417 connectionState === "connected"
1430 const isRemoving = removingEnvironmentId === environmentId;
1431 const stateDotClassName = !enabled
1432 ? "bg-muted-foreground/40"
1433 : connectionState === "connected"
14181434 ? "bg-success"
14191435 : connectionState === "connecting" || connectionState === "reconnecting"
14201436 ? "bg-warning"
14211437 : connectionState === "error"
14221438 ? "bg-destructive"
14231439 : "bg-muted-foreground/40";
1424 const statusTooltip = connectionStatusText(environment.connection);
1440 const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off";
14251441 const errorTraceId = environment.connection.traceId;
14261442 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({
14271443 target: "trace ID",
......@@ -1461,6 +1477,7 @@ function SavedBackendListRow({
14611477 const metadataBits = [
14621478 sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,
14631479 environment.relayManaged ? "T3 Connect" : null,
1480 enabled ? null : "Off",
14641481 ].filter((value): value is string => value !== null);
14651482
14661483 // The WSL backend is a desktop-managed local backend (it surfaces as a bearer
......@@ -1468,9 +1485,16 @@ function SavedBackendListRow({
14681485 // environment you connect to or remove here — its lifecycle is driven by the
14691486 // WSL on/off + distro picker on this page.
14701487 const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target);
1488 // Only a connected, enabled machine can take a remote update; a switched-off
1489 // one keeps the "update available" note so the icon is not a surprise later.
1490 const showUpdateAction =
1491 enabled &&
1492 isConnected &&
1493 versionMismatch !== null &&
1494 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed");
14711495
14721496 return (
1473 <div className={ITEM_ROW_CLASSNAME}>
1497 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>
14741498 <div className={ITEM_ROW_INNER_CLASSNAME}>
14751499 <div className="min-w-0 flex-1 space-y-1">
14761500 <div className="flex min-h-5 items-center gap-1.5">
......@@ -1478,7 +1502,7 @@ function SavedBackendListRow({
14781502 tooltipText={statusTooltip}
14791503 dotClassName={stateDotClassName}
14801504 pingClassName={
1481 connectionState === "connecting" || connectionState === "reconnecting"
1505 enabled && (connectionState === "connecting" || connectionState === "reconnecting")
14821506 ? "bg-warning/60 duration-2000"
14831507 : null
14841508 }
......@@ -1526,7 +1550,7 @@ function SavedBackendListRow({
15261550 </TooltipPopup>
15271551 </Tooltip>
15281552 ) : null}
1529 {environment.connection.error && !resumingServerUpdate ? (
1553 {enabled && environment.connection.error && !resumingServerUpdate ? (
15301554 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
15311555 <span className="min-w-0 break-words">
15321556 {connectionStatusText(environment.connection)}
......@@ -1543,9 +1567,8 @@ function SavedBackendListRow({
15431567 </p>
15441568 ) : null}
15451569 </div>
1546 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1547 {versionMismatch &&
1548 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? (
1570 <div className="flex w-full shrink-0 items-center gap-1 sm:w-auto sm:justify-end">
1571 {showUpdateAction ? (
15491572 <ServerUpdateAction
15501573 environmentId={environmentId}
15511574 serverLabel={`${environment.label} server`}
......@@ -1553,7 +1576,8 @@ function SavedBackendListRow({
15531576 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
15541577 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
15551578 targetVersion={versionMismatch.clientVersion}
1556 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
1579 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1580 appearance="icon"
15571581 />
15581582 ) : null}
15591583 {isWslEnvironment ? (
......@@ -1571,32 +1595,44 @@ function SavedBackendListRow({
15711595 </Tooltip>
15721596 ) : (
15731597 <>
1574 {!isConnected ? (
1575 <Button
1576 size="xs"
1577 variant="outline"
1578 disabled={removingEnvironmentId === environmentId}
1579 onClick={() => void onRemove(environmentId)}
1598 <Tooltip>
1599 <TooltipTrigger
1600 render={
1601 <Switch
1602 size="sm"
1603 checked={enabled}
1604 disabled={isRemoving}
1605 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
1606 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
1607 />
1608 }
1609 />
1610 <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup>
1611 </Tooltip>
1612 <Menu>
1613 <MenuTrigger
1614 render={
1615 <Button
1616 type="button"
1617 variant="ghost"
1618 size="icon-xs"
1619 className="text-muted-foreground hover:text-foreground"
1620 disabled={isRemoving}
1621 aria-label={`More actions for ${environment.label}`}
1622 />
1623 }
15801624 >
1581 {removingEnvironmentId === environmentId ? "Removing…" : "Remove"}
1582 </Button>
1583 ) : null}
1584 <Button
1585 size="xs"
1586 variant="outline"
1587 disabled={isConnecting || removingEnvironmentId === environmentId}
1588 onClick={() =>
1589 void (isConnected ? onRemove(environmentId) : onConnect(environmentId))
1590 }
1591 >
1592 {isConnected
1593 ? removingEnvironmentId === environmentId
1594 ? "Disconnecting…"
1595 : "Disconnect"
1596 : isConnecting
1597 ? "Connecting…"
1598 : "Connect"}
1599 </Button>
1625 <EllipsisIcon className="size-3.5" />
1626 </MenuTrigger>
1627 <MenuPopup align="end" className="min-w-52">
1628 {errorTraceId ? (
1629 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1630 ) : null}
1631 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
1632 {isRemoving ? "Removing…" : "Remove from this device…"}
1633 </MenuItem>
1634 </MenuPopup>
1635 </Menu>
16001636 </>
16011637 )}
16021638 </div>
......@@ -1781,7 +1817,9 @@ export function ConnectionsSettings() {
17811817 reportFailure: false,
17821818 });
17831819 const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });
1784 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });
1820 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
1821 reportFailure: false,
1822 });
17851823 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
17861824 const primarySessionState = usePrimarySessionState();
17871825 const currentSessionScopes = desktopBridge
......@@ -1797,6 +1835,59 @@ export function ConnectionsSettings() {
17971835 .toSorted((left, right) => left.label.localeCompare(right.label)),
17981836 [environments],
17991837 );
1838 // Machines "Update all" can reach: switched on, connected, behind the client
1839 // version, remotely updatable, and not already mid-update. The button only
1840 // renders when this list is non-empty.
1841 const savedServerUpdateStatesAtom = useMemo(
1842 () =>
1843 Atom.make((get) =>
1844 savedEnvironments.map((environment) => ({
1845 environment,
1846 updateStatus: get(serverEnvironment.updateStateAtom(environment.environmentId)).status,
1847 })),
1848 ),
1849 [savedEnvironments],
1850 );
1851 const savedServerUpdateStates = useAtomValue(savedServerUpdateStatesAtom);
1852 const savedServerUpdateTargets = useMemo(
1853 () =>
1854 savedServerUpdateStates.flatMap(({ environment, updateStatus }): ServerUpdateTarget[] => {
1855 const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
1856 const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig);
1857 const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig);
1858 if (
1859 !mismatch ||
1860 updateStatus === "running" ||
1861 !environment.entry.enabled ||
1862 environment.connection.phase !== "connected" ||
1863 isDesktopLocalConnectionTarget(environment.entry.target) ||
1864 // Manual-update machines only offer a copy command on their row.
1865 selfUpdate === null ||
1866 (selfUpdate === "desktop-managed" && !desktopAppUpdate)
1867 ) {
1868 return [];
1869 }
1870 return [
1871 {
1872 environmentId: environment.environmentId,
1873 serverLabel: environment.label,
1874 selfUpdate,
1875 desktopAppUpdate,
1876 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
1877 continueThreadsAfterServerUpdate:
1878 environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false,
1879 targetVersion: mismatch.clientVersion,
1880 },
1881 ];
1882 }),
1883 [savedServerUpdateStates],
1884 );
1885 // Switched-off machines never receive threads, so they stay out of the
1886 // load balancing list.
1887 const loadBalancingEnvironments = useMemo(
1888 () => environments.filter((environment) => environment.entry.enabled),
1889 [environments],
1890 );
18001891 const savedDesktopSshEnvironmentKeys = useMemo(() => {
18011892 const keys = new Set<string>();
18021893 for (const environment of savedEnvironments) {
......@@ -2367,28 +2458,42 @@ export function ConnectionsSettings() {
23672458 ],
23682459 );
23692460
2370 const handleConnectSavedBackend = useCallback(
2371 async (environmentId: EnvironmentId) => {
2461 const handleSetSavedBackendEnabled = useCallback(
2462 async (environmentId: EnvironmentId, enabled: boolean) => {
23722463 setSavedBackendError(null);
2373 const result = await retryEnvironment(environmentId);
2464 const result = await setEnvironmentEnabled({ environmentId, enabled });
23742465 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
23752466 const error = squashAtomCommandFailure(result);
2376 const message = error instanceof Error ? error.message : "Failed to connect backend.";
2467 const message =
2468 error instanceof Error
2469 ? error.message
2470 : `Failed to switch the backend ${enabled ? "on" : "off"}.`;
23772471 setSavedBackendError(message);
23782472 toastManager.add(
23792473 stackedThreadToast({
23802474 type: "error",
2381 title: "Could not connect backend",
2475 title: `Could not switch backend ${enabled ? "on" : "off"}`,
23822476 description: message,
23832477 }),
23842478 );
23852479 }
23862480 },
2387 [retryEnvironment],
2481 [setEnvironmentEnabled],
23882482 );
23892483
2484 // Removing forgets the pairing, credentials, and cached threads on this
2485 // device. Switching off is the reversible path, so removal always confirms.
23902486 const handleRemoveSavedBackend = useCallback(
2391 async (environmentId: EnvironmentId) => {
2487 async (environment: EnvironmentPresentation) => {
2488 // Fail closed: no mounted confirm host means no removal.
2489 const confirmed = await requestConfirmDialog(
2490 `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`,
2491 { variant: "destructive" },
2492 );
2493 if (confirmed !== true) {
2494 return;
2495 }
2496 const environmentId = environment.environmentId;
23922497 setRemovingSavedEnvironmentId(environmentId);
23932498 setSavedBackendError(null);
23942499 const result = await removeEnvironment(environmentId);
......@@ -3524,65 +3629,75 @@ export function ConnectionsSettings() {
35243629 <SettingsSection
35253630 {...searchableSetting("remote-environments")}
35263631 headerAction={
3527 <Dialog
3528 open={addBackendDialogOpen}
3529 onOpenChange={(open) => {
3530 setAddBackendDialogOpen(open);
3531 if (!open) {
3532 setSavedBackendError(null);
3533 }
3534 }}
3535 >
3536 <Tooltip>
3537 <TooltipTrigger
3538 render={
3539 <DialogTrigger
3540 render={
3541 <Button
3542 size="xs"
3543 variant="ghost"
3544 className="font-normal text-muted-foreground/60 hover:text-muted-foreground"
3545 aria-label="Add environment"
3546 >
3547 <PlusIcon className="size-3" />
3548 <span>Add environment</span>
3549 </Button>
3550 }
3551 />
3552 }
3632 <div className="flex items-center gap-1">
3633 {savedServerUpdateTargets.length > 0 ? (
3634 <ServerUpdatesAction
3635 targets={savedServerUpdateTargets}
3636 variant="ghost"
3637 className="font-normal text-muted-foreground/60 hover:text-muted-foreground"
35533638 />
3554 <TooltipPopup side="top">Add environment</TooltipPopup>
3555 </Tooltip>
3556 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
3557 <DialogHeader>
3558 <DialogTitle>Add Environment</DialogTitle>
3559 <DialogDescription>Pair another environment to this client.</DialogDescription>
3560 </DialogHeader>
3561 <DialogPanel>
3562 <div className="space-y-4">
3563 <div className="grid gap-3 sm:grid-cols-2">
3564 {renderConnectionModeCard({
3565 mode: "remote",
3566 title: "Remote link",
3567 description: "Enter a backend host and pairing code.",
3568 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,
3569 })}
3570 {desktopBridge
3571 ? renderConnectionModeCard({
3572 mode: "ssh",
3573 title: "SSH",
3574 description: "Use local SSH config, agent, and tunnels for the backend.",
3575 icon: <TerminalIcon aria-hidden className="size-4" />,
3576 })
3577 : null}
3639 ) : null}
3640 <Dialog
3641 open={addBackendDialogOpen}
3642 onOpenChange={(open) => {
3643 setAddBackendDialogOpen(open);
3644 if (!open) {
3645 setSavedBackendError(null);
3646 }
3647 }}
3648 >
3649 <Tooltip>
3650 <TooltipTrigger
3651 render={
3652 <DialogTrigger
3653 render={
3654 <Button
3655 size="xs"
3656 variant="ghost"
3657 className="font-normal text-muted-foreground/60 hover:text-muted-foreground"
3658 aria-label="Add environment"
3659 >
3660 <PlusIcon className="size-3" />
3661 <span>Add environment</span>
3662 </Button>
3663 }
3664 />
3665 }
3666 />
3667 <TooltipPopup side="top">Add environment</TooltipPopup>
3668 </Tooltip>
3669 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
3670 <DialogHeader>
3671 <DialogTitle>Add Environment</DialogTitle>
3672 <DialogDescription>Pair another environment to this client.</DialogDescription>
3673 </DialogHeader>
3674 <DialogPanel>
3675 <div className="space-y-4">
3676 <div className="grid gap-3 sm:grid-cols-2">
3677 {renderConnectionModeCard({
3678 mode: "remote",
3679 title: "Remote link",
3680 description: "Enter a backend host and pairing code.",
3681 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,
3682 })}
3683 {desktopBridge
3684 ? renderConnectionModeCard({
3685 mode: "ssh",
3686 title: "SSH",
3687 description:
3688 "Use local SSH config, agent, and tunnels for the backend.",
3689 icon: <TerminalIcon aria-hidden className="size-4" />,
3690 })
3691 : null}
3692 </div>
3693 <AnimatedHeight>
3694 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}
3695 </AnimatedHeight>
35783696 </div>
3579 <AnimatedHeight>
3580 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}
3581 </AnimatedHeight>
3582 </div>
3583 </DialogPanel>
3584 </DialogPopup>
3585 </Dialog>
3697 </DialogPanel>
3698 </DialogPopup>
3699 </Dialog>
3700 </div>
35863701 }
35873702 >
35883703 {savedEnvironments.map((environment) => (
......@@ -3590,7 +3705,7 @@ export function ConnectionsSettings() {
35903705 key={environment.environmentId}
35913706 environment={environment}
35923707 removingEnvironmentId={removingSavedEnvironmentId}
3593 onConnect={handleConnectSavedBackend}
3708 onSetEnabled={handleSetSavedBackendEnabled}
35943709 onRemove={handleRemoveSavedBackend}
35953710 />
35963711 ))}
......@@ -3599,7 +3714,7 @@ export function ConnectionsSettings() {
35993714 savedEnvironments={savedEnvironments}
36003715 />
36013716 </SettingsSection>
3602 <LoadBalancingSettings environments={environments} />
3717 <LoadBalancingSettings environments={loadBalancingEnvironments} />
36033718 </SettingsPageContainer>
36043719 );
36053720}
apps/web/src/connection/storage.test.ts+1
......@@ -13,6 +13,7 @@ const emptyCatalog = {
1313 profiles: [],
1414 credentials: [],
1515 remoteDpopTokens: [],
16 disabledEnvironmentIds: [],
1617} as const;
1718const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));
1819
apps/web/src/connection/storage.ts+11
......@@ -10,6 +10,7 @@ import {
1010 registerConnectionInCatalog,
1111 removeCatalogValue,
1212 removeConnectionFromCatalog,
13 setConnectionEnabledInCatalog,
1314 replaceCatalogValue,
1415} from "@t3tools/client-runtime/platform";
1516import { TokenStore } from "@t3tools/client-runtime/authorization";
......@@ -99,8 +100,10 @@ function catalogError(operation: string, cause: unknown) {
99100function persistenceError(
100101 operation:
101102 | "list-targets"
103 | "list-disabled-targets"
102104 | "register-connection"
103105 | "remove-connection"
106 | "set-connection-enabled"
104107 | "load-shell"
105108 | "save-shell"
106109 | "load-thread"
......@@ -377,6 +380,10 @@ export const connectionStorageLayer = Layer.effectContext(
377380 Effect.map((document) => document.targets),
378381 Effect.mapError((cause) => persistenceError("list-targets", cause)),
379382 ),
383 listDisabled: catalog.read.pipe(
384 Effect.map((document) => document.disabledEnvironmentIds),
385 Effect.mapError((cause) => persistenceError("list-disabled-targets", cause)),
386 ),
380387 });
381388 const registrationStore = ConnectionRegistrationStore.of({
382389 register: (registration) =>
......@@ -387,6 +394,10 @@ export const connectionStorageLayer = Layer.effectContext(
387394 catalog
388395 .update((document) => removeConnectionFromCatalog(document, target))
389396 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),
397 setEnabled: (environmentId, enabled) =>
398 catalog
399 .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled))
400 .pipe(Effect.mapError((cause) => persistenceError("set-connection-enabled", cause))),
390401 });
391402 const profileStore = ProfileStore.make({
392403 get: (connectionId) =>
apps/web/src/state/shell.test.ts+1
......@@ -51,6 +51,7 @@ function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCata
5151 label: environmentId,
5252 }),
5353 profile: Option.none(),
54 enabled: true,
5455 },
5556 ]),
5657 ),
apps/web/src/state/shell.ts+6-3
......@@ -8,7 +8,10 @@ import {
88 createShellEnvironmentAtoms,
99 type EnvironmentShellState,
1010} from "@t3tools/client-runtime/state/shell";
11import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections";
11import {
12 type EnvironmentCatalogState,
13 enabledEnvironmentIds,
14} from "@t3tools/client-runtime/state/connections";
1215import type { EnvironmentId } from "@t3tools/contracts";
1316import * as Option from "effect/Option";
1417import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -26,7 +29,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
2629 if (Option.isNone(catalog)) {
2730 return false;
2831 }
29 for (const environmentId of catalog.value.entries.keys()) {
32 for (const environmentId of enabledEnvironmentIds(catalog.value)) {
3033 if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {
3134 continue;
3235 }
......@@ -65,7 +68,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {
6568 ) {
6669 return false;
6770 }
68 for (const environmentId of catalog.entries.keys()) {
71 for (const environmentId of enabledEnvironmentIds(catalog)) {
6972 const shell = get(input.shellStateValueAtom(environmentId));
7073 if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false;
7174 }
packages/client-runtime/src/connection/catalog.ts+4
......@@ -39,6 +39,8 @@ export type ConnectionProfile = typeof ConnectionProfile.Type;
3939export interface ConnectionCatalogEntry {
4040 readonly target: ConnectionTarget;
4141 readonly profile: Option.Option<ConnectionProfile>;
42 /** False when the user switched the environment off: saved, but never connects. */
43 readonly enabled: boolean;
4244}
4345
4446export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
......@@ -113,12 +115,14 @@ export function connectionRegistrationCatalogEntry(
113115 return {
114116 target: registration.target,
115117 profile: Option.none(),
118 enabled: true,
116119 };
117120 case "BearerConnectionRegistration":
118121 case "SshConnectionRegistration":
119122 return {
120123 target: registration.target,
121124 profile: Option.some(registration.profile),
125 enabled: true,
122126 };
123127 }
124128}
packages/client-runtime/src/connection/onboarding.test.ts+1
......@@ -185,6 +185,7 @@ describe("connection onboarding", () => {
185185 wsBaseUrl: "ws://old.example.test/",
186186 }),
187187 ),
188 enabled: true,
188189 }),
189190 credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })),
190191 });
packages/client-runtime/src/connection/presentation.test.ts+1
......@@ -33,6 +33,7 @@ const ENTRY: ConnectionCatalogEntry = {
3333 wsBaseUrl: "wss://environment.example.test",
3434 }),
3535 ),
36 enabled: true,
3637};
3738
3839function supervisorState(overrides: Partial<SupervisorConnectionState>): SupervisorConnectionState {
packages/client-runtime/src/connection/registry.test.ts+132
......@@ -140,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
140140 readonly beforeRegistrationRemove?: (
141141 target: ConnectionTarget,
142142 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
143 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;
143144 },
144145) {
145146 const storedTargets = yield* Ref.make(
......@@ -176,8 +177,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
176177 );
177178 const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]);
178179
180 const storedDisabled = yield* Ref.make<ReadonlySet<EnvironmentId>>(
181 new Set(options?.initialDisabled ?? []),
182 );
179183 const targetStore = Persistence.ConnectionTargetStore.of({
180184 list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])),
185 listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])),
181186 });
182187 const registrationStore = Persistence.ConnectionRegistrationStore.of({
183188 register: (registration) =>
......@@ -237,6 +242,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
237242 return next;
238243 });
239244 }),
245 setEnabled: (environmentId, enabled) =>
246 Ref.update(storedDisabled, (current) => {
247 const next = new Set(current);
248 if (enabled) {
249 next.delete(environmentId);
250 } else {
251 next.add(environmentId);
252 }
253 return next;
254 }),
240255 });
241256 const cacheStore = Persistence.EnvironmentCacheStore.of({
242257 loadShell: (environmentId) =>
......@@ -405,6 +420,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
405420 profileReadCount,
406421 storedCredentials,
407422 storedRemoteTokens,
423 storedDisabled,
408424 disconnectedSshTargets,
409425 networkStatus,
410426 };
......@@ -653,6 +669,122 @@ describe("EnvironmentRegistry", () => {
653669 }),
654670 );
655671
672 it.effect("switching an environment off disconnects it and persists the flag", () =>
673 Effect.gen(function* () {
674 const harness = yield* makeHarness([RELAY_TARGET]);
675
676 yield* Effect.gen(function* () {
677 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
678 yield* registry.start;
679 yield* awaitConnectionState(
680 registry,
681 RELAY_TARGET.environmentId,
682 (state) => state.phase === "connected",
683 );
684
685 yield* registry.setEnabled(RELAY_TARGET.environmentId, false);
686 yield* awaitConnectionState(
687 registry,
688 RELAY_TARGET.environmentId,
689 (state) => state.phase === "available",
690 );
691
692 const entry = (yield* SubscriptionRef.get(registry.entries)).get(
693 RELAY_TARGET.environmentId,
694 );
695 expect(entry?.enabled).toBe(false);
696 expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true);
697 expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true);
698 expect(yield* Ref.get(harness.releasedSessions)).toBe(1);
699
700 yield* registry.setEnabled(RELAY_TARGET.environmentId, true);
701 yield* awaitConnectionState(
702 registry,
703 RELAY_TARGET.environmentId,
704 (state) => state.phase === "connected",
705 );
706 expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(
707 false,
708 );
709 }).pipe(Effect.provide(harness.layer));
710 }),
711 );
712
713 it.effect("re-registering a switched-off environment keeps it off", () =>
714 Effect.gen(function* () {
715 const harness = yield* makeHarness([RELAY_TARGET], [], [], {
716 initialDisabled: [RELAY_TARGET.environmentId],
717 });
718
719 yield* Effect.gen(function* () {
720 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
721 yield* registry.start;
722 yield* registry.register(
723 new RelayConnectionRegistration({
724 target: new RelayConnectionTarget({ ...RELAY_TARGET, label: "Renamed" }),
725 }),
726 );
727 yield* Effect.yieldNow;
728
729 const entry = (yield* SubscriptionRef.get(registry.entries)).get(
730 RELAY_TARGET.environmentId,
731 );
732 expect(entry?.target.label).toBe("Renamed");
733 expect(entry?.enabled).toBe(false);
734 expect(yield* Ref.get(harness.sessions)).toHaveLength(0);
735 }).pipe(Effect.provide(harness.layer));
736 }),
737 );
738
739 it.effect("switching an SSH environment off tears down its managed backend", () =>
740 Effect.gen(function* () {
741 const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
742
743 yield* Effect.gen(function* () {
744 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
745 yield* registry.start;
746 yield* awaitConnectionState(
747 registry,
748 SSH_CONNECTION.environmentId,
749 (state) => state.phase === "connected",
750 );
751
752 yield* registry.setEnabled(SSH_CONNECTION.environmentId, false);
753 yield* awaitConnectionState(
754 registry,
755 SSH_CONNECTION.environmentId,
756 (state) => state.phase === "available",
757 );
758
759 expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]);
760 expect((yield* Ref.get(harness.storedTargets)).has(SSH_CONNECTION.environmentId)).toBe(
761 true,
762 );
763 }).pipe(Effect.provide(harness.layer));
764 }),
765 );
766
767 it.effect("does not connect a persisted environment that was switched off", () =>
768 Effect.gen(function* () {
769 const harness = yield* makeHarness([RELAY_TARGET], [], [], {
770 initialDisabled: [RELAY_TARGET.environmentId],
771 });
772
773 yield* Effect.gen(function* () {
774 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
775 yield* registry.start;
776 yield* Effect.yieldNow;
777
778 const entry = (yield* SubscriptionRef.get(registry.entries)).get(
779 RELAY_TARGET.environmentId,
780 );
781 expect(entry?.enabled).toBe(false);
782 expect((yield* registry.state(RELAY_TARGET.environmentId)).phase).toBe("available");
783 expect(yield* Ref.get(harness.sessions)).toHaveLength(0);
784 }).pipe(Effect.provide(harness.layer));
785 }),
786 );
787
656788 it.effect("moves durable streams to a replacement supervisor", () =>
657789 Effect.gen(function* () {
658790 const replacement = new RelayConnectionTarget({
packages/client-runtime/src/connection/registry.ts+88-4
......@@ -90,6 +90,18 @@ export class EnvironmentRegistry extends Context.Service<
9090 | PlatformEnvironmentRemovalError
9191 >;
9292 readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>;
93 /**
94 * Switches a saved environment on or off. Off drops the socket, stops the
95 * retry ladder, and persists so the next launch stays off. Registration,
96 * credentials, and cache are untouched.
97 */
98 readonly setEnabled: (
99 environmentId: EnvironmentId,
100 enabled: boolean,
101 ) => Effect.Effect<
102 void,
103 EnvironmentNotRegisteredError | Persistence.ConnectionPersistenceError
104 >;
93105 readonly state: (
94106 environmentId: EnvironmentId,
95107 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;
......@@ -139,6 +151,7 @@ export const make = Effect.gen(function* () {
139151 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
140152 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
141153 const persistedTargets = yield* storage.list;
154 const disabledEnvironmentIds = new Set(yield* storage.listDisabled);
142155 const initialEntries = new Map(
143156 yield* Effect.forEach(
144157 persistedTargets,
......@@ -149,7 +162,11 @@ export const make = Effect.gen(function* () {
149162 : Option.none();
150163 return [
151164 target.environmentId,
152 { target, profile } satisfies ConnectionCatalogEntry,
165 {
166 target,
167 profile,
168 enabled: !disabledEnvironmentIds.has(target.environmentId),
169 } satisfies ConnectionCatalogEntry,
153170 ] as const;
154171 }),
155172 { concurrency: "unbounded" },
......@@ -261,7 +278,9 @@ export const make = Effect.gen(function* () {
261278 Scope.provide(scope),
262279 Effect.onError(() => Scope.close(scope, Exit.void)),
263280 );
264 yield* supervisor.connect;
281 if (entry.enabled) {
282 yield* supervisor.connect;
283 }
265284 yield* SubscriptionRef.update(serviceScopes, (current) => {
266285 const next = new Map(current);
267286 next.set(environmentId, { entry, supervisor, scope });
......@@ -391,14 +410,19 @@ export const make = Effect.gen(function* () {
391410 const register = Effect.fn("EnvironmentRegistry.register")(function* (
392411 registration: ConnectionRegistration,
393412 ) {
394 const entry = connectionRegistrationCatalogEntry(registration);
395 const environmentId = entry.target.environmentId;
413 const registered = connectionRegistrationCatalogEntry(registration);
414 const environmentId = registered.target.environmentId;
396415 yield* withLeaseLock(
397416 environmentId,
398417 Effect.gen(function* () {
399418 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
400419 return;
401420 }
421 // Editing a saved environment re-registers it; that must not switch a
422 // disabled one back on.
423 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
424 const entry: ConnectionCatalogEntry =
425 previous === undefined ? registered : { ...registered, enabled: previous.enabled };
402426 yield* registrations.register(registration);
403427 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
404428 const next = new Map(current);
......@@ -630,6 +654,65 @@ export const make = Effect.gen(function* () {
630654 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
631655 Effect.withSpan("EnvironmentRegistry.retryNow"),
632656 );
657 const setEnabled = Effect.fn("EnvironmentRegistry.setEnabled")(function* (
658 environmentId: EnvironmentId,
659 enabled: boolean,
660 ) {
661 yield* withLeaseLock(
662 environmentId,
663 Effect.gen(function* () {
664 const entry = yield* getEntry(environmentId);
665 if (entry.enabled === enabled) {
666 return;
667 }
668 // Platform-managed environments are reconciled from the host and are
669 // never persisted, so only user-saved ones write the flag.
670 if (!(yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
671 yield* registrations.setEnabled(environmentId, enabled);
672 }
673 const next: ConnectionCatalogEntry = { ...entry, enabled };
674 // Update the lease in place so the supervisor keeps its generation and
675 // durable streams; `installEntryLocked` would tear it down instead.
676 const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId);
677 if (lease !== undefined) {
678 yield* SubscriptionRef.update(serviceScopes, (current) => {
679 const nextScopes = new Map(current);
680 nextScopes.set(environmentId, { ...lease, entry: next });
681 return nextScopes;
682 });
683 }
684 yield* SubscriptionRef.update(entries, (current) => {
685 const nextEntries = new Map(current);
686 nextEntries.set(environmentId, next);
687 return nextEntries;
688 });
689 if (lease !== undefined) {
690 yield* enabled ? lease.supervisor.connect : lease.supervisor.disconnect;
691 } else if (enabled) {
692 yield* createServiceScope(next);
693 }
694 // The supervisor only owns the RPC session. A managed SSH backend and
695 // its tunnel outlive it, so switching off tears those down as well.
696 if (
697 !enabled &&
698 entry.target._tag === "SshConnectionTarget" &&
699 Option.isSome(entry.profile) &&
700 isSshConnectionProfile(entry.profile.value)
701 ) {
702 yield* ssh.disconnect(entry.profile.value.target).pipe(
703 Effect.tapError((error) =>
704 Effect.logWarning("Could not disconnect the switched-off SSH environment.", {
705 environmentId,
706 error,
707 }),
708 ),
709 Effect.ignore,
710 );
711 }
712 }),
713 );
714 });
715
633716 const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) {
634717 const supervisor = yield* acquireSupervisor(environmentId);
635718 return yield* SubscriptionRef.get(supervisor.state);
......@@ -669,6 +752,7 @@ export const make = Effect.gen(function* () {
669752 remove,
670753 removeRelayEnvironments,
671754 retryNow,
755 setEnabled,
672756 state,
673757 stateChanges,
674758 run,
packages/client-runtime/src/connection/resolver.test.ts+1-1
......@@ -45,7 +45,7 @@ function catalogEntry(
4545 target: ConnectionTarget,
4646 profile: Option.Option<ConnectionProfile> = Option.none(),
4747): ConnectionCatalogEntry {
48 return { target, profile };
48 return { target, profile, enabled: true };
4949}
5050
5151function collectingTracer(spans: Array<string>): Tracer.Tracer {
packages/client-runtime/src/connection/supervisor.test.ts+2
......@@ -56,11 +56,13 @@ const RELAY_TARGET = new RelayConnectionTarget({
5656const TARGET_ENTRY: ConnectionCatalogEntry = {
5757 target: TARGET,
5858 profile: Option.none(),
59 enabled: true,
5960};
6061
6162const RELAY_ENTRY: ConnectionCatalogEntry = {
6263 target: RELAY_TARGET,
6364 profile: Option.none(),
65 enabled: true,
6466};
6567
6668const PREPARED_CONNECTION: PreparedConnection = {
packages/client-runtime/src/platform/persistence.ts+8
......@@ -19,8 +19,10 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer
1919 {
2020 operation: Schema.Literals([
2121 "list-targets",
22 "list-disabled-targets",
2223 "register-connection",
2324 "remove-connection",
25 "set-connection-enabled",
2426 "load-shell",
2527 "save-shell",
2628 "load-thread",
......@@ -42,6 +44,8 @@ export class ConnectionTargetStore extends Context.Service<
4244 ConnectionTargetStore,
4345 {
4446 readonly list: Effect.Effect<ReadonlyArray<ConnectionTarget>, ConnectionPersistenceError>;
47 /** Saved environments the user switched off. See `ConnectionRegistrationStore.setEnabled`. */
48 readonly listDisabled: Effect.Effect<ReadonlyArray<EnvironmentId>, ConnectionPersistenceError>;
4549 }
4650>()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {}
4751
......@@ -52,6 +56,10 @@ export class ConnectionRegistrationStore extends Context.Service<
5256 registration: ConnectionRegistration,
5357 ) => Effect.Effect<void, ConnectionPersistenceError>;
5458 readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>;
59 readonly setEnabled: (
60 environmentId: EnvironmentId,
61 enabled: boolean,
62 ) => Effect.Effect<void, ConnectionPersistenceError>;
5563 }
5664>()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {}
5765
packages/client-runtime/src/platform/storageDocument.test.ts+50
......@@ -22,9 +22,11 @@ import {
2222 putRemoteDpopTokenInCatalog,
2323 registerConnectionInCatalog,
2424 removeConnectionFromCatalog,
25 setConnectionEnabledInCatalog,
2526} from "./storageDocument.ts";
2627
2728const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
29const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument);
2830
2931const RELAY_TARGET = new RelayConnectionTarget({
3032 environmentId: ENVIRONMENT_ID,
......@@ -203,6 +205,54 @@ describe("ConnectionCatalogDocument", () => {
203205 expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay);
204206 });
205207
208 it("decodes a document written before the disabled list existed", () => {
209 const decoded = decodeCatalogDocument({
210 schemaVersion: 1,
211 targets: [],
212 profiles: [],
213 credentials: [],
214 remoteDpopTokens: [],
215 });
216
217 expect(decoded.disabledEnvironmentIds).toEqual([]);
218 });
219
220 it("switches a saved environment off and back on without touching its records", () => {
221 const registered = registerConnectionInCatalog(
222 EMPTY_CONNECTION_CATALOG_DOCUMENT,
223 new BearerConnectionRegistration({
224 target: BEARER_TARGET,
225 profile: BEARER_PROFILE,
226 credential: BEARER_CREDENTIAL,
227 }),
228 );
229
230 const disabled = setConnectionEnabledInCatalog(registered, ENVIRONMENT_ID, false);
231 expect(disabled.disabledEnvironmentIds).toEqual([ENVIRONMENT_ID]);
232 expect(disabled.targets).toEqual(registered.targets);
233 expect(disabled.credentials).toEqual(registered.credentials);
234 // Idempotent: switching off twice stores the id once.
235 expect(
236 setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, false).disabledEnvironmentIds,
237 ).toEqual([ENVIRONMENT_ID]);
238
239 expect(
240 setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, true).disabledEnvironmentIds,
241 ).toEqual([]);
242 // Re-registering (editing label or URL) keeps the flag.
243 expect(
244 registerConnectionInCatalog(
245 disabled,
246 new BearerConnectionRegistration({
247 target: BEARER_TARGET,
248 profile: BEARER_PROFILE,
249 credential: BEARER_CREDENTIAL,
250 }),
251 ).disabledEnvironmentIds,
252 ).toEqual([ENVIRONMENT_ID]);
253 expect(removeConnectionFromCatalog(disabled, BEARER_TARGET).disabledEnvironmentIds).toEqual([]);
254 });
255
206256 it("persists the normalized SSH profile beside its target", () => {
207257 const target = new SshConnectionTarget({
208258 environmentId: ENVIRONMENT_ID,
packages/client-runtime/src/platform/storageDocument.ts+34
......@@ -1,3 +1,5 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
24
35import {
......@@ -20,6 +22,12 @@ export const ConnectionCatalogDocument = Schema.Struct({
2022 profiles: Schema.Array(ConnectionProfile),
2123 credentials: Schema.Array(StoredConnectionCredential),
2224 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),
25 // Saved environments the user switched off. They stay registered with their
26 // credentials and cache but never connect until switched back on. Older
27 // documents predate the key, so decoding defaults it to none.
28 disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe(
29 Schema.withDecodingDefaultKey(Effect.succeed([])),
30 ),
2331});
2432export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;
2533
......@@ -29,6 +37,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje
2937 profiles: [],
3038 credentials: [],
3139 remoteDpopTokens: [],
40 disabledEnvironmentIds: [],
3241});
3342
3443export function replaceCatalogValue<A>(
......@@ -87,6 +96,11 @@ function removeConnectionMetadata(
8796 target.environmentId,
8897 )
8998 : document.remoteDpopTokens,
99 // Re-registration passes `removeRemoteToken: false` and must keep the
100 // switched-off flag; only a real removal clears it.
101 disabledEnvironmentIds: removeRemoteToken
102 ? removeCatalogValue(document.disabledEnvironmentIds, (value) => value, target.environmentId)
103 : document.disabledEnvironmentIds,
90104 };
91105}
92106
......@@ -100,6 +114,8 @@ export function registerConnectionInCatalog(
100114 );
101115 const cleaned =
102116 previous === undefined ? document : removeConnectionMetadata(document, previous, false);
117 // Re-registering (for example editing a label or URL) keeps the disabled
118 // flag; only `setConnectionEnabledInCatalog` or removal changes it.
103119 const next: ConnectionCatalogDocument = {
104120 ...cleaned,
105121 targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target),
......@@ -140,6 +156,24 @@ export function removeConnectionFromCatalog(
140156 return removeConnectionMetadata(document, target, true);
141157}
142158
159/** Flips the disabled flag for a saved environment; unknown ids are ignored. */
160export function setConnectionEnabledInCatalog(
161 document: ConnectionCatalogDocument,
162 environmentId: EnvironmentId,
163 enabled: boolean,
164): ConnectionCatalogDocument {
165 const registered = document.targets.some((target) => target.environmentId === environmentId);
166 const without = removeCatalogValue(
167 document.disabledEnvironmentIds,
168 (value) => value,
169 environmentId,
170 );
171 return {
172 ...document,
173 disabledEnvironmentIds: registered && !enabled ? [...without, environmentId] : without,
174 };
175}
176
143177export function putRemoteDpopTokenInCatalog(
144178 document: ConnectionCatalogDocument,
145179 token: TokenStore.RemoteDpopAccessToken,
packages/client-runtime/src/state/connections.ts+25
......@@ -20,6 +20,21 @@ export interface EnvironmentCatalogState {
2020 readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>;
2121}
2222
23/**
24 * Environments that take part in the workspace: projects, threads, and shell
25 * summaries only come from these. Disabled environments stay in `entries` so
26 * Settings can list them and switch them back on.
27 */
28export function* enabledEnvironmentIds(
29 catalog: EnvironmentCatalogState,
30): Generator<EnvironmentIdType> {
31 for (const [environmentId, entry] of catalog.entries) {
32 if (entry.enabled) {
33 yield environmentId;
34 }
35 }
36}
37
2338const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({
2439 isReady: false,
2540 entries: new Map(),
......@@ -106,6 +121,15 @@ export function createEnvironmentCatalogAtoms<R, E>(
106121 Effect.flatMap((registry) => registry.removeRelayEnvironments()),
107122 ),
108123 });
124 const setEnabled = createRuntimeCommand(runtime, {
125 label: "environment-catalog:set-enabled",
126 scheduler: commandScheduler,
127 concurrency: serial,
128 execute: (input: { readonly environmentId: EnvironmentIdType; readonly enabled: boolean }) =>
129 EnvironmentRegistry.EnvironmentRegistry.pipe(
130 Effect.flatMap((registry) => registry.setEnabled(input.environmentId, input.enabled)),
131 ),
132 });
109133 const retryNow = createRuntimeCommand(runtime, {
110134 label: "environment-catalog:retry-now",
111135 scheduler: commandScheduler,
......@@ -126,5 +150,6 @@ export function createEnvironmentCatalogAtoms<R, E>(
126150 remove,
127151 removeRelayEnvironments,
128152 retryNow,
153 setEnabled,
129154 };
130155}
packages/client-runtime/src/state/entities.test.ts+22-1
......@@ -151,7 +151,10 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState
151151 };
152152}
153153
154function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID]) {
154function makeHarness(
155 environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID],
156 disabledEnvironmentIds: ReadonlySet<EnvironmentId> = new Set(),
157) {
155158 const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>
156159 Atom.make(AsyncResult.success(shellState(SNAPSHOT))),
157160 );
......@@ -171,6 +174,7 @@ function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT
171174 wsBaseUrl: "wss://example.test",
172175 }),
173176 profile: Option.none(),
177 enabled: !disabledEnvironmentIds.has(environmentId),
174178 },
175179 ]),
176180 ),
......@@ -361,6 +365,23 @@ describe("environment entity projections", () => {
361365 }
362366 });
363367
368 it("hides projects and threads of a switched-off environment while keeping its cache", () => {
369 const offEnvironmentId = EnvironmentId.make("off-environment");
370 const harness = makeHarness([ENVIRONMENT_ID, offEnvironmentId], new Set([offEnvironmentId]));
371 const projects = harness.registry.get(harness.projects.projectsAtom);
372 const threads = harness.registry.get(harness.threadShells.threadShellsAtom);
373
374 expect(projects.every((project) => project.environmentId === ENVIRONMENT_ID)).toBe(true);
375 expect(projects).toHaveLength(2);
376 expect(threads.every((thread) => thread.environmentId === ENVIRONMENT_ID)).toBe(true);
377 expect(threads).toHaveLength(2);
378 // The per-environment atoms still read the cached snapshot, so switching
379 // back on restores the rows without a refetch.
380 expect(
381 harness.registry.get(harness.projects.environmentProjectsAtom(offEnvironmentId)),
382 ).toHaveLength(2);
383 });
384
364385 it("keeps scoped identities and list order across project and environment changes", () => {
365386 const remoteEnvironmentId = EnvironmentId.make("remote-environment");
366387 const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]);
packages/client-runtime/src/state/projectEntities.ts+2-2
......@@ -9,7 +9,7 @@ import { Atom } from "effect/unstable/reactivity";
99
1010import type { EnvironmentProject } from "./models.ts";
1111import { scopeProject } from "./models.ts";
12import type { EnvironmentCatalogState } from "./connections.ts";
12import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
1313import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";
1414
1515const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]);
......@@ -71,7 +71,7 @@ export function createEnvironmentProjectAtoms(input: {
7171 let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = [];
7272 const projectRefsAtom = Atom.make((get) => {
7373 const refs: ScopedProjectRef[] = [];
74 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
74 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
7575 refs.push(...get(environmentProjectRefsAtom(environmentId)));
7676 }
7777 if (projectRefsEqual(previousProjectRefs, refs)) {
packages/client-runtime/src/state/shell.test.ts+1
......@@ -20,6 +20,7 @@ function environmentEntry(environmentId: EnvironmentId, label: string) {
2020 wsBaseUrl: `wss://${environmentId}.example.test`,
2121 }),
2222 profile: Option.none(),
23 enabled: true,
2324 };
2425}
2526
packages/client-runtime/src/state/shell.ts+3-3
......@@ -24,7 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts";
2424import type { RpcSession } from "../rpc/session.ts";
2525import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
2626import { applyShellStreamEvent } from "./shellReducer.ts";
27import type { EnvironmentCatalogState } from "./connections.ts";
27import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
2828import { followStreamInEnvironment } from "./runtime.ts";
2929
3030export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";
......@@ -342,7 +342,7 @@ export function createEnvironmentShellSummaryAtom(input: {
342342 let firstError: string | null = null;
343343 let latestSnapshotUpdatedAt: string | null = null;
344344
345 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
345 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
346346 const state = get(input.shellStateValueAtom(environmentId));
347347 hasSynchronizingShell ||= state.status === "synchronizing";
348348 hasCachedShell ||= state.status === "cached";
......@@ -383,7 +383,7 @@ export function createEnvironmentServerConfigsAtom(input: {
383383 let previousServerConfigs = EMPTY_SERVER_CONFIGS;
384384 return Atom.make((get) => {
385385 const next = new Map<EnvironmentId, ServerConfig>();
386 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
386 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
387387 const config = get(input.serverConfigValueAtom(environmentId));
388388 if (config !== null) {
389389 next.set(environmentId, config);
packages/client-runtime/src/state/threadShell.ts+3-3
......@@ -11,7 +11,7 @@ import { Atom } from "effect/unstable/reactivity";
1111
1212import type { EnvironmentThreadShell } from "./models.ts";
1313import { scopeThreadShell } from "./models.ts";
14import type { EnvironmentCatalogState } from "./connections.ts";
14import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
1515import {
1616 arrayElementsEqual,
1717 parseProjectRefCollectionKey,
......@@ -174,7 +174,7 @@ export function createEnvironmentThreadShellAtoms(input: {
174174 let previousThreadRefs: ReadonlyArray<ScopedThreadRef> = [];
175175 const threadRefsAtom = Atom.make((get) => {
176176 const refs: ScopedThreadRef[] = [];
177 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
177 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
178178 refs.push(...get(environmentThreadRefsAtom(environmentId)));
179179 }
180180 if (threadRefsEqual(previousThreadRefs, refs)) {
......@@ -187,7 +187,7 @@ export function createEnvironmentThreadShellAtoms(input: {
187187 let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = [];
188188 const threadShellsAtom = Atom.make((get) => {
189189 const next: EnvironmentThreadShell[] = [];
190 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
190 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
191191 for (const thread of get(environmentThreadsAtom(environmentId))) {
192192 next.push(scopedThread(environmentId, thread));
193193 }
packages/client-runtime/src/state/threads-atoms.test.ts+1
......@@ -181,6 +181,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?
181181 remove: () => Effect.die("Unexpected environment removal"),
182182 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
183183 retryNow: () => Effect.void,
184 setEnabled: () => Effect.die("Unexpected environment toggle"),
184185 state: () => SubscriptionRef.get(supervisor.state),
185186 stateChanges: () => SubscriptionRef.changes(supervisor.state),
186187 run: (_environmentId, effect) =>