diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 4265107912b..a5d6fc7e8af 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -25,6 +25,13 @@ import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; +import { useSettledWorkspaceSyncTone } from "./use-settled-workspace-sync-tone"; +import { + WorkspaceSyncStatusButton, + workspaceSyncToneSymbol, +} from "./WorkspaceSyncStatusButton"; +import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; +import type { WorkspaceState } from "../../state/workspaceModel"; export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; @@ -43,6 +50,9 @@ export function HomeHeader(props: { readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; + /** Workspace connection/sync state — surfaced as the header sync button. */ + readonly catalogState: WorkspaceState; + readonly onOpenEnvironments: () => void; }) { if (Platform.OS === "android") { return ; @@ -212,6 +222,14 @@ function AndroidHomeHeader(props: HomeHeaderProps) { + {/* Sync/connection state — moved out of the thread list's scroll + view, where mounting and unmounting a list header row made it + flash and reflowed every row below it. */} + + [ + ...(syncSymbol === null + ? [] + : [ + withNativeGlassHeaderItem({ + accessibilityLabel: syncLabel, + icon: { name: syncSymbol, type: "sfSymbol" } as const, + identifier: "home-sync-status", + label: "", + onPress: props.onOpenEnvironments, + type: "button" as const, + }), + ]), withNativeGlassHeaderItem({ accessibilityLabel: "Open settings", icon: { name: "ellipsis", type: "sfSymbol" } as const, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 62f6e324602..c12c9816213 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -118,6 +118,10 @@ export function HomeRouteScreen() { {/* Restore the compact title in case the split branch blanked it. */} + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } environments={environments} projects={projectFilterOptions} searchQuery={searchQuery} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3c2ee4f7b92..9d158cc1264 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -862,15 +862,6 @@ export function HomeScreen(props: HomeScreenProps) { catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; if (!hasAnyThreads) { return ( @@ -894,7 +885,11 @@ export function HomeScreen(props: HomeScreenProps) { ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( + {/* Kept inline here (unlike the thread list, where this moved to the + header sync button): with no threads on screen there is nothing + else to explain why the page is empty, and nothing below it to + reflow when it appears. */} + {shouldShowConnectionStatus ? ( ) : null} - {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } - - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - - ); + // Connection/sync state is reported by the header's sync button + // (WorkspaceSyncStatusButton), not from inside this scroll view: mounting and + // unmounting it as row 0 flashed the indicator and reflowed every row below + // it each time the (inherently bouncy) sync signal flipped. + const listHeader = <>{Platform.OS === "ios" ? null : }; // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). @@ -1024,7 +1008,6 @@ export function HomeScreen(props: HomeScreenProps) { }} /> - {connectionStatus} ); } @@ -1076,7 +1059,6 @@ export function HomeScreen(props: HomeScreenProps) { } /> - {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts index 8c3c873cc9e..09bd6ad61c6 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import type { WorkspaceState } from "../../state/workspaceModel"; import { + isTransientWorkspaceSyncTone, shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceSyncTone, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -85,3 +87,61 @@ describe("workspace connection status", () => { expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); }); }); + +describe("workspace sync tone", () => { + it("is idle while a ready environment is connected", () => { + expect(workspaceSyncTone(workspaceState())).toBe("idle"); + }); + + it("reports offline ahead of every other state", () => { + const state = workspaceState({ + networkStatus: "offline", + connectionError: "Could not reach Julius’s Mac mini", + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + }); + + expect(workspaceSyncTone(state)).toBe("offline"); + }); + + it("reports a connection error ahead of in-progress work", () => { + const state = workspaceState({ + connectionError: "Could not reach Julius’s Mac mini", + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + }); + + expect(workspaceSyncTone(state)).toBe("error"); + }); + + it("reports connecting ahead of shell sync", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasPendingShellSnapshot: true, + hasReadyEnvironment: false, + }); + + expect(workspaceSyncTone(state)).toBe("connecting"); + }); + + it("reports shell catch-up as syncing", () => { + expect(workspaceSyncTone(workspaceState({ hasPendingShellSnapshot: true }))).toBe("syncing"); + }); + + it("falls back to disconnected once a snapshot loaded without a ready environment", () => { + const state = workspaceState({ hasReadyEnvironment: false }); + + expect(workspaceSyncTone(state)).toBe("disconnected"); + }); + + // Only these two are driven by the bouncy shell-sync signal, so only these + // get settled before reaching the header (useSettledWorkspaceSyncTone). + it("treats only connecting and syncing as transient", () => { + expect(isTransientWorkspaceSyncTone("connecting")).toBe(true); + expect(isTransientWorkspaceSyncTone("syncing")).toBe(true); + expect(isTransientWorkspaceSyncTone("offline")).toBe(false); + expect(isTransientWorkspaceSyncTone("error")).toBe(false); + expect(isTransientWorkspaceSyncTone("disconnected")).toBe(false); + expect(isTransientWorkspaceSyncTone("idle")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx b/apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx new file mode 100644 index 00000000000..7b9f14f90c1 --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx @@ -0,0 +1,96 @@ +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { SymbolView, type SFSymbol } from "../../components/AppSymbol"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { useSettledWorkspaceSyncTone } from "./use-settled-workspace-sync-tone"; +import { + workspaceConnectionStatusLabel, + type WorkspaceSyncTone, +} from "./workspace-connection-status"; + +/** + * SF Symbol for a settled tone, or null when the tone is shown as a spinner + * instead. Exported so the iOS native header-item builders (which can only + * carry an icon name, not a React child) can style their own button the same way. + */ +export function workspaceSyncToneSymbol(tone: WorkspaceSyncTone): SFSymbol | null { + switch (tone) { + case "offline": + return "wifi.slash"; + case "error": + case "disconnected": + return "exclamationmark.triangle"; + case "connecting": + case "syncing": + // Rendered as an ActivityIndicator in React headers; native header items + // fall back to this icon since they can't host a spinner. + return "arrow.clockwise"; + case "idle": + return null; + } +} + +/** + * Header button reporting workspace connection / thread-sync state. + * + * Replaces the old inline WorkspaceConnectionStatus row that lived inside the + * thread list's scroll view, where every sync-state flip mounted and unmounted + * a list header — flashing the indicator and reflowing every row beneath it. + * Here the button's slot is always occupied (idle renders an empty box of the + * same size), so state changes swap the icon without moving anything, and the + * tone itself is settled first (see useSettledWorkspaceSyncTone) so the + * inherently bouncy sync signal can't strobe the header. + */ +export function WorkspaceSyncStatusButton(props: { + readonly state: WorkspaceState; + readonly onPress: () => void; + /** + * "home" matches the Android home header's circular 44pt buttons; + * "sidebar-grouped" matches the split-view sidebar's shared capsule group. + */ + readonly variant?: "home" | "sidebar-grouped"; +}) { + const tone = useSettledWorkspaceSyncTone(props.state); + const iconColor = useThemeColor("--color-icon"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const variant = props.variant ?? "home"; + const isGrouped = variant === "sidebar-grouped"; + const sizeClassName = isGrouped ? "h-11 w-[50px] rounded-[22px]" : "size-11 rounded-full"; + const symbol = workspaceSyncToneSymbol(tone); + const isBusy = tone === "connecting" || tone === "syncing"; + + // Idle keeps the slot — an empty box the exact size of the button — so the + // neighbouring header controls never shift when sync state changes. + if (tone === "idle") { + return ; + } + + const label = workspaceConnectionStatusLabel(props.state); + + return ( + (pressed ? { backgroundColor: pressedBackgroundColor } : undefined)} + > + {isBusy ? ( + + ) : symbol ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/home/use-settled-workspace-sync-tone.test.ts b/apps/mobile/src/features/home/use-settled-workspace-sync-tone.test.ts new file mode 100644 index 00000000000..ca4bdf05eb3 --- /dev/null +++ b/apps/mobile/src/features/home/use-settled-workspace-sync-tone.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + planWorkspaceSyncToneSettlement, + SYNC_TONE_ENTER_DELAY_MS, +} from "./use-settled-workspace-sync-tone"; + +describe("workspace sync tone settlement", () => { + it("delays a newly-started sync instead of showing it immediately", () => { + // The sub-second sync blips a healthy connection produces never survive + // this delay, which is the whole point — they used to strobe the indicator. + expect( + planWorkspaceSyncToneSettlement({ + rawTone: "syncing", + settledTone: "idle", + holdUntil: 0, + now: 1_000, + }), + ).toEqual({ kind: "schedule", tone: "syncing", delayMs: SYNC_TONE_ENTER_DELAY_MS }); + }); + + it("does nothing while the shown tone already matches", () => { + expect( + planWorkspaceSyncToneSettlement({ + rawTone: "syncing", + settledTone: "syncing", + holdUntil: 5_000, + now: 1_000, + }), + ).toEqual({ kind: "hold" }); + }); + + it("surfaces a real fault immediately, even over a held spinner", () => { + expect( + planWorkspaceSyncToneSettlement({ + rawTone: "offline", + settledTone: "syncing", + holdUntil: 5_000, + now: 1_000, + }), + ).toEqual({ kind: "apply", tone: "offline" }); + }); + + it("clears to idle once the minimum-visible hold has elapsed", () => { + expect( + planWorkspaceSyncToneSettlement({ + rawTone: "idle", + settledTone: "syncing", + holdUntil: 900, + now: 1_000, + }), + ).toEqual({ kind: "apply", tone: "idle" }); + }); + + it("defers clearing to idle for the remainder of the hold", () => { + // Shown at t=1000 with a 900ms floor; a sync that resolves at t=1200 must + // still leave the spinner up for the remaining 700ms rather than blinking. + expect( + planWorkspaceSyncToneSettlement({ + rawTone: "idle", + settledTone: "syncing", + holdUntil: 1_900, + now: 1_200, + }), + ).toEqual({ kind: "schedule", tone: "idle", delayMs: 700 }); + }); + + it("re-delays when swapping between two transient tones", () => { + expect( + planWorkspaceSyncToneSettlement({ + rawTone: "syncing", + settledTone: "connecting", + holdUntil: 0, + now: 1_000, + }), + ).toEqual({ kind: "schedule", tone: "syncing", delayMs: SYNC_TONE_ENTER_DELAY_MS }); + }); +}); diff --git a/apps/mobile/src/features/home/use-settled-workspace-sync-tone.ts b/apps/mobile/src/features/home/use-settled-workspace-sync-tone.ts new file mode 100644 index 00000000000..96fb56904af --- /dev/null +++ b/apps/mobile/src/features/home/use-settled-workspace-sync-tone.ts @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; + +import type { WorkspaceState } from "../../state/workspaceModel"; +import { + isTransientWorkspaceSyncTone, + workspaceSyncTone, + type WorkspaceSyncTone, +} from "./workspace-connection-status"; + +/** + * How long a "connecting"/"syncing" tone must persist before it is shown. + * + * The underlying shell-sync signal re-enters "synchronizing" on every + * websocket resubscribe and retries expected failures every 250ms, so a + * healthy connection still produces a stream of sub-second sync blips. Longer + * than that retry cycle, so a reconnect that resolves on its own never reaches + * the header at all. + */ +export const SYNC_TONE_ENTER_DELAY_MS = 400; + +/** + * Once a transient tone is actually shown, hold it at least this long. + * Without a floor, a sync that resolves immediately after crossing + * SYNC_TONE_ENTER_DELAY_MS would appear and vanish within a frame or two — the + * flicker this exists to prevent, just moved later. + */ +export const SYNC_TONE_MIN_VISIBLE_MS = 900; + +export type SyncToneSettlement = + /** Show this tone now. */ + | { readonly kind: "apply"; readonly tone: WorkspaceSyncTone } + /** Nothing to change and nothing to schedule. */ + | { readonly kind: "hold" } + /** Show this tone, but only if it is still current after `delayMs`. */ + | { readonly kind: "schedule"; readonly tone: WorkspaceSyncTone; readonly delayMs: number }; + +/** + * Pure settling decision behind useSettledWorkspaceSyncTone. + * + * Transient tones must persist before they appear and linger before they + * leave; tones that report a real problem (offline, connection error, no ready + * environment) bypass both delays, since suppressing those would mean hiding a + * fault the user needs to see. + */ +export function planWorkspaceSyncToneSettlement(input: { + readonly rawTone: WorkspaceSyncTone; + readonly settledTone: WorkspaceSyncTone; + /** Timestamp (ms) before which a shown transient tone must not be cleared. */ + readonly holdUntil: number; + readonly now: number; +}): SyncToneSettlement { + if (!isTransientWorkspaceSyncTone(input.rawTone)) { + // A real fault: show it immediately, even over a held spinner. + if (input.rawTone !== "idle") { + return { kind: "apply", tone: input.rawTone }; + } + + // Back to idle, but respect an in-flight minimum-visible hold so a + // just-shown spinner isn't yanked away mid-blink. + const remainingHold = input.holdUntil - input.now; + return remainingHold <= 0 + ? { kind: "apply", tone: "idle" } + : { kind: "schedule", tone: "idle", delayMs: remainingHold }; + } + + // Already showing this exact transient tone — nothing to schedule. + if (input.settledTone === input.rawTone) { + return { kind: "hold" }; + } + + return { kind: "schedule", tone: input.rawTone, delayMs: SYNC_TONE_ENTER_DELAY_MS }; +} + +/** + * Settles the raw workspace sync tone into one stable enough to render. + * + * The sync signal this derives from toggles on every resubscribe and on a + * 250ms retry cycle; rendering it directly is what made the old inline + * indicator flash in and out. + */ +export function useSettledWorkspaceSyncTone(state: WorkspaceState): WorkspaceSyncTone { + const rawTone = workspaceSyncTone(state); + const [settledTone, setSettledTone] = useState(() => + isTransientWorkspaceSyncTone(rawTone) ? "idle" : rawTone, + ); + const holdUntilRef = useRef(0); + + useEffect(() => { + const settlement = planWorkspaceSyncToneSettlement({ + rawTone, + settledTone, + holdUntil: holdUntilRef.current, + now: Date.now(), + }); + + if (settlement.kind === "hold") { + return; + } + + if (settlement.kind === "apply") { + setSettledTone(settlement.tone); + return; + } + + const timeout = setTimeout(() => { + if (isTransientWorkspaceSyncTone(settlement.tone)) { + holdUntilRef.current = Date.now() + SYNC_TONE_MIN_VISIBLE_MS; + } + setSettledTone(settlement.tone); + }, settlement.delayMs); + return () => clearTimeout(timeout); + }, [rawTone, settledTone]); + + return settledTone; +} diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b1..af5439c6799 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -10,6 +10,41 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } +/** + * What the workspace sync indicator should currently convey. + * + * "idle" means there is nothing worth reporting — the indicator renders no + * content in that state rather than unmounting, so its slot in the header + * never reflows its neighbours. + */ +export type WorkspaceSyncTone = + | "offline" + | "error" + | "disconnected" + | "connecting" + | "syncing" + | "idle"; + +export function workspaceSyncTone(state: WorkspaceState): WorkspaceSyncTone { + if (state.networkStatus === "offline") return "offline"; + if (state.connectionError !== null) return "error"; + if (state.connectingEnvironments.length > 0 || state.hasConnectingEnvironment) return "connecting"; + if (state.hasPendingShellSnapshot) return "syncing"; + if (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) return "disconnected"; + return "idle"; +} + +/** + * Tones that describe work in progress. These are the ones driven by the + * shell-sync signal, which toggles on every websocket resubscribe and on a + * 250ms retry cycle — they need settling before they reach the UI (see + * useSettledWorkspaceSyncTone). The rest are steady-state facts about a real + * problem and should surface immediately. + */ +export function isTransientWorkspaceSyncTone(tone: WorkspaceSyncTone): boolean { + return tone === "connecting" || tone === "syncing"; +} + export function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 36a86ceb1e3..4908f9732f8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -55,8 +55,12 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; -import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { + WorkspaceSyncStatusButton, + workspaceSyncToneSymbol, +} from "../home/WorkspaceSyncStatusButton"; +import { useSettledWorkspaceSyncTone } from "../home/use-settled-workspace-sync-tone"; +import { workspaceConnectionStatusLabel } from "../home/workspace-connection-status"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -550,7 +554,11 @@ function ThreadNavigationSidebarPane( threadListV2Enabled, threadListV2Layout, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); + // Settled first: the raw sync signal re-enters "synchronizing" on every + // resubscribe, which used to mount/unmount this indicator as a list header row. + const syncTone = useSettledWorkspaceSyncTone(catalogState); + const syncSymbol = workspaceSyncToneSymbol(syncTone); + const syncLabel = workspaceConnectionStatusLabel(catalogState); const listMenuActions = useMemo( () => [ { @@ -1028,8 +1036,18 @@ function ThreadNavigationSidebarPane( filterIcon, filterMenu, onOpenSettings: props.onOpenSettings, + syncIcon: syncSymbol, + syncLabel, + onOpenEnvironmentSettings: props.onOpenEnvironmentSettings, }), - [filterIcon, filterMenu, props.onOpenSettings], + [ + filterIcon, + filterMenu, + props.onOpenSettings, + props.onOpenEnvironmentSettings, + syncSymbol, + syncLabel, + ], ); // "No threads yet" over an inbox that is merely all-snoozed reads as // data loss; name the snoozed threads instead. @@ -1114,17 +1132,9 @@ function ThreadNavigationSidebarPane( scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.threadList} - ListHeaderComponent={ - showsConnectionStatus ? ( - - - - ) : null - } + // Connection/sync state lives in the navigation bar + // (createSidebarHeaderItems) — as a list header it mounted and + // unmounted on every sync flip, flashing and reflowing the list. ListEmptyComponent={listEmpty} /> @@ -1219,6 +1229,13 @@ function ThreadNavigationSidebarPane( Threads + {/* Sync state as a grouped header button rather than a row above + the list, which flashed and reflowed on every sync flip. */} + - - {showsConnectionStatus ? ( - - - - ) : null} ); diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index b80fffda057..7c893f44bf5 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -40,8 +40,27 @@ export function createSidebarHeaderItems(input: { readonly filterIcon: string; readonly filterMenu: HomeListFilterMenu; readonly onOpenSettings: () => void; + /** + * SF Symbol for the workspace sync state, or null when there is nothing to + * report. Already settled by the caller (useSettledWorkspaceSyncTone) so the + * bouncy sync signal can't strobe the navigation bar. + */ + readonly syncIcon?: string | null; + readonly syncLabel?: string; + readonly onOpenEnvironmentSettings?: () => void; }): NativeStackHeaderItem[] { return [ + ...(input.syncIcon && input.onOpenEnvironmentSettings + ? [ + withNativeGlassHeaderItem({ + type: "button" as const, + label: "", + accessibilityLabel: input.syncLabel ?? "Workspace sync status", + icon: sfSymbolIcon(input.syncIcon), + onPress: input.onOpenEnvironmentSettings, + }), + ] + : []), withNativeGlassHeaderItem({ type: "menu", label: "",