| author | |
| committer | |
| log | 6d70e6d77824eed51b1531a2f42b2ed5cdae8501 |
| tree | 58169d895d96fd55cee0ed186d4878ee3993da6f |
| parent | be1a836745395286cbd392512179ab5816f538ba |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 16 | const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; |
| 17 | 17 | const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; |
| 18 | 18 | |
| 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 | */ | |
| 23 | export 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 | 32 | * Compact brand lockup sized for native navigation bars. |
| 21 | 33 | */ |
| ... | ... | @@ -28,16 +40,7 @@ export function CompactBrandTitle( |
| 28 | 40 | const mutedColor = useThemeColor("--color-foreground-muted"); |
| 29 | 41 | const subtleColor = useThemeColor("--color-subtle"); |
| 30 | 42 | 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); | |
| 41 | 44 | |
| 42 | 45 | return ( |
| 43 | 46 | <View |
apps/mobile/src/features/home/HomeHeader.tsx+23-12| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 22 | 22 | } from "../layout/native-mail-search-toolbar"; |
| 23 | 23 | import type { HomeProjectSortOrder } from "./homeThreadList"; |
| 24 | import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; | |
| 24 | 25 | import { |
| 25 | 26 | buildHomeListFilterMenu, |
| 26 | 27 | type HomeListFilterMenuEnvironment, |
| ... | ... | @@ -47,6 +48,7 @@ export function HomeHeader(props: { |
| 47 | 48 | readonly onProjectChange: (projectKey: string | null) => void; |
| 48 | 49 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; |
| 49 | 50 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; |
| 51 | readonly onOpenEnvironments: () => void; | |
| 50 | 52 | readonly onOpenSettings: () => void; |
| 51 | 53 | readonly onStartNewTask: () => void; |
| 52 | 54 | }) { |
| ... | ... | @@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) { |
| 207 | 209 | > |
| 208 | 210 | <View className="w-full max-w-[720px] self-center gap-3"> |
| 209 | 211 | <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 | /> | |
| 222 | 233 | |
| 223 | 234 | <ControlPillMenu |
| 224 | 235 | actions={menuActions} |
apps/mobile/src/features/home/HomeRouteScreen.tsx+14-6| ... | ... | @@ -3,7 +3,6 @@ import * as Order from "effect/Order"; |
| 3 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 4 | 4 | import { useEffect, useMemo, useState } from "react"; |
| 5 | 5 | |
| 6 | import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; | |
| 7 | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | 7 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 9 | 8 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| ... | ... | @@ -20,6 +19,7 @@ import { useHomeListOptions } from "./home-list-options"; |
| 20 | 19 | import { buildHomeProjectScopes } from "./homeThreadList"; |
| 21 | 20 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; |
| 22 | 21 | import { useThreadListActions } from "./useThreadListActions"; |
| 22 | import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; | |
| 23 | 23 | |
| 24 | 24 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 25 | 25 | |
| ... | ... | @@ -126,8 +126,16 @@ export function HomeRouteScreen() { |
| 126 | 126 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 127 | 127 | > |
| 128 | 128 | <> |
| 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 | /> | |
| 131 | 139 | <HomeHeader |
| 132 | 140 | environments={environments} |
| 133 | 141 | projects={projectFilterOptions} |
| ... | ... | @@ -138,6 +146,9 @@ export function HomeRouteScreen() { |
| 138 | 146 | threadSortOrder={listOptions.threadSortOrder} |
| 139 | 147 | onEnvironmentChange={setSelectedEnvironmentId} |
| 140 | 148 | onProjectChange={setSelectedProjectKey} |
| 149 | onOpenEnvironments={() => | |
| 150 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) | |
| 151 | } | |
| 141 | 152 | onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} |
| 142 | 153 | onProjectSortOrderChange={setProjectSortOrder} |
| 143 | 154 | onSearchQueryChange={setSearchQuery} |
| ... | ... | @@ -161,9 +172,6 @@ export function HomeRouteScreen() { |
| 161 | 172 | onUnpinThread={unpinThread} |
| 162 | 173 | onEnvironmentChange={setSelectedEnvironmentId} |
| 163 | 174 | onProjectChange={setSelectedProjectKey} |
| 164 | onOpenEnvironments={() => | |
| 165 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) | |
| 166 | } | |
| 167 | 175 | onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} |
| 168 | 176 | onProjectSortOrderChange={setProjectSortOrder} |
| 169 | 177 | onSearchQueryChange={setSearchQuery} |
apps/mobile/src/features/home/HomeScreen.tsx+5-41| ... | ... | @@ -71,8 +71,6 @@ import { |
| 71 | 71 | type HomeProjectSortOrder, |
| 72 | 72 | } from "./homeThreadList"; |
| 73 | 73 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 74 | import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; | |
| 75 | import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; | |
| 76 | 74 | |
| 77 | 75 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 78 | 76 | |
| ... | ... | @@ -97,7 +95,6 @@ interface HomeScreenProps { |
| 97 | 95 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; |
| 98 | 96 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; |
| 99 | 97 | readonly onAddConnection: () => void; |
| 100 | readonly onOpenEnvironments: () => void; | |
| 101 | 98 | readonly onOpenSettings: () => void; |
| 102 | 99 | readonly onStartNewTask: () => void; |
| 103 | 100 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| ... | ... | @@ -976,20 +973,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 976 | 973 | ? null |
| 977 | 974 | : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? |
| 978 | 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 | 979 | const emptyState = deriveEmptyState({ |
| 981 | 980 | catalogState: props.catalogState, |
| 982 | 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; | |
| 993 | 983 | |
| 994 | 984 | if (!hasAnyThreads) { |
| 995 | 985 | return ( |
| ... | ... | @@ -1008,41 +998,17 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1008 | 998 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} |
| 1009 | 999 | variant="plain" |
| 1010 | 1000 | /> |
| 1011 | {emptyState.loading && !shouldShowConnectionStatus ? ( | |
| 1001 | {emptyState.loading ? ( | |
| 1012 | 1002 | <View className="mt-4 items-center"> |
| 1013 | 1003 | <ActivityIndicator color={accentColor} /> |
| 1014 | 1004 | </View> |
| 1015 | 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 | 1006 | </View> |
| 1026 | {connectionStatus} | |
| 1027 | 1007 | </View> |
| 1028 | 1008 | ); |
| 1029 | 1009 | } |
| 1030 | 1010 | |
| 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 />; | |
| 1046 | 1012 | |
| 1047 | 1013 | // Project scoping lives in the header filter menu (no inline chip row on |
| 1048 | 1014 | // mobile — the menu is the one filter surface). |
| ... | ... | @@ -1123,7 +1089,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1123 | 1089 | }} |
| 1124 | 1090 | /> |
| 1125 | 1091 | </SwipeableScrollGateProvider> |
| 1126 | {connectionStatus} | |
| 1127 | 1092 | </View> |
| 1128 | 1093 | ); |
| 1129 | 1094 | } |
| ... | ... | @@ -1178,7 +1143,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1178 | 1143 | } |
| 1179 | 1144 | /> |
| 1180 | 1145 | </SwipeableScrollGateProvider> |
| 1181 | {connectionStatus} | |
| 1182 | 1146 | </View> |
| 1183 | 1147 | ); |
| 1184 | 1148 | } |
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts deleted-87| ... | ... | @@ -1,87 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import type { WorkspaceState } from "../../state/workspaceModel"; | |
| 4 | import { | |
| 5 | shouldShowWorkspaceConnectionStatus, | |
| 6 | workspaceConnectionStatusLabel, | |
| 7 | } from "./workspace-connection-status"; | |
| 8 | ||
| 9 | function 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 | ||
| 27 | describe("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 @@ |
| 1 | import { SymbolView } from "../../components/AppSymbol"; | |
| 2 | import { ActivityIndicator, Pressable } from "react-native"; | |
| 3 | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | |
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 6 | import type { WorkspaceState } from "../../state/workspaceModel"; | |
| 7 | import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; | |
| 8 | ||
| 9 | export 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 @@ |
| 1 | import type { | |
| 2 | NativeStackHeaderItem, | |
| 3 | NativeStackNavigationOptions, | |
| 4 | } from "@react-navigation/native-stack"; | |
| 5 | import { useEffect, useRef, useState, type ReactNode } from "react"; | |
| 6 | import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; | |
| 7 | ||
| 8 | import { SymbolView } from "../../components/AppSymbol"; | |
| 9 | import { AppText as Text } from "../../components/AppText"; | |
| 10 | import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; | |
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 12 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | |
| 13 | import { useWorkspaceState } from "../../state/workspace"; | |
| 14 | import { | |
| 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 | */ | |
| 23 | const STATUS_SHOW_DELAY_MS = 800; | |
| 24 | const 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 | */ | |
| 31 | function 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 | */ | |
| 55 | function 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 | */ | |
| 90 | export 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 | */ | |
| 158 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import type { WorkspaceState } from "../../state/workspaceModel"; | |
| 4 | import { | |
| 5 | shouldShowWorkspaceConnectionStatus, | |
| 6 | workspaceConnectionStatusLabel, | |
| 7 | workspaceConnectionStatusPresentation, | |
| 8 | } from "./workspace-connection-status"; | |
| 9 | ||
| 10 | function 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 | ||
| 28 | describe("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 | 1 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 2 | 2 | |
| 3 | export 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 | ||
| 3 | 9 | export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { |
| 4 | 10 | return ( |
| 5 | 11 | state.networkStatus === "offline" || |
| ... | ... | @@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { |
| 24 | 30 | } |
| 25 | 31 | return "Not connected"; |
| 26 | 32 | } |
| 33 | ||
| 34 | /** Header-title presentation of the connection state, or null while connected. */ | |
| 35 | export 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 | 55 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; |
| 56 | 56 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; |
| 57 | 57 | import { useThreadListActions } from "../home/useThreadListActions"; |
| 58 | import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; | |
| 59 | import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; | |
| 58 | import { | |
| 59 | getConnectionAwareBrandHeaderOptions, | |
| 60 | WorkspaceConnectionTitle, | |
| 61 | } from "../home/WorkspaceConnectionTitle"; | |
| 60 | 62 | import { SidebarHeaderActions } from "./sidebar-header-actions"; |
| 61 | 63 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 62 | 64 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| ... | ... | @@ -600,7 +602,6 @@ function ThreadNavigationSidebarPane( |
| 600 | 602 | threadListV2Enabled, |
| 601 | 603 | threadListV2Layout, |
| 602 | 604 | ]); |
| 603 | const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); | |
| 604 | 605 | const listMenuActions = useMemo<MenuAction[]>( |
| 605 | 606 | () => [ |
| 606 | 607 | { |
| ... | ... | @@ -1153,6 +1154,13 @@ function ThreadNavigationSidebarPane( |
| 1153 | 1154 | <NativeStackScreenOptions |
| 1154 | 1155 | optionsVersion={nativeHeaderItems} |
| 1155 | 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 | 1164 | headerSearchBarOptions: { |
| 1157 | 1165 | ref: searchBarRef, |
| 1158 | 1166 | autoCapitalize: "none", |
| ... | ... | @@ -1203,17 +1211,6 @@ function ThreadNavigationSidebarPane( |
| 1203 | 1211 | scrollEventThrottle={16} |
| 1204 | 1212 | showsVerticalScrollIndicator={false} |
| 1205 | 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 | 1214 | ListEmptyComponent={listEmpty} |
| 1218 | 1215 | /> |
| 1219 | 1216 | </GestureDetector> |
| ... | ... | @@ -1304,9 +1301,19 @@ function ThreadNavigationSidebarPane( |
| 1304 | 1301 | </Svg> |
| 1305 | 1302 | </View> |
| 1306 | 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}> | |
| 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 | /> | |
| 1310 | 1317 | <SidebarHeaderButtonGroup colorScheme={colorScheme}> |
| 1311 | 1318 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> |
| 1312 | 1319 | <SidebarFilterButton |
| ... | ... | @@ -1335,16 +1342,6 @@ function ThreadNavigationSidebarPane( |
| 1335 | 1342 | value={props.searchQuery} |
| 1336 | 1343 | /> |
| 1337 | 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 | 1345 | </View> |
| 1349 | 1346 | </View> |
| 1350 | 1347 | ); |