diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx
index f0710e85d36..28f7cfe57a7 100644
--- a/apps/mobile/src/components/CompactBrandTitle.tsx
+++ b/apps/mobile/src/components/CompactBrandTitle.tsx
@@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass";
const IOS_NATIVE_LEADING_TITLE_OFFSET = -6;
const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7;
+/**
+ * Horizontal correction applied to content rendered in the brand title slot,
+ * shared with the connection-status swap so both align identically.
+ */
+export function brandTitleOffset(nativeLeadingItem: boolean): number {
+ if (Platform.OS !== "ios") return 0;
+ if (nativeLeadingItem) {
+ return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET;
+ }
+ return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0;
+}
+
/**
* Compact brand lockup sized for native navigation bars.
*/
@@ -28,16 +40,7 @@ export function CompactBrandTitle(
const mutedColor = useThemeColor("--color-foreground-muted");
const subtleColor = useThemeColor("--color-subtle");
const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
- const titleOffset =
- Platform.OS !== "ios"
- ? 0
- : props.nativeLeadingItem
- ? Platform.isPad
- ? IPAD_NATIVE_LEADING_TITLE_OFFSET
- : IOS_NATIVE_LEADING_TITLE_OFFSET
- : Platform.isPad
- ? IPAD_HOME_TITLE_OFFSET
- : 0;
+ const titleOffset = brandTitleOffset(props.nativeLeadingItem === true);
return (
void;
readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
+ readonly onOpenEnvironments: () => void;
readonly onOpenSettings: () => void;
readonly onStartNewTask: () => void;
}) {
@@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
>
-
- {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
-
-
- Code
-
-
-
- {stageLabel}
-
-
-
+ {/* Brand slot doubles as the connection status surface: while an
+ environment reconnects, the lockup fades to a status label in
+ place (no layout shift in the list below). */}
+
+ {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
+
+
+ Code
+
+
+
+ {stageLabel}
+
+
+
+ }
+ />
navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
>
<>
- {/* Restore the compact title after the split branch blanks the detail header. */}
-
+ {/* Restore the compact title after the split branch blanks the detail
+ header. The brand slot doubles as the connection status surface:
+ while an environment reconnects, the lockup fades to a status label
+ in place (no layout shift in the list below). */}
+
+ navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }),
+ })}
+ />
+ navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
+ }
onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
onProjectSortOrderChange={setProjectSortOrder}
onSearchQueryChange={setSearchQuery}
@@ -161,9 +172,6 @@ export function HomeRouteScreen() {
onUnpinThread={unpinThread}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
- onOpenEnvironments={() =>
- navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
- }
onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
onProjectSortOrderChange={setProjectSortOrder}
onSearchQueryChange={setSearchQuery}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 2b58bc342d4..8e1e16eae3f 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -71,8 +71,6 @@ import {
type HomeProjectSortOrder,
} from "./homeThreadList";
import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
-import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
-import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
/* ─── Types ──────────────────────────────────────────────────────────── */
@@ -97,7 +95,6 @@ interface HomeScreenProps {
readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
readonly onAddConnection: () => void;
- readonly onOpenEnvironments: () => void;
readonly onOpenSettings: () => void;
readonly onStartNewTask: () => void;
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
@@ -969,20 +966,13 @@ export function HomeScreen(props: HomeScreenProps) {
? null
: (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
"this environment");
- const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);
+ // Connection state surfaces in the header title slot
+ // (WorkspaceConnectionTitle) — nothing renders inside the list, so
+ // reconnects never shift the rows.
const emptyState = deriveEmptyState({
catalogState: props.catalogState,
projectCount: props.projects.length,
});
- const connectionStatus =
- shouldShowConnectionStatus && Platform.OS !== "ios" ? (
-
-
-
- ) : null;
if (!hasAnyThreads) {
return (
@@ -1001,41 +991,17 @@ export function HomeScreen(props: HomeScreenProps) {
onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
variant="plain"
/>
- {emptyState.loading && !shouldShowConnectionStatus ? (
+ {emptyState.loading ? (
) : null}
- {shouldShowConnectionStatus && Platform.OS === "ios" ? (
-
-
-
- ) : null}
- {connectionStatus}
);
}
- const listHeader = (
- <>
- {Platform.OS === "ios" ? null : }
-
- {shouldShowConnectionStatus && Platform.OS === "ios" ? (
-
-
-
- ) : null}
- >
- );
+ 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).
@@ -1116,7 +1082,6 @@ export function HomeScreen(props: HomeScreenProps) {
}}
/>
- {connectionStatus}
);
}
@@ -1171,7 +1136,6 @@ export function HomeScreen(props: HomeScreenProps) {
}
/>
- {connectionStatus}
);
}
diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx
deleted file mode 100644
index 1e986ad1a50..00000000000
--- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { SymbolView } from "../../components/AppSymbol";
-import { ActivityIndicator, Pressable } from "react-native";
-
-import { AppText as Text } from "../../components/AppText";
-import { useThemeColor } from "../../lib/useThemeColor";
-import type { WorkspaceState } from "../../state/workspaceModel";
-import { workspaceConnectionStatusLabel } from "./workspace-connection-status";
-
-export function WorkspaceConnectionStatus(props: {
- readonly state: WorkspaceState;
- readonly onPress: () => void;
- readonly variant?: "floating" | "sidebar";
-}) {
- const iconColor = useThemeColor("--color-icon-muted");
- const isSynchronizing =
- props.state.networkStatus !== "offline" &&
- props.state.connectionError === null &&
- (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot);
- const variant = props.variant ?? "floating";
-
- return (
-
- {isSynchronizing ? (
-
- ) : (
-
- )}
-
- {workspaceConnectionStatusLabel(props.state)}
-
- {variant === "sidebar" ? (
-
- ) : null}
-
- );
-}
diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
new file mode 100644
index 00000000000..1867042988b
--- /dev/null
+++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
@@ -0,0 +1,194 @@
+import type {
+ NativeStackHeaderItem,
+ NativeStackNavigationOptions,
+} from "@react-navigation/native-stack";
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native";
+
+import { SymbolView } from "../../components/AppSymbol";
+import { AppText as Text } from "../../components/AppText";
+import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
+import { useWorkspaceState } from "../../state/workspace";
+import {
+ workspaceConnectionStatusPresentation,
+ type WorkspaceConnectionStatusPresentation,
+} from "./workspace-connection-status";
+
+/**
+ * Delay before a connection interruption surfaces in the title slot. Sub-second
+ * blips (the common reconnect case) resolve without any UI at all.
+ */
+const STATUS_SHOW_DELAY_MS = 800;
+const FADE_IN_MS = 250;
+
+/**
+ * Connection status presentation, debounced for display: null until the
+ * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS,
+ * then live-updating until the workspace reconnects (null again immediately).
+ */
+function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null {
+ const { state } = useWorkspaceState();
+ const presentation = workspaceConnectionStatusPresentation(state);
+ const hasStatus = presentation !== null;
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (!hasStatus) {
+ setVisible(false);
+ return;
+ }
+ const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, [hasStatus]);
+
+ return visible ? presentation : null;
+}
+
+/**
+ * One-shot entrance fade for the status label. Deliberately JS-driven: this can
+ * mount inside a native header item (RNSScreenStackHeaderSubview), where
+ * native-driver animated nodes blank the re-hosted view entirely. The JS driver
+ * updates opacity through the ordinary style path, which those subviews handle.
+ */
+function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) {
+ const opacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ const animation = Animated.timing(opacity, {
+ duration: FADE_IN_MS,
+ toValue: 1,
+ useNativeDriver: false,
+ });
+ animation.start();
+ return () => animation.stop();
+ }, [opacity]);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+/**
+ * Renders the brand/title slot of a thread-list surface, swapping the brand
+ * for the workspace connection status while an environment is unavailable.
+ *
+ * Both states occupy the same slot, so connection changes never shift the
+ * layout below. While connected the brand renders untouched — no wrapper —
+ * keeping the native header item on the exact element tree that predates the
+ * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted
+ * a row above the thread list.
+ */
+export function WorkspaceConnectionTitle(props: {
+ /** Content shown while connected (brand lockup or a screen title). */
+ readonly brand: ReactNode;
+ /** Opens environment settings. Status is not pressable when omitted. */
+ readonly onPress?: () => void;
+ /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */
+ readonly grow?: boolean;
+ readonly size?: "navbar" | "pageTitle";
+ /** Horizontal correction so the status aligns with the brand in native title slots. */
+ readonly statusOffset?: number;
+}) {
+ const iconColor = String(useThemeColor("--color-icon-muted"));
+ const status = useDelayedConnectionStatus();
+ const size = props.size ?? "navbar";
+
+ if (status === null) {
+ return props.grow ? (
+
+ {props.brand}
+
+ ) : (
+ <>{props.brand}>
+ );
+ }
+
+ return (
+
+
+ {status.showsProgress ? (
+
+ ) : (
+
+ )}
+
+ {status.label}
+
+
+
+ );
+}
+
+/**
+ * getCompactBrandHeaderOptions with the brand slot upgraded to the
+ * connection-status swap. Screens with an environment-settings callback apply
+ * this over the static brand options at mount.
+ */
+export function getConnectionAwareBrandHeaderOptions(opts: {
+ readonly onOpenEnvironments: () => void;
+ readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"];
+}): NativeStackNavigationOptions {
+ if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) {
+ return {
+ headerTitle: "Threads",
+ headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" },
+ title: "Threads",
+ unstable_headerLeftItems: (): NativeStackHeaderItem[] => [
+ {
+ element: (
+ }
+ onPress={opts.onOpenEnvironments}
+ statusOffset={brandTitleOffset(true)}
+ />
+ ),
+ hidesSharedBackground: true,
+ type: "custom",
+ },
+ ],
+ };
+ }
+
+ return {
+ headerTitle: () => (
+ }
+ onPress={opts.onOpenEnvironments}
+ statusOffset={brandTitleOffset(false)}
+ />
+ ),
+ headerTitleStyle: opts.fallbackTitleStyle,
+ title: "Threads",
+ };
+}
diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts
similarity index 72%
rename from apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts
rename to apps/mobile/src/features/home/workspace-connection-status.test.ts
index 8c3c873cc9e..15a990bb1cb 100644
--- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts
+++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts
@@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel";
import {
shouldShowWorkspaceConnectionStatus,
workspaceConnectionStatusLabel,
+ workspaceConnectionStatusPresentation,
} from "./workspace-connection-status";
function workspaceState(overrides: Partial = {}): WorkspaceState {
@@ -84,4 +85,36 @@ describe("workspace connection status", () => {
expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads...");
});
+
+ it("presents nothing while connected", () => {
+ expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull();
+ });
+
+ it("presents progress while reconnecting but not while offline", () => {
+ const reconnecting = workspaceState({
+ hasConnectingEnvironment: true,
+ hasReadyEnvironment: false,
+ connectingEnvironments: [
+ {
+ environmentId: "environment-1" as never,
+ environmentLabel: "Julius’s Mac mini",
+ displayUrl: "",
+ isRelayManaged: false,
+ connectionState: "reconnecting",
+ connectionError: null,
+ connectionErrorTraceId: null,
+ },
+ ],
+ });
+ expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({
+ label: "Reconnecting to Julius’s Mac mini",
+ showsProgress: true,
+ });
+
+ const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
+ expect(workspaceConnectionStatusPresentation(offline)).toEqual({
+ label: "You are offline",
+ showsProgress: false,
+ });
+ });
});
diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts
index d8eed4383b1..6f9898b1bb0 100644
--- a/apps/mobile/src/features/home/workspace-connection-status.ts
+++ b/apps/mobile/src/features/home/workspace-connection-status.ts
@@ -1,5 +1,11 @@
import type { WorkspaceState } from "../../state/workspaceModel";
+export interface WorkspaceConnectionStatusPresentation {
+ readonly label: string;
+ /** True while actively working (connecting/syncing) — render a spinner. False for offline/error/idle states — render a wifi-slash icon. */
+ readonly showsProgress: boolean;
+}
+
export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
return (
state.networkStatus === "offline" ||
@@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
}
return "Not connected";
}
+
+/** Header-title presentation of the connection state, or null while connected. */
+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),
+ };
+}
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 322ac60759d..206232ebe7b 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -55,8 +55,10 @@ 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 {
+ getConnectionAwareBrandHeaderOptions,
+ WorkspaceConnectionTitle,
+} from "../home/WorkspaceConnectionTitle";
import { SidebarHeaderActions } from "./sidebar-header-actions";
import { SidebarFilterButton } from "./sidebar-filter-button";
import { createSidebarHeaderItems } from "./sidebar-native-header-items";
@@ -600,7 +602,6 @@ function ThreadNavigationSidebarPane(
threadListV2Enabled,
threadListV2Layout,
]);
- const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
const listMenuActions = useMemo(
() => [
{
@@ -1153,6 +1154,13 @@ function ThreadNavigationSidebarPane(
-
-
- ) : null
- }
ListEmptyComponent={listEmpty}
/>
@@ -1304,9 +1301,19 @@ function ThreadNavigationSidebarPane(
-
- Threads
-
+ {/* Title slot doubles as the connection status surface: while an
+ environment reconnects, "Threads" fades to a status label in
+ place (no layout shift in the list below). */}
+
+ Threads
+
+ }
+ />
-
- {showsConnectionStatus ? (
-
-
-
- ) : null}
);