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: "",