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