From 17f7ee952ccc3c0991f698416d28505b5e3c2bed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 11:31:36 -0700 Subject: [PATCH 1/2] perf(mobile): isolate connection UI from thread updates --- .../connection/useConnectionController.ts | 9 +- .../src/features/keyboard/CommandPalette.tsx | 4 +- .../terminal/ThreadTerminalRouteScreen.tsx | 6 +- .../features/threads/ThreadRouteScreen.tsx | 12 +- apps/mobile/src/state/shell.ts | 35 ++-- .../state/use-remote-environment-registry.ts | 30 +-- .../state/workspace-connection-atoms.test.ts | 184 ++++++++++++++++++ .../src/state/workspace-connection-atoms.ts | 64 ++++++ apps/mobile/src/state/workspace.ts | 32 ++- apps/mobile/src/state/workspaceModel.test.ts | 13 +- apps/mobile/src/state/workspaceModel.ts | 51 +++-- 11 files changed, 351 insertions(+), 89 deletions(-) create mode 100644 apps/mobile/src/state/workspace-connection-atoms.test.ts create mode 100644 apps/mobile/src/state/workspace-connection-atoms.ts diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index a509ac60a4a6..78bf8a5a4702 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -16,10 +16,9 @@ import { connectPairingUrl as connectPairingUrlAtom, updateBearerConnection, } from "../../connection/onboarding"; -import { useEnvironments } from "../../state/environments"; +import { useWorkspaceEnvironments } from "../../state/workspace"; import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; -import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; import { relayManagedEnvironmentIds } from "./environmentSections"; export interface RelayEnvironmentView { @@ -31,7 +30,7 @@ export interface RelayEnvironmentView { } export function useConnectionController() { - const { environments } = useEnvironments(); + const connectedEnvironments = useWorkspaceEnvironments(); const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); const connectPairingUrlMutation = useAtomCommand(connectPairingUrlAtom, { reportFailure: false, @@ -49,10 +48,6 @@ export function useConnectionController() { "relay environment refresh", ); - const connectedEnvironments = useMemo>( - () => environments.map(projectWorkspaceEnvironment), - [environments], - ); const registeredIds = useMemo( () => relayManagedEnvironmentIds(connectedEnvironments), [connectedEnvironments], diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index a79e99fc212a..a0af33a6342a 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -24,7 +24,7 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; -import { useWorkspaceState } from "../../state/workspace"; +import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; @@ -123,7 +123,7 @@ export function CommandPalette(props: { const threads = useThreadShells(); const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); const activeThread = useThreadShell(activeThreadRef); - const { environments } = useWorkspaceState(); + const environments = useWorkspaceEnvironments(); const { savedConnectionsById } = useSavedRemoteConnections(); const [query, setQuery] = useState(""); const [selection, setSelection] = useState(null); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 7f6433afb61d..603493347923 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -34,7 +34,7 @@ import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useServerConfigs } from "../../state/entities"; -import { useWorkspaceState } from "../../state/workspace"; +import { useConnectionsReady } from "../../state/workspace"; import { MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, @@ -166,7 +166,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); - const { state: workspaceState } = useWorkspaceState(); + const connectionsReady = useConnectionsReady(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const params = props.route.params; const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = @@ -1114,7 +1114,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, [retryEnvironment, routeEnvironmentId]); if (!selectedThread) { - if (workspaceState.isLoadingConnections) { + if (!connectionsReady) { return ; } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 4146fe499c71..fccef608b201 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -20,8 +20,8 @@ import { } from "@t3tools/shared/projectScripts"; import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useWorkspaceState } from "../../state/workspace"; -import { useEnvironmentShellState } from "../../state/shell"; +import { useConnectionsReady } from "../../state/workspace"; +import { useEnvironmentShellReadiness } from "../../state/shell"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; @@ -148,7 +148,7 @@ function ThreadUnavailableScreen(props: { } export function ThreadRouteScreen(props: ThreadRouteScreenProps) { - const { state: workspaceState } = useWorkspaceState(); + const connectionsReady = useConnectionsReady(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); const params = props.route.params; @@ -156,7 +156,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const threadIdRaw = firstRouteParam(params.threadId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); - const routeEnvironmentShellState = useEnvironmentShellState(environmentId); + const routeEnvironmentShellState = useEnvironmentShellReadiness(environmentId); const { onReconnectEnvironment } = useRemoteConnections(); const navigation = useNavigation(); const routeConnectionState = @@ -185,10 +185,10 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { } const stillHydrating = threadRouteIsHydrating({ - isLoadingConnections: workspaceState.isLoadingConnections, + isLoadingConnections: !connectionsReady, connectionState: routeConnectionState, shellStatus: routeEnvironmentShellState.status, - shellHasError: Option.isSome(routeEnvironmentShellState.error), + shellHasError: routeEnvironmentShellState.hasError, detailStatus: selectedThreadDetailState.status, detailHasError: Option.isSome(selectedThreadDetailState.error), }); diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index e20209c7cd81..75c4db8701bb 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -8,7 +8,7 @@ import { import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -21,24 +21,23 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ shellStateValueAtom: environmentShell.stateValueAtom, }); -const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make( - AsyncResult.success({ - snapshot: Option.none(), - status: "empty", - error: Option.none(), - }), -).pipe(Atom.withLabel("mobile-environment-shell:empty")); +const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make({ + snapshot: Option.none(), + status: "empty", + error: Option.none(), +}); + +const shellStatus = (state: EnvironmentShellState) => state.status; +const shellHasError = (state: EnvironmentShellState) => Option.isSome(state.error); -/** Reads one environment's shell projection without waiting on other environments. */ -export function useEnvironmentShellState(environmentId: EnvironmentId | null) { - const result = useAtomValue( +/** Snapshot contents do not affect whether the route is still hydrating. */ +export function useEnvironmentShellReadiness(environmentId: EnvironmentId | null) { + const atom = environmentId === null ? EMPTY_ENVIRONMENT_SHELL_STATE_ATOM - : environmentShell.stateAtom(environmentId), - ); - return Option.getOrElse(AsyncResult.value(result), () => ({ - snapshot: Option.none(), - status: "empty" as const, - error: Option.none(), - })); + : environmentShell.stateValueAtom(environmentId); + return { + status: useAtomValue(atom, shellStatus), + hasError: useAtomValue(atom, shellHasError), + }; } diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 1664dc7461aa..d95e1688efc6 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -2,12 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { Alert } from "react-native"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; -import { useWorkspaceState } from "../state/workspace"; +import { useWorkspaceConnectionState, useWorkspaceEnvironments } from "./workspace"; import type { SavedRemoteConnection } from "../lib/connection"; import { appAtomRegistry } from "./atom-registry"; import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; @@ -45,9 +45,9 @@ const EMPTY_RUNTIME_STATE_ATOM = Atom.make(null) ); const savedConnectionsByIdAtom = Atom.make((get) => { - const presentationById = get(environmentPresentations.presentationsAtom); + const catalog = get(environmentCatalog.catalogValueAtom); return Object.fromEntries( - [...presentationById.keys()].flatMap((environmentId) => { + [...catalog.entries.keys()].flatMap((environmentId) => { const connection = get(remoteEnvironmentProjections.savedConnectionAtom(environmentId)); return connection === null ? [] : [[environmentId, connection]]; }), @@ -85,27 +85,15 @@ export function useRemoteEnvironmentRuntime( } export function useRemoteConnectionStatus() { - const workspace = useWorkspaceState(); + const state = useWorkspaceConnectionState(); + const connectedEnvironments: ReadonlyArray = + useWorkspaceEnvironments(); const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); - const connectedEnvironments = useMemo>( - () => - workspace.environments.map((environment) => ({ - environmentId: environment.environmentId, - environmentLabel: environment.environmentLabel, - displayUrl: environment.displayUrl, - isRelayManaged: environment.isRelayManaged, - isEnabled: environment.isEnabled, - connectionState: environment.connectionState, - connectionError: environment.connectionError, - connectionErrorTraceId: environment.connectionErrorTraceId, - })), - [workspace.environments], - ); return { connectedEnvironments, - connectionState: workspace.state.connectionState, - connectionError: pendingConnectionError ?? workspace.state.connectionError, + connectionState: state.connectionState, + connectionError: pendingConnectionError ?? state.connectionError, }; } diff --git a/apps/mobile/src/state/workspace-connection-atoms.test.ts b/apps/mobile/src/state/workspace-connection-atoms.test.ts new file mode 100644 index 000000000000..a48daf76628d --- /dev/null +++ b/apps/mobile/src/state/workspace-connection-atoms.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + PrimaryConnectionTarget, + type EnvironmentPresentation, + type NetworkStatus, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { createWorkspaceConnectionAtoms } from "./workspace-connection-atoms"; +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; + +const ID = EnvironmentId.make("environment-1"); +function presentation(): EnvironmentPresentation { + return { + entry: { + target: new PrimaryConnectionTarget({ + environmentId: ID, + label: "Mac", + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + enabled: true, + profile: Option.none(), + }, + connection: { phase: "connected", error: null, traceId: null }, + serverConfig: null, + }; +} +function harness() { + const source = Atom.make(presentation()); + const catalog = Atom.make({ + isReady: true, + entries: new Map([[ID, presentation().entry]]), + }); + const network = Atom.make("online"); + const atoms = createWorkspaceConnectionAtoms({ + catalogValueAtom: catalog, + networkStatusValueAtom: network, + presentationAtom: () => source, + }); + const registry = AtomRegistry.make(); + return { source, catalog, network, registry, ...atoms }; +} + +describe("workspace connection subscriptions", () => { + it("keeps connection consumers stable across shell and provider updates", () => { + const h = harness(); + const shell = Atom.make({ + hasSnapshot: true, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: true, + firstError: null, + latestSnapshotUpdatedAt: null, + }); + const broadState = Atom.make((get) => + projectWorkspaceState({ + isReady: get(h.catalog).isReady, + networkStatus: get(h.network), + environments: [projectWorkspaceEnvironment(ID, get(h.source)!)], + shellSummary: get(shell), + }), + ); + const initial = h.registry.get(h.stateAtom); + const initialEnvironments = h.registry.get(h.environmentsAtom); + let broadChanges = 0; + let connectionChanges = 0; + const stopBroad = h.registry.subscribe(broadState, () => broadChanges++); + const stopNarrow = h.registry.subscribe(h.stateAtom, () => connectionChanges++); + try { + for (let index = 0; index < 20; index++) { + h.registry.set(shell, { + ...h.registry.get(shell), + latestSnapshotUpdatedAt: new Date(index * 1000).toISOString(), + }); + h.registry.get(broadState); + h.registry.get(h.stateAtom); + } + expect(broadChanges).toBe(20); + expect(connectionChanges).toBe(0); + for (let index = 0; index < 4; index++) { + // Full config still advances for consumers such as Settings. + const config = { cwd: `/workspace-${index}` } as ServerConfig; + h.registry.set(h.source, { ...presentation(), serverConfig: config }); + expect(h.registry.get(h.source)?.serverConfig).toBe(config); + expect(h.registry.get(h.stateAtom)).toBe(initial); + expect(h.registry.get(h.environmentsAtom)).toBe(initialEnvironments); + } + expect(connectionChanges).toBe(0); + } finally { + stopBroad(); + stopNarrow(); + h.registry.dispose(); + } + }); + + it("propagates errors, offline state, disabling and reconnection", () => { + const h = harness(); + try { + expect(h.registry.get(h.stateAtom).connectionState).toBe("connected"); + const failed = { + ...presentation(), + connection: { phase: "error" as const, error: "Connection failed", traceId: "trace-1" }, + }; + h.registry.set(h.source, failed); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + connectionState: "error", + connectionError: "Connection failed", + hasReadyEnvironment: false, + }); + expect(h.registry.get(h.environmentsAtom)[0]?.connectionErrorTraceId).toBe("trace-1"); + h.registry.set(h.source, { ...failed, entry: { ...failed.entry, enabled: false } }); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + hasConnections: true, + connectionState: "available", + connectionError: null, + }); + h.registry.set(h.source, { + ...presentation(), + connection: { phase: "reconnecting", error: null, traceId: null }, + }); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + hasConnectingEnvironment: true, + connectionState: "reconnecting", + }); + h.registry.set(h.source, presentation()); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + hasReadyEnvironment: true, + hasConnectingEnvironment: false, + }); + h.registry.set(h.network, "offline"); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + connectionState: "offline", + hasReadyEnvironment: false, + }); + h.registry.set(h.network, "online"); + expect(h.registry.get(h.stateAtom).connectionState).toBe("connected"); + } finally { + h.registry.dispose(); + } + }); + + it("updates labels and endpoints, removes environments, and finishes catalog loading", () => { + const h = harness(); + try { + h.registry.set(h.catalog, { isReady: false, entries: new Map() }); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + isLoadingConnections: true, + hasConnections: false, + }); + h.registry.set(h.source, { + ...presentation(), + entry: { + ...presentation().entry, + target: new PrimaryConnectionTarget({ + environmentId: ID, + label: "Renamed", + httpBaseUrl: "https://new.example.test", + wsBaseUrl: "wss://new.example.test", + }), + }, + }); + h.registry.set(h.catalog, { isReady: true, entries: new Map([[ID, presentation().entry]]) }); + expect(h.registry.get(h.environmentsAtom)[0]).toMatchObject({ + environmentLabel: "Renamed", + displayUrl: "https://new.example.test", + }); + expect(h.registry.get(h.stateAtom).isLoadingConnections).toBe(false); + h.registry.set(h.catalog, { isReady: true, entries: new Map() }); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + hasConnections: false, + connectionState: "available", + hasReadyEnvironment: false, + }); + expect(h.registry.get(h.environmentsAtom)).toEqual([]); + } finally { + h.registry.dispose(); + } + }); +}); diff --git a/apps/mobile/src/state/workspace-connection-atoms.ts b/apps/mobile/src/state/workspace-connection-atoms.ts new file mode 100644 index 000000000000..7cb131dccc89 --- /dev/null +++ b/apps/mobile/src/state/workspace-connection-atoms.ts @@ -0,0 +1,64 @@ +import type { EnvironmentPresentation, NetworkStatus } from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { + projectWorkspaceConnectionState, + projectWorkspaceEnvironment, + type WorkspaceEnvironment, +} from "./workspaceModel"; + +export function createWorkspaceConnectionAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly networkStatusValueAtom: Atom.Atom; + readonly presentationAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const presentation = get(input.presentationAtom(environmentId)); + if (presentation === null) return null; + const next = projectWorkspaceEnvironment(environmentId, presentation); + const previous = Option.getOrNull(get.self()); + // Provider refreshes and transport heartbeats do not change connection UI. + if ( + previous !== null && + previous.environmentId === next.environmentId && + previous.environmentLabel === next.environmentLabel && + previous.displayUrl === next.displayUrl && + previous.isRelayManaged === next.isRelayManaged && + previous.isEnabled === next.isEnabled && + previous.connectionState === next.connectionState && + previous.connectionError === next.connectionError && + previous.connectionErrorTraceId === next.connectionErrorTraceId + ) + return previous; + return next; + }), + ); + const environmentsAtom = Atom.make((get) => { + const next: Array = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const environment = get(environmentAtom(environmentId)); + if (environment !== null) next.push(environment); + } + const previous = Option.getOrNull(get.self>()); + return previous !== null && + previous.length === next.length && + next.every((value, index) => value === previous[index]) + ? previous + : next; + }).pipe(Atom.withLabel("mobile:workspace-connections")); + const isReadyAtom = input.catalogValueAtom.pipe(Atom.map((catalog) => catalog.isReady)); + const stateAtom = Atom.make((get) => + projectWorkspaceConnectionState({ + isReady: get(isReadyAtom), + networkStatus: get(input.networkStatusValueAtom), + environments: get(environmentsAtom), + }), + ).pipe(Atom.withLabel("mobile:workspace-connection-state")); + return { environmentsAtom, stateAtom, isReadyAtom }; +} diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts index 368cd0bc4683..edc253e243a6 100644 --- a/apps/mobile/src/state/workspace.ts +++ b/apps/mobile/src/state/workspace.ts @@ -2,16 +2,34 @@ import { useAtomValue } from "@effect/atom-react"; import { useMemo } from "react"; import { environmentShellSummaryAtom } from "./shell"; -import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; -import { useEnvironments } from "./environments"; +import { projectWorkspaceState } from "./workspaceModel"; +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "./presentation"; +import { createWorkspaceConnectionAtoms } from "./workspace-connection-atoms"; + +export const workspaceConnections = createWorkspaceConnectionAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + networkStatusValueAtom: environmentCatalog.networkStatusValueAtom, + presentationAtom: environmentPresentations.presentationAtom, +}); + +export function useWorkspaceEnvironments() { + return useAtomValue(workspaceConnections.environmentsAtom); +} + +export function useWorkspaceConnectionState() { + return useAtomValue(workspaceConnections.stateAtom); +} + +export function useConnectionsReady() { + return useAtomValue(workspaceConnections.isReadyAtom); +} export function useWorkspaceState() { - const { isReady, networkStatus, environments } = useEnvironments(); + const isReady = useConnectionsReady(); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const projectedEnvironments = useWorkspaceEnvironments(); const shellSummary = useAtomValue(environmentShellSummaryAtom); - const projectedEnvironments = useMemo( - () => environments.map(projectWorkspaceEnvironment), - [environments], - ); const state = useMemo( () => projectWorkspaceState({ diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts index 3070e8ae6b28..a751ca281e4d 100644 --- a/apps/mobile/src/state/workspaceModel.test.ts +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -66,14 +66,14 @@ const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = { describe("mobile workspace projection", () => { it("preserves explicit offline state without presenting it as a connection error", () => { - const projected = projectWorkspaceEnvironment(environment("offline")); + const projected = projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("offline")); expect(projected.connectionState).toBe("offline"); expect(projected.connectionError).toBeNull(); }); it("reports offline before stale connected presentations", () => { - const environments = [projectWorkspaceEnvironment(environment("connected"))]; + const environments = [projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("connected"))]; const state = projectWorkspaceState({ isReady: true, networkStatus: "offline", @@ -88,11 +88,8 @@ describe("mobile workspace projection", () => { it("projects reconnecting environments dynamically from active phases", () => { const environments = [ - projectWorkspaceEnvironment(environment("reconnecting")), - projectWorkspaceEnvironment({ - ...environment("connected"), - environmentId: EnvironmentId.make("environment-2"), - }), + projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("reconnecting")), + projectWorkspaceEnvironment(EnvironmentId.make("environment-2"), environment("connected")), ]; const state = projectWorkspaceState({ isReady: true, @@ -108,7 +105,7 @@ describe("mobile workspace projection", () => { }); it("keeps retained snapshots visible while reconnecting without claiming readiness", () => { - const environments = [projectWorkspaceEnvironment(environment("reconnecting"))]; + const environments = [projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("reconnecting"))]; const state = projectWorkspaceState({ isReady: true, networkStatus: "online", diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index 66bf6f49d964..a738cac2e468 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -1,9 +1,12 @@ import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; -import { type NetworkStatus } from "@t3tools/client-runtime/connection"; -import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; -import type { EnvironmentPresentation } from "./environments"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation, + type EnvironmentConnectionPhase, + type NetworkStatus, +} from "@t3tools/client-runtime/connection"; export interface WorkspaceEnvironment { readonly environmentId: EnvironmentId; @@ -16,29 +19,33 @@ export interface WorkspaceEnvironment { readonly connectionErrorTraceId: string | null; } -export interface WorkspaceState { +export interface WorkspaceConnectionState { readonly isLoadingConnections: boolean; readonly hasConnections: boolean; - readonly hasLoadedShellSnapshot: boolean; - readonly hasPendingShellSnapshot: boolean; readonly hasReadyEnvironment: boolean; readonly hasConnectingEnvironment: boolean; readonly connectingEnvironments: ReadonlyArray; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; + readonly networkStatus: NetworkStatus; +} + +export interface WorkspaceState extends WorkspaceConnectionState { + readonly hasLoadedShellSnapshot: boolean; + readonly hasPendingShellSnapshot: boolean; readonly shellSnapshotError: string | null; readonly latestCachedSnapshotReceivedAt: string | null; - readonly networkStatus: NetworkStatus; } export function projectWorkspaceEnvironment( + environmentId: EnvironmentId, environment: EnvironmentPresentation, ): WorkspaceEnvironment { return { - environmentId: environment.environmentId, - environmentLabel: environment.label, - displayUrl: environment.displayUrl ?? "", - isRelayManaged: environment.relayManaged, + environmentId, + environmentLabel: environment.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "", + isRelayManaged: environment.entry.target._tag === "RelayConnectionTarget", isEnabled: environment.entry.enabled, connectionState: environment.connection.phase, connectionError: environment.connection.error, @@ -77,12 +84,11 @@ function overallConnectionState( return "available"; } -export function projectWorkspaceState(input: { +export function projectWorkspaceConnectionState(input: { readonly isReady: boolean; readonly networkStatus: NetworkStatus; readonly environments: ReadonlyArray; - readonly shellSummary: EnvironmentShellSummary; -}): WorkspaceState { +}): WorkspaceConnectionState { // Switched-off environments still count as saved connections, but they do // not drive the overall connection state or surface their last error. const activeEnvironments = input.environments.filter((environment) => environment.isEnabled); @@ -95,8 +101,6 @@ export function projectWorkspaceState(input: { return { isLoadingConnections: !input.isReady, hasConnections: input.environments.length > 0, - hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, - hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, hasReadyEnvironment: input.networkStatus !== "offline" && activeEnvironments.some((environment) => environment.connectionState === "connected"), @@ -106,9 +110,22 @@ export function projectWorkspaceState(input: { connectionError: activeEnvironments.find((environment) => environment.connectionError !== null) ?.connectionError ?? null, + networkStatus: input.networkStatus, + }; +} + +export function projectWorkspaceState(input: { + readonly isReady: boolean; + readonly networkStatus: NetworkStatus; + readonly environments: ReadonlyArray; + readonly shellSummary: EnvironmentShellSummary; +}): WorkspaceState { + return { + ...projectWorkspaceConnectionState(input), + hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, + hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, shellSnapshotError: input.shellSummary.firstError, latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, - networkStatus: input.networkStatus, }; } From 66b6eea0988f7129af081f63705ecb47441fd246 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 12:51:19 -0700 Subject: [PATCH 2/2] perf(client): share narrow environment subscriptions with web --- .../src/state/workspace-connection-atoms.ts | 44 +---- apps/mobile/src/state/workspaceModel.ts | 32 +--- apps/web/src/components/Sidebar.tsx | 25 +-- ...readNotificationCoordinator.badge.test.tsx | 4 +- .../ThreadNotificationCoordinator.test.tsx | 2 +- .../ThreadNotificationCoordinator.tsx | 14 +- .../src/components/sidebar/SidebarChrome.tsx | 9 +- apps/web/src/state/environments.ts | 22 ++- apps/web/src/state/presentation.ts | 10 +- .../src/state/presentation.test.ts | 168 ++++++++++++++++++ .../client-runtime/src/state/presentation.ts | 138 +++++++++++++- 11 files changed, 361 insertions(+), 107 deletions(-) create mode 100644 packages/client-runtime/src/state/presentation.test.ts diff --git a/apps/mobile/src/state/workspace-connection-atoms.ts b/apps/mobile/src/state/workspace-connection-atoms.ts index 7cb131dccc89..4a1c24f3839a 100644 --- a/apps/mobile/src/state/workspace-connection-atoms.ts +++ b/apps/mobile/src/state/workspace-connection-atoms.ts @@ -1,14 +1,10 @@ import type { EnvironmentPresentation, NetworkStatus } from "@t3tools/client-runtime/connection"; import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; +import { createEnvironmentSummaryAtoms } from "@t3tools/client-runtime/state/presentation"; import { Atom } from "effect/unstable/reactivity"; -import { - projectWorkspaceConnectionState, - projectWorkspaceEnvironment, - type WorkspaceEnvironment, -} from "./workspaceModel"; +import { projectWorkspaceConnectionState } from "./workspaceModel"; export function createWorkspaceConnectionAtoms(input: { readonly catalogValueAtom: Atom.Atom; @@ -17,41 +13,7 @@ export function createWorkspaceConnectionAtoms(input: { environmentId: EnvironmentId, ) => Atom.Atom; }) { - const environmentAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => { - const presentation = get(input.presentationAtom(environmentId)); - if (presentation === null) return null; - const next = projectWorkspaceEnvironment(environmentId, presentation); - const previous = Option.getOrNull(get.self()); - // Provider refreshes and transport heartbeats do not change connection UI. - if ( - previous !== null && - previous.environmentId === next.environmentId && - previous.environmentLabel === next.environmentLabel && - previous.displayUrl === next.displayUrl && - previous.isRelayManaged === next.isRelayManaged && - previous.isEnabled === next.isEnabled && - previous.connectionState === next.connectionState && - previous.connectionError === next.connectionError && - previous.connectionErrorTraceId === next.connectionErrorTraceId - ) - return previous; - return next; - }), - ); - const environmentsAtom = Atom.make((get) => { - const next: Array = []; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { - const environment = get(environmentAtom(environmentId)); - if (environment !== null) next.push(environment); - } - const previous = Option.getOrNull(get.self>()); - return previous !== null && - previous.length === next.length && - next.every((value, index) => value === previous[index]) - ? previous - : next; - }).pipe(Atom.withLabel("mobile:workspace-connections")); + const { environmentsAtom } = createEnvironmentSummaryAtoms(input); const isReadyAtom = input.catalogValueAtom.pipe(Atom.map((catalog) => catalog.isReady)); const stateAtom = Atom.make((get) => projectWorkspaceConnectionState({ diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index a738cac2e468..6b03c6f8340f 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -2,22 +2,14 @@ import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shel import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; import { - connectionCatalogDisplayUrl, - type EnvironmentPresentation, type EnvironmentConnectionPhase, type NetworkStatus, } from "@t3tools/client-runtime/connection"; -export interface WorkspaceEnvironment { - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - readonly displayUrl: string; - readonly isRelayManaged: boolean; - readonly isEnabled: boolean; - readonly connectionState: EnvironmentConnectionPhase; - readonly connectionError: string | null; - readonly connectionErrorTraceId: string | null; -} +import type { EnvironmentConnectionSummary as WorkspaceEnvironment } from "@t3tools/client-runtime/state/presentation"; + +export { projectEnvironmentConnectionSummary as projectWorkspaceEnvironment } from "@t3tools/client-runtime/state/presentation"; +export type { EnvironmentConnectionSummary as WorkspaceEnvironment } from "@t3tools/client-runtime/state/presentation"; export interface WorkspaceConnectionState { readonly isLoadingConnections: boolean; @@ -37,22 +29,6 @@ export interface WorkspaceState extends WorkspaceConnectionState { readonly latestCachedSnapshotReceivedAt: string | null; } -export function projectWorkspaceEnvironment( - environmentId: EnvironmentId, - environment: EnvironmentPresentation, -): WorkspaceEnvironment { - return { - environmentId, - environmentLabel: environment.entry.target.label, - displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "", - isRelayManaged: environment.entry.target._tag === "RelayConnectionTarget", - isEnabled: environment.entry.enabled, - connectionState: environment.connection.phase, - connectionError: environment.connection.error, - connectionErrorTraceId: environment.connection.traceId, - }; -} - function overallConnectionState( environments: ReadonlyArray, networkStatus: NetworkStatus, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 33bc2de4dfb7..90aa8acf6e82 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -36,7 +36,6 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { - resolveEnvironmentMachineKind, type EnvironmentMachineKind, type ScopedThreadRef, type ThreadId, @@ -126,7 +125,11 @@ import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useEnvironmentIdentities, + useEnvironmentMachines, + usePrimaryEnvironmentId, +} from "../state/environments"; import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, @@ -2283,7 +2286,8 @@ export default function Sidebar() { () => openCommandPalette({ open: "add-project" }), [], ); - const { environments } = useEnvironments(); + const environments = useEnvironmentIdentities(); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); @@ -2323,19 +2327,7 @@ export default function Sidebar() { ), [environments], ); - const environmentMachineById = useMemo( - () => - new Map( - environments.map( - (environment) => - [ - environment.environmentId, - resolveEnvironmentMachineKind(environment.serverConfig), - ] as const, - ), - ), - [environments], - ); + const environmentMachineById = useEnvironmentMachines(); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -2372,7 +2364,6 @@ export default function Sidebar() { ); const projectGroupsRef = useRef(projectGroups); projectGroupsRef.current = projectGroups; - const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids // are driver slugs, so a flat map would collide across environments. diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx index 479d5fa9a9b7..f7f600986a0b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -23,9 +23,7 @@ vi.mock("@tanstack/react-router", () => ({ vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } })); vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } })); vi.mock("../state/environments", () => ({ - useEnvironments: () => ({ - environments: state.environmentIds.map((environmentId) => ({ environmentId })), - }), + useEnvironmentIds: () => state.environmentIds, })); vi.mock("../hooks/useSettings", () => ({ useClientSettings: ( diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 98460c13fc8d..8483b37f82c5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -101,7 +101,7 @@ vi.mock("../hooks/useSettings", () => ({ getClientSettings: () => ({ notificationMode: state.mode }), })); vi.mock("../state/environments", () => ({ - useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }), + useEnvironmentIds: () => ["env-1"], })); vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: vi.fn() }, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fe1350389150..bc0ea3ce13f4 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import { useCallback, useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; -import { useEnvironments } from "../state/environments"; +import { useEnvironmentIds } from "../state/environments"; import { environmentShell } from "../state/shell"; import { hasDesktopNotifications, @@ -19,7 +19,7 @@ import { resolveSidebarThreadStatus } from "./Sidebar.logic"; import { toastManager } from "./ui/toast"; export function ThreadNotificationCoordinator() { - const { environments } = useEnvironments(); + const environmentIds = useEnvironmentIds(); const mode = useClientSettings((settings) => settings.notificationMode); const inAppNotificationsEnabled = useClientSettings( (settings) => settings.inAppNotificationsEnabled, @@ -34,7 +34,7 @@ export function ThreadNotificationCoordinator() { }, []); useEffect(() => { - const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); + const activeIds = new Set(environmentIds); const count = pending.current.size; for (const [tag, { environmentId, notification }] of pending.current) { if (activeIds.has(environmentId)) continue; @@ -42,7 +42,7 @@ export function ThreadNotificationCoordinator() { pending.current.delete(tag); } if (count !== pending.current.size) setNotificationBadge(pending.current.size); - }, [environments]); + }, [environmentIds]); useEffect(() => { const clear = () => { @@ -73,10 +73,10 @@ export function ThreadNotificationCoordinator() { if (mode === "off" && !inAppNotificationsEnabled) return null; - return environments.map((environment) => ( + return environmentIds.map((environmentId) => ( )); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 5a332b86057f..730fdaf8ee16 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -10,7 +10,7 @@ import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-ro import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { useEnvironments } from "../../state/environments"; +import { usePullRequestsSupported } from "../../state/environments"; import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, @@ -148,12 +148,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ? "pull-requests" : null, }); - const { environments } = useEnvironments(); - // The page reads every connected server, so one of them offering pull requests is enough for - // the link to lead somewhere. - const pullRequestsSupported = environments.some( - (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, - ); + const pullRequestsSupported = usePullRequestsSupported(); const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index f085075fdd7c..5e13fd5f7474 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -9,7 +9,11 @@ import * as Option from "effect/Option"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; -import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; +import { + environmentPresentations, + environmentSummaries, + useEnvironmentPresentation, +} from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -84,3 +88,19 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } + +export function useEnvironmentIds() { + return useAtomValue(environmentSummaries.environmentIdsAtom); +} + +export function useEnvironmentIdentities() { + return useAtomValue(environmentSummaries.identitiesAtom); +} + +export function usePullRequestsSupported() { + return useAtomValue(environmentSummaries.pullRequestsSupportedAtom); +} + +export function useEnvironmentMachines() { + return useAtomValue(environmentSummaries.machineByIdAtom); +} diff --git a/apps/web/src/state/presentation.ts b/apps/web/src/state/presentation.ts index 1c2fb7b6a627..cc1cf39839fe 100644 --- a/apps/web/src/state/presentation.ts +++ b/apps/web/src/state/presentation.ts @@ -1,6 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; -import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import { + createEnvironmentPresentationAtoms, + createEnvironmentSummaryAtoms, +} from "@t3tools/client-runtime/state/presentation"; import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; @@ -13,6 +16,11 @@ export const environmentPresentations = createEnvironmentPresentationAtoms({ serverConfigValueAtom: serverEnvironment.configValueAtom, }); +export const environmentSummaries = createEnvironmentSummaryAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + presentationAtom: environmentPresentations.presentationAtom, +}); + const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( Atom.withLabel("web-environment-presentation:empty"), ); diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts new file mode 100644 index 000000000000..ea39a7e0b674 --- /dev/null +++ b/packages/client-runtime/src/state/presentation.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { AVAILABLE_CONNECTION_STATE, PrimaryConnectionTarget } from "../connection/model.ts"; +import { + createEnvironmentPresentationAtoms, + createEnvironmentSummaryAtoms, +} from "./presentation.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; + +const FIRST = EnvironmentId.make("first"); +const SECOND = EnvironmentId.make("second"); +function entry(environmentId: EnvironmentId, label = environmentId as string) { + return { + target: new PrimaryConnectionTarget({ + environmentId, + label, + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + enabled: true, + profile: Option.none(), + }; +} +function config(pullRequests = false, cwd = "/workspace"): ServerConfig { + return { + cwd, + environment: { capabilities: { pullRequests }, platform: { machine: "desktop" } }, + } as ServerConfig; +} +function harness() { + const catalog = Atom.make({ + isReady: true, + entries: new Map([ + [FIRST, entry(FIRST)], + [SECOND, entry(SECOND)], + ]), + }); + const configs = Atom.family((_id: EnvironmentId) => Atom.make(config())); + const state = Atom.make(AsyncResult.success(AVAILABLE_CONNECTION_STATE)); + const full = createEnvironmentPresentationAtoms({ + catalogValueAtom: catalog, + stateAtom: () => state, + serverConfigValueAtom: configs, + }); + const summaries = createEnvironmentSummaryAtoms({ + catalogValueAtom: catalog, + presentationAtom: full.presentationAtom, + }); + return { catalog, configs, state, full, ...summaries, registry: AtomRegistry.make() }; +} + +describe("environment summary subscriptions", () => { + it("publishes full config updates without notifying membership, labels, connections or capability consumers", () => { + const h = harness(); + h.registry.get(h.full.presentationsAtom); + h.registry.get(h.environmentIdsAtom); + h.registry.get(h.identitiesAtom); + h.registry.get(h.environmentsAtom); + h.registry.get(h.pullRequestsSupportedAtom); + h.registry.get(h.machineByIdAtom); + const counts = { full: 0, ids: 0, labels: 0, connections: 0, capability: 0, machines: 0 }; + const stops = [ + h.registry.subscribe(h.machineByIdAtom, () => counts.machines++), + h.registry.subscribe(h.full.presentationsAtom, () => counts.full++), + h.registry.subscribe(h.environmentIdsAtom, () => counts.ids++), + h.registry.subscribe(h.identitiesAtom, () => counts.labels++), + h.registry.subscribe(h.environmentsAtom, () => counts.connections++), + h.registry.subscribe(h.pullRequestsSupportedAtom, () => counts.capability++), + ]; + const initialIds = h.registry.get(h.environmentIdsAtom); + const initialLabels = h.registry.get(h.identitiesAtom); + try { + for (let i = 0; i < 20; i++) { + const next = config(false, `/workspace-${i}`); + h.registry.set(h.configs(FIRST), next); + expect(h.registry.get(h.full.presentationsAtom).get(FIRST)?.serverConfig).toBe(next); + expect(h.registry.get(h.environmentIdsAtom)).toBe(initialIds); + expect(h.registry.get(h.identitiesAtom)).toBe(initialLabels); + h.registry.get(h.environmentsAtom); + expect(h.registry.get(h.machineByIdAtom).get(FIRST)).toBe("desktop"); + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(false); + } + expect(counts).toEqual({ + full: 20, + ids: 0, + labels: 0, + connections: 0, + capability: 0, + machines: 0, + }); + h.registry.set(h.catalog, { + isReady: true, + entries: new Map([ + [FIRST, entry(FIRST, "Renamed")], + [SECOND, entry(SECOND)], + ]), + }); + expect(h.registry.get(h.identitiesAtom)[0]?.label).toBe("Renamed"); + expect(h.registry.get(h.environmentIdsAtom)).toBe(initialIds); + expect(h.registry.get(h.environmentsAtom)[0]?.environmentLabel).toBe("Renamed"); + expect(counts.labels).toBe(1); + expect(counts.ids).toBe(0); + } finally { + stops.forEach((stop) => stop()); + h.registry.dispose(); + } + }); + + it("updates machine icons and preserves cached icons for disabled environments", () => { + const h = harness(); + try { + expect(h.registry.get(h.machineByIdAtom).get(FIRST)).toBe("desktop"); + h.registry.set(h.configs(FIRST), { + ...config(), + settings: { environmentIcon: "laptop" }, + } as ServerConfig); + expect(h.registry.get(h.machineByIdAtom).get(FIRST)).toBe("laptop"); + h.registry.set(h.catalog, { + isReady: true, + entries: new Map([[FIRST, { ...entry(FIRST), enabled: false }]]), + }); + expect(h.registry.get(h.machineByIdAtom)).toEqual(new Map([[FIRST, "laptop"]])); + h.registry.set(h.configs(FIRST), null); + expect(h.registry.get(h.machineByIdAtom).get(FIRST)).toBe("server"); + } finally { + h.registry.dispose(); + } + }); + + it("tracks capabilities across environments, config loss and removal", () => { + const h = harness(); + const stop = h.registry.subscribe(h.pullRequestsSupportedAtom, () => {}); + try { + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(false); + h.registry.set(h.configs(FIRST), config(true)); + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(true); + // The first true result short-circuits: the second must be read when the first stops supporting it. + h.registry.set(h.configs(SECOND), config(true)); + h.registry.set(h.configs(FIRST), null); + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(true); + h.registry.set(h.catalog, { isReady: true, entries: new Map([[FIRST, entry(FIRST)]]) }); + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(false); + expect(h.registry.get(h.environmentIdsAtom)).toEqual([FIRST]); + expect(h.registry.get(h.identitiesAtom)).toEqual([{ environmentId: FIRST, label: "first" }]); + h.registry.set(h.catalog, { + isReady: true, + entries: new Map([ + [SECOND, entry(SECOND)], + [FIRST, entry(FIRST)], + ]), + }); + expect(h.registry.get(h.environmentIdsAtom)).toEqual([SECOND, FIRST]); + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(true); + h.registry.set(h.configs(SECOND), config(false)); + expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(false); + h.registry.set(h.catalog, { isReady: true, entries: new Map() }); + expect(h.registry.get(h.environmentIdsAtom)).toEqual([]); + expect(h.registry.get(h.identitiesAtom)).toEqual([]); + expect(h.registry.get(h.environmentsAtom)).toEqual([]); + } finally { + stop(); + h.registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 302be9f7cc13..c51a91d3b89a 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -1,10 +1,17 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { + resolveEnvironmentMachineKind, + type EnvironmentMachineKind, + type EnvironmentId, + type ServerConfig, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; import { + connectionCatalogDisplayUrl, presentEnvironmentConnection, + type EnvironmentConnectionPhase, type EnvironmentPresentation, } from "../connection/presentation.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -71,3 +78,132 @@ export function createEnvironmentPresentationAtoms(input: { presentationsAtom, }; } + +export interface EnvironmentConnectionSummary { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly displayUrl: string; + readonly isRelayManaged: boolean; + readonly isEnabled: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; +} + +export function projectEnvironmentConnectionSummary( + environmentId: EnvironmentId, + environment: EnvironmentPresentation, +): EnvironmentConnectionSummary { + return { + environmentId, + environmentLabel: environment.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "", + isRelayManaged: environment.entry.target._tag === "RelayConnectionTarget", + isEnabled: environment.entry.enabled, + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + }; +} + +// Keep list membership and connection chrome independent of provider/config refreshes. +export function createEnvironmentSummaryAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly presentationAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentIdsAtom = Atom.make((get) => { + const next = [...get(input.catalogValueAtom).entries.keys()]; + const previous = Option.getOrNull(get.self>()); + return previous !== null && + previous.length === next.length && + next.every((id, index) => id === previous[index]) + ? previous + : next; + }); + const identitiesAtom = Atom.make((get) => { + const next = [...get(input.catalogValueAtom).entries].map(([environmentId, entry]) => ({ + environmentId, + label: entry.target.label, + })); + const previous = Option.getOrNull( + get.self>(), + ); + return previous !== null && + previous.length === next.length && + next.every( + (value, index) => + value.environmentId === previous[index]?.environmentId && + value.label === previous[index]?.label, + ) + ? previous + : next; + }); + const environmentAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const presentation = get(input.presentationAtom(environmentId)); + if (presentation === null) return null; + const next = projectEnvironmentConnectionSummary(environmentId, presentation); + const previous = Option.getOrNull(get.self()); + // Provider refreshes and transport heartbeats do not change connection UI. + if ( + previous !== null && + previous.environmentId === next.environmentId && + previous.environmentLabel === next.environmentLabel && + previous.displayUrl === next.displayUrl && + previous.isRelayManaged === next.isRelayManaged && + previous.isEnabled === next.isEnabled && + previous.connectionState === next.connectionState && + previous.connectionError === next.connectionError && + previous.connectionErrorTraceId === next.connectionErrorTraceId + ) + return previous; + return next; + }), + ); + const environmentsAtom = Atom.make((get) => { + const next: Array = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const environment = get(environmentAtom(environmentId)); + if (environment !== null) next.push(environment); + } + const previous = Option.getOrNull(get.self>()); + return previous !== null && + previous.length === next.length && + next.every((value, index) => value === previous[index]) + ? previous + : next; + }).pipe(Atom.withLabel("environment-connection-summaries")); + const machineByIdAtom = Atom.make((get) => { + const next = new Map( + get(environmentIdsAtom).map( + (environmentId) => + [ + environmentId, + resolveEnvironmentMachineKind( + get(input.presentationAtom(environmentId))?.serverConfig ?? null, + ), + ] as const, + ), + ); + const previous = Option.getOrNull( + get.self>(), + ); + return previous !== null && mapsEqual(previous, next) ? previous : next; + }); + const pullRequestsSupportedAtom = Atom.make((get) => + get(environmentIdsAtom).some( + (environmentId) => + get(input.presentationAtom(environmentId))?.serverConfig?.environment.capabilities + .pullRequests === true, + ), + ); + return { + environmentIdsAtom, + identitiesAtom, + environmentsAtom, + machineByIdAtom, + pullRequestsSupportedAtom, + }; +}