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, }; }