authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 09:21:04-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 06:21:04-07:00
log6d70e6d77824eed51b1531a2f42b2ed5cdae8501
tree58169d895d96fd55cee0ed186d4878ee3993da6f
parentbe1a836745395286cbd392512179ab5816f538ba
signature Signed by PGP key B5690EEEBB952194

fix(mobile): reconnects no longer shift the thread list (#5372)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

10 files changed, 413 insertions(+), 239 deletions(-)

apps/mobile/src/components/CompactBrandTitle.tsx+13-10
...@@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass";...@@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass";
16const IOS_NATIVE_LEADING_TITLE_OFFSET = -6;16const IOS_NATIVE_LEADING_TITLE_OFFSET = -6;
17const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7;17const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7;
1818
19/**
20 * Horizontal correction applied to content rendered in the brand title slot,
21 * shared with the connection-status swap so both align identically.
22 */
23export function brandTitleOffset(nativeLeadingItem: boolean): number {
24 if (Platform.OS !== "ios") return 0;
25 if (nativeLeadingItem) {
26 return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET;
27 }
28 return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0;
29}
30
19/**31/**
20 * Compact brand lockup sized for native navigation bars.32 * Compact brand lockup sized for native navigation bars.
21 */33 */
...@@ -28,16 +40,7 @@ export function CompactBrandTitle(...@@ -28,16 +40,7 @@ export function CompactBrandTitle(
28 const mutedColor = useThemeColor("--color-foreground-muted");40 const mutedColor = useThemeColor("--color-foreground-muted");
29 const subtleColor = useThemeColor("--color-subtle");41 const subtleColor = useThemeColor("--color-subtle");
30 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);42 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
31 const titleOffset =43 const titleOffset = brandTitleOffset(props.nativeLeadingItem === true);
32 Platform.OS !== "ios"
33 ? 0
34 : props.nativeLeadingItem
35 ? Platform.isPad
36 ? IPAD_NATIVE_LEADING_TITLE_OFFSET
37 : IOS_NATIVE_LEADING_TITLE_OFFSET
38 : Platform.isPad
39 ? IPAD_HOME_TITLE_OFFSET
40 : 0;
4144
42 return (45 return (
43 <View46 <View
apps/mobile/src/features/home/HomeHeader.tsx+23-12
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,21 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
22} from "../layout/native-mail-search-toolbar";22} from "../layout/native-mail-search-toolbar";
23import type { HomeProjectSortOrder } from "./homeThreadList";23import type { HomeProjectSortOrder } from "./homeThreadList";
24import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
24import {25import {
25 buildHomeListFilterMenu,26 buildHomeListFilterMenu,
26 type HomeListFilterMenuEnvironment,27 type HomeListFilterMenuEnvironment,
...@@ -47,6 +48,7 @@ export function HomeHeader(props: {...@@ -47,6 +48,7 @@ export function HomeHeader(props: {
47 readonly onProjectChange: (projectKey: string | null) => void;48 readonly onProjectChange: (projectKey: string | null) => void;
48 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;49 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
49 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;50 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
51 readonly onOpenEnvironments: () => void;
50 readonly onOpenSettings: () => void;52 readonly onOpenSettings: () => void;
51 readonly onStartNewTask: () => void;53 readonly onStartNewTask: () => void;
52}) {54}) {
...@@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) {...@@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
207 >209 >
208 <View className="w-full max-w-[720px] self-center gap-3">210 <View className="w-full max-w-[720px] self-center gap-3">
209 <View className="flex-row items-center gap-2.5">211 <View className="flex-row items-center gap-2.5">
210 <View className="flex-1 flex-row items-center gap-2">212 {/* Brand slot doubles as the connection status surface: while an
211 {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}213 environment reconnects, the lockup fades to a status label in
212 <T3Wordmark color={iconColor} height={15} />214 place (no layout shift in the list below). */}
213 <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">215 <WorkspaceConnectionTitle
214 Code216 grow
215 </RNText>217 onPress={props.onOpenEnvironments}
216 <View className="rounded-full bg-subtle px-2 py-0.75">218 brand={
217 <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase">219 <View className="flex-row items-center gap-2">
218 {stageLabel}220 {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
219 </RNText>221 <T3Wordmark color={iconColor} height={15} />
220 </View>222 <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">
221 </View>223 Code
224 </RNText>
225 <View className="rounded-full bg-subtle px-2 py-0.75">
226 <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase">
227 {stageLabel}
228 </RNText>
229 </View>
230 </View>
231 }
232 />
222233
223 <ControlPillMenu234 <ControlPillMenu
224 actions={menuActions}235 actions={menuActions}
apps/mobile/src/features/home/HomeRouteScreen.tsx+14-6
...@@ -3,7 +3,6 @@ import * as Order from "effect/Order";...@@ -3,7 +3,6 @@ import * as Order from "effect/Order";
3import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
4import { useEffect, useMemo, useState } from "react";4import { useEffect, useMemo, useState } from "react";
55
6import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
7import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
8import { useProjects, useThreadShells } from "../../state/entities";7import { useProjects, useThreadShells } from "../../state/entities";
9import { usePendingNewTasks } from "../../state/use-pending-new-tasks";8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
...@@ -20,6 +19,7 @@ import { useHomeListOptions } from "./home-list-options";...@@ -20,6 +19,7 @@ import { useHomeListOptions } from "./home-list-options";
20import { buildHomeProjectScopes } from "./homeThreadList";19import { buildHomeProjectScopes } from "./homeThreadList";
21import { usePendingTaskListActions } from "./usePendingTaskListActions";20import { usePendingTaskListActions } from "./usePendingTaskListActions";
22import { useThreadListActions } from "./useThreadListActions";21import { useThreadListActions } from "./useThreadListActions";
22import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle";
2323
24/* ─── Route screen ───────────────────────────────────────────────────── */24/* ─── Route screen ───────────────────────────────────────────────────── */
2525
...@@ -126,8 +126,16 @@ export function HomeRouteScreen() {...@@ -126,8 +126,16 @@ export function HomeRouteScreen() {
126 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}126 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
127 >127 >
128 <>128 <>
129 {/* Restore the compact title after the split branch blanks the detail header. */}129 {/* Restore the compact title after the split branch blanks the detail
130 <NativeStackScreenOptions options={getCompactBrandHeaderOptions()} />130 header. The brand slot doubles as the connection status surface:
131 while an environment reconnects, the lockup fades to a status label
132 in place (no layout shift in the list below). */}
133 <NativeStackScreenOptions
134 options={getConnectionAwareBrandHeaderOptions({
135 onOpenEnvironments: () =>
136 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }),
137 })}
138 />
131 <HomeHeader139 <HomeHeader
132 environments={environments}140 environments={environments}
133 projects={projectFilterOptions}141 projects={projectFilterOptions}
...@@ -138,6 +146,9 @@ export function HomeRouteScreen() {...@@ -138,6 +146,9 @@ export function HomeRouteScreen() {
138 threadSortOrder={listOptions.threadSortOrder}146 threadSortOrder={listOptions.threadSortOrder}
139 onEnvironmentChange={setSelectedEnvironmentId}147 onEnvironmentChange={setSelectedEnvironmentId}
140 onProjectChange={setSelectedProjectKey}148 onProjectChange={setSelectedProjectKey}
149 onOpenEnvironments={() =>
150 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
151 }
141 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}152 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
142 onProjectSortOrderChange={setProjectSortOrder}153 onProjectSortOrderChange={setProjectSortOrder}
143 onSearchQueryChange={setSearchQuery}154 onSearchQueryChange={setSearchQuery}
...@@ -161,9 +172,6 @@ export function HomeRouteScreen() {...@@ -161,9 +172,6 @@ export function HomeRouteScreen() {
161 onUnpinThread={unpinThread}172 onUnpinThread={unpinThread}
162 onEnvironmentChange={setSelectedEnvironmentId}173 onEnvironmentChange={setSelectedEnvironmentId}
163 onProjectChange={setSelectedProjectKey}174 onProjectChange={setSelectedProjectKey}
164 onOpenEnvironments={() =>
165 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
166 }
167 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}175 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
168 onProjectSortOrderChange={setProjectSortOrder}176 onProjectSortOrderChange={setProjectSortOrder}
169 onSearchQueryChange={setSearchQuery}177 onSearchQueryChange={setSearchQuery}
apps/mobile/src/features/home/HomeScreen.tsx+5-41
...@@ -71,8 +71,6 @@ import {...@@ -71,8 +71,6 @@ import {
71 type HomeProjectSortOrder,71 type HomeProjectSortOrder,
72} from "./homeThreadList";72} from "./homeThreadList";
73import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";73import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
74import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
75import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
7674
77/* ─── Types ──────────────────────────────────────────────────────────── */75/* ─── Types ──────────────────────────────────────────────────────────── */
7876
...@@ -97,7 +95,6 @@ interface HomeScreenProps {...@@ -97,7 +95,6 @@ interface HomeScreenProps {
97 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;95 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
98 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;96 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
99 readonly onAddConnection: () => void;97 readonly onAddConnection: () => void;
100 readonly onOpenEnvironments: () => void;
101 readonly onOpenSettings: () => void;98 readonly onOpenSettings: () => void;
102 readonly onStartNewTask: () => void;99 readonly onStartNewTask: () => void;
103 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;100 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
...@@ -976,20 +973,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -976,20 +973,13 @@ export function HomeScreen(props: HomeScreenProps) {
976 ? null973 ? null
977 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??974 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
978 "this environment");975 "this environment");
979 const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);976 // Connection state surfaces in the header title slot
977 // (WorkspaceConnectionTitle) — nothing renders inside the list, so
978 // reconnects never shift the rows.
980 const emptyState = deriveEmptyState({979 const emptyState = deriveEmptyState({
981 catalogState: props.catalogState,980 catalogState: props.catalogState,
982 projectCount: props.projects.length,981 projectCount: props.projects.length,
983 });982 });
984 const connectionStatus =
985 shouldShowConnectionStatus && Platform.OS !== "ios" ? (
986 <View
987 className="absolute left-0 right-0 items-center"
988 style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
989 >
990 <WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
991 </View>
992 ) : null;
993983
994 if (!hasAnyThreads) {984 if (!hasAnyThreads) {
995 return (985 return (
...@@ -1008,41 +998,17 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1008,41 +998,17 @@ export function HomeScreen(props: HomeScreenProps) {
1008 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}998 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
1009 variant="plain"999 variant="plain"
1010 />1000 />
1011 {emptyState.loading && !shouldShowConnectionStatus ? (1001 {emptyState.loading ? (
1012 <View className="mt-4 items-center">1002 <View className="mt-4 items-center">
1013 <ActivityIndicator color={accentColor} />1003 <ActivityIndicator color={accentColor} />
1014 </View>1004 </View>
1015 ) : null}1005 ) : null}
1016 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
1017 <View className="mt-4">
1018 <WorkspaceConnectionStatus
1019 state={props.catalogState}
1020 onPress={props.onOpenEnvironments}
1021 variant="sidebar"
1022 />
1023 </View>
1024 ) : null}
1025 </View>1006 </View>
1026 {connectionStatus}
1027 </View>1007 </View>
1028 );1008 );
1029 }1009 }
10301010
1031 const listHeader = (1011 const listHeader = Platform.OS === "ios" ? null : <HomeTopContentSpacer />;
1032 <>
1033 {Platform.OS === "ios" ? null : <HomeTopContentSpacer />}
1034
1035 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
1036 <View className="pb-4">
1037 <WorkspaceConnectionStatus
1038 state={props.catalogState}
1039 onPress={props.onOpenEnvironments}
1040 variant="sidebar"
1041 />
1042 </View>
1043 ) : null}
1044 </>
1045 );
10461012
1047 // Project scoping lives in the header filter menu (no inline chip row on1013 // Project scoping lives in the header filter menu (no inline chip row on
1048 // mobile — the menu is the one filter surface).1014 // mobile — the menu is the one filter surface).
...@@ -1123,7 +1089,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1123,7 +1089,6 @@ export function HomeScreen(props: HomeScreenProps) {
1123 }}1089 }}
1124 />1090 />
1125 </SwipeableScrollGateProvider>1091 </SwipeableScrollGateProvider>
1126 {connectionStatus}
1127 </View>1092 </View>
1128 );1093 );
1129 }1094 }
...@@ -1178,7 +1143,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1178,7 +1143,6 @@ export function HomeScreen(props: HomeScreenProps) {
1178 }1143 }
1179 />1144 />
1180 </SwipeableScrollGateProvider>1145 </SwipeableScrollGateProvider>
1181 {connectionStatus}
1182 </View>1146 </View>
1183 );1147 );
1184}1148}
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts deleted-87
...@@ -1,87 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { WorkspaceState } from "../../state/workspaceModel";
4import {
5 shouldShowWorkspaceConnectionStatus,
6 workspaceConnectionStatusLabel,
7} from "./workspace-connection-status";
8
9function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
10 return {
11 isLoadingConnections: false,
12 hasConnections: true,
13 hasLoadedShellSnapshot: true,
14 hasPendingShellSnapshot: false,
15 hasReadyEnvironment: true,
16 hasConnectingEnvironment: false,
17 connectingEnvironments: [],
18 connectionState: "connected",
19 connectionError: null,
20 shellSnapshotError: null,
21 latestCachedSnapshotReceivedAt: null,
22 networkStatus: "online",
23 ...overrides,
24 };
25}
26
27describe("workspace connection status", () => {
28 it("stays hidden while a ready environment is connected", () => {
29 expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false);
30 });
31
32 it("surfaces offline snapshots", () => {
33 const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
34
35 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
36 expect(workspaceConnectionStatusLabel(state)).toBe("You are offline");
37 });
38
39 it("names the environment while reconnecting", () => {
40 const state = workspaceState({
41 hasConnectingEnvironment: true,
42 hasReadyEnvironment: false,
43 connectingEnvironments: [
44 {
45 environmentId: "environment-1" as never,
46 environmentLabel: "Julius’s Mac mini",
47 displayUrl: "",
48 isRelayManaged: false,
49 connectionState: "reconnecting",
50 connectionError: null,
51 connectionErrorTraceId: null,
52 },
53 ],
54 });
55
56 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
57 expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini");
58 });
59
60 it("surfaces connection errors before the generic disconnected fallback", () => {
61 const state = workspaceState({
62 connectionError: "Could not reach Julius’s Mac mini",
63 hasLoadedShellSnapshot: false,
64 hasReadyEnvironment: false,
65 });
66
67 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
68 expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini");
69 });
70
71 it("shows shell catch-up while cached threads remain visible", () => {
72 const state = workspaceState({ hasPendingShellSnapshot: true });
73
74 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
75 expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads...");
76 });
77
78 it("distinguishes initial shell loading from cached catch-up", () => {
79 const state = workspaceState({
80 hasLoadedShellSnapshot: false,
81 hasPendingShellSnapshot: true,
82 });
83
84 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
85 expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads...");
86 });
87});
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx deleted-56
...@@ -1,56 +0,0 @@
1import { SymbolView } from "../../components/AppSymbol";
2import { ActivityIndicator, Pressable } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6import type { WorkspaceState } from "../../state/workspaceModel";
7import { workspaceConnectionStatusLabel } from "./workspace-connection-status";
8
9export function WorkspaceConnectionStatus(props: {
10 readonly state: WorkspaceState;
11 readonly onPress: () => void;
12 readonly variant?: "floating" | "sidebar";
13}) {
14 const iconColor = useThemeColor("--color-icon-muted");
15 const isSynchronizing =
16 props.state.networkStatus !== "offline" &&
17 props.state.connectionError === null &&
18 (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot);
19 const variant = props.variant ?? "floating";
20
21 return (
22 <Pressable
23 accessibilityHint="Opens environment settings"
24 accessibilityLabel={workspaceConnectionStatusLabel(props.state)}
25 accessibilityRole="button"
26 onPress={props.onPress}
27 className={
28 variant === "sidebar"
29 ? "mx-3 flex-row items-center gap-2 rounded-xl bg-subtle px-3 py-2.5"
30 : "flex-row items-center gap-2 rounded-full bg-card px-4 py-2.5"
31 }
32 style={
33 variant === "floating"
34 ? {
35 shadowColor: "#000",
36 shadowOffset: { width: 0, height: 10 },
37 shadowOpacity: 0.12,
38 shadowRadius: 24,
39 }
40 : undefined
41 }
42 >
43 {isSynchronizing ? (
44 <ActivityIndicator color={iconColor} size="small" />
45 ) : (
46 <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" />
47 )}
48 <Text className="min-w-0 flex-1 text-sm font-t3-bold text-foreground" numberOfLines={1}>
49 {workspaceConnectionStatusLabel(props.state)}
50 </Text>
51 {variant === "sidebar" ? (
52 <SymbolView name="chevron.right" size={11} tintColor={iconColor} type="monochrome" />
53 ) : null}
54 </Pressable>
55 );
56}
apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx created+194
...@@ -0,0 +1,194 @@
1import type {
2 NativeStackHeaderItem,
3 NativeStackNavigationOptions,
4} from "@react-navigation/native-stack";
5import { useEffect, useRef, useState, type ReactNode } from "react";
6import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native";
7
8import { SymbolView } from "../../components/AppSymbol";
9import { AppText as Text } from "../../components/AppText";
10import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle";
11import { useThemeColor } from "../../lib/useThemeColor";
12import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
13import { useWorkspaceState } from "../../state/workspace";
14import {
15 workspaceConnectionStatusPresentation,
16 type WorkspaceConnectionStatusPresentation,
17} from "./workspace-connection-status";
18
19/**
20 * Delay before a connection interruption surfaces in the title slot. Sub-second
21 * blips (the common reconnect case) resolve without any UI at all.
22 */
23const STATUS_SHOW_DELAY_MS = 800;
24const FADE_IN_MS = 250;
25
26/**
27 * Connection status presentation, debounced for display: null until the
28 * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS,
29 * then live-updating until the workspace reconnects (null again immediately).
30 */
31function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null {
32 const { state } = useWorkspaceState();
33 const presentation = workspaceConnectionStatusPresentation(state);
34 const hasStatus = presentation !== null;
35 const [visible, setVisible] = useState(false);
36
37 useEffect(() => {
38 if (!hasStatus) {
39 setVisible(false);
40 return;
41 }
42 const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS);
43 return () => clearTimeout(timer);
44 }, [hasStatus]);
45
46 return visible ? presentation : null;
47}
48
49/**
50 * One-shot entrance fade for the status label. Deliberately JS-driven: this can
51 * mount inside a native header item (RNSScreenStackHeaderSubview), where
52 * native-driver animated nodes blank the re-hosted view entirely. The JS driver
53 * updates opacity through the ordinary style path, which those subviews handle.
54 */
55function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) {
56 const opacity = useRef(new Animated.Value(0)).current;
57
58 useEffect(() => {
59 const animation = Animated.timing(opacity, {
60 duration: FADE_IN_MS,
61 toValue: 1,
62 useNativeDriver: false,
63 });
64 animation.start();
65 return () => animation.stop();
66 }, [opacity]);
67
68 return (
69 <Animated.View
70 style={[
71 { alignItems: "center", flexDirection: "row", opacity },
72 props.grow ? { flex: 1, minWidth: 0 } : null,
73 ]}
74 >
75 {props.children}
76 </Animated.View>
77 );
78}
79
80/**
81 * Renders the brand/title slot of a thread-list surface, swapping the brand
82 * for the workspace connection status while an environment is unavailable.
83 *
84 * Both states occupy the same slot, so connection changes never shift the
85 * layout below. While connected the brand renders untouched — no wrapper —
86 * keeping the native header item on the exact element tree that predates the
87 * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted
88 * a row above the thread list.
89 */
90export function WorkspaceConnectionTitle(props: {
91 /** Content shown while connected (brand lockup or a screen title). */
92 readonly brand: ReactNode;
93 /** Opens environment settings. Status is not pressable when omitted. */
94 readonly onPress?: () => void;
95 /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */
96 readonly grow?: boolean;
97 readonly size?: "navbar" | "pageTitle";
98 /** Horizontal correction so the status aligns with the brand in native title slots. */
99 readonly statusOffset?: number;
100}) {
101 const iconColor = String(useThemeColor("--color-icon-muted"));
102 const status = useDelayedConnectionStatus();
103 const size = props.size ?? "navbar";
104
105 if (status === null) {
106 return props.grow ? (
107 <View style={{ alignItems: "center", flex: 1, flexDirection: "row", minWidth: 0 }}>
108 {props.brand}
109 </View>
110 ) : (
111 <>{props.brand}</>
112 );
113 }
114
115 return (
116 <StatusFadeIn grow={props.grow}>
117 <Pressable
118 accessibilityHint="Opens environment settings"
119 accessibilityLabel={status.label}
120 accessibilityRole="button"
121 disabled={props.onPress === undefined}
122 hitSlop={8}
123 onPress={props.onPress}
124 className="flex-row items-center gap-2"
125 style={{ marginLeft: props.statusOffset ?? 0 }}
126 >
127 {status.showsProgress ? (
128 <ActivityIndicator color={iconColor} size="small" />
129 ) : (
130 <SymbolView
131 name="wifi.slash"
132 size={size === "pageTitle" ? 17 : 15}
133 tintColor={iconColor}
134 type="monochrome"
135 />
136 )}
137 <Text
138 className={
139 size === "pageTitle"
140 ? "text-[20px] font-t3-bold text-foreground-muted"
141 : "text-[16px] font-t3-bold text-foreground-muted"
142 }
143 numberOfLines={1}
144 style={{ flexShrink: 1 }}
145 >
146 {status.label}
147 </Text>
148 </Pressable>
149 </StatusFadeIn>
150 );
151}
152
153/**
154 * getCompactBrandHeaderOptions with the brand slot upgraded to the
155 * connection-status swap. Screens with an environment-settings callback apply
156 * this over the static brand options at mount.
157 */
158export function getConnectionAwareBrandHeaderOptions(opts: {
159 readonly onOpenEnvironments: () => void;
160 readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"];
161}): NativeStackNavigationOptions {
162 if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) {
163 return {
164 headerTitle: "Threads",
165 headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" },
166 title: "Threads",
167 unstable_headerLeftItems: (): NativeStackHeaderItem[] => [
168 {
169 element: (
170 <WorkspaceConnectionTitle
171 brand={<CompactBrandTitle nativeLeadingItem />}
172 onPress={opts.onOpenEnvironments}
173 statusOffset={brandTitleOffset(true)}
174 />
175 ),
176 hidesSharedBackground: true,
177 type: "custom",
178 },
179 ],
180 };
181 }
182
183 return {
184 headerTitle: () => (
185 <WorkspaceConnectionTitle
186 brand={<CompactBrandTitle />}
187 onPress={opts.onOpenEnvironments}
188 statusOffset={brandTitleOffset(false)}
189 />
190 ),
191 headerTitleStyle: opts.fallbackTitleStyle,
192 title: "Threads",
193 };
194}
apps/mobile/src/features/home/workspace-connection-status.test.ts created+120
...@@ -0,0 +1,120 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { WorkspaceState } from "../../state/workspaceModel";
4import {
5 shouldShowWorkspaceConnectionStatus,
6 workspaceConnectionStatusLabel,
7 workspaceConnectionStatusPresentation,
8} from "./workspace-connection-status";
9
10function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
11 return {
12 isLoadingConnections: false,
13 hasConnections: true,
14 hasLoadedShellSnapshot: true,
15 hasPendingShellSnapshot: false,
16 hasReadyEnvironment: true,
17 hasConnectingEnvironment: false,
18 connectingEnvironments: [],
19 connectionState: "connected",
20 connectionError: null,
21 shellSnapshotError: null,
22 latestCachedSnapshotReceivedAt: null,
23 networkStatus: "online",
24 ...overrides,
25 };
26}
27
28describe("workspace connection status", () => {
29 it("stays hidden while a ready environment is connected", () => {
30 expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false);
31 });
32
33 it("surfaces offline snapshots", () => {
34 const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
35
36 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
37 expect(workspaceConnectionStatusLabel(state)).toBe("You are offline");
38 });
39
40 it("names the environment while reconnecting", () => {
41 const state = workspaceState({
42 hasConnectingEnvironment: true,
43 hasReadyEnvironment: false,
44 connectingEnvironments: [
45 {
46 environmentId: "environment-1" as never,
47 environmentLabel: "Julius’s Mac mini",
48 displayUrl: "",
49 isRelayManaged: false,
50 connectionState: "reconnecting",
51 connectionError: null,
52 connectionErrorTraceId: null,
53 },
54 ],
55 });
56
57 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
58 expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini");
59 });
60
61 it("surfaces connection errors before the generic disconnected fallback", () => {
62 const state = workspaceState({
63 connectionError: "Could not reach Julius’s Mac mini",
64 hasLoadedShellSnapshot: false,
65 hasReadyEnvironment: false,
66 });
67
68 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
69 expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini");
70 });
71
72 it("shows shell catch-up while cached threads remain visible", () => {
73 const state = workspaceState({ hasPendingShellSnapshot: true });
74
75 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
76 expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads...");
77 });
78
79 it("distinguishes initial shell loading from cached catch-up", () => {
80 const state = workspaceState({
81 hasLoadedShellSnapshot: false,
82 hasPendingShellSnapshot: true,
83 });
84
85 expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
86 expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads...");
87 });
88
89 it("presents nothing while connected", () => {
90 expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull();
91 });
92
93 it("presents progress while reconnecting but not while offline", () => {
94 const reconnecting = workspaceState({
95 hasConnectingEnvironment: true,
96 hasReadyEnvironment: false,
97 connectingEnvironments: [
98 {
99 environmentId: "environment-1" as never,
100 environmentLabel: "Julius’s Mac mini",
101 displayUrl: "",
102 isRelayManaged: false,
103 connectionState: "reconnecting",
104 connectionError: null,
105 connectionErrorTraceId: null,
106 },
107 ],
108 });
109 expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({
110 label: "Reconnecting to Julius’s Mac mini",
111 showsProgress: true,
112 });
113
114 const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });
115 expect(workspaceConnectionStatusPresentation(offline)).toEqual({
116 label: "You are offline",
117 showsProgress: false,
118 });
119 });
120});
apps/mobile/src/features/home/workspace-connection-status.ts+20
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import type { WorkspaceState } from "../../state/workspaceModel";1import type { WorkspaceState } from "../../state/workspaceModel";
22
3export interface WorkspaceConnectionStatusPresentation {
4 readonly label: string;
5 /** True while actively working (connecting/syncing) — render a spinner. False for offline/error/idle states — render a wifi-slash icon. */
6 readonly showsProgress: boolean;
7}
8
3export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {9export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
4 return (10 return (
5 state.networkStatus === "offline" ||11 state.networkStatus === "offline" ||
...@@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string {...@@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
24 }30 }
25 return "Not connected";31 return "Not connected";
26}32}
33
34/** Header-title presentation of the connection state, or null while connected. */
35export function workspaceConnectionStatusPresentation(
36 state: WorkspaceState,
37): WorkspaceConnectionStatusPresentation | null {
38 if (!shouldShowWorkspaceConnectionStatus(state)) return null;
39 return {
40 label: workspaceConnectionStatusLabel(state),
41 showsProgress:
42 state.networkStatus !== "offline" &&
43 state.connectionError === null &&
44 (state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot),
45 };
46}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+24-27
...@@ -55,8 +55,10 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea...@@ -55,8 +55,10 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea
55import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";55import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
56import { usePendingTaskListActions } from "../home/usePendingTaskListActions";56import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
57import { useThreadListActions } from "../home/useThreadListActions";57import { useThreadListActions } from "../home/useThreadListActions";
58import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";58import {
59import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";59 getConnectionAwareBrandHeaderOptions,
60 WorkspaceConnectionTitle,
61} from "../home/WorkspaceConnectionTitle";
60import { SidebarHeaderActions } from "./sidebar-header-actions";62import { SidebarHeaderActions } from "./sidebar-header-actions";
61import { SidebarFilterButton } from "./sidebar-filter-button";63import { SidebarFilterButton } from "./sidebar-filter-button";
62import { createSidebarHeaderItems } from "./sidebar-native-header-items";64import { createSidebarHeaderItems } from "./sidebar-native-header-items";
...@@ -600,7 +602,6 @@ function ThreadNavigationSidebarPane(...@@ -600,7 +602,6 @@ function ThreadNavigationSidebarPane(
600 threadListV2Enabled,602 threadListV2Enabled,
601 threadListV2Layout,603 threadListV2Layout,
602 ]);604 ]);
603 const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
604 const listMenuActions = useMemo<MenuAction[]>(605 const listMenuActions = useMemo<MenuAction[]>(
605 () => [606 () => [
606 {607 {
...@@ -1153,6 +1154,13 @@ function ThreadNavigationSidebarPane(...@@ -1153,6 +1154,13 @@ function ThreadNavigationSidebarPane(
1153 <NativeStackScreenOptions1154 <NativeStackScreenOptions
1154 optionsVersion={nativeHeaderItems}1155 optionsVersion={nativeHeaderItems}
1155 options={{1156 options={{
1157 // Re-applies the shell's static brand slot with the
1158 // connection-status swap so reconnects surface in the header
1159 // instead of shifting the list.
1160 ...getConnectionAwareBrandHeaderOptions({
1161 onOpenEnvironments: props.onOpenEnvironmentSettings,
1162 fallbackTitleStyle: { fontSize: 18, fontWeight: "800" },
1163 }),
1156 headerSearchBarOptions: {1164 headerSearchBarOptions: {
1157 ref: searchBarRef,1165 ref: searchBarRef,
1158 autoCapitalize: "none",1166 autoCapitalize: "none",
...@@ -1203,17 +1211,6 @@ function ThreadNavigationSidebarPane(...@@ -1203,17 +1211,6 @@ function ThreadNavigationSidebarPane(
1203 scrollEventThrottle={16}1211 scrollEventThrottle={16}
1204 showsVerticalScrollIndicator={false}1212 showsVerticalScrollIndicator={false}
1205 style={styles.threadList}1213 style={styles.threadList}
1206 ListHeaderComponent={
1207 showsConnectionStatus ? (
1208 <View className="px-1.5 pt-0.5 pb-2">
1209 <WorkspaceConnectionStatus
1210 onPress={props.onOpenEnvironmentSettings}
1211 state={catalogState}
1212 variant="sidebar"
1213 />
1214 </View>
1215 ) : null
1216 }
1217 ListEmptyComponent={listEmpty}1214 ListEmptyComponent={listEmpty}
1218 />1215 />
1219 </GestureDetector>1216 </GestureDetector>
...@@ -1304,9 +1301,19 @@ function ThreadNavigationSidebarPane(...@@ -1304,9 +1301,19 @@ function ThreadNavigationSidebarPane(
1304 </Svg>1301 </Svg>
1305 </View>1302 </View>
1306 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">1303 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">
1307 <Text className="flex-1 text-[34px] font-t3-bold text-foreground" numberOfLines={1}>1304 {/* Title slot doubles as the connection status surface: while an
1308 Threads1305 environment reconnects, "Threads" fades to a status label in
1309 </Text>1306 place (no layout shift in the list below). */}
1307 <WorkspaceConnectionTitle
1308 grow
1309 onPress={props.onOpenEnvironmentSettings}
1310 size="pageTitle"
1311 brand={
1312 <Text className="flex-1 text-[34px] font-t3-bold text-foreground" numberOfLines={1}>
1313 Threads
1314 </Text>
1315 }
1316 />
1310 <SidebarHeaderButtonGroup colorScheme={colorScheme}>1317 <SidebarHeaderButtonGroup colorScheme={colorScheme}>
1311 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>1318 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1312 <SidebarFilterButton1319 <SidebarFilterButton
...@@ -1335,16 +1342,6 @@ function ThreadNavigationSidebarPane(...@@ -1335,16 +1342,6 @@ function ThreadNavigationSidebarPane(
1335 value={props.searchQuery}1342 value={props.searchQuery}
1336 />1343 />
1337 </View>1344 </View>
1338
1339 {showsConnectionStatus ? (
1340 <View className="px-3.5 pt-2.5">
1341 <WorkspaceConnectionStatus
1342 onPress={props.onOpenEnvironmentSettings}
1343 state={catalogState}
1344 variant="sidebar"
1345 />
1346 </View>
1347 ) : null}
1348 </View>1345 </View>
1349 </View>1346 </View>
1350 );1347 );