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";
1616const IOS_NATIVE_LEADING_TITLE_OFFSET = -6;
1717const 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
1931/**
2032 * Compact brand lockup sized for native navigation bars.
2133 */
......@@ -28,16 +40,7 @@ export function CompactBrandTitle(
2840 const mutedColor = useThemeColor("--color-foreground-muted");
2941 const subtleColor = useThemeColor("--color-subtle");
3042 const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
31 const titleOffset =
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;
43 const titleOffset = brandTitleOffset(props.nativeLeadingItem === true);
4144
4245 return (
4346 <View
apps/mobile/src/features/home/HomeHeader.tsx+23-12
......@@ -21,6 +21,7 @@ import {
2121 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
2222} from "../layout/native-mail-search-toolbar";
2323import type { HomeProjectSortOrder } from "./homeThreadList";
24import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
2425import {
2526 buildHomeListFilterMenu,
2627 type HomeListFilterMenuEnvironment,
......@@ -47,6 +48,7 @@ export function HomeHeader(props: {
4748 readonly onProjectChange: (projectKey: string | null) => void;
4849 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
4950 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
51 readonly onOpenEnvironments: () => void;
5052 readonly onOpenSettings: () => void;
5153 readonly onStartNewTask: () => void;
5254}) {
......@@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
207209 >
208210 <View className="w-full max-w-[720px] self-center gap-3">
209211 <View className="flex-row items-center gap-2.5">
210 <View className="flex-1 flex-row items-center gap-2">
211 {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
212 <T3Wordmark color={iconColor} height={15} />
213 <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">
214 Code
215 </RNText>
216 <View className="rounded-full bg-subtle px-2 py-0.75">
217 <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase">
218 {stageLabel}
219 </RNText>
220 </View>
221 </View>
212 {/* Brand slot doubles as the connection status surface: while an
213 environment reconnects, the lockup fades to a status label in
214 place (no layout shift in the list below). */}
215 <WorkspaceConnectionTitle
216 grow
217 onPress={props.onOpenEnvironments}
218 brand={
219 <View className="flex-row items-center gap-2">
220 {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
221 <T3Wordmark color={iconColor} height={15} />
222 <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">
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
223234 <ControlPillMenu
224235 actions={menuActions}
apps/mobile/src/features/home/HomeRouteScreen.tsx+14-6
......@@ -3,7 +3,6 @@ import * as Order from "effect/Order";
33import { useNavigation } from "@react-navigation/native";
44import { useEffect, useMemo, useState } from "react";
55
6import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
76import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
87import { useProjects, useThreadShells } from "../../state/entities";
98import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
......@@ -20,6 +19,7 @@ import { useHomeListOptions } from "./home-list-options";
2019import { buildHomeProjectScopes } from "./homeThreadList";
2120import { usePendingTaskListActions } from "./usePendingTaskListActions";
2221import { useThreadListActions } from "./useThreadListActions";
22import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle";
2323
2424/* ─── Route screen ───────────────────────────────────────────────────── */
2525
......@@ -126,8 +126,16 @@ export function HomeRouteScreen() {
126126 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
127127 >
128128 <>
129 {/* Restore the compact title after the split branch blanks the detail header. */}
130 <NativeStackScreenOptions options={getCompactBrandHeaderOptions()} />
129 {/* Restore the compact title after the split branch blanks the detail
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 />
131139 <HomeHeader
132140 environments={environments}
133141 projects={projectFilterOptions}
......@@ -138,6 +146,9 @@ export function HomeRouteScreen() {
138146 threadSortOrder={listOptions.threadSortOrder}
139147 onEnvironmentChange={setSelectedEnvironmentId}
140148 onProjectChange={setSelectedProjectKey}
149 onOpenEnvironments={() =>
150 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
151 }
141152 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
142153 onProjectSortOrderChange={setProjectSortOrder}
143154 onSearchQueryChange={setSearchQuery}
......@@ -161,9 +172,6 @@ export function HomeRouteScreen() {
161172 onUnpinThread={unpinThread}
162173 onEnvironmentChange={setSelectedEnvironmentId}
163174 onProjectChange={setSelectedProjectKey}
164 onOpenEnvironments={() =>
165 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
166 }
167175 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
168176 onProjectSortOrderChange={setProjectSortOrder}
169177 onSearchQueryChange={setSearchQuery}
apps/mobile/src/features/home/HomeScreen.tsx+5-41
......@@ -71,8 +71,6 @@ import {
7171 type HomeProjectSortOrder,
7272} from "./homeThreadList";
7373import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
74import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
75import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";
7674
7775/* ─── Types ──────────────────────────────────────────────────────────── */
7876
......@@ -97,7 +95,6 @@ interface HomeScreenProps {
9795 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
9896 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
9997 readonly onAddConnection: () => void;
100 readonly onOpenEnvironments: () => void;
10198 readonly onOpenSettings: () => void;
10299 readonly onStartNewTask: () => void;
103100 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
......@@ -976,20 +973,13 @@ export function HomeScreen(props: HomeScreenProps) {
976973 ? null
977974 : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
978975 "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.
980979 const emptyState = deriveEmptyState({
981980 catalogState: props.catalogState,
982981 projectCount: props.projects.length,
983982 });
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
994984 if (!hasAnyThreads) {
995985 return (
......@@ -1008,41 +998,17 @@ export function HomeScreen(props: HomeScreenProps) {
1008998 onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
1009999 variant="plain"
10101000 />
1011 {emptyState.loading && !shouldShowConnectionStatus ? (
1001 {emptyState.loading ? (
10121002 <View className="mt-4 items-center">
10131003 <ActivityIndicator color={accentColor} />
10141004 </View>
10151005 ) : 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}
10251006 </View>
1026 {connectionStatus}
10271007 </View>
10281008 );
10291009 }
10301010
1031 const listHeader = (
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 );
1011 const listHeader = Platform.OS === "ios" ? null : <HomeTopContentSpacer />;
10461012
10471013 // Project scoping lives in the header filter menu (no inline chip row on
10481014 // mobile — the menu is the one filter surface).
......@@ -1123,7 +1089,6 @@ export function HomeScreen(props: HomeScreenProps) {
11231089 }}
11241090 />
11251091 </SwipeableScrollGateProvider>
1126 {connectionStatus}
11271092 </View>
11281093 );
11291094 }
......@@ -1178,7 +1143,6 @@ export function HomeScreen(props: HomeScreenProps) {
11781143 }
11791144 />
11801145 </SwipeableScrollGateProvider>
1181 {connectionStatus}
11821146 </View>
11831147 );
11841148}
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 @@
11import 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
39export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean {
410 return (
511 state.networkStatus === "offline" ||
......@@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string {
2430 }
2531 return "Not connected";
2632}
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
5555import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
5656import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
5757import { useThreadListActions } from "../home/useThreadListActions";
58import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";
59import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";
58import {
59 getConnectionAwareBrandHeaderOptions,
60 WorkspaceConnectionTitle,
61} from "../home/WorkspaceConnectionTitle";
6062import { SidebarHeaderActions } from "./sidebar-header-actions";
6163import { SidebarFilterButton } from "./sidebar-filter-button";
6264import { createSidebarHeaderItems } from "./sidebar-native-header-items";
......@@ -600,7 +602,6 @@ function ThreadNavigationSidebarPane(
600602 threadListV2Enabled,
601603 threadListV2Layout,
602604 ]);
603 const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
604605 const listMenuActions = useMemo<MenuAction[]>(
605606 () => [
606607 {
......@@ -1153,6 +1154,13 @@ function ThreadNavigationSidebarPane(
11531154 <NativeStackScreenOptions
11541155 optionsVersion={nativeHeaderItems}
11551156 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 }),
11561164 headerSearchBarOptions: {
11571165 ref: searchBarRef,
11581166 autoCapitalize: "none",
......@@ -1203,17 +1211,6 @@ function ThreadNavigationSidebarPane(
12031211 scrollEventThrottle={16}
12041212 showsVerticalScrollIndicator={false}
12051213 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 }
12171214 ListEmptyComponent={listEmpty}
12181215 />
12191216 </GestureDetector>
......@@ -1304,9 +1301,19 @@ function ThreadNavigationSidebarPane(
13041301 </Svg>
13051302 </View>
13061303 <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}>
1308 Threads
1309 </Text>
1304 {/* Title slot doubles as the connection status surface: while an
1305 environment reconnects, "Threads" fades to a status label in
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 />
13101317 <SidebarHeaderButtonGroup colorScheme={colorScheme}>
13111318 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
13121319 <SidebarFilterButton
......@@ -1335,16 +1342,6 @@ function ThreadNavigationSidebarPane(
13351342 value={props.searchQuery}
13361343 />
13371344 </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}
13481345 </View>
13491346 </View>
13501347 );