From d61499fe1dc131b94c89c982f0085db1a1bb6d54 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 4 Aug 2026 17:04:03 -0700 Subject: [PATCH] fix(mobile): reconnects no longer shift the thread list The environment connection status rendered as a row above the thread list, so every reconnect pushed the rows down and popped them back. The brand/title slot in the header now doubles as the status surface: while an environment is unavailable the lockup ("Threads" on the iPad sidebar) fades to a spinner plus status label in place, and fades back on reconnect. Nothing is inserted into the list, so its geometry never moves. An 800ms debounce keeps sub-second blips invisible entirely. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/CompactBrandTitle.tsx | 23 ++- apps/mobile/src/features/home/HomeHeader.tsx | 35 ++-- .../src/features/home/HomeRouteScreen.tsx | 20 +- apps/mobile/src/features/home/HomeScreen.tsx | 46 +---- .../home/WorkspaceConnectionStatus.tsx | 56 ----- .../home/WorkspaceConnectionTitle.tsx | 194 ++++++++++++++++++ ...ts => workspace-connection-status.test.ts} | 33 +++ .../home/workspace-connection-status.ts | 20 ++ .../threads/ThreadNavigationSidebar.tsx | 51 +++-- 9 files changed, 326 insertions(+), 152 deletions(-) delete mode 100644 apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx create mode 100644 apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx rename apps/mobile/src/features/home/{WorkspaceConnectionStatus.test.ts => workspace-connection-status.test.ts} (72%) 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} );