From 6330f6b61f7c98e4e9efa16167f98474da5d05df Mon Sep 17 00:00:00 2001 From: Carson Loyal Date: Thu, 30 Jul 2026 10:08:06 -0500 Subject: [PATCH] fix(mobile): move the thread sync indicator into the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Syncing threads..." indicator flashed in and out above the thread list. Two causes, both fixed here. It lived inside the scroll view. WorkspaceConnectionStatus was rendered as ListHeaderComponent (row 0) in HomeScreen and ThreadNavigationSidebar, so every sync flip mounted or unmounted a row and reflowed everything below it. It now renders as a header button instead, and the Android floating overlay it duplicated is gone. The signal itself strobes. hasSynchronizingShell re-enters "synchronizing" on every websocket resubscribe and on a 250ms retry cycle, so relocating the UI alone would have kept the blinking. The raw state is now reduced to a tone (offline / error / disconnected / connecting / syncing / idle) and settled before it renders: transient tones must persist 400ms — longer than the retry cycle, so self-resolving blips never appear — and stay 900ms once shown. Faults bypass both delays, since delaying those would hide a problem worth seeing. The button keeps its slot when idle (an empty box of the same size), so neighbouring header controls never shift. Wired into the Android home header, the iOS native header items, and the split-view sidebar. Tapping still opens environment settings. The full-page empty state keeps its inline pill on purpose: with no threads on screen it is the only thing explaining why the page is blank, and there is nothing below it to reflow. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/src/features/home/HomeHeader.tsx | 42 ++++++- .../src/features/home/HomeRouteScreen.tsx | 4 + apps/mobile/src/features/home/HomeScreen.tsx | 38 ++---- .../home/WorkspaceConnectionStatus.test.ts | 60 +++++++++ .../home/WorkspaceSyncStatusButton.tsx | 96 +++++++++++++++ .../use-settled-workspace-sync-tone.test.ts | 78 ++++++++++++ .../home/use-settled-workspace-sync-tone.ts | 115 ++++++++++++++++++ .../home/workspace-connection-status.ts | 35 ++++++ .../threads/ThreadNavigationSidebar.tsx | 57 +++++---- .../threads/sidebar-native-header-items.ts | 19 +++ 10 files changed, 490 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx create mode 100644 apps/mobile/src/features/home/use-settled-workspace-sync-tone.test.ts create mode 100644 apps/mobile/src/features/home/use-settled-workspace-sync-tone.ts 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: "",