| 1 | import type { |
| 2 | EnvironmentProject, |
| 3 | EnvironmentThreadShell, |
| 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 6 | import { useAtomValue } from "@effect/atom-react"; |
| 7 | import { useFocusEffect } from "@react-navigation/native"; |
| 8 | import { |
| 9 | CommonActions, |
| 10 | NavigationContext, |
| 11 | NavigationRouteContext, |
| 12 | StackActions, |
| 13 | useNavigation, |
| 14 | } from "@react-navigation/native"; |
| 15 | import { |
| 16 | createContext, |
| 17 | use, |
| 18 | useCallback, |
| 19 | useEffect, |
| 20 | useMemo, |
| 21 | useRef, |
| 22 | useState, |
| 23 | type ReactNode, |
| 24 | } from "react"; |
| 25 | import { Platform, useWindowDimensions, View } from "react-native"; |
| 26 | import Animated, { |
| 27 | runOnJS, |
| 28 | useAnimatedStyle, |
| 29 | useDerivedValue, |
| 30 | useSharedValue, |
| 31 | withTiming, |
| 32 | } from "react-native-reanimated"; |
| 33 | import { AsyncResult } from "effect/reactivity"; |
| 34 | |
| 35 | import { |
| 36 | deriveFileInspectorPaneLayout, |
| 37 | deriveLayout, |
| 38 | deriveWorkspacePaneLayout, |
| 39 | type FileInspectorPaneLayout, |
| 40 | type Layout, |
| 41 | type WorkspaceAuxiliaryPaneRole, |
| 42 | type WorkspacePaneLayout, |
| 43 | } from "../../lib/layout"; |
| 44 | import { |
| 45 | resolveThreadSelectionNavigationAction, |
| 46 | resolveThreadSelectionOverlayState, |
| 47 | } from "../../lib/adaptive-navigation"; |
| 48 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 49 | import type { ThreadListFilters } from "../../persistence/mobile-preferences"; |
| 50 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 51 | import { |
| 52 | DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, |
| 53 | resolveMobileProjectGroupingSettings, |
| 54 | } from "../../state/project-grouping"; |
| 55 | import { |
| 56 | parseActiveThreadPath, |
| 57 | useHardwareKeyboardCommand, |
| 58 | } from "../keyboard/hardwareKeyboardCommands"; |
| 59 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; |
| 60 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; |
| 61 | import { |
| 62 | AndroidWorkspaceNavigation, |
| 63 | ANDROID_WORKSPACE_RAIL_WIDTH, |
| 64 | } from "../workspace/AndroidWorkspaceNavigation"; |
| 65 | import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; |
| 66 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; |
| 67 | import { HomeListOptionsProvider } from "../home/home-list-options"; |
| 68 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 69 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; |
| 70 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 71 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| 72 | import { WorkspaceContentWidthContext } from "./workspace-content-width"; |
| 73 | import { |
| 74 | NativeWorkspaceModeContext, |
| 75 | NativeWorkspaceInspectorContext, |
| 76 | } from "../../native/v5-workspace-context"; |
| 77 | |
| 78 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; |
| 79 | |
| 80 | interface AdaptiveWorkspaceContextValue { |
| 81 | readonly layout: Layout; |
| 82 | readonly panes: WorkspacePaneLayout; |
| 83 | readonly fileInspector: FileInspectorPaneLayout; |
| 84 | readonly primarySidebarSearchQuery: string; |
| 85 | readonly selectThread: (thread: EnvironmentThreadShell) => void; |
| 86 | readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; |
| 87 | /** |
| 88 | * Route screens hand their inspector pane content to the workspace so it |
| 89 | * renders BESIDE the navigator (outside the native stack header) instead of |
| 90 | * inside the route. Returns a deactivate callback: the pane animates closed |
| 91 | * (content kept mounted for the exit transition) unless a newer |
| 92 | * registration already took over — stale deactivates never clobber it. |
| 93 | * Prefer useRegisterWorkspaceInspector over calling this directly. |
| 94 | */ |
| 95 | readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; |
| 96 | readonly setPrimarySidebarSearchQuery: (query: string) => void; |
| 97 | readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; |
| 98 | readonly toggleAuxiliaryPane: () => void; |
| 99 | readonly togglePrimarySidebar: () => void; |
| 100 | readonly setAuxiliaryPaneWidth: (width: number) => void; |
| 101 | } |
| 102 | |
| 103 | const compactLayout = deriveLayout({ width: 0, height: 0 }); |
| 104 | const compactPanes = deriveWorkspacePaneLayout({ |
| 105 | layout: compactLayout, |
| 106 | viewportWidth: 0, |
| 107 | primarySidebarPreferredVisible: true, |
| 108 | auxiliaryPanePreferredVisible: true, |
| 109 | }); |
| 110 | const compactFileInspector = deriveFileInspectorPaneLayout({ |
| 111 | layout: compactLayout, |
| 112 | viewportWidth: 0, |
| 113 | }); |
| 114 | const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({ |
| 115 | layout: compactLayout, |
| 116 | panes: compactPanes, |
| 117 | fileInspector: compactFileInspector, |
| 118 | primarySidebarSearchQuery: "", |
| 119 | selectThread: () => undefined, |
| 120 | activateAuxiliaryPaneRole: () => () => undefined, |
| 121 | registerWorkspaceInspector: () => () => undefined, |
| 122 | setPrimarySidebarSearchQuery: () => undefined, |
| 123 | showAuxiliaryPane: () => undefined, |
| 124 | toggleAuxiliaryPane: () => undefined, |
| 125 | togglePrimarySidebar: () => undefined, |
| 126 | setAuxiliaryPaneWidth: () => undefined, |
| 127 | }); |
| 128 | |
| 129 | export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { |
| 130 | return use(AdaptiveWorkspaceContext); |
| 131 | } |
| 132 | |
| 133 | export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { |
| 134 | const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); |
| 135 | |
| 136 | useFocusEffect( |
| 137 | useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), |
| 138 | ); |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Register this screen's inspector pane content with the workspace column. |
| 143 | * |
| 144 | * The column renders BESIDE the navigator — outside any screen — so the |
| 145 | * registering screen's navigation and route contexts are captured here and |
| 146 | * re-provided around the portal content. Without them, useNavigation/useRoute |
| 147 | * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw |
| 148 | * "Couldn't find a route object". |
| 149 | * |
| 150 | * Registration is FOCUS-scoped, driven by navigation events rather than the |
| 151 | * screen's own render cycle: react-native-screens freezes blurred screens, so |
| 152 | * a cleanup that depends on the blurred subtree re-rendering never runs and |
| 153 | * would leak the pane into the next route. Blur deactivates the pane (it |
| 154 | * animates closed, or is replaced seamlessly when the next route registers in |
| 155 | * the same commit); focus re-registers it. |
| 156 | */ |
| 157 | export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { |
| 158 | const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); |
| 159 | // Raw context values (not the useNavigation/useRoute wrappers) so the |
| 160 | // portal re-provides exactly what this screen sees. |
| 161 | const navigation = use(NavigationContext); |
| 162 | const route = use(NavigationRouteContext); |
| 163 | |
| 164 | const wrappedRender = useMemo(() => { |
| 165 | if (render === undefined) { |
| 166 | return undefined; |
| 167 | } |
| 168 | return () => ( |
| 169 | <NavigationContext.Provider value={navigation}> |
| 170 | <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider> |
| 171 | </NavigationContext.Provider> |
| 172 | ); |
| 173 | }, [navigation, render, route]); |
| 174 | |
| 175 | const wrappedRenderRef = useRef(wrappedRender); |
| 176 | wrappedRenderRef.current = wrappedRender; |
| 177 | const focusedRef = useRef(false); |
| 178 | const deactivateRef = useRef<(() => void) | null>(null); |
| 179 | |
| 180 | const syncRegistration = useCallback(() => { |
| 181 | if (!focusedRef.current || wrappedRenderRef.current === undefined) { |
| 182 | deactivateRef.current?.(); |
| 183 | return; |
| 184 | } |
| 185 | deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); |
| 186 | }, [registerWorkspaceInspector]); |
| 187 | |
| 188 | // Focus lifecycle. Blur/focus events fire even when the blurred subtree is |
| 189 | // frozen (events are navigation-driven, renders are not). |
| 190 | useFocusEffect( |
| 191 | useCallback(() => { |
| 192 | focusedRef.current = true; |
| 193 | syncRegistration(); |
| 194 | return () => { |
| 195 | focusedRef.current = false; |
| 196 | syncRegistration(); |
| 197 | }; |
| 198 | }, [syncRegistration]), |
| 199 | ); |
| 200 | |
| 201 | // Content changes while focused re-register in place. |
| 202 | useEffect(() => { |
| 203 | if (focusedRef.current) { |
| 204 | syncRegistration(); |
| 205 | } |
| 206 | }, [syncRegistration, wrappedRender]); |
| 207 | |
| 208 | // Unmount: hand the pane back (owner-guarded, so a route that already |
| 209 | // took over is unaffected). |
| 210 | useEffect( |
| 211 | () => () => { |
| 212 | deactivateRef.current?.(); |
| 213 | deactivateRef.current = null; |
| 214 | }, |
| 215 | [], |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | export function AdaptiveWorkspaceLayout(props: { |
| 220 | readonly children: ReactNode; |
| 221 | readonly pathname: string; |
| 222 | readonly workspaceRouteKey: string | undefined; |
| 223 | readonly selectedPullRequestKey?: string | null; |
| 224 | }) { |
| 225 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 226 | if (!AsyncResult.isSuccess(preferencesResult)) { |
| 227 | return AsyncResult.isFailure(preferencesResult) ? ( |
| 228 | <AdaptiveWorkspaceLayoutContent |
| 229 | {...props} |
| 230 | projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode} |
| 231 | /> |
| 232 | ) : null; |
| 233 | } |
| 234 | const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); |
| 235 | return ( |
| 236 | <AdaptiveWorkspaceLayoutContent |
| 237 | {...props} |
| 238 | projectGroupingMode={groupingSettings.sidebarProjectGroupingMode} |
| 239 | savedThreadListFilters={preferencesResult.value.threadListFilters} |
| 240 | /> |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | function AdaptiveWorkspaceLayoutContent( |
| 245 | props: { |
| 246 | readonly children: ReactNode; |
| 247 | readonly pathname: string; |
| 248 | readonly workspaceRouteKey: string | undefined; |
| 249 | readonly selectedPullRequestKey?: string | null; |
| 250 | } & { |
| 251 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 252 | readonly savedThreadListFilters?: ThreadListFilters | undefined; |
| 253 | }, |
| 254 | ) { |
| 255 | const projectGroupingMode = props.projectGroupingMode; |
| 256 | const nativeWorkspace = use(NativeWorkspaceModeContext); |
| 257 | const windowDimensions = useWindowDimensions(); |
| 258 | const nativeMetrics = useNativeLayoutMetrics(); |
| 259 | const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; |
| 260 | const { scale: androidControlScale } = useAndroidControlSizing(); |
| 261 | const pathname = props.pathname; |
| 262 | const navigation = useNavigation(); |
| 263 | const activeRoleOwner = useRef<symbol | null>(null); |
| 264 | const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); |
| 265 | const showPrimarySidebar = pathname === "/" || primarySidebarPreferredVisible; |
| 266 | const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); |
| 267 | const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< |
| 268 | number | null |
| 269 | >(null); |
| 270 | const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); |
| 271 | const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState<number | null>( |
| 272 | null, |
| 273 | ); |
| 274 | const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); |
| 275 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = |
| 276 | useState<WorkspaceAuxiliaryPaneRole | null>(null); |
| 277 | const baseLayout = useMemo( |
| 278 | () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), |
| 279 | [height, viewportWidth, nativeMetrics], |
| 280 | ); |
| 281 | const layout = baseLayout; |
| 282 | // The rail consumes real space before either pane; inspectors and the detail |
| 283 | // navigator must use the remaining viewport rather than clipping behind it. |
| 284 | const shouldRenderNavigationRail = Platform.OS === "android" && layout.usesSplitView; |
| 285 | const navigationRailWidth = |
| 286 | shouldRenderNavigationRail && showPrimarySidebar |
| 287 | ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale) |
| 288 | : 0; |
| 289 | const width = viewportWidth - navigationRailWidth; |
| 290 | // In split layouts the sidebar IS the thread list — it renders on every |
| 291 | // route, including Home (which shows an empty-detail pane instead of the |
| 292 | // compact list). |
| 293 | const shouldRenderPrimarySidebar = layout.usesSplitView; |
| 294 | const fileInspector = useMemo( |
| 295 | () => |
| 296 | deriveFileInspectorPaneLayout({ |
| 297 | layout, |
| 298 | viewportWidth: width, |
| 299 | preferredWidth: fileInspectorPreferredWidth ?? undefined, |
| 300 | reservedLeadingWidth: |
| 301 | shouldRenderPrimarySidebar && showPrimarySidebar |
| 302 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) |
| 303 | : 0, |
| 304 | }), |
| 305 | [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], |
| 306 | ); |
| 307 | const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = |
| 308 | focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); |
| 309 | const auxiliaryPanePreferredVisible = |
| 310 | auxiliaryPaneRole === "inspector" |
| 311 | ? fileInspectorPreferredVisible |
| 312 | : supplementaryPanePreferredVisible; |
| 313 | const auxiliaryPanePreferredWidth = |
| 314 | auxiliaryPaneRole === "inspector" |
| 315 | ? fileInspectorPreferredWidth |
| 316 | : supplementaryPanePreferredWidth; |
| 317 | const panes = useMemo( |
| 318 | () => |
| 319 | deriveWorkspacePaneLayout({ |
| 320 | layout, |
| 321 | viewportWidth: width, |
| 322 | primarySidebarPreferredVisible: showPrimarySidebar, |
| 323 | auxiliaryPanePreferredVisible, |
| 324 | auxiliaryPaneRole, |
| 325 | auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, |
| 326 | }), |
| 327 | [ |
| 328 | auxiliaryPanePreferredVisible, |
| 329 | auxiliaryPaneRole, |
| 330 | auxiliaryPanePreferredWidth, |
| 331 | layout, |
| 332 | showPrimarySidebar, |
| 333 | width, |
| 334 | ], |
| 335 | ); |
| 336 | const activeThread = parseActiveThreadPath(pathname); |
| 337 | const environmentId = activeThread?.environmentId ?? null; |
| 338 | const threadId = activeThread?.threadId ?? null; |
| 339 | const selectedThreadKey = useMemo(() => { |
| 340 | if (environmentId === null || threadId === null) { |
| 341 | return null; |
| 342 | } |
| 343 | try { |
| 344 | return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); |
| 345 | } catch { |
| 346 | return null; |
| 347 | } |
| 348 | }, [environmentId, threadId]); |
| 349 | // Wrapped in an object: bare functions in useState would be treated as |
| 350 | // lazy initializers/updaters. `active: false` keeps the outgoing route's |
| 351 | // content mounted so the pane can animate closed (or be replaced |
| 352 | // seamlessly by the next route's registration in the same commit). |
| 353 | const [workspaceInspector, setWorkspaceInspector] = useState<{ |
| 354 | readonly render: () => ReactNode; |
| 355 | readonly active: boolean; |
| 356 | } | null>(null); |
| 357 | const workspaceInspectorOwner = useRef<symbol | null>(null); |
| 358 | const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { |
| 359 | const owner = Symbol("workspace-inspector"); |
| 360 | workspaceInspectorOwner.current = owner; |
| 361 | setWorkspaceInspector({ render, active: true }); |
| 362 | |
| 363 | return () => { |
| 364 | // During a push/replace the outgoing screen deactivates AFTER the |
| 365 | // incoming screen registered — only the current owner may deactivate. |
| 366 | if (workspaceInspectorOwner.current !== owner) { |
| 367 | return; |
| 368 | } |
| 369 | setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); |
| 370 | }; |
| 371 | }, []); |
| 372 | // Once the close animation settles, drop the stale content entirely. |
| 373 | const handleWorkspaceInspectorClosed = useCallback(() => { |
| 374 | setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); |
| 375 | }, []); |
| 376 | const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { |
| 377 | const owner = Symbol(role); |
| 378 | activeRoleOwner.current = owner; |
| 379 | setFocusedAuxiliaryPaneRole(role); |
| 380 | |
| 381 | return () => { |
| 382 | if (activeRoleOwner.current !== owner) { |
| 383 | return; |
| 384 | } |
| 385 | activeRoleOwner.current = null; |
| 386 | setFocusedAuxiliaryPaneRole(null); |
| 387 | }; |
| 388 | }, []); |
| 389 | const togglePrimarySidebar = useCallback(() => { |
| 390 | if (pathname === "/") { |
| 391 | return; |
| 392 | } |
| 393 | if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { |
| 394 | setFileInspectorPreferredVisible(false); |
| 395 | setPrimarySidebarPreferredVisible(true); |
| 396 | return; |
| 397 | } |
| 398 | setPrimarySidebarPreferredVisible((current) => !current); |
| 399 | }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible, pathname]); |
| 400 | const revealPrimarySidebar = useCallback(() => { |
| 401 | if (panes.primarySidebarSuppressedByAuxiliary) { |
| 402 | setFileInspectorPreferredVisible(false); |
| 403 | } |
| 404 | setPrimarySidebarPreferredVisible(true); |
| 405 | }, [panes.primarySidebarSuppressedByAuxiliary]); |
| 406 | const handleToggleSidebarCommand = useCallback(() => { |
| 407 | togglePrimarySidebar(); |
| 408 | return true; |
| 409 | }, [togglePrimarySidebar]); |
| 410 | const sidebarCommands = useMemo( |
| 411 | () => (pathname === "/" ? [] : (["toggleSidebar"] as const)), |
| 412 | [pathname], |
| 413 | ); |
| 414 | useHardwareKeyboardCommand(sidebarCommands, handleToggleSidebarCommand); |
| 415 | const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { |
| 416 | if (role === "inspector") { |
| 417 | setFocusedAuxiliaryPaneRole("inspector"); |
| 418 | setFileInspectorPreferredVisible(true); |
| 419 | return; |
| 420 | } |
| 421 | setFocusedAuxiliaryPaneRole("supplementary"); |
| 422 | setSupplementaryPanePreferredVisible(true); |
| 423 | }, []); |
| 424 | const handleOpenFilesCommand = useCallback(() => { |
| 425 | const activeThread = parseActiveThreadPath(pathname); |
| 426 | if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { |
| 427 | return false; |
| 428 | } |
| 429 | showAuxiliaryPane("inspector"); |
| 430 | if (/\/files(?:\/|$)/.test(pathname)) { |
| 431 | return true; |
| 432 | } |
| 433 | navigation.navigate("ThreadFiles", activeThread); |
| 434 | return true; |
| 435 | }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]); |
| 436 | useHardwareKeyboardCommand("files", handleOpenFilesCommand); |
| 437 | const toggleAuxiliaryPane = useCallback(() => { |
| 438 | if (auxiliaryPaneRole === "inspector") { |
| 439 | setFileInspectorPreferredVisible((current) => !current); |
| 440 | return; |
| 441 | } |
| 442 | setSupplementaryPanePreferredVisible((current) => !current); |
| 443 | }, [auxiliaryPaneRole]); |
| 444 | const setAuxiliaryPaneWidth = useCallback( |
| 445 | (nextWidth: number) => { |
| 446 | if (auxiliaryPaneRole === "inspector") { |
| 447 | setFileInspectorPreferredWidth(nextWidth); |
| 448 | return; |
| 449 | } |
| 450 | setSupplementaryPanePreferredWidth(nextWidth); |
| 451 | }, |
| 452 | [auxiliaryPaneRole], |
| 453 | ); |
| 454 | const handleOpenSettings = useCallback(() => { |
| 455 | navigation.navigate("SettingsSheet", { |
| 456 | screen: "SettingsContent", |
| 457 | params: { screen: "Settings" }, |
| 458 | }); |
| 459 | }, [navigation]); |
| 460 | |
| 461 | const handleStartNewTask = useCallback(() => { |
| 462 | navigation.navigate("NewTaskSheet", { screen: "NewTask" }); |
| 463 | }, [navigation]); |
| 464 | |
| 465 | // Minted here (root stack navigation) so the sidebar pane stays free of |
| 466 | // navigation hooks — on iOS it renders inside an independent nav tree. |
| 467 | const handleOpenEnvironmentSettings = useCallback(() => { |
| 468 | navigation.navigate("SettingsSheet", { |
| 469 | screen: "SettingsContent", |
| 470 | params: { screen: "SettingsEnvironments" }, |
| 471 | }); |
| 472 | }, [navigation]); |
| 473 | |
| 474 | const handleNewThreadOnBranch = useCallback( |
| 475 | (thread: EnvironmentThreadShell) => { |
| 476 | navigation.navigate("NewTaskSheet", { |
| 477 | screen: "NewTaskDraft", |
| 478 | params: { |
| 479 | environmentId: String(thread.environmentId), |
| 480 | projectId: String(thread.projectId), |
| 481 | branch: thread.branch, |
| 482 | worktreePath: thread.worktreePath, |
| 483 | }, |
| 484 | }); |
| 485 | }, |
| 486 | [navigation], |
| 487 | ); |
| 488 | |
| 489 | const handleNewThreadInProject = useCallback( |
| 490 | (project: EnvironmentProject) => { |
| 491 | navigation.navigate("NewTaskSheet", { |
| 492 | screen: "NewTaskDraft", |
| 493 | params: { |
| 494 | environmentId: String(project.environmentId), |
| 495 | projectId: String(project.id), |
| 496 | title: project.title, |
| 497 | }, |
| 498 | }); |
| 499 | }, |
| 500 | [navigation], |
| 501 | ); |
| 502 | |
| 503 | const inspectorColumnTargetWidth = |
| 504 | workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible |
| 505 | ? (panes.auxiliaryPaneWidth ?? 0) |
| 506 | : 0; |
| 507 | const contentTargetWidth = layout.usesSplitView |
| 508 | ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) |
| 509 | : null; |
| 510 | const [contentSizing, setContentSizing] = useState({ |
| 511 | viewportWidth, |
| 512 | target: contentTargetWidth, |
| 513 | sidebarVisible: panes.primarySidebarVisible, |
| 514 | width: contentTargetWidth, |
| 515 | revision: 0, |
| 516 | }); |
| 517 | if ( |
| 518 | Platform.OS === "android" && |
| 519 | (contentSizing.viewportWidth !== viewportWidth || |
| 520 | contentSizing.target !== contentTargetWidth || |
| 521 | contentSizing.sidebarVisible !== panes.primarySidebarVisible) |
| 522 | ) { |
| 523 | const openingSidebar = |
| 524 | Platform.OS === "android" && |
| 525 | contentSizing.viewportWidth === viewportWidth && |
| 526 | !contentSizing.sidebarVisible && |
| 527 | panes.primarySidebarVisible && |
| 528 | contentSizing.width !== null && |
| 529 | contentTargetWidth !== null; |
| 530 | setContentSizing({ |
| 531 | viewportWidth, |
| 532 | target: contentTargetWidth, |
| 533 | sidebarVisible: panes.primarySidebarVisible, |
| 534 | // Clip the wide navigator during expansion, then reflow once at the end. |
| 535 | // Narrowing it first exposes an empty strip before the sidebar arrives. |
| 536 | width: openingSidebar |
| 537 | ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0) |
| 538 | : contentTargetWidth, |
| 539 | revision: contentSizing.revision + 1, |
| 540 | }); |
| 541 | } |
| 542 | const settleContentWidth = useCallback((revision: number) => { |
| 543 | setContentSizing((current) => |
| 544 | current.revision !== revision || current.width === current.target |
| 545 | ? current |
| 546 | : { ...current, width: current.target }, |
| 547 | ); |
| 548 | }, []); |
| 549 | const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth; |
| 550 | const contentRevision = contentSizing.revision; |
| 551 | |
| 552 | const renderedSidebarWidth = useSharedValue( |
| 553 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, |
| 554 | ); |
| 555 | const renderedRailWidth = useSharedValue(navigationRailWidth); |
| 556 | useEffect(() => { |
| 557 | if (nativeWorkspace) return; |
| 558 | const targetWidth = panes.primarySidebarVisible |
| 559 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) |
| 560 | : 0; |
| 561 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING, (finished) => { |
| 562 | if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision); |
| 563 | }); |
| 564 | renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); |
| 565 | }, [ |
| 566 | nativeWorkspace, |
| 567 | navigationRailWidth, |
| 568 | layout.listPaneWidth, |
| 569 | layout.listPaneGap, |
| 570 | panes.primarySidebarVisible, |
| 571 | renderedSidebarWidth, |
| 572 | renderedRailWidth, |
| 573 | settleContentWidth, |
| 574 | contentRevision, |
| 575 | ]); |
| 576 | const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); |
| 577 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 578 | // Fading a UIKit glass ancestor can leave remounted header effects missing. |
| 579 | // The clipped width already reveals and hides the iOS sidebar. |
| 580 | opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80), |
| 581 | width: renderedSidebarWidth.value, |
| 582 | })); |
| 583 | |
| 584 | // Freeze the content pane at its SETTLED width while the side panes |
| 585 | // animate. The navigator (native header + markdown feed) lays out ONCE per |
| 586 | // pane toggle instead of re-measuring on every animation frame — the |
| 587 | // animating columns merely clip/reveal it over a matching background. |
| 588 | // Continuously re-wrapping the chat feed was the main source of dropped |
| 589 | // frames during sidebar/inspector transitions. |
| 590 | const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); |
| 591 | const renderedContentWidth = useDerivedValue(() => |
| 592 | Math.max( |
| 593 | 0, |
| 594 | viewportWidth - |
| 595 | renderedRailWidth.value - |
| 596 | renderedSidebarWidth.value - |
| 597 | renderedInspectorWidth.value, |
| 598 | ), |
| 599 | ); |
| 600 | |
| 601 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); |
| 602 | const handleSelectThread = useCallback( |
| 603 | (thread: EnvironmentThreadShell) => { |
| 604 | const params = { |
| 605 | environmentId: String(thread.environmentId), |
| 606 | threadId: String(thread.id), |
| 607 | }; |
| 608 | if (Platform.OS === "android") { |
| 609 | // Selecting a sidebar row enters its base chat, including from that chat's nested screens. |
| 610 | setFileInspectorPreferredVisible(false); |
| 611 | selectAndroidWorkspace({ name: "Thread", params }); |
| 612 | return; |
| 613 | } |
| 614 | const navigationAction = resolveThreadSelectionNavigationAction({ |
| 615 | usesSplitView: layout.usesSplitView, |
| 616 | pathname, |
| 617 | }); |
| 618 | const overlayState = resolveThreadSelectionOverlayState({ |
| 619 | state: navigation.getState(), |
| 620 | workspaceRouteKey: props.workspaceRouteKey, |
| 621 | action: navigationAction, |
| 622 | params, |
| 623 | }); |
| 624 | if (overlayState !== null) { |
| 625 | setFileInspectorPreferredVisible(false); |
| 626 | navigation.dispatch(CommonActions.reset(overlayState)); |
| 627 | return; |
| 628 | } |
| 629 | if (navigationAction === "set-params") { |
| 630 | const nextThreadKey = scopedThreadKey(thread.environmentId, thread.id); |
| 631 | if (nextThreadKey === selectedThreadKey) { |
| 632 | return; |
| 633 | } |
| 634 | setFileInspectorPreferredVisible(false); |
| 635 | navigation.navigate("Thread", params); |
| 636 | return; |
| 637 | } |
| 638 | if (navigationAction === "replace") { |
| 639 | setFileInspectorPreferredVisible(false); |
| 640 | navigation.dispatch(StackActions.replace("Thread", params)); |
| 641 | return; |
| 642 | } |
| 643 | navigation.navigate("Thread", params); |
| 644 | }, |
| 645 | [ |
| 646 | layout.usesSplitView, |
| 647 | pathname, |
| 648 | navigation, |
| 649 | selectedThreadKey, |
| 650 | props.workspaceRouteKey, |
| 651 | selectAndroidWorkspace, |
| 652 | ], |
| 653 | ); |
| 654 | |
| 655 | const contextValue = useMemo( |
| 656 | () => ({ |
| 657 | layout, |
| 658 | panes, |
| 659 | fileInspector, |
| 660 | primarySidebarSearchQuery, |
| 661 | selectThread: handleSelectThread, |
| 662 | activateAuxiliaryPaneRole, |
| 663 | registerWorkspaceInspector, |
| 664 | setPrimarySidebarSearchQuery, |
| 665 | showAuxiliaryPane, |
| 666 | toggleAuxiliaryPane, |
| 667 | togglePrimarySidebar, |
| 668 | setAuxiliaryPaneWidth, |
| 669 | }), |
| 670 | [ |
| 671 | activateAuxiliaryPaneRole, |
| 672 | fileInspector, |
| 673 | handleSelectThread, |
| 674 | layout, |
| 675 | panes, |
| 676 | primarySidebarSearchQuery, |
| 677 | registerWorkspaceInspector, |
| 678 | showAuxiliaryPane, |
| 679 | setPrimarySidebarSearchQuery, |
| 680 | setAuxiliaryPaneWidth, |
| 681 | toggleAuxiliaryPane, |
| 682 | togglePrimarySidebar, |
| 683 | ], |
| 684 | ); |
| 685 | |
| 686 | if (nativeWorkspace) { |
| 687 | return ( |
| 688 | <HomeListOptionsProvider |
| 689 | projectGroupingMode={projectGroupingMode} |
| 690 | savedFilters={props.savedThreadListFilters} |
| 691 | > |
| 692 | <AdaptiveWorkspaceContext value={contextValue}> |
| 693 | <NativeWorkspaceInspectorContext |
| 694 | value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }} |
| 695 | > |
| 696 | {props.children} |
| 697 | </NativeWorkspaceInspectorContext> |
| 698 | </AdaptiveWorkspaceContext> |
| 699 | </HomeListOptionsProvider> |
| 700 | ); |
| 701 | } |
| 702 | return ( |
| 703 | <HomeListOptionsProvider |
| 704 | projectGroupingMode={projectGroupingMode} |
| 705 | savedFilters={props.savedThreadListFilters} |
| 706 | > |
| 707 | <AdaptiveWorkspaceContext.Provider value={contextValue}> |
| 708 | <View |
| 709 | testID="adaptive-workspace-layout" |
| 710 | className={Platform.OS === "android" ? "flex-1 flex-row bg-header" : "flex-1 flex-row"} |
| 711 | > |
| 712 | {shouldRenderNavigationRail ? ( |
| 713 | <Animated.View |
| 714 | className="self-stretch overflow-hidden" |
| 715 | accessibilityElementsHidden={navigationRailWidth === 0} |
| 716 | importantForAccessibility={navigationRailWidth > 0 ? "auto" : "no-hide-descendants"} |
| 717 | pointerEvents={navigationRailWidth > 0 ? "auto" : "none"} |
| 718 | style={railAnimatedStyle} |
| 719 | > |
| 720 | <AndroidWorkspaceNavigation rail onSelect={revealPrimarySidebar} /> |
| 721 | </Animated.View> |
| 722 | ) : null} |
| 723 | {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( |
| 724 | <Animated.View |
| 725 | className="self-stretch overflow-hidden" |
| 726 | accessibilityElementsHidden={!panes.primarySidebarVisible} |
| 727 | collapsable={false} |
| 728 | importantForAccessibility={ |
| 729 | panes.primarySidebarVisible ? "auto" : "no-hide-descendants" |
| 730 | } |
| 731 | pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} |
| 732 | style={sidebarAnimatedStyle} |
| 733 | > |
| 734 | <View className="flex-1" style={{ width: layout.listPaneWidth }}> |
| 735 | <RenderErrorBoundary |
| 736 | renderFallback={(fallback) => ( |
| 737 | <RenderFailureView |
| 738 | {...fallback} |
| 739 | title="The sidebar couldn't be displayed" |
| 740 | exit={{ label: "Open settings", onPress: handleOpenSettings }} |
| 741 | /> |
| 742 | )} |
| 743 | > |
| 744 | <AndroidWorkspaceSidebar |
| 745 | onRequestVisibility={revealPrimarySidebar} |
| 746 | selectedPullRequestKey={props.selectedPullRequestKey ?? null} |
| 747 | > |
| 748 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> |
| 749 | <ThreadNavigationSidebar |
| 750 | width={layout.listPaneWidth} |
| 751 | visible={panes.primarySidebarVisible} |
| 752 | onRequestVisibility={revealPrimarySidebar} |
| 753 | selectedThreadKey={selectedThreadKey} |
| 754 | onOpenSettings={handleOpenSettings} |
| 755 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} |
| 756 | onNewThreadInProject={handleNewThreadInProject} |
| 757 | onNewThreadOnBranch={handleNewThreadOnBranch} |
| 758 | onSelectThread={handleSelectThread} |
| 759 | onSearchQueryChange={setPrimarySidebarSearchQuery} |
| 760 | searchQuery={primarySidebarSearchQuery} |
| 761 | /> |
| 762 | </AndroidHomeFabLayout> |
| 763 | </AndroidWorkspaceSidebar> |
| 764 | </RenderErrorBoundary> |
| 765 | </View> |
| 766 | </Animated.View> |
| 767 | ) : null} |
| 768 | <View |
| 769 | className={ |
| 770 | Platform.OS === "android" |
| 771 | ? "flex-1 overflow-hidden bg-header" |
| 772 | : "flex-1 overflow-hidden bg-screen" |
| 773 | } |
| 774 | collapsable={false} |
| 775 | > |
| 776 | <View |
| 777 | collapsable={false} |
| 778 | style={ |
| 779 | contentSettledWidth !== null |
| 780 | ? { |
| 781 | flex: 1, |
| 782 | width: contentSettledWidth, |
| 783 | } |
| 784 | : { flex: 1 } |
| 785 | } |
| 786 | > |
| 787 | <WorkspaceContentWidthContext |
| 788 | value={layout.usesSplitView ? renderedContentWidth : null} |
| 789 | > |
| 790 | {props.children} |
| 791 | </WorkspaceContentWidthContext> |
| 792 | </View> |
| 793 | </View> |
| 794 | <WorkspaceInspectorPane |
| 795 | pathname={props.pathname} |
| 796 | renderedInspectorWidth={renderedInspectorWidth} |
| 797 | active={workspaceInspector?.active ?? false} |
| 798 | panes={panes} |
| 799 | renderInspector={workspaceInspector?.render} |
| 800 | setAuxiliaryPaneWidth={setAuxiliaryPaneWidth} |
| 801 | onClosed={handleWorkspaceInspectorClosed} |
| 802 | /> |
| 803 | </View> |
| 804 | </AdaptiveWorkspaceContext.Provider> |
| 805 | </HomeListOptionsProvider> |
| 806 | ); |
| 807 | } |