| 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,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; |
| 16 | const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; | 16 | const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; |
| 17 | const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; | 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 | * 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; | ||
| 41 | 44 | ||
| 42 | return ( | 45 | return ( |
| 43 | <View | 46 | <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"; |
| 23 | import type { HomeProjectSortOrder } from "./homeThreadList"; | 23 | import type { HomeProjectSortOrder } from "./homeThreadList"; |
| 24 | import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; | ||
| 24 | import { | 25 | import { |
| 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 | Code | 216 | 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 | /> | ||
| 222 | 233 | ||
| 223 | <ControlPillMenu | 234 | <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"; |
| 3 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 4 | import { useEffect, useMemo, useState } from "react"; | 4 | import { useEffect, useMemo, useState } from "react"; |
| 5 | 5 | ||
| 6 | import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; | ||
| 7 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | import { useProjects, useThreadShells } from "../../state/entities"; | 7 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 9 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | 8 | import { 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"; |
| 20 | import { buildHomeProjectScopes } from "./homeThreadList"; | 19 | import { buildHomeProjectScopes } from "./homeThreadList"; |
| 21 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; | 20 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; |
| 22 | import { useThreadListActions } from "./useThreadListActions"; | 21 | import { useThreadListActions } from "./useThreadListActions"; |
| 22 | import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; | ||
| 23 | 23 | ||
| 24 | /* ─── Route screen ───────────────────────────────────────────────────── */ | 24 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 25 | 25 | ||
| ... | @@ -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 | <HomeHeader | 139 | <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"; |
| 73 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; | 73 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 74 | import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; | ||
| 75 | import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; | ||
| 76 | 74 | ||
| 77 | /* ─── Types ──────────────────────────────────────────────────────────── */ | 75 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 78 | 76 | ||
| ... | @@ -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 | ? null | 973 | ? 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; | ||
| 993 | 983 | ||
| 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 | } |
| 1030 | 1010 | ||
| 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 | ); | ||
| 1046 | 1012 | ||
| 1047 | // Project scoping lives in the header filter menu (no inline chip row on | 1013 | // 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 @@ | ||
| 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,5 +1,11 @@ |
| 1 | import type { WorkspaceState } from "../../state/workspaceModel"; | 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 | export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { | 9 | export 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. */ | ||
| 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,8 +55,10 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea |
| 55 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; | 55 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; |
| 56 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; | 56 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; |
| 57 | import { useThreadListActions } from "../home/useThreadListActions"; | 57 | import { useThreadListActions } from "../home/useThreadListActions"; |
| 58 | import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; | 58 | import { |
| 59 | import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; | 59 | getConnectionAwareBrandHeaderOptions, |
| 60 | WorkspaceConnectionTitle, | ||
| 61 | } from "../home/WorkspaceConnectionTitle"; | ||
| 60 | import { SidebarHeaderActions } from "./sidebar-header-actions"; | 62 | import { SidebarHeaderActions } from "./sidebar-header-actions"; |
| 61 | import { SidebarFilterButton } from "./sidebar-filter-button"; | 63 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 62 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; | 64 | import { 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 | <NativeStackScreenOptions | 1154 | <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 | Threads | 1305 | 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 | <SidebarFilterButton | 1319 | <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 | ); |