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(...@@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn(
372 profiles,372 profiles,
373 credentials,373 credentials,
374 remoteDpopTokens: [],374 remoteDpopTokens: [],
375 disabledEnvironmentIds: [],
375 };376 };
376});377});
377378
apps/mobile/src/connection/storage.ts+17-1
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 putRemoteDpopTokenInCatalog,5 putRemoteDpopTokenInCatalog,
6 registerConnectionInCatalog,6 registerConnectionInCatalog,
7 removeConnectionFromCatalog,7 removeConnectionFromCatalog,
8 setConnectionEnabledInCatalog,
8 removeCatalogValue,9 removeCatalogValue,
9 replaceCatalogValue,10 replaceCatalogValue,
10} from "@t3tools/client-runtime/platform";11} from "@t3tools/client-runtime/platform";
...@@ -21,7 +22,12 @@ import * as Option from "effect/Option";...@@ -21,7 +22,12 @@ import * as Option from "effect/Option";
21import * as CatalogStore from "./catalog-store";22import * as CatalogStore from "./catalog-store";
2223
23function targetPersistenceError(24function 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",
25 error: ConnectionTransientError,31 error: ConnectionTransientError,
26) {32) {
27 return new ConnectionPersistenceError({33 return new ConnectionPersistenceError({
...@@ -39,6 +45,10 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -39,6 +45,10 @@ export const connectionStorageLayer = Layer.effectContext(
39 Effect.map((document) => document.targets),45 Effect.map((document) => document.targets),
40 Effect.mapError((error) => targetPersistenceError("list-targets", error)),46 Effect.mapError((error) => targetPersistenceError("list-targets", error)),
41 ),47 ),
48 listDisabled: catalog.read.pipe(
49 Effect.map((document) => document.disabledEnvironmentIds),
50 Effect.mapError((error) => targetPersistenceError("list-disabled-targets", error)),
51 ),
42 });52 });
43 const registrationStore = ConnectionRegistrationStore.of({53 const registrationStore = ConnectionRegistrationStore.of({
44 register: (registration) =>54 register: (registration) =>
...@@ -49,6 +59,12 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -49,6 +59,12 @@ export const connectionStorageLayer = Layer.effectContext(
49 catalog59 catalog
50 .update((document) => removeConnectionFromCatalog(document, target))60 .update((document) => removeConnectionFromCatalog(document, target))
51 .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),61 .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 ),
52 });68 });
53 const profileStore = ProfileStore.make({69 const profileStore = ProfileStore.make({
54 get: (connectionId) =>70 get: (connectionId) =>
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+4-2
...@@ -45,7 +45,8 @@ function ConfiguredConnectOnboardingRouteScreen() {...@@ -45,7 +45,8 @@ function ConfiguredConnectOnboardingRouteScreen() {
45 const navigation = useNavigation();45 const navigation = useNavigation();
46 const insets = useSafeAreaInsets();46 const insets = useSafeAreaInsets();
47 const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });47 const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
48 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();48 const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } =
49 useRemoteConnections();
49 const { refreshRelayEnvironments } = useConnectionController();50 const { refreshRelayEnvironments } = useConnectionController();
50 const { connectedCloudEnvironments } = splitEnvironmentSections({51 const { connectedCloudEnvironments } = splitEnvironmentSections({
51 connectedEnvironments,52 connectedEnvironments,
...@@ -110,7 +111,8 @@ function ConfiguredConnectOnboardingRouteScreen() {...@@ -110,7 +111,8 @@ function ConfiguredConnectOnboardingRouteScreen() {
110 {isSignedIn ? (111 {isSignedIn ? (
111 <CloudEnvironmentRows112 <CloudEnvironmentRows
112 connectedCloudEnvironments={connectedCloudEnvironments}113 connectedCloudEnvironments={connectedCloudEnvironments}
113 onReconnectEnvironment={onReconnectEnvironment}114 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
115 onRemoveEnvironment={onRemoveEnvironmentPress}
114 showHeader={false}116 showHeader={false}
115 />117 />
116 ) : (118 ) : (
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+26-23
...@@ -33,7 +33,9 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect...@@ -33,7 +33,9 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
3333
34interface CloudEnvironmentRowsProps {34interface CloudEnvironmentRowsProps {
35 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;35 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;
37 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;39 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
38 readonly showcaseSignedIn?: boolean;40 readonly showcaseSignedIn?: boolean;
39 /**41 /**
...@@ -97,11 +99,6 @@ function CloudEnvironmentRowsContent(...@@ -97,11 +99,6 @@ function CloudEnvironmentRowsContent(
97 [controller],99 [controller],
98 );100 );
99101
100 const handleDisconnectCloudEnvironment = useCallback(
101 (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId),
102 [controller],
103 );
104
105 const handleToggleCloudError = useCallback((environmentId: string) => {102 const handleToggleCloudError = useCallback((environmentId: string) => {
106 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));103 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
107 }, []);104 }, []);
...@@ -144,8 +141,10 @@ function CloudEnvironmentRowsContent(...@@ -144,8 +141,10 @@ function CloudEnvironmentRowsContent(
144 key={environment.environmentId}141 key={environment.environmentId}
145 environment={environment}142 environment={environment}
146 borderTop={index !== 0}143 borderTop={index !== 0}
147 onConnect={() => props.onReconnectEnvironment(environment.environmentId)}144 onSetEnabled={(enabled) =>
148 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}145 props.onSetEnvironmentEnabled(environment.environmentId, enabled)
146 }
147 onRemove={() => props.onRemoveEnvironment(environment.environmentId)}
149 errorExpanded={expandedErrorId === environment.environmentId}148 errorExpanded={expandedErrorId === environment.environmentId}
150 onToggleError={() => handleToggleCloudError(environment.environmentId)}149 onToggleError={() => handleToggleCloudError(environment.environmentId)}
151 />150 />
...@@ -204,38 +203,42 @@ function CloudEnvironmentRowsContent(...@@ -204,38 +203,42 @@ function CloudEnvironmentRowsContent(
204 );203 );
205}204}
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 */
207function ConnectedCloudEnvironmentRow(props: {211function ConnectedCloudEnvironmentRow(props: {
208 readonly environment: ConnectedEnvironmentSummary;212 readonly environment: ConnectedEnvironmentSummary;
209 readonly borderTop: boolean;213 readonly borderTop: boolean;
210 readonly errorExpanded: boolean;214 readonly errorExpanded: boolean;
211 readonly onConnect: () => void;215 readonly onSetEnabled: (enabled: boolean) => void;
212 readonly onDisconnect: () => void;216 readonly onRemove: () => void;
213 readonly onToggleError: () => void;217 readonly onToggleError: () => void;
214}) {218}) {
215 const serverConfig = useAtomValue(219 const serverConfig = useAtomValue(
216 serverEnvironment.configValueAtom(props.environment.environmentId),220 serverEnvironment.configValueAtom(props.environment.environmentId),
217 );221 );
222 const enabled = props.environment.isEnabled;
218 return (223 return (
219 <View>224 <Pressable
225 accessibilityHint="Long press to remove from this device"
226 onLongPress={props.onRemove}
227 >
220 <CloudEnvironmentRowShell228 <CloudEnvironmentRowShell
221 borderTop={props.borderTop}229 borderTop={props.borderTop}
222 connectionError={props.environment.connectionError}230 connectionError={enabled ? props.environment.connectionError : null}
223 connectionErrorTraceId={props.environment.connectionErrorTraceId}231 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
224 connectionState={props.environment.connectionState}232 connectionState={enabled ? props.environment.connectionState : "available"}
225 errorExpanded={props.errorExpanded}233 errorExpanded={props.errorExpanded}
226 label={props.environment.environmentLabel}234 label={props.environment.environmentLabel}
227 machine={resolveEnvironmentMachineKind(serverConfig)}235 machine={resolveEnvironmentMachineKind(serverConfig)}
228 onValueChange={(enabled) => {236 onValueChange={props.onSetEnabled}
229 if (enabled) {
230 props.onConnect();
231 return;
232 }
233 props.onDisconnect();
234 }}
235 onToggleError={props.onToggleError}237 onToggleError={props.onToggleError}
236 value={props.environment.connectionState !== "available"}238 {...(enabled ? {} : { statusText: "Off" })}
239 value={enabled}
237 />240 />
238 </View>241 </Pressable>
239 );242 );
240}243}
241244
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+18-6
...@@ -11,6 +11,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim...@@ -11,6 +11,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim
1111
12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
14import { ThemedSwitch } from "../../components/ThemedSwitch";
14import { cn } from "../../lib/cn";15import { cn } from "../../lib/cn";
15import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";16import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
16import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";17import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
...@@ -18,6 +19,9 @@ import { serverEnvironment } from "../../state/server";...@@ -18,6 +19,9 @@ import { serverEnvironment } from "../../state/server";
18import { ConnectionStatusDot } from "./ConnectionStatusDot";19import { ConnectionStatusDot } from "./ConnectionStatusDot";
1920
20function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {21function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
22 if (!environment.isEnabled) {
23 return "Off";
24 }
21 return connectionStatusText({25 return connectionStatusText({
22 phase: environment.connectionState,26 phase: environment.connectionState,
23 error: environment.connectionError,27 error: environment.connectionError,
...@@ -31,6 +35,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -31,6 +35,7 @@ export function ConnectionEnvironmentRow(props: {
31 readonly onToggle: () => void;35 readonly onToggle: () => void;
32 readonly onReconnect: (environmentId: EnvironmentId) => void;36 readonly onReconnect: (environmentId: EnvironmentId) => void;
33 readonly onRemove: (environmentId: EnvironmentId) => void;37 readonly onRemove: (environmentId: EnvironmentId) => void;
38 readonly onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
34 readonly onUpdate: (39 readonly onUpdate: (
35 environmentId: EnvironmentId,40 environmentId: EnvironmentId,
36 updates: { readonly label: string; readonly displayUrl: string },41 updates: { readonly label: string; readonly displayUrl: string },
...@@ -41,12 +46,14 @@ export function ConnectionEnvironmentRow(props: {...@@ -41,12 +46,14 @@ export function ConnectionEnvironmentRow(props: {
41 const serverConfig = useAtomValue(46 const serverConfig = useAtomValue(
42 serverEnvironment.configValueAtom(props.environment.environmentId),47 serverEnvironment.configValueAtom(props.environment.environmentId),
43 );48 );
49 const enabled = props.environment.isEnabled;
44 const statusLabel = connectionStatusLabel(props.environment);50 const statusLabel = connectionStatusLabel(props.environment);
45 const statusTraceId = props.environment.connectionErrorTraceId;51 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
46 const hasConnectionFailure = props.environment.connectionError !== null;52 const hasConnectionFailure = enabled && props.environment.connectionError !== null;
47 const isRetrying =53 const isRetrying =
48 props.environment.connectionState === "connecting" ||54 enabled &&
49 props.environment.connectionState === "reconnecting";55 (props.environment.connectionState === "connecting" ||
56 props.environment.connectionState === "reconnecting");
50 const handleSave = useCallback(async () => {57 const handleSave = useCallback(async () => {
51 const result = await props.onUpdate(props.environment.environmentId, {58 const result = await props.onUpdate(props.environment.environmentId, {
52 label: label.trim(),59 label: label.trim(),
...@@ -70,7 +77,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -70,7 +77,7 @@ export function ConnectionEnvironmentRow(props: {
70 onPress={props.onToggle}77 onPress={props.onToggle}
71 >78 >
72 <ConnectionStatusDot79 <ConnectionStatusDot
73 state={props.environment.connectionState}80 state={enabled ? props.environment.connectionState : "available"}
74 pulse={isRetrying}81 pulse={isRetrying}
75 size={8}82 size={8}
76 />83 />
...@@ -125,6 +132,10 @@ export function ConnectionEnvironmentRow(props: {...@@ -125,6 +132,10 @@ export function ConnectionEnvironmentRow(props: {
125 ) : null}132 ) : null}
126 </View>133 </View>
127134
135 <ThemedSwitch
136 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
137 value={enabled}
138 />
128 <SymbolView139 <SymbolView
129 name="chevron.down"140 name="chevron.down"
130 size={12}141 size={12}
...@@ -198,7 +209,8 @@ export function ConnectionEnvironmentRow(props: {...@@ -198,7 +209,8 @@ export function ConnectionEnvironmentRow(props: {
198 )}209 )}
199210
200 <Pressable211 <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}
202 onPress={() => props.onReconnect(props.environment.environmentId)}214 onPress={() => props.onReconnect(props.environment.environmentId)}
203 >215 >
204 <SymbolView216 <SymbolView
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2
...@@ -17,6 +17,7 @@ export function ConnectionsRouteScreen() {...@@ -17,6 +17,7 @@ export function ConnectionsRouteScreen() {
17 connectedEnvironments,17 connectedEnvironments,
18 onReconnectEnvironment,18 onReconnectEnvironment,
19 onRemoveEnvironmentPress,19 onRemoveEnvironmentPress,
20 onSetEnvironmentEnabled,
20 onUpdateEnvironment,21 onUpdateEnvironment,
21 } = useRemoteConnections();22 } = useRemoteConnections();
22 const navigation = useNavigation();23 const navigation = useNavigation();
...@@ -74,6 +75,7 @@ export function ConnectionsRouteScreen() {...@@ -74,6 +75,7 @@ export function ConnectionsRouteScreen() {
74 onToggle={() => handleToggle(environment.environmentId)}75 onToggle={() => handleToggle(environment.environmentId)}
75 onReconnect={onReconnectEnvironment}76 onReconnect={onReconnectEnvironment}
76 onRemove={onRemoveEnvironmentPress}77 onRemove={onRemoveEnvironmentPress}
78 onSetEnabled={onSetEnvironmentEnabled}
77 onUpdate={onUpdateEnvironment}79 onUpdate={onUpdateEnvironment}
78 />80 />
79 </View>81 </View>
apps/mobile/src/features/connection/environmentSections.test.ts+1
...@@ -15,6 +15,7 @@ function connectedEnvironment(...@@ -15,6 +15,7 @@ function connectedEnvironment(
15 environmentLabel: input.environmentLabel ?? input.environmentId,15 environmentLabel: input.environmentLabel ?? input.environmentId,
16 displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`,16 displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`,
17 isRelayManaged: input.isRelayManaged,17 isRelayManaged: input.isRelayManaged,
18 isEnabled: input.isEnabled ?? true,
18 connectionState: input.connectionState ?? "connected",19 connectionState: input.connectionState ?? "connected",
19 connectionError: input.connectionError ?? null,20 connectionError: input.connectionError ?? null,
20 connectionErrorTraceId: input.connectionErrorTraceId ?? null,21 connectionErrorTraceId: input.connectionErrorTraceId ?? null,
apps/mobile/src/features/connection/useConnectionController.ts+10
...@@ -40,6 +40,10 @@ export function useConnectionController() {...@@ -40,6 +40,10 @@ export function useConnectionController() {
40 const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register");40 const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register");
41 const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove");41 const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove");
42 const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry");42 const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry");
43 const setEnvironmentEnabledMutation = useAtomCommand(
44 environmentCatalog.setEnabled,
45 "environment toggle",
46 );
43 const refreshRelayEnvironments = useAtomCommand(47 const refreshRelayEnvironments = useAtomCommand(
44 relayEnvironmentDiscovery.refresh,48 relayEnvironmentDiscovery.refresh,
45 "relay environment refresh",49 "relay environment refresh",
...@@ -93,6 +97,11 @@ export function useConnectionController() {...@@ -93,6 +97,11 @@ export function useConnectionController() {
93 (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId),97 (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId),
94 [retryEnvironmentMutation],98 [retryEnvironmentMutation],
95 );99 );
100 const setEnvironmentEnabled = useCallback(
101 (environmentId: EnvironmentId, enabled: boolean) =>
102 setEnvironmentEnabledMutation({ environmentId, enabled }),
103 [setEnvironmentEnabledMutation],
104 );
96 const updateEnvironment = useCallback(105 const updateEnvironment = useCallback(
97 (106 (
98 environmentId: EnvironmentId,107 environmentId: EnvironmentId,
...@@ -120,6 +129,7 @@ export function useConnectionController() {...@@ -120,6 +129,7 @@ export function useConnectionController() {
120 connectRelayEnvironment,129 connectRelayEnvironment,
121 removeEnvironment,130 removeEnvironment,
122 retryEnvironment,131 retryEnvironment,
132 setEnvironmentEnabled,
123 updateEnvironment,133 updateEnvironment,
124 refreshRelayEnvironments,134 refreshRelayEnvironments,
125 };135 };
apps/mobile/src/features/home/workspace-connection-status.test.ts+1
...@@ -45,6 +45,7 @@ describe("workspace connection status", () => {...@@ -45,6 +45,7 @@ describe("workspace connection status", () => {
45 environmentLabel: "Julius’s Mac mini",45 environmentLabel: "Julius’s Mac mini",
46 displayUrl: "",46 displayUrl: "",
47 isRelayManaged: false,47 isRelayManaged: false,
48 isEnabled: true,
48 connectionState: "reconnecting",49 connectionState: "reconnecting",
49 connectionError: null,50 connectionError: null,
50 connectionErrorTraceId: null,51 connectionErrorTraceId: null,
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+4-1
...@@ -28,6 +28,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -28,6 +28,7 @@ export function SettingsEnvironmentsRouteScreen() {
28 connectedEnvironments,28 connectedEnvironments,
29 onReconnectEnvironment,29 onReconnectEnvironment,
30 onRemoveEnvironmentPress,30 onRemoveEnvironmentPress,
31 onSetEnvironmentEnabled,
31 onUpdateEnvironment,32 onUpdateEnvironment,
32 } = useRemoteConnections();33 } = useRemoteConnections();
33 const navigation = useNavigation();34 const navigation = useNavigation();
...@@ -136,6 +137,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -136,6 +137,7 @@ export function SettingsEnvironmentsRouteScreen() {
136 onToggle={() => handleToggle(environment.environmentId)}137 onToggle={() => handleToggle(environment.environmentId)}
137 onReconnect={onReconnectEnvironment}138 onReconnect={onReconnectEnvironment}
138 onRemove={onRemoveEnvironmentPress}139 onRemove={onRemoveEnvironmentPress}
140 onSetEnabled={onSetEnvironmentEnabled}
139 onUpdate={handleUpdateEnvironment}141 onUpdate={handleUpdateEnvironment}
140 />142 />
141 </View>143 </View>
...@@ -163,7 +165,8 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -163,7 +165,8 @@ export function SettingsEnvironmentsRouteScreen() {
163 user is signed out — the component gates discovery itself. */}165 user is signed out — the component gates discovery itself. */}
164 <CloudEnvironmentRows166 <CloudEnvironmentRows
165 connectedCloudEnvironments={connectedCloudEnvironments}167 connectedCloudEnvironments={connectedCloudEnvironments}
166 onReconnectEnvironment={onReconnectEnvironment}168 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
169 onRemoveEnvironment={onRemoveEnvironmentPress}
167 {...(SHOWCASE_ENABLED170 {...(SHOWCASE_ENABLED
168 ? {171 ? {
169 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,172 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts+1
...@@ -17,6 +17,7 @@ function environment(...@@ -17,6 +17,7 @@ function environment(
17 environmentLabel,17 environmentLabel,
18 displayUrl,18 displayUrl,
19 isRelayManaged: false,19 isRelayManaged: false,
20 isEnabled: true,
20 connectionState: "connected",21 connectionState: "connected",
21 connectionError: null,22 connectionError: null,
22 connectionErrorTraceId: null,23 connectionErrorTraceId: null,
apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts+1
...@@ -43,6 +43,7 @@ export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray<ConnectedEnvir...@@ -43,6 +43,7 @@ export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray<ConnectedEnvir
43 environmentLabel: "Aurora GPU Pod",43 environmentLabel: "Aurora GPU Pod",
44 displayUrl: "https://aurora-gpu.t3.sh",44 displayUrl: "https://aurora-gpu.t3.sh",
45 isRelayManaged: true,45 isRelayManaged: true,
46 isEnabled: true,
46 connectionState: "connected",47 connectionState: "connected",
47 connectionError: null,48 connectionError: null,
48 connectionErrorTraceId: null,49 connectionErrorTraceId: null,
apps/mobile/src/state/remote-environment-projections.test.ts+1-1
...@@ -29,7 +29,7 @@ function presentation(...@@ -29,7 +29,7 @@ function presentation(
29 serverConfig: ServerConfig | null = null,29 serverConfig: ServerConfig | null = null,
30): EnvironmentPresentation {30): EnvironmentPresentation {
31 return {31 return {
32 entry: { target: target(environmentId, endpoint), profile: Option.none() },32 entry: { target: target(environmentId, endpoint), profile: Option.none(), enabled: true },
33 connection: { phase: "connected", error: null, traceId: null },33 connection: { phase: "connected", error: null, traceId: null },
34 serverConfig,34 serverConfig,
35 };35 };
apps/mobile/src/state/remote-runtime-types.ts+2
...@@ -13,6 +13,8 @@ export interface ConnectedEnvironmentSummary {...@@ -13,6 +13,8 @@ export interface ConnectedEnvironmentSummary {
13 readonly environmentLabel: string;13 readonly environmentLabel: string;
14 readonly displayUrl: string;14 readonly displayUrl: string;
15 readonly isRelayManaged: boolean;15 readonly isRelayManaged: boolean;
16 /** False when the user switched the environment off in Settings. */
17 readonly isEnabled: boolean;
16 readonly connectionState: EnvironmentConnectionPhase;18 readonly connectionState: EnvironmentConnectionPhase;
17 readonly connectionError: string | null;19 readonly connectionError: string | null;
18 readonly connectionErrorTraceId: string | null;20 readonly connectionErrorTraceId: string | null;
apps/mobile/src/state/use-remote-environment-registry.ts+9-2
...@@ -94,6 +94,7 @@ export function useRemoteConnectionStatus() {...@@ -94,6 +94,7 @@ export function useRemoteConnectionStatus() {
94 environmentLabel: environment.environmentLabel,94 environmentLabel: environment.environmentLabel,
95 displayUrl: environment.displayUrl,95 displayUrl: environment.displayUrl,
96 isRelayManaged: environment.isRelayManaged,96 isRelayManaged: environment.isRelayManaged,
97 isEnabled: environment.isEnabled,
97 connectionState: environment.connectionState,98 connectionState: environment.connectionState,
98 connectionError: environment.connectionError,99 connectionError: environment.connectionError,
99 connectionErrorTraceId: environment.connectionErrorTraceId,100 connectionErrorTraceId: environment.connectionErrorTraceId,
...@@ -140,6 +141,11 @@ export function useRemoteConnections() {...@@ -140,6 +141,11 @@ export function useRemoteConnections() {
140 (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId),141 (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId),
141 [controller],142 [controller],
142 );143 );
144 const onSetEnvironmentEnabled = useCallback(
145 (environmentId: EnvironmentId, enabled: boolean) =>
146 controller.setEnvironmentEnabled(environmentId, enabled),
147 [controller],
148 );
143 const onUpdateEnvironment = useCallback(149 const onUpdateEnvironment = useCallback(
144 (150 (
145 environmentId: EnvironmentId,151 environmentId: EnvironmentId,
...@@ -157,8 +163,8 @@ export function useRemoteConnections() {...@@ -157,8 +163,8 @@ export function useRemoteConnections() {
157 return;163 return;
158 }164 }
159 Alert.alert(165 Alert.alert(
160 "Remove environment?",166 "Remove from this device?",
161 `Disconnect and forget ${environment.environmentLabel} on this device.`,167 `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,
162 [168 [
163 { text: "Cancel", style: "cancel" },169 { text: "Cancel", style: "cancel" },
164 {170 {
...@@ -184,6 +190,7 @@ export function useRemoteConnections() {...@@ -184,6 +190,7 @@ export function useRemoteConnections() {
184 onChangeConnectionPairingUrl,190 onChangeConnectionPairingUrl,
185 onConnectPress,191 onConnectPress,
186 onReconnectEnvironment,192 onReconnectEnvironment,
193 onSetEnvironmentEnabled,
187 onUpdateEnvironment,194 onUpdateEnvironment,
188 onRemoveEnvironmentPress,195 onRemoveEnvironmentPress,
189 };196 };
apps/mobile/src/state/workspaceModel.test.ts+1
...@@ -36,6 +36,7 @@ function environment(...@@ -36,6 +36,7 @@ function environment(
36 wsBaseUrl: "wss://environment.example.test",36 wsBaseUrl: "wss://environment.example.test",
37 }),37 }),
38 ),38 ),
39 enabled: true,
39 },40 },
40 connection: {41 connection: {
41 phase,42 phase,
apps/mobile/src/state/workspaceModel.ts+9-4
...@@ -10,6 +10,7 @@ export interface WorkspaceEnvironment {...@@ -10,6 +10,7 @@ export interface WorkspaceEnvironment {
10 readonly environmentLabel: string;10 readonly environmentLabel: string;
11 readonly displayUrl: string;11 readonly displayUrl: string;
12 readonly isRelayManaged: boolean;12 readonly isRelayManaged: boolean;
13 readonly isEnabled: boolean;
13 readonly connectionState: EnvironmentConnectionPhase;14 readonly connectionState: EnvironmentConnectionPhase;
14 readonly connectionError: string | null;15 readonly connectionError: string | null;
15 readonly connectionErrorTraceId: string | null;16 readonly connectionErrorTraceId: string | null;
...@@ -38,6 +39,7 @@ export function projectWorkspaceEnvironment(...@@ -38,6 +39,7 @@ export function projectWorkspaceEnvironment(
38 environmentLabel: environment.label,39 environmentLabel: environment.label,
39 displayUrl: environment.displayUrl ?? "",40 displayUrl: environment.displayUrl ?? "",
40 isRelayManaged: environment.relayManaged,41 isRelayManaged: environment.relayManaged,
42 isEnabled: environment.entry.enabled,
41 connectionState: environment.connection.phase,43 connectionState: environment.connection.phase,
42 connectionError: environment.connection.error,44 connectionError: environment.connection.error,
43 connectionErrorTraceId: environment.connection.traceId,45 connectionErrorTraceId: environment.connection.traceId,
...@@ -78,7 +80,10 @@ export function projectWorkspaceState(input: {...@@ -78,7 +80,10 @@ export function projectWorkspaceState(input: {
78 readonly environments: ReadonlyArray<WorkspaceEnvironment>;80 readonly environments: ReadonlyArray<WorkspaceEnvironment>;
79 readonly shellSummary: EnvironmentShellSummary;81 readonly shellSummary: EnvironmentShellSummary;
80}): WorkspaceState {82}): 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(
82 (environment) =>87 (environment) =>
83 environment.connectionState === "connecting" ||88 environment.connectionState === "connecting" ||
84 environment.connectionState === "reconnecting",89 environment.connectionState === "reconnecting",
...@@ -91,12 +96,12 @@ export function projectWorkspaceState(input: {...@@ -91,12 +96,12 @@ export function projectWorkspaceState(input: {
91 hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell,96 hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell,
92 hasReadyEnvironment:97 hasReadyEnvironment:
93 input.networkStatus !== "offline" &&98 input.networkStatus !== "offline" &&
94 input.environments.some((environment) => environment.connectionState === "connected"),99 activeEnvironments.some((environment) => environment.connectionState === "connected"),
95 hasConnectingEnvironment: connectingEnvironments.length > 0,100 hasConnectingEnvironment: connectingEnvironments.length > 0,
96 connectingEnvironments,101 connectingEnvironments,
97 connectionState: overallConnectionState(input.environments, input.networkStatus),102 connectionState: overallConnectionState(activeEnvironments, input.networkStatus),
98 connectionError:103 connectionError:
99 input.environments.find((environment) => environment.connectionError !== null)104 activeEnvironments.find((environment) => environment.connectionError !== null)
100 ?.connectionError ?? null,105 ?.connectionError ?? null,
101 shellSnapshotError: input.shellSummary.firstError,106 shellSnapshotError: input.shellSummary.firstError,
102 latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt,107 latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt,
apps/web/src/components/ServerUpdateAction.tsx+34-8
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";6} from "@t3tools/client-runtime/state/runtime";
7import { CircleArrowUpIcon } from "lucide-react";
7import { type ComponentProps, useRef, useState } from "react";8import { type ComponentProps, useRef, useState } from "react";
89
9import { requestConfirmDialog } from "~/confirmDialog";10import { requestConfirmDialog } from "~/confirmDialog";
...@@ -44,8 +45,10 @@ export interface ServerUpdateTarget {...@@ -44,8 +45,10 @@ export interface ServerUpdateTarget {
44 readonly continueThreadsAfterServerUpdate?: boolean;45 readonly continueThreadsAfterServerUpdate?: boolean;
45}46}
4647
47type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"> & {48type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | "className"> & {
48 readonly label?: string;49 readonly label?: string;
50 /** "icon" renders a compact icon button with the label in a tooltip. */
51 readonly appearance?: "button" | "icon";
49};52};
5053
51function useServerUpdate() {54function useServerUpdate() {
...@@ -94,6 +97,7 @@ export function ServerUpdatesAction({...@@ -94,6 +97,7 @@ export function ServerUpdatesAction({
94 label = "Update all",97 label = "Update all",
95 variant = "outline",98 variant = "outline",
96 size = "xs",99 size = "xs",
100 className,
97}: UpdateButtonProps & {101}: UpdateButtonProps & {
98 readonly targets: ReadonlyArray<ServerUpdateTarget>;102 readonly targets: ReadonlyArray<ServerUpdateTarget>;
99}) {103}) {
...@@ -133,6 +137,7 @@ export function ServerUpdatesAction({...@@ -133,6 +137,7 @@ export function ServerUpdatesAction({
133 <Button137 <Button
134 size={size}138 size={size}
135 variant={variant}139 variant={variant}
140 className={className}
136 disabled={isPending || eligible.length === 0}141 disabled={isPending || eligible.length === 0}
137 onClick={() => void handleUpdate()}142 onClick={() => void handleUpdate()}
138 >143 >
...@@ -191,6 +196,8 @@ export function ServerUpdateAction({...@@ -191,6 +196,8 @@ export function ServerUpdateAction({
191 label = "Update",196 label = "Update",
192 variant = "outline",197 variant = "outline",
193 size = "xs",198 size = "xs",
199 className,
200 appearance = "button",
194}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {201}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
195 const isDesktopAppUpdate = selfUpdate === "desktop-managed";202 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
196 const continueThreadsAfterServerUpdate = useEnvironmentSettings(203 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
...@@ -251,18 +258,37 @@ export function ServerUpdateAction({...@@ -251,18 +258,37 @@ export function ServerUpdateAction({
251 );258 );
252 }259 }
253260
254 if (selfUpdate === null) {261 const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion) : null;
255 const command = manualServerUpdateCommand(targetVersion);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") {
256 return (269 return (
257 <Button size={size} variant={variant} onClick={() => copyToClipboard(command, { command })}>270 <Tooltip>
258 Copy update command271 <TooltipTrigger
259 </Button>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>
260 );286 );
261 }287 }
262288
263 return (289 return (
264 <Button size={size} variant={variant} onClick={() => void handleUpdate()}>290 <Button size={size} variant={variant} className={className} onClick={onClick}>
265 {label}291 {actionLabel}
266 </Button>292 </Button>
267 );293 );
268}294}
apps/web/src/components/settings/ConnectionsSettings.tsx+223-108
...@@ -1,5 +1,12 @@...@@ -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";
2import { useAtomValue } from "@effect/atom-react";8import { useAtomValue } from "@effect/atom-react";
9import { Atom } from "effect/unstable/reactivity";
3import {10import {
4 type KeyboardEvent,11 type KeyboardEvent,
5 type ReactNode,12 type ReactNode,
...@@ -97,6 +104,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";...@@ -97,6 +104,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
97import { QRCodeSvg } from "../ui/qr-code";104import { QRCodeSvg } from "../ui/qr-code";
98import { Spinner } from "../ui/spinner";105import { Spinner } from "../ui/spinner";
99import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";106import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
107import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
100import { Switch } from "../ui/switch";108import { Switch } from "../ui/switch";
101import { stackedThreadToast, toastManager } from "../ui/toast";109import { stackedThreadToast, toastManager } from "../ui/toast";
102import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";110import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -145,10 +153,16 @@ import {...@@ -145,10 +153,16 @@ import {
145 useEnvironments,153 useEnvironments,
146 usePrimaryEnvironment,154 usePrimaryEnvironment,
147} from "~/state/environments";155} from "~/state/environments";
156import { requestConfirmDialog } from "~/confirmDialog";
148import { useAtomCommand } from "../../state/use-atom-command";157import { useAtomCommand } from "../../state/use-atom-command";
149import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";158import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
150import { ConnectionStatusDot } from "../ConnectionStatusDot";159import { ConnectionStatusDot } from "../ConnectionStatusDot";
151import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction";160import {
161 ServerUpdateAction,
162 ServerUpdateProgress,
163 ServerUpdatesAction,
164 type ServerUpdateTarget,
165} from "../ServerUpdateAction";
152import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";166import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
153import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";167import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
154import {168import {
...@@ -1399,29 +1413,31 @@ function NetworkAccessDescription({...@@ -1399,29 +1413,31 @@ function NetworkAccessDescription({
1399type SavedBackendListRowProps = {1413type SavedBackendListRowProps = {
1400 environment: EnvironmentPresentation;1414 environment: EnvironmentPresentation;
1401 removingEnvironmentId: EnvironmentId | null;1415 removingEnvironmentId: EnvironmentId | null;
1402 onConnect: (environmentId: EnvironmentId) => void;1416 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
1403 onRemove: (environmentId: EnvironmentId) => void;1417 onRemove: (environment: EnvironmentPresentation) => void;
1404};1418};
14051419
1406function SavedBackendListRow({1420function SavedBackendListRow({
1407 environment,1421 environment,
1408 removingEnvironmentId,1422 removingEnvironmentId,
1409 onConnect,1423 onSetEnabled,
1410 onRemove,1424 onRemove,
1411}: SavedBackendListRowProps) {1425}: SavedBackendListRowProps) {
1412 const environmentId = environment.environmentId;1426 const environmentId = environment.environmentId;
1427 const enabled = environment.entry.enabled;
1413 const connectionState = environment.connection.phase;1428 const connectionState = environment.connection.phase;
1414 const isConnected = connectionState === "connected";1429 const isConnected = connectionState === "connected";
1415 const isConnecting = connectionState === "connecting" || connectionState === "reconnecting";1430 const isRemoving = removingEnvironmentId === environmentId;
1416 const stateDotClassName =1431 const stateDotClassName = !enabled
1417 connectionState === "connected"1432 ? "bg-muted-foreground/40"
1433 : connectionState === "connected"
1418 ? "bg-success"1434 ? "bg-success"
1419 : connectionState === "connecting" || connectionState === "reconnecting"1435 : connectionState === "connecting" || connectionState === "reconnecting"
1420 ? "bg-warning"1436 ? "bg-warning"
1421 : connectionState === "error"1437 : connectionState === "error"
1422 ? "bg-destructive"1438 ? "bg-destructive"
1423 : "bg-muted-foreground/40";1439 : "bg-muted-foreground/40";
1424 const statusTooltip = connectionStatusText(environment.connection);1440 const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off";
1425 const errorTraceId = environment.connection.traceId;1441 const errorTraceId = environment.connection.traceId;
1426 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({1442 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({
1427 target: "trace ID",1443 target: "trace ID",
...@@ -1461,6 +1477,7 @@ function SavedBackendListRow({...@@ -1461,6 +1477,7 @@ function SavedBackendListRow({
1461 const metadataBits = [1477 const metadataBits = [
1462 sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,1478 sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,
1463 environment.relayManaged ? "T3 Connect" : null,1479 environment.relayManaged ? "T3 Connect" : null,
1480 enabled ? null : "Off",
1464 ].filter((value): value is string => value !== null);1481 ].filter((value): value is string => value !== null);
14651482
1466 // The WSL backend is a desktop-managed local backend (it surfaces as a bearer1483 // The WSL backend is a desktop-managed local backend (it surfaces as a bearer
...@@ -1468,9 +1485,16 @@ function SavedBackendListRow({...@@ -1468,9 +1485,16 @@ function SavedBackendListRow({
1468 // environment you connect to or remove here — its lifecycle is driven by the1485 // environment you connect to or remove here — its lifecycle is driven by the
1469 // WSL on/off + distro picker on this page.1486 // WSL on/off + distro picker on this page.
1470 const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target);1487 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
1472 return (1496 return (
1473 <div className={ITEM_ROW_CLASSNAME}>1497 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>
1474 <div className={ITEM_ROW_INNER_CLASSNAME}>1498 <div className={ITEM_ROW_INNER_CLASSNAME}>
1475 <div className="min-w-0 flex-1 space-y-1">1499 <div className="min-w-0 flex-1 space-y-1">
1476 <div className="flex min-h-5 items-center gap-1.5">1500 <div className="flex min-h-5 items-center gap-1.5">
...@@ -1478,7 +1502,7 @@ function SavedBackendListRow({...@@ -1478,7 +1502,7 @@ function SavedBackendListRow({
1478 tooltipText={statusTooltip}1502 tooltipText={statusTooltip}
1479 dotClassName={stateDotClassName}1503 dotClassName={stateDotClassName}
1480 pingClassName={1504 pingClassName={
1481 connectionState === "connecting" || connectionState === "reconnecting"1505 enabled && (connectionState === "connecting" || connectionState === "reconnecting")
1482 ? "bg-warning/60 duration-2000"1506 ? "bg-warning/60 duration-2000"
1483 : null1507 : null
1484 }1508 }
...@@ -1526,7 +1550,7 @@ function SavedBackendListRow({...@@ -1526,7 +1550,7 @@ function SavedBackendListRow({
1526 </TooltipPopup>1550 </TooltipPopup>
1527 </Tooltip>1551 </Tooltip>
1528 ) : null}1552 ) : null}
1529 {environment.connection.error && !resumingServerUpdate ? (1553 {enabled && environment.connection.error && !resumingServerUpdate ? (
1530 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">1554 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
1531 <span className="min-w-0 break-words">1555 <span className="min-w-0 break-words">
1532 {connectionStatusText(environment.connection)}1556 {connectionStatusText(environment.connection)}
...@@ -1543,9 +1567,8 @@ function SavedBackendListRow({...@@ -1543,9 +1567,8 @@ function SavedBackendListRow({
1543 </p>1567 </p>
1544 ) : null}1568 ) : null}
1545 </div>1569 </div>
1546 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">1570 <div className="flex w-full shrink-0 items-center gap-1 sm:w-auto sm:justify-end">
1547 {versionMismatch &&1571 {showUpdateAction ? (
1548 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? (
1549 <ServerUpdateAction1572 <ServerUpdateAction
1550 environmentId={environmentId}1573 environmentId={environmentId}
1551 serverLabel={`${environment.label} server`}1574 serverLabel={`${environment.label} server`}
...@@ -1553,7 +1576,8 @@ function SavedBackendListRow({...@@ -1553,7 +1576,8 @@ function SavedBackendListRow({
1553 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}1576 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1554 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}1577 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
1555 targetVersion={versionMismatch.clientVersion}1578 targetVersion={versionMismatch.clientVersion}
1556 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}1579 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1580 appearance="icon"
1557 />1581 />
1558 ) : null}1582 ) : null}
1559 {isWslEnvironment ? (1583 {isWslEnvironment ? (
...@@ -1571,32 +1595,44 @@ function SavedBackendListRow({...@@ -1571,32 +1595,44 @@ function SavedBackendListRow({
1571 </Tooltip>1595 </Tooltip>
1572 ) : (1596 ) : (
1573 <>1597 <>
1574 {!isConnected ? (1598 <Tooltip>
1575 <Button1599 <TooltipTrigger
1576 size="xs"1600 render={
1577 variant="outline"1601 <Switch
1578 disabled={removingEnvironmentId === environmentId}1602 size="sm"
1579 onClick={() => void onRemove(environmentId)}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 }
1580 >1624 >
1581 {removingEnvironmentId === environmentId ? "Removing…" : "Remove"}1625 <EllipsisIcon className="size-3.5" />
1582 </Button>1626 </MenuTrigger>
1583 ) : null}1627 <MenuPopup align="end" className="min-w-52">
1584 <Button1628 {errorTraceId ? (
1585 size="xs"1629 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1586 variant="outline"1630 ) : null}
1587 disabled={isConnecting || removingEnvironmentId === environmentId}1631 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
1588 onClick={() =>1632 {isRemoving ? "Removing…" : "Remove from this device…"}
1589 void (isConnected ? onRemove(environmentId) : onConnect(environmentId))1633 </MenuItem>
1590 }1634 </MenuPopup>
1591 >1635 </Menu>
1592 {isConnected
1593 ? removingEnvironmentId === environmentId
1594 ? "Disconnecting…"
1595 : "Disconnect"
1596 : isConnecting
1597 ? "Connecting…"
1598 : "Connect"}
1599 </Button>
1600 </>1636 </>
1601 )}1637 )}
1602 </div>1638 </div>
...@@ -1781,7 +1817,9 @@ export function ConnectionsSettings() {...@@ -1781,7 +1817,9 @@ export function ConnectionsSettings() {
1781 reportFailure: false,1817 reportFailure: false,
1782 });1818 });
1783 const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });1819 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 });
1785 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;1823 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
1786 const primarySessionState = usePrimarySessionState();1824 const primarySessionState = usePrimarySessionState();
1787 const currentSessionScopes = desktopBridge1825 const currentSessionScopes = desktopBridge
...@@ -1797,6 +1835,59 @@ export function ConnectionsSettings() {...@@ -1797,6 +1835,59 @@ export function ConnectionsSettings() {
1797 .toSorted((left, right) => left.label.localeCompare(right.label)),1835 .toSorted((left, right) => left.label.localeCompare(right.label)),
1798 [environments],1836 [environments],
1799 );1837 );
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 );
1800 const savedDesktopSshEnvironmentKeys = useMemo(() => {1891 const savedDesktopSshEnvironmentKeys = useMemo(() => {
1801 const keys = new Set<string>();1892 const keys = new Set<string>();
1802 for (const environment of savedEnvironments) {1893 for (const environment of savedEnvironments) {
...@@ -2367,28 +2458,42 @@ export function ConnectionsSettings() {...@@ -2367,28 +2458,42 @@ export function ConnectionsSettings() {
2367 ],2458 ],
2368 );2459 );
23692460
2370 const handleConnectSavedBackend = useCallback(2461 const handleSetSavedBackendEnabled = useCallback(
2371 async (environmentId: EnvironmentId) => {2462 async (environmentId: EnvironmentId, enabled: boolean) => {
2372 setSavedBackendError(null);2463 setSavedBackendError(null);
2373 const result = await retryEnvironment(environmentId);2464 const result = await setEnvironmentEnabled({ environmentId, enabled });
2374 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {2465 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2375 const error = squashAtomCommandFailure(result);2466 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"}.`;
2377 setSavedBackendError(message);2471 setSavedBackendError(message);
2378 toastManager.add(2472 toastManager.add(
2379 stackedThreadToast({2473 stackedThreadToast({
2380 type: "error",2474 type: "error",
2381 title: "Could not connect backend",2475 title: `Could not switch backend ${enabled ? "on" : "off"}`,
2382 description: message,2476 description: message,
2383 }),2477 }),
2384 );2478 );
2385 }2479 }
2386 },2480 },
2387 [retryEnvironment],2481 [setEnvironmentEnabled],
2388 );2482 );
23892483
2484 // Removing forgets the pairing, credentials, and cached threads on this
2485 // device. Switching off is the reversible path, so removal always confirms.
2390 const handleRemoveSavedBackend = useCallback(2486 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;
2392 setRemovingSavedEnvironmentId(environmentId);2497 setRemovingSavedEnvironmentId(environmentId);
2393 setSavedBackendError(null);2498 setSavedBackendError(null);
2394 const result = await removeEnvironment(environmentId);2499 const result = await removeEnvironment(environmentId);
...@@ -3524,65 +3629,75 @@ export function ConnectionsSettings() {...@@ -3524,65 +3629,75 @@ export function ConnectionsSettings() {
3524 <SettingsSection3629 <SettingsSection
3525 {...searchableSetting("remote-environments")}3630 {...searchableSetting("remote-environments")}
3526 headerAction={3631 headerAction={
3527 <Dialog3632 <div className="flex items-center gap-1">
3528 open={addBackendDialogOpen}3633 {savedServerUpdateTargets.length > 0 ? (
3529 onOpenChange={(open) => {3634 <ServerUpdatesAction
3530 setAddBackendDialogOpen(open);3635 targets={savedServerUpdateTargets}
3531 if (!open) {3636 variant="ghost"
3532 setSavedBackendError(null);3637 className="font-normal text-muted-foreground/60 hover:text-muted-foreground"
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 }
3553 />3638 />
3554 <TooltipPopup side="top">Add environment</TooltipPopup>3639 ) : null}
3555 </Tooltip>3640 <Dialog
3556 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">3641 open={addBackendDialogOpen}
3557 <DialogHeader>3642 onOpenChange={(open) => {
3558 <DialogTitle>Add Environment</DialogTitle>3643 setAddBackendDialogOpen(open);
3559 <DialogDescription>Pair another environment to this client.</DialogDescription>3644 if (!open) {
3560 </DialogHeader>3645 setSavedBackendError(null);
3561 <DialogPanel>3646 }
3562 <div className="space-y-4">3647 }}
3563 <div className="grid gap-3 sm:grid-cols-2">3648 >
3564 {renderConnectionModeCard({3649 <Tooltip>
3565 mode: "remote",3650 <TooltipTrigger
3566 title: "Remote link",3651 render={
3567 description: "Enter a backend host and pairing code.",3652 <DialogTrigger
3568 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,3653 render={
3569 })}3654 <Button
3570 {desktopBridge3655 size="xs"
3571 ? renderConnectionModeCard({3656 variant="ghost"
3572 mode: "ssh",3657 className="font-normal text-muted-foreground/60 hover:text-muted-foreground"
3573 title: "SSH",3658 aria-label="Add environment"
3574 description: "Use local SSH config, agent, and tunnels for the backend.",3659 >
3575 icon: <TerminalIcon aria-hidden className="size-4" />,3660 <PlusIcon className="size-3" />
3576 })3661 <span>Add environment</span>
3577 : null}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>
3578 </div>3696 </div>
3579 <AnimatedHeight>3697 </DialogPanel>
3580 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}3698 </DialogPopup>
3581 </AnimatedHeight>3699 </Dialog>
3582 </div>3700 </div>
3583 </DialogPanel>
3584 </DialogPopup>
3585 </Dialog>
3586 }3701 }
3587 >3702 >
3588 {savedEnvironments.map((environment) => (3703 {savedEnvironments.map((environment) => (
...@@ -3590,7 +3705,7 @@ export function ConnectionsSettings() {...@@ -3590,7 +3705,7 @@ export function ConnectionsSettings() {
3590 key={environment.environmentId}3705 key={environment.environmentId}
3591 environment={environment}3706 environment={environment}
3592 removingEnvironmentId={removingSavedEnvironmentId}3707 removingEnvironmentId={removingSavedEnvironmentId}
3593 onConnect={handleConnectSavedBackend}3708 onSetEnabled={handleSetSavedBackendEnabled}
3594 onRemove={handleRemoveSavedBackend}3709 onRemove={handleRemoveSavedBackend}
3595 />3710 />
3596 ))}3711 ))}
...@@ -3599,7 +3714,7 @@ export function ConnectionsSettings() {...@@ -3599,7 +3714,7 @@ export function ConnectionsSettings() {
3599 savedEnvironments={savedEnvironments}3714 savedEnvironments={savedEnvironments}
3600 />3715 />
3601 </SettingsSection>3716 </SettingsSection>
3602 <LoadBalancingSettings environments={environments} />3717 <LoadBalancingSettings environments={loadBalancingEnvironments} />
3603 </SettingsPageContainer>3718 </SettingsPageContainer>
3604 );3719 );
3605}3720}
apps/web/src/connection/storage.test.ts+1
...@@ -13,6 +13,7 @@ const emptyCatalog = {...@@ -13,6 +13,7 @@ const emptyCatalog = {
13 profiles: [],13 profiles: [],
14 credentials: [],14 credentials: [],
15 remoteDpopTokens: [],15 remoteDpopTokens: [],
16 disabledEnvironmentIds: [],
16} as const;17} as const;
17const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));18const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));
1819
apps/web/src/connection/storage.ts+11
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 registerConnectionInCatalog,10 registerConnectionInCatalog,
11 removeCatalogValue,11 removeCatalogValue,
12 removeConnectionFromCatalog,12 removeConnectionFromCatalog,
13 setConnectionEnabledInCatalog,
13 replaceCatalogValue,14 replaceCatalogValue,
14} from "@t3tools/client-runtime/platform";15} from "@t3tools/client-runtime/platform";
15import { TokenStore } from "@t3tools/client-runtime/authorization";16import { TokenStore } from "@t3tools/client-runtime/authorization";
...@@ -99,8 +100,10 @@ function catalogError(operation: string, cause: unknown) {...@@ -99,8 +100,10 @@ function catalogError(operation: string, cause: unknown) {
99function persistenceError(100function persistenceError(
100 operation:101 operation:
101 | "list-targets"102 | "list-targets"
103 | "list-disabled-targets"
102 | "register-connection"104 | "register-connection"
103 | "remove-connection"105 | "remove-connection"
106 | "set-connection-enabled"
104 | "load-shell"107 | "load-shell"
105 | "save-shell"108 | "save-shell"
106 | "load-thread"109 | "load-thread"
...@@ -377,6 +380,10 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -377,6 +380,10 @@ export const connectionStorageLayer = Layer.effectContext(
377 Effect.map((document) => document.targets),380 Effect.map((document) => document.targets),
378 Effect.mapError((cause) => persistenceError("list-targets", cause)),381 Effect.mapError((cause) => persistenceError("list-targets", cause)),
379 ),382 ),
383 listDisabled: catalog.read.pipe(
384 Effect.map((document) => document.disabledEnvironmentIds),
385 Effect.mapError((cause) => persistenceError("list-disabled-targets", cause)),
386 ),
380 });387 });
381 const registrationStore = ConnectionRegistrationStore.of({388 const registrationStore = ConnectionRegistrationStore.of({
382 register: (registration) =>389 register: (registration) =>
...@@ -387,6 +394,10 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -387,6 +394,10 @@ export const connectionStorageLayer = Layer.effectContext(
387 catalog394 catalog
388 .update((document) => removeConnectionFromCatalog(document, target))395 .update((document) => removeConnectionFromCatalog(document, target))
389 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),396 .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))),
390 });401 });
391 const profileStore = ProfileStore.make({402 const profileStore = ProfileStore.make({
392 get: (connectionId) =>403 get: (connectionId) =>
apps/web/src/state/shell.test.ts+1
...@@ -51,6 +51,7 @@ function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCata...@@ -51,6 +51,7 @@ function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCata
51 label: environmentId,51 label: environmentId,
52 }),52 }),
53 profile: Option.none(),53 profile: Option.none(),
54 enabled: true,
54 },55 },
55 ]),56 ]),
56 ),57 ),
apps/web/src/state/shell.ts+6-3
...@@ -8,7 +8,10 @@ import {...@@ -8,7 +8,10 @@ import {
8 createShellEnvironmentAtoms,8 createShellEnvironmentAtoms,
9 type EnvironmentShellState,9 type EnvironmentShellState,
10} from "@t3tools/client-runtime/state/shell";10} 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";
12import type { EnvironmentId } from "@t3tools/contracts";15import type { EnvironmentId } from "@t3tools/contracts";
13import * as Option from "effect/Option";16import * as Option from "effect/Option";
14import { AsyncResult, Atom } from "effect/unstable/reactivity";17import { AsyncResult, Atom } from "effect/unstable/reactivity";
...@@ -26,7 +29,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {...@@ -26,7 +29,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
26 if (Option.isNone(catalog)) {29 if (Option.isNone(catalog)) {
27 return false;30 return false;
28 }31 }
29 for (const environmentId of catalog.value.entries.keys()) {32 for (const environmentId of enabledEnvironmentIds(catalog.value)) {
30 if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {33 if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {
31 continue;34 continue;
32 }35 }
...@@ -65,7 +68,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {...@@ -65,7 +68,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {
65 ) {68 ) {
66 return false;69 return false;
67 }70 }
68 for (const environmentId of catalog.entries.keys()) {71 for (const environmentId of enabledEnvironmentIds(catalog)) {
69 const shell = get(input.shellStateValueAtom(environmentId));72 const shell = get(input.shellStateValueAtom(environmentId));
70 if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false;73 if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false;
71 }74 }
packages/client-runtime/src/connection/catalog.ts+4
...@@ -39,6 +39,8 @@ export type ConnectionProfile = typeof ConnectionProfile.Type;...@@ -39,6 +39,8 @@ export type ConnectionProfile = typeof ConnectionProfile.Type;
39export interface ConnectionCatalogEntry {39export interface ConnectionCatalogEntry {
40 readonly target: ConnectionTarget;40 readonly target: ConnectionTarget;
41 readonly profile: Option.Option<ConnectionProfile>;41 readonly profile: Option.Option<ConnectionProfile>;
42 /** False when the user switched the environment off: saved, but never connects. */
43 readonly enabled: boolean;
42}44}
4345
44export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(46export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()(
...@@ -113,12 +115,14 @@ export function connectionRegistrationCatalogEntry(...@@ -113,12 +115,14 @@ export function connectionRegistrationCatalogEntry(
113 return {115 return {
114 target: registration.target,116 target: registration.target,
115 profile: Option.none(),117 profile: Option.none(),
118 enabled: true,
116 };119 };
117 case "BearerConnectionRegistration":120 case "BearerConnectionRegistration":
118 case "SshConnectionRegistration":121 case "SshConnectionRegistration":
119 return {122 return {
120 target: registration.target,123 target: registration.target,
121 profile: Option.some(registration.profile),124 profile: Option.some(registration.profile),
125 enabled: true,
122 };126 };
123 }127 }
124}128}
packages/client-runtime/src/connection/onboarding.test.ts+1
...@@ -185,6 +185,7 @@ describe("connection onboarding", () => {...@@ -185,6 +185,7 @@ describe("connection onboarding", () => {
185 wsBaseUrl: "ws://old.example.test/",185 wsBaseUrl: "ws://old.example.test/",
186 }),186 }),
187 ),187 ),
188 enabled: true,
188 }),189 }),
189 credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })),190 credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })),
190 });191 });
packages/client-runtime/src/connection/presentation.test.ts+1
...@@ -33,6 +33,7 @@ const ENTRY: ConnectionCatalogEntry = {...@@ -33,6 +33,7 @@ const ENTRY: ConnectionCatalogEntry = {
33 wsBaseUrl: "wss://environment.example.test",33 wsBaseUrl: "wss://environment.example.test",
34 }),34 }),
35 ),35 ),
36 enabled: true,
36};37};
3738
38function supervisorState(overrides: Partial<SupervisorConnectionState>): SupervisorConnectionState {39function 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* (...@@ -140,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
140 readonly beforeRegistrationRemove?: (140 readonly beforeRegistrationRemove?: (
141 target: ConnectionTarget,141 target: ConnectionTarget,
142 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;142 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
143 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;
143 },144 },
144) {145) {
145 const storedTargets = yield* Ref.make(146 const storedTargets = yield* Ref.make(
...@@ -176,8 +177,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -176,8 +177,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
176 );177 );
177 const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]);178 const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]);
178179
180 const storedDisabled = yield* Ref.make<ReadonlySet<EnvironmentId>>(
181 new Set(options?.initialDisabled ?? []),
182 );
179 const targetStore = Persistence.ConnectionTargetStore.of({183 const targetStore = Persistence.ConnectionTargetStore.of({
180 list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])),184 list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])),
185 listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])),
181 });186 });
182 const registrationStore = Persistence.ConnectionRegistrationStore.of({187 const registrationStore = Persistence.ConnectionRegistrationStore.of({
183 register: (registration) =>188 register: (registration) =>
...@@ -237,6 +242,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -237,6 +242,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
237 return next;242 return next;
238 });243 });
239 }),244 }),
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 }),
240 });255 });
241 const cacheStore = Persistence.EnvironmentCacheStore.of({256 const cacheStore = Persistence.EnvironmentCacheStore.of({
242 loadShell: (environmentId) =>257 loadShell: (environmentId) =>
...@@ -405,6 +420,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -405,6 +420,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
405 profileReadCount,420 profileReadCount,
406 storedCredentials,421 storedCredentials,
407 storedRemoteTokens,422 storedRemoteTokens,
423 storedDisabled,
408 disconnectedSshTargets,424 disconnectedSshTargets,
409 networkStatus,425 networkStatus,
410 };426 };
...@@ -653,6 +669,122 @@ describe("EnvironmentRegistry", () => {...@@ -653,6 +669,122 @@ describe("EnvironmentRegistry", () => {
653 }),669 }),
654 );670 );
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
656 it.effect("moves durable streams to a replacement supervisor", () =>788 it.effect("moves durable streams to a replacement supervisor", () =>
657 Effect.gen(function* () {789 Effect.gen(function* () {
658 const replacement = new RelayConnectionTarget({790 const replacement = new RelayConnectionTarget({
packages/client-runtime/src/connection/registry.ts+88-4
...@@ -90,6 +90,18 @@ export class EnvironmentRegistry extends Context.Service<...@@ -90,6 +90,18 @@ export class EnvironmentRegistry extends Context.Service<
90 | PlatformEnvironmentRemovalError90 | PlatformEnvironmentRemovalError
91 >;91 >;
92 readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>;92 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 >;
93 readonly state: (105 readonly state: (
94 environmentId: EnvironmentId,106 environmentId: EnvironmentId,
95 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;107 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;
...@@ -139,6 +151,7 @@ export const make = Effect.gen(function* () {...@@ -139,6 +151,7 @@ export const make = Effect.gen(function* () {
139 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;151 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
140 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;152 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
141 const persistedTargets = yield* storage.list;153 const persistedTargets = yield* storage.list;
154 const disabledEnvironmentIds = new Set(yield* storage.listDisabled);
142 const initialEntries = new Map(155 const initialEntries = new Map(
143 yield* Effect.forEach(156 yield* Effect.forEach(
144 persistedTargets,157 persistedTargets,
...@@ -149,7 +162,11 @@ export const make = Effect.gen(function* () {...@@ -149,7 +162,11 @@ export const make = Effect.gen(function* () {
149 : Option.none();162 : Option.none();
150 return [163 return [
151 target.environmentId,164 target.environmentId,
152 { target, profile } satisfies ConnectionCatalogEntry,165 {
166 target,
167 profile,
168 enabled: !disabledEnvironmentIds.has(target.environmentId),
169 } satisfies ConnectionCatalogEntry,
153 ] as const;170 ] as const;
154 }),171 }),
155 { concurrency: "unbounded" },172 { concurrency: "unbounded" },
...@@ -261,7 +278,9 @@ export const make = Effect.gen(function* () {...@@ -261,7 +278,9 @@ export const make = Effect.gen(function* () {
261 Scope.provide(scope),278 Scope.provide(scope),
262 Effect.onError(() => Scope.close(scope, Exit.void)),279 Effect.onError(() => Scope.close(scope, Exit.void)),
263 );280 );
264 yield* supervisor.connect;281 if (entry.enabled) {
282 yield* supervisor.connect;
283 }
265 yield* SubscriptionRef.update(serviceScopes, (current) => {284 yield* SubscriptionRef.update(serviceScopes, (current) => {
266 const next = new Map(current);285 const next = new Map(current);
267 next.set(environmentId, { entry, supervisor, scope });286 next.set(environmentId, { entry, supervisor, scope });
...@@ -391,14 +410,19 @@ export const make = Effect.gen(function* () {...@@ -391,14 +410,19 @@ export const make = Effect.gen(function* () {
391 const register = Effect.fn("EnvironmentRegistry.register")(function* (410 const register = Effect.fn("EnvironmentRegistry.register")(function* (
392 registration: ConnectionRegistration,411 registration: ConnectionRegistration,
393 ) {412 ) {
394 const entry = connectionRegistrationCatalogEntry(registration);413 const registered = connectionRegistrationCatalogEntry(registration);
395 const environmentId = entry.target.environmentId;414 const environmentId = registered.target.environmentId;
396 yield* withLeaseLock(415 yield* withLeaseLock(
397 environmentId,416 environmentId,
398 Effect.gen(function* () {417 Effect.gen(function* () {
399 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {418 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
400 return;419 return;
401 }420 }
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 };
402 yield* registrations.register(registration);426 yield* registrations.register(registration);
403 yield* Ref.update(persistedTargetsByEnvironment, (current) => {427 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
404 const next = new Map(current);428 const next = new Map(current);
...@@ -630,6 +654,65 @@ export const make = Effect.gen(function* () {...@@ -630,6 +654,65 @@ export const make = Effect.gen(function* () {
630 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),654 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
631 Effect.withSpan("EnvironmentRegistry.retryNow"),655 Effect.withSpan("EnvironmentRegistry.retryNow"),
632 );656 );
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
633 const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) {716 const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) {
634 const supervisor = yield* acquireSupervisor(environmentId);717 const supervisor = yield* acquireSupervisor(environmentId);
635 return yield* SubscriptionRef.get(supervisor.state);718 return yield* SubscriptionRef.get(supervisor.state);
...@@ -669,6 +752,7 @@ export const make = Effect.gen(function* () {...@@ -669,6 +752,7 @@ export const make = Effect.gen(function* () {
669 remove,752 remove,
670 removeRelayEnvironments,753 removeRelayEnvironments,
671 retryNow,754 retryNow,
755 setEnabled,
672 state,756 state,
673 stateChanges,757 stateChanges,
674 run,758 run,
packages/client-runtime/src/connection/resolver.test.ts+1-1
...@@ -45,7 +45,7 @@ function catalogEntry(...@@ -45,7 +45,7 @@ function catalogEntry(
45 target: ConnectionTarget,45 target: ConnectionTarget,
46 profile: Option.Option<ConnectionProfile> = Option.none(),46 profile: Option.Option<ConnectionProfile> = Option.none(),
47): ConnectionCatalogEntry {47): ConnectionCatalogEntry {
48 return { target, profile };48 return { target, profile, enabled: true };
49}49}
5050
51function collectingTracer(spans: Array<string>): Tracer.Tracer {51function collectingTracer(spans: Array<string>): Tracer.Tracer {
packages/client-runtime/src/connection/supervisor.test.ts+2
...@@ -56,11 +56,13 @@ const RELAY_TARGET = new RelayConnectionTarget({...@@ -56,11 +56,13 @@ const RELAY_TARGET = new RelayConnectionTarget({
56const TARGET_ENTRY: ConnectionCatalogEntry = {56const TARGET_ENTRY: ConnectionCatalogEntry = {
57 target: TARGET,57 target: TARGET,
58 profile: Option.none(),58 profile: Option.none(),
59 enabled: true,
59};60};
6061
61const RELAY_ENTRY: ConnectionCatalogEntry = {62const RELAY_ENTRY: ConnectionCatalogEntry = {
62 target: RELAY_TARGET,63 target: RELAY_TARGET,
63 profile: Option.none(),64 profile: Option.none(),
65 enabled: true,
64};66};
6567
66const PREPARED_CONNECTION: PreparedConnection = {68const PREPARED_CONNECTION: PreparedConnection = {
packages/client-runtime/src/platform/persistence.ts+8
...@@ -19,8 +19,10 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer...@@ -19,8 +19,10 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer
19 {19 {
20 operation: Schema.Literals([20 operation: Schema.Literals([
21 "list-targets",21 "list-targets",
22 "list-disabled-targets",
22 "register-connection",23 "register-connection",
23 "remove-connection",24 "remove-connection",
25 "set-connection-enabled",
24 "load-shell",26 "load-shell",
25 "save-shell",27 "save-shell",
26 "load-thread",28 "load-thread",
...@@ -42,6 +44,8 @@ export class ConnectionTargetStore extends Context.Service<...@@ -42,6 +44,8 @@ export class ConnectionTargetStore extends Context.Service<
42 ConnectionTargetStore,44 ConnectionTargetStore,
43 {45 {
44 readonly list: Effect.Effect<ReadonlyArray<ConnectionTarget>, ConnectionPersistenceError>;46 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>;
45 }49 }
46>()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {}50>()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {}
4751
...@@ -52,6 +56,10 @@ export class ConnectionRegistrationStore extends Context.Service<...@@ -52,6 +56,10 @@ export class ConnectionRegistrationStore extends Context.Service<
52 registration: ConnectionRegistration,56 registration: ConnectionRegistration,
53 ) => Effect.Effect<void, ConnectionPersistenceError>;57 ) => Effect.Effect<void, ConnectionPersistenceError>;
54 readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>;58 readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>;
59 readonly setEnabled: (
60 environmentId: EnvironmentId,
61 enabled: boolean,
62 ) => Effect.Effect<void, ConnectionPersistenceError>;
55 }63 }
56>()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {}64>()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {}
5765
packages/client-runtime/src/platform/storageDocument.test.ts+50
...@@ -22,9 +22,11 @@ import {...@@ -22,9 +22,11 @@ import {
22 putRemoteDpopTokenInCatalog,22 putRemoteDpopTokenInCatalog,
23 registerConnectionInCatalog,23 registerConnectionInCatalog,
24 removeConnectionFromCatalog,24 removeConnectionFromCatalog,
25 setConnectionEnabledInCatalog,
25} from "./storageDocument.ts";26} from "./storageDocument.ts";
2627
27const ENVIRONMENT_ID = EnvironmentId.make("environment-1");28const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
29const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument);
2830
29const RELAY_TARGET = new RelayConnectionTarget({31const RELAY_TARGET = new RelayConnectionTarget({
30 environmentId: ENVIRONMENT_ID,32 environmentId: ENVIRONMENT_ID,
...@@ -203,6 +205,54 @@ describe("ConnectionCatalogDocument", () => {...@@ -203,6 +205,54 @@ describe("ConnectionCatalogDocument", () => {
203 expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay);205 expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay);
204 });206 });
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
206 it("persists the normalized SSH profile beside its target", () => {256 it("persists the normalized SSH profile beside its target", () => {
207 const target = new SshConnectionTarget({257 const target = new SshConnectionTarget({
208 environmentId: ENVIRONMENT_ID,258 environmentId: ENVIRONMENT_ID,
packages/client-runtime/src/platform/storageDocument.ts+34
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
1import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
24
3import {5import {
...@@ -20,6 +22,12 @@ export const ConnectionCatalogDocument = Schema.Struct({...@@ -20,6 +22,12 @@ export const ConnectionCatalogDocument = Schema.Struct({
20 profiles: Schema.Array(ConnectionProfile),22 profiles: Schema.Array(ConnectionProfile),
21 credentials: Schema.Array(StoredConnectionCredential),23 credentials: Schema.Array(StoredConnectionCredential),
22 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),24 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 ),
23});31});
24export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;32export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;
2533
...@@ -29,6 +37,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje...@@ -29,6 +37,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje
29 profiles: [],37 profiles: [],
30 credentials: [],38 credentials: [],
31 remoteDpopTokens: [],39 remoteDpopTokens: [],
40 disabledEnvironmentIds: [],
32});41});
3342
34export function replaceCatalogValue<A>(43export function replaceCatalogValue<A>(
...@@ -87,6 +96,11 @@ function removeConnectionMetadata(...@@ -87,6 +96,11 @@ function removeConnectionMetadata(
87 target.environmentId,96 target.environmentId,
88 )97 )
89 : document.remoteDpopTokens,98 : 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,
90 };104 };
91}105}
92106
...@@ -100,6 +114,8 @@ export function registerConnectionInCatalog(...@@ -100,6 +114,8 @@ export function registerConnectionInCatalog(
100 );114 );
101 const cleaned =115 const cleaned =
102 previous === undefined ? document : removeConnectionMetadata(document, previous, false);116 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.
103 const next: ConnectionCatalogDocument = {119 const next: ConnectionCatalogDocument = {
104 ...cleaned,120 ...cleaned,
105 targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target),121 targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target),
...@@ -140,6 +156,24 @@ export function removeConnectionFromCatalog(...@@ -140,6 +156,24 @@ export function removeConnectionFromCatalog(
140 return removeConnectionMetadata(document, target, true);156 return removeConnectionMetadata(document, target, true);
141}157}
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
143export function putRemoteDpopTokenInCatalog(177export function putRemoteDpopTokenInCatalog(
144 document: ConnectionCatalogDocument,178 document: ConnectionCatalogDocument,
145 token: TokenStore.RemoteDpopAccessToken,179 token: TokenStore.RemoteDpopAccessToken,
packages/client-runtime/src/state/connections.ts+25
...@@ -20,6 +20,21 @@ export interface EnvironmentCatalogState {...@@ -20,6 +20,21 @@ export interface EnvironmentCatalogState {
20 readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>;20 readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>;
21}21}
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
23const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({38const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({
24 isReady: false,39 isReady: false,
25 entries: new Map(),40 entries: new Map(),
...@@ -106,6 +121,15 @@ export function createEnvironmentCatalogAtoms<R, E>(...@@ -106,6 +121,15 @@ export function createEnvironmentCatalogAtoms<R, E>(
106 Effect.flatMap((registry) => registry.removeRelayEnvironments()),121 Effect.flatMap((registry) => registry.removeRelayEnvironments()),
107 ),122 ),
108 });123 });
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 });
109 const retryNow = createRuntimeCommand(runtime, {133 const retryNow = createRuntimeCommand(runtime, {
110 label: "environment-catalog:retry-now",134 label: "environment-catalog:retry-now",
111 scheduler: commandScheduler,135 scheduler: commandScheduler,
...@@ -126,5 +150,6 @@ export function createEnvironmentCatalogAtoms<R, E>(...@@ -126,5 +150,6 @@ export function createEnvironmentCatalogAtoms<R, E>(
126 remove,150 remove,
127 removeRelayEnvironments,151 removeRelayEnvironments,
128 retryNow,152 retryNow,
153 setEnabled,
129 };154 };
130}155}
packages/client-runtime/src/state/entities.test.ts+22-1
...@@ -151,7 +151,10 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState...@@ -151,7 +151,10 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState
151 };151 };
152}152}
153153
154function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID]) {154function makeHarness(
155 environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID],
156 disabledEnvironmentIds: ReadonlySet<EnvironmentId> = new Set(),
157) {
155 const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>158 const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>
156 Atom.make(AsyncResult.success(shellState(SNAPSHOT))),159 Atom.make(AsyncResult.success(shellState(SNAPSHOT))),
157 );160 );
...@@ -171,6 +174,7 @@ function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT...@@ -171,6 +174,7 @@ function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT
171 wsBaseUrl: "wss://example.test",174 wsBaseUrl: "wss://example.test",
172 }),175 }),
173 profile: Option.none(),176 profile: Option.none(),
177 enabled: !disabledEnvironmentIds.has(environmentId),
174 },178 },
175 ]),179 ]),
176 ),180 ),
...@@ -361,6 +365,23 @@ describe("environment entity projections", () => {...@@ -361,6 +365,23 @@ describe("environment entity projections", () => {
361 }365 }
362 });366 });
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
364 it("keeps scoped identities and list order across project and environment changes", () => {385 it("keeps scoped identities and list order across project and environment changes", () => {
365 const remoteEnvironmentId = EnvironmentId.make("remote-environment");386 const remoteEnvironmentId = EnvironmentId.make("remote-environment");
366 const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]);387 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";...@@ -9,7 +9,7 @@ import { Atom } from "effect/unstable/reactivity";
99
10import type { EnvironmentProject } from "./models.ts";10import type { EnvironmentProject } from "./models.ts";
11import { scopeProject } from "./models.ts";11import { scopeProject } from "./models.ts";
12import type { EnvironmentCatalogState } from "./connections.ts";12import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
13import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";13import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";
1414
15const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]);15const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]);
...@@ -71,7 +71,7 @@ export function createEnvironmentProjectAtoms(input: {...@@ -71,7 +71,7 @@ export function createEnvironmentProjectAtoms(input: {
71 let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = [];71 let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = [];
72 const projectRefsAtom = Atom.make((get) => {72 const projectRefsAtom = Atom.make((get) => {
73 const refs: ScopedProjectRef[] = [];73 const refs: ScopedProjectRef[] = [];
74 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {74 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
75 refs.push(...get(environmentProjectRefsAtom(environmentId)));75 refs.push(...get(environmentProjectRefsAtom(environmentId)));
76 }76 }
77 if (projectRefsEqual(previousProjectRefs, refs)) {77 if (projectRefsEqual(previousProjectRefs, refs)) {
packages/client-runtime/src/state/shell.test.ts+1
...@@ -20,6 +20,7 @@ function environmentEntry(environmentId: EnvironmentId, label: string) {...@@ -20,6 +20,7 @@ function environmentEntry(environmentId: EnvironmentId, label: string) {
20 wsBaseUrl: `wss://${environmentId}.example.test`,20 wsBaseUrl: `wss://${environmentId}.example.test`,
21 }),21 }),
22 profile: Option.none(),22 profile: Option.none(),
23 enabled: true,
23 };24 };
24}25}
2526
packages/client-runtime/src/state/shell.ts+3-3
...@@ -24,7 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts";...@@ -24,7 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts";
24import type { RpcSession } from "../rpc/session.ts";24import type { RpcSession } from "../rpc/session.ts";
25import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";25import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
26import { applyShellStreamEvent } from "./shellReducer.ts";26import { applyShellStreamEvent } from "./shellReducer.ts";
27import type { EnvironmentCatalogState } from "./connections.ts";27import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
28import { followStreamInEnvironment } from "./runtime.ts";28import { followStreamInEnvironment } from "./runtime.ts";
2929
30export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";30export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live";
...@@ -342,7 +342,7 @@ export function createEnvironmentShellSummaryAtom(input: {...@@ -342,7 +342,7 @@ export function createEnvironmentShellSummaryAtom(input: {
342 let firstError: string | null = null;342 let firstError: string | null = null;
343 let latestSnapshotUpdatedAt: string | null = null;343 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))) {
346 const state = get(input.shellStateValueAtom(environmentId));346 const state = get(input.shellStateValueAtom(environmentId));
347 hasSynchronizingShell ||= state.status === "synchronizing";347 hasSynchronizingShell ||= state.status === "synchronizing";
348 hasCachedShell ||= state.status === "cached";348 hasCachedShell ||= state.status === "cached";
...@@ -383,7 +383,7 @@ export function createEnvironmentServerConfigsAtom(input: {...@@ -383,7 +383,7 @@ export function createEnvironmentServerConfigsAtom(input: {
383 let previousServerConfigs = EMPTY_SERVER_CONFIGS;383 let previousServerConfigs = EMPTY_SERVER_CONFIGS;
384 return Atom.make((get) => {384 return Atom.make((get) => {
385 const next = new Map<EnvironmentId, ServerConfig>();385 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))) {
387 const config = get(input.serverConfigValueAtom(environmentId));387 const config = get(input.serverConfigValueAtom(environmentId));
388 if (config !== null) {388 if (config !== null) {
389 next.set(environmentId, config);389 next.set(environmentId, config);
packages/client-runtime/src/state/threadShell.ts+3-3
...@@ -11,7 +11,7 @@ import { Atom } from "effect/unstable/reactivity";...@@ -11,7 +11,7 @@ import { Atom } from "effect/unstable/reactivity";
1111
12import type { EnvironmentThreadShell } from "./models.ts";12import type { EnvironmentThreadShell } from "./models.ts";
13import { scopeThreadShell } from "./models.ts";13import { scopeThreadShell } from "./models.ts";
14import type { EnvironmentCatalogState } from "./connections.ts";14import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
15import {15import {
16 arrayElementsEqual,16 arrayElementsEqual,
17 parseProjectRefCollectionKey,17 parseProjectRefCollectionKey,
...@@ -174,7 +174,7 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -174,7 +174,7 @@ export function createEnvironmentThreadShellAtoms(input: {
174 let previousThreadRefs: ReadonlyArray<ScopedThreadRef> = [];174 let previousThreadRefs: ReadonlyArray<ScopedThreadRef> = [];
175 const threadRefsAtom = Atom.make((get) => {175 const threadRefsAtom = Atom.make((get) => {
176 const refs: ScopedThreadRef[] = [];176 const refs: ScopedThreadRef[] = [];
177 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {177 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
178 refs.push(...get(environmentThreadRefsAtom(environmentId)));178 refs.push(...get(environmentThreadRefsAtom(environmentId)));
179 }179 }
180 if (threadRefsEqual(previousThreadRefs, refs)) {180 if (threadRefsEqual(previousThreadRefs, refs)) {
...@@ -187,7 +187,7 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -187,7 +187,7 @@ export function createEnvironmentThreadShellAtoms(input: {
187 let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = [];187 let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = [];
188 const threadShellsAtom = Atom.make((get) => {188 const threadShellsAtom = Atom.make((get) => {
189 const next: EnvironmentThreadShell[] = [];189 const next: EnvironmentThreadShell[] = [];
190 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {190 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
191 for (const thread of get(environmentThreadsAtom(environmentId))) {191 for (const thread of get(environmentThreadsAtom(environmentId))) {
192 next.push(scopedThread(environmentId, thread));192 next.push(scopedThread(environmentId, thread));
193 }193 }
packages/client-runtime/src/state/threads-atoms.test.ts+1
...@@ -181,6 +181,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?...@@ -181,6 +181,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?
181 remove: () => Effect.die("Unexpected environment removal"),181 remove: () => Effect.die("Unexpected environment removal"),
182 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),182 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
183 retryNow: () => Effect.void,183 retryNow: () => Effect.void,
184 setEnabled: () => Effect.die("Unexpected environment toggle"),
184 state: () => SubscriptionRef.get(supervisor.state),185 state: () => SubscriptionRef.get(supervisor.state),
185 stateChanges: () => SubscriptionRef.changes(supervisor.state),186 stateChanges: () => SubscriptionRef.changes(supervisor.state),
186 run: (_environmentId, effect) =>187 run: (_environmentId, effect) =>