From 4caaf973c086b4ac49f40f2fb0acaa0ab31b4c60 Mon Sep 17 00:00:00 2001 From: tris203 Date: Tue, 13 Oct 2026 14:43:01 +0000 Subject: [PATCH] fix(mobile): asleep environments no longer take over the home header The home header swapped the brand for "Reconnecting to " while any enabled environment was connecting. The supervisor retries forever, so one sleeping machine kept that label up indefinitely even with another environment connected and usable. The header now reports connection state only when nothing is usable, says "unreachable" once an attempt has failed, and reports thread sync only for connected environments. Rows of an unreachable environment swap their machine glyph for a wifi-slash glyph instead. --- .../src/features/home/HomeRouteScreen.tsx | 4 +- apps/mobile/src/features/home/HomeScreen.tsx | 6 ++ .../home/workspace-connection-status.test.ts | 90 ++++++++++++++++++- .../home/workspace-connection-status.ts | 54 ++++++++--- .../threads/ThreadNavigationSidebar.tsx | 6 +- .../features/threads/thread-list-v2-items.tsx | 57 ++++++++++-- .../state/workspace-connection-atoms.test.ts | 1 + apps/mobile/src/state/workspace.ts | 35 +++++++- apps/mobile/src/state/workspaceModel.test.ts | 27 +++++- apps/mobile/src/state/workspaceModel.ts | 25 ++++++ 10 files changed, 275 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index e15b861b341a9..d9598a184879a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -14,7 +14,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; -import { useWorkspaceState } from "../../state/workspace"; +import { useUnreachableEnvironmentIds, useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; @@ -116,6 +116,7 @@ export function HomeRouteScreen() { Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), ); }, [savedConnectionsById, workspaceEnvironments]); + const unreachableIds = useUnreachableEnvironmentIds(); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], @@ -254,6 +255,7 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e90fc1672bc7d..9b4a480edf5c1 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -97,6 +97,7 @@ interface HomeScreenProps { readonly environments: ReadonlyArray< HomeListFilterMenuEnvironment & Pick >; + readonly unreachableEnvironmentIds: ReadonlySet; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -587,6 +588,9 @@ export function HomeScreen(props: HomeScreenProps) { : null } environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} + environmentUnreachable={props.unreachableEnvironmentIds.has( + item.pendingTask.environmentId, + )} showPendingDivider={item.showPendingDivider} showTrailingDivider={item.showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} @@ -652,6 +656,7 @@ export function HomeScreen(props: HomeScreenProps) { : null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} + environmentUnreachable={props.unreachableEnvironmentIds.has(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, @@ -703,6 +708,7 @@ export function HomeScreen(props: HomeScreenProps) { projectTitleByProjectKey: v2ProjectTitleByProjectKey, listEnvironments, savedConnectionsById: props.savedConnectionsById, + unreachableEnvironmentIds: props.unreachableEnvironmentIds, searchQuery: props.searchQuery, selectedThreadKey, threadSearchMatchByKey, diff --git a/apps/mobile/src/features/home/workspace-connection-status.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts index 1d7239fc018f7..ffa735f2cbe40 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -9,6 +9,7 @@ function workspaceState(overrides: Partial = {}): WorkspaceState hasConnections: true, hasLoadedShellSnapshot: true, hasPendingShellSnapshot: false, + hasSyncingConnectedEnvironment: false, hasReadyEnvironment: true, hasConnectingEnvironment: false, connectingEnvironments: [], @@ -20,6 +21,22 @@ function workspaceState(overrides: Partial = {}): WorkspaceState }; } +function environment( + overrides: Partial = {}, +): WorkspaceState["connectingEnvironments"][number] { + return { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + isEnabled: true, + connectionState: "reconnecting", + connectionError: null, + connectionErrorTraceId: null, + ...overrides, + }; +} + describe("workspace connection status", () => { it("stays hidden while a ready environment is connected", () => { expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); @@ -58,6 +75,73 @@ describe("workspace connection status", () => { }); }); + it("stays hidden while another environment is connected", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + connectingEnvironments: [environment({ connectionError: "Timed out" })], + connectionError: "Timed out", + }); + + expect(workspaceConnectionStatusPresentation(state)).toBeNull(); + }); + + it("ignores the pending shell of an environment that is only being retried", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasPendingShellSnapshot: true, + connectingEnvironments: [environment({ connectionError: "Timed out" })], + connectionError: "Timed out", + }); + + expect(workspaceConnectionStatusPresentation(state)).toBeNull(); + }); + + it("reports sync of a connected environment while another is unreachable", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasPendingShellSnapshot: true, + hasSyncingConnectedEnvironment: true, + connectingEnvironments: [environment({ connectionError: "Timed out" })], + connectionError: "Timed out", + }); + + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Syncing threads...", + showsProgress: true, + }); + }); + + it("stops promising a reconnect once an attempt has failed", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [environment({ connectionError: "Timed out" })], + connectionError: "Timed out", + }); + + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Julius’s Mac mini is unreachable", + showsProgress: false, + }); + }); + + it("keeps reconnecting while any environment is still on a first attempt", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + environment({ connectionError: "Timed out" }), + environment({ environmentId: "environment-2" as never, connectionState: "connecting" }), + ], + connectionError: "Timed out", + }); + + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Reconnecting 2 environments", + showsProgress: true, + }); + }); + it("surfaces connection errors before the generic disconnected fallback", () => { const state = workspaceState({ connectionError: "Could not reach Julius’s Mac mini", @@ -72,7 +156,10 @@ describe("workspace connection status", () => { }); it("shows shell catch-up while cached threads remain visible", () => { - const state = workspaceState({ hasPendingShellSnapshot: true }); + const state = workspaceState({ + hasPendingShellSnapshot: true, + hasSyncingConnectedEnvironment: true, + }); expect(workspaceConnectionStatusPresentation(state)).toEqual({ label: "Syncing threads...", @@ -84,6 +171,7 @@ describe("workspace connection status", () => { const state = workspaceState({ hasLoadedShellSnapshot: false, hasPendingShellSnapshot: true, + hasSyncingConnectedEnvironment: true, }); expect(workspaceConnectionStatusPresentation(state)).toEqual({ diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 88ce6a3bde1b3..c89d6cb283194 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -1,4 +1,4 @@ -import type { WorkspaceState } from "../../state/workspaceModel"; +import { environmentIsUnreachable, type WorkspaceState } from "../../state/workspaceModel"; export interface WorkspaceConnectionStatusPresentation { readonly label: string; @@ -6,39 +6,65 @@ export interface WorkspaceConnectionStatusPresentation { } function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { + if (state.networkStatus === "offline") return true; + // One connected environment keeps the app usable, so another that is asleep + // or unreachable is reported on its own rows instead of taking the title. + // Its retries also mark its shell as pending, so only a connected + // environment's sync counts here. + if (state.hasReadyEnvironment) return state.hasSyncingConnectedEnvironment; return ( - state.networkStatus === "offline" || + state.hasPendingShellSnapshot || state.connectionError !== null || state.hasConnectingEnvironment || - state.hasPendingShellSnapshot || - (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) + state.hasLoadedShellSnapshot ); } +function connectingEnvironmentsLabel( + environments: WorkspaceState["connectingEnvironments"], +): string | null { + if (environments.length === 0) return null; + // After a failed attempt the retries continue in the background, possibly + // for hours, so the label stops promising a reconnect. + const unreachable = environments.every(environmentIsUnreachable); + if (environments.length === 1) { + const label = environments[0]!.environmentLabel; + return unreachable ? `${label} is unreachable` : `Reconnecting to ${label}`; + } + return unreachable + ? `${environments.length} environments unreachable` + : `Reconnecting ${environments.length} environments`; +} + function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; - if (state.connectingEnvironments.length === 1) { - return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; + if (!state.hasReadyEnvironment) { + const connectingLabel = connectingEnvironmentsLabel(state.connectingEnvironments); + if (connectingLabel !== null) return connectingLabel; + if (state.connectionError !== null) return state.connectionError; } - if (state.connectingEnvironments.length > 1) { - return `Reconnecting ${state.connectingEnvironments.length} environments`; - } - if (state.connectionError !== null) return state.connectionError; if (state.hasPendingShellSnapshot) { return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads..."; } return "Not connected"; } +function showsConnectionProgress(state: WorkspaceState): boolean { + if (state.networkStatus === "offline") return false; + // Only shell sync reaches the title while an environment is connected. + if (state.hasReadyEnvironment) return true; + if (state.connectingEnvironments.length > 0) { + return !state.connectingEnvironments.every(environmentIsUnreachable); + } + return state.connectionError === null && state.hasPendingShellSnapshot; +} + export function workspaceConnectionStatusPresentation( state: WorkspaceState, ): WorkspaceConnectionStatusPresentation | null { if (!shouldShowWorkspaceConnectionStatus(state)) return null; return { label: workspaceConnectionStatusLabel(state), - showsProgress: - state.networkStatus !== "offline" && - state.connectionError === null && - (state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot), + showsProgress: showsConnectionProgress(state), }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d9cf141d2f98e..11bcee7c2316b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -35,7 +35,7 @@ import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; -import { useWorkspaceState } from "../../state/workspace"; +import { useUnreachableEnvironmentIds, useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; @@ -162,6 +162,7 @@ function ThreadNavigationSidebarPane( label: connection.environmentLabel, })) .sort((left, right) => left.label.localeCompare(right.label)); + const unreachableIds = useUnreachableEnvironmentIds(); const availableEnvironmentIds = new Set( environments.map((environment) => environment.environmentId), ); @@ -516,6 +517,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectTitleByProjectKey, savedConnectionsById, + unreachableIds, listEnvironments, threadSearchMatchByKey, workingShelfEnabled, @@ -566,6 +568,7 @@ function ThreadNavigationSidebarPane( : null } environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} + environmentUnreachable={unreachableIds.has(item.pendingTask.environmentId)} pane="sidebar" showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} @@ -597,6 +600,7 @@ function ThreadNavigationSidebarPane( : null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} + environmentUnreachable={unreachableIds.has(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index f49e96381d789..1ace0dc41082c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -306,6 +306,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly projectTitle?: string; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; + /** Swaps the machine glyph for an unreachable mark. */ + readonly environmentUnreachable?: boolean; readonly pane?: "screen" | "sidebar"; readonly showPendingDivider: boolean; readonly showTrailingDivider?: boolean; @@ -409,7 +411,17 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props ) : null} - {props.environmentLabel && props.environmentMachine ? ( + {props.environmentLabel && props.environmentUnreachable ? ( + + ) : props.environmentLabel && props.environmentMachine ? ( ) : null} - {props.environmentLabel && props.environmentMachine ? ( + {props.environmentLabel && props.environmentUnreachable ? ( + + ) : props.environmentLabel && props.environmentMachine ? ( part !== null) + .join(", "); + const rowContent = (close: () => void) => variant === "card" ? ( { networkStatus: get(h.network), environments: [projectWorkspaceEnvironment(ID, get(h.source)!)], shellSummary: get(shell), + hasSyncingConnectedEnvironment: false, }), ); const initial = h.registry.get(h.stateAtom); diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts index edc253e243a6f..790529972afa3 100644 --- a/apps/mobile/src/state/workspace.ts +++ b/apps/mobile/src/state/workspace.ts @@ -1,8 +1,11 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useMemo } from "react"; -import { environmentShellSummaryAtom } from "./shell"; -import { projectWorkspaceState } from "./workspaceModel"; +import { Atom } from "effect/reactivity"; + +import { environmentShell, environmentShellSummaryAtom } from "./shell"; +import { environmentIsUnreachable, projectWorkspaceState } from "./workspaceModel"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations } from "./presentation"; import { createWorkspaceConnectionAtoms } from "./workspace-connection-atoms"; @@ -13,10 +16,34 @@ export const workspaceConnections = createWorkspaceConnectionAtoms({ presentationAtom: environmentPresentations.presentationAtom, }); +const hasSyncingConnectedEnvironmentAtom = Atom.make((get) => + get(workspaceConnections.environmentsAtom).some( + (environment) => + environment.isEnabled && + environment.connectionState === "connected" && + get(environmentShell.stateValueAtom(environment.environmentId)).status === "synchronizing", + ), +).pipe(Atom.withLabel("mobile:has-syncing-connected-environment")); + export function useWorkspaceEnvironments() { return useAtomValue(workspaceConnections.environmentsAtom); } +/** + * Environments whose rows are marked unreachable. The set keeps its identity + * until membership changes, so background retries do not invalidate list rows. + */ +export function useUnreachableEnvironmentIds(): ReadonlySet { + const key = useWorkspaceEnvironments() + .filter(environmentIsUnreachable) + .map((environment) => environment.environmentId) + .join("\n"); + return useMemo( + () => new Set(key === "" ? [] : key.split("\n").map((id) => EnvironmentId.make(id))), + [key], + ); +} + export function useWorkspaceConnectionState() { return useAtomValue(workspaceConnections.stateAtom); } @@ -30,6 +57,7 @@ export function useWorkspaceState() { const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); const projectedEnvironments = useWorkspaceEnvironments(); const shellSummary = useAtomValue(environmentShellSummaryAtom); + const hasSyncingConnectedEnvironment = useAtomValue(hasSyncingConnectedEnvironmentAtom); const state = useMemo( () => projectWorkspaceState({ @@ -37,8 +65,9 @@ export function useWorkspaceState() { networkStatus, environments: projectedEnvironments, shellSummary, + hasSyncingConnectedEnvironment, }), - [isReady, networkStatus, projectedEnvironments, shellSummary], + [isReady, networkStatus, projectedEnvironments, shellSummary, hasSyncingConnectedEnvironment], ); return { diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts index 60bc6830321f0..5842eb1624db6 100644 --- a/apps/mobile/src/state/workspaceModel.test.ts +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -7,7 +7,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import { + environmentIsUnreachable, + projectWorkspaceEnvironment, + projectWorkspaceState, +} from "./workspaceModel"; import type { EnvironmentPresentation } from "./environments"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); @@ -77,6 +81,7 @@ describe("mobile workspace projection", () => { networkStatus: "offline", environments, shellSummary: EMPTY_SHELL_SUMMARY, + hasSyncingConnectedEnvironment: false, }); expect(state.connectionState).toBe("offline"); @@ -94,6 +99,7 @@ describe("mobile workspace projection", () => { networkStatus: "online", environments, shellSummary: EMPTY_SHELL_SUMMARY, + hasSyncingConnectedEnvironment: false, }); expect(state.connectingEnvironments).toHaveLength(1); @@ -109,6 +115,7 @@ describe("mobile workspace projection", () => { networkStatus: "online", environments, shellSummary: CACHED_SHELL_SUMMARY, + hasSyncingConnectedEnvironment: false, }); expect(state.hasLoadedShellSnapshot).toBe(true); @@ -117,3 +124,21 @@ describe("mobile workspace projection", () => { expect(state.connectionState).toBe("reconnecting"); }); }); + +describe("environmentIsUnreachable", () => { + const failed = { + isEnabled: true, + connectionState: "reconnecting", + connectionError: "Timed out", + } as const; + + it("counts an environment still retrying after a failed attempt", () => { + expect(environmentIsUnreachable(failed)).toBe(true); + expect(environmentIsUnreachable({ ...failed, connectionState: "error" })).toBe(true); + }); + + it("ignores a first attempt and switched-off environments", () => { + expect(environmentIsUnreachable({ ...failed, connectionError: null })).toBe(false); + expect(environmentIsUnreachable({ ...failed, isEnabled: false })).toBe(false); + }); +}); diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index 5584a06a8c769..80112ca4c57ea 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -25,9 +25,32 @@ export interface WorkspaceConnectionState { export interface WorkspaceState extends WorkspaceConnectionState { readonly hasLoadedShellSnapshot: boolean; readonly hasPendingShellSnapshot: boolean; + /** A connected environment is still catching up its thread list. Unlike + hasPendingShellSnapshot, a background connection retry does not count. */ + readonly hasSyncingConnectedEnvironment: boolean; readonly shellSnapshotError: string | null; } +/** + * True once an enabled environment has failed to connect and is only being + * retried in the background, such as a laptop that is asleep. A first attempt + * still in flight does not count. + */ +export function environmentIsUnreachable( + environment: Pick, +): boolean { + if (!environment.isEnabled) return false; + switch (environment.connectionState) { + case "error": + return true; + case "connecting": + case "reconnecting": + return environment.connectionError !== null; + default: + return false; + } +} + function overallConnectionState( environments: ReadonlyArray, networkStatus: NetworkStatus, @@ -92,11 +115,13 @@ export function projectWorkspaceState(input: { readonly networkStatus: NetworkStatus; readonly environments: ReadonlyArray; readonly shellSummary: EnvironmentShellSummary; + readonly hasSyncingConnectedEnvironment: boolean; }): WorkspaceState { return { ...projectWorkspaceConnectionState(input), hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, + hasSyncingConnectedEnvironment: input.hasSyncingConnectedEnvironment, shellSnapshotError: input.shellSummary.firstError, }; }