1import {
2 WorkspaceColumnsRendererContext,
3 WorkspacePresentationHostContext,
4} from "./workspace-presentation-context";
5import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider";
6import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
7import {
8 AdaptiveWorkspaceContext,
9 WorkspacePaneStateContext,
10 useAdaptiveWorkspaceLayout,
11 useWorkspacePaneState,
12 type AdaptiveWorkspaceActions,
13} from "./adaptive-workspace-context";
14import { threadSelectionDeferral } from "./deferred-navigation";
15import { uuidv4 } from "../../lib/uuid";
16import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
17import { useAtomValue } from "@effect/atom-react";
18import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native";
19import {
20 use,
21 useEffect,
22 useEffectEvent,
23 useLayoutEffect,
24 useMemo,
25 useRef,
26 useState,
27 type ReactNode,
28} from "react";
29import {
30 BackHandler,
31 Keyboard,
32 Platform,
33 Pressable,
34 useWindowDimensions,
35 View,
36} from "react-native";
37import Animated, {
38 runOnJS,
39 useAnimatedStyle,
40 useDerivedValue,
41 useSharedValue,
42 withTiming,
43} from "react-native-reanimated";
44import { AsyncResult } from "effect/reactivity";
45
46import {
47 deriveFileInspectorPaneLayout,
48 alignListPaneToHinge,
49 deriveLayout,
50 type VerticalHinge,
51 deriveWorkspacePaneLayout,
52 isPrimarySidebarPinned,
53 isWorkspaceListPath,
54 nativeInspectorColumnWidth,
55} from "../../lib/layout";
56import type { ThreadListFilters } from "../../persistence/mobile-preferences";
57import { appAtomRegistry } from "../../state/atom-registry";
58import { useProjects } from "../../state/entities";
59import { environmentThreadShells } from "../../state/threads";
60import { withKnownPullRequestSummary } from "../../state/use-thread-pr";
61import { mobilePreferencesAtom } from "../../state/preferences";
62import {
63 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
64 resolveMobileProjectGroupingSettings,
65} from "../../state/project-grouping";
66import {
67 parseActiveThreadPath,
68 useHardwareKeyboardCommand,
69} from "../keyboard/hardwareKeyboardCommands";
70import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
71import { useEnabledNavigationTabs } from "./workspace/navigation-tabs";
72import { FLOATING_TABS, floatingTabsMetrics } from "./workspace/floating-workspace-tabs";
73import {
74 AndroidWorkspaceNavigation,
75 FloatingWorkspaceTabs,
76 ANDROID_WORKSPACE_RAIL_WIDTH,
77} from "./workspace/AndroidWorkspaceNavigation";
78import {
79 selectWorkspaceDetail,
80 type WorkspaceDetail,
81 type WorkspaceDetailSheet,
82} from "./workspace/workspace-detail-selection";
83import { startOpeningDetail } from "./workspace/useSelectWorkspaceDetail";
84import { useWorkspaceTabs } from "./workspace/WorkspaceTabsContext";
85import { HomeListOptionsProvider } from "../home/home-list-options";
86import { newTaskDestination } from "../threads/new-task-project-selection";
87import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
88import { PaneContext } from "../../components/ui/Pane";
89import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
90import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
91import { WorkspaceInspectorSheet } from "./workspace-inspector-sheet";
92import type { InspectorContent } from "./inspector-panes";
93import {
94 WorkspaceContentWidthContext,
95 WorkspaceSettledContentWidthContext,
96} from "./workspace-content-width";
97import {
98 NativeWorkspaceModeContext,
99 NativeWorkspaceInspectorContext,
100} from "../../native/v5-workspace-context";
101
102import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
103import { AndroidHingeObserver } from "../../native/AndroidHingeObserver";
104import { WindowHingeContext } from "../../native/window-hinge-context";
105import { useAndroidWorkspaceRoot } from "./workspace/AndroidWorkspaceTabsProvider";
106import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset";
107import {
108 currentWorkspaceSelections,
109 workspaceSelectionCheck,
110} from "./workspace/workspace-selection-store";
111import {
112 workspaceTabColumns,
113 workspaceTabNavigationState,
114} from "./workspace/workspace-tab-navigation";
115import { workspaceTabOf, type WorkspaceTab } from "./workspace/workspace-tabs-state";
116import { workspaceRoutesOf } from "./workspace-overlay-routes";
117import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout";
118import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar";
119
120export { useAdaptiveWorkspaceLayout, useWorkspacePaneState };
121
122/**
123 * Shows these panes in the workspace's inspector while this screen is focused. Register on focus
124 * events because frozen blurred screens may never clean up.
125 */
126export function Inspector(props: {
127 readonly content: InspectorContent;
128 /** Opens the column the first time this key registers while its route stays on the stack. */
129 readonly revealKey?: string;
130}) {
131 const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout();
132 const navigation = use(NavigationContext);
133 const route = use(NavigationRouteContext);
134 const content: InspectorContent = {
135 ...props.content,
136 resetKeys: [route?.key, ...(props.content.resetKeys ?? [])],
137 panes: props.content.panes.map((pane) => ({
138 ...pane,
139 render: () => (
140 <NavigationContext.Provider value={navigation}>
141 <NavigationRouteContext.Provider value={route}>
142 {pane.render()}
143 </NavigationRouteContext.Provider>
144 </NavigationContext.Provider>
145 ),
146 })),
147 };
148 const focusedRef = useRef(false);
149 const deactivateRef = useRef<(() => void) | null>(null);
150
151 const syncRegistration = useEffectEvent(() => {
152 if (!focusedRef.current) {
153 deactivateRef.current?.();
154 return;
155 }
156 deactivateRef.current = registerWorkspaceInspector(content, route?.key, props.revealKey);
157 });
158
159 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Navigation focus events own the inspector registration lifetime.
160 useEffect(() => {
161 if (navigation === undefined) return;
162 const focus = () => {
163 if (focusedRef.current) return;
164 focusedRef.current = true;
165 syncRegistration();
166 };
167 const blur = () => {
168 focusedRef.current = false;
169 syncRegistration();
170 };
171 if (navigation.isFocused()) focus();
172 const unsubscribeFocus = navigation.addListener("focus", focus);
173 const unsubscribeBlur = navigation.addListener("blur", blur);
174 return () => {
175 unsubscribeFocus();
176 unsubscribeBlur();
177 focusedRef.current = false;
178 deactivateRef.current?.();
179 deactivateRef.current = null;
180 };
181 }, [navigation]);
182
183 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Changed column content replaces the focused inspector registration.
184 useEffect(() => {
185 if (focusedRef.current) {
186 syncRegistration();
187 }
188 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed column content replaces its existing native workspace registration.
189 }, [props.content, props.revealKey]);
190 return null;
191}
192
193/**
194 * Actions whose identities never change, so a pane toggle doesn't re-render every screen that
195 * holds one. Each call runs the latest render's handler.
196 */
197function useStableActions(actions: Required<AdaptiveWorkspaceActions>): AdaptiveWorkspaceActions {
198 const latest = useRef(actions);
199 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- points the stable actions at the handlers this commit rendered
200 useLayoutEffect(() => {
201 latest.current = actions;
202 });
203 const [stable] = useState((): AdaptiveWorkspaceActions => ({
204 startNewTask: () => latest.current.startNewTask(),
205 selectDetail: (detail, sheet) => latest.current.selectDetail(detail, sheet),
206 registerWorkspaceInspector: (content, routeKey, revealKey) =>
207 latest.current.registerWorkspaceInspector(content, routeKey, revealKey),
208 setPrimarySidebarSearchQuery: (query) => latest.current.setPrimarySidebarSearchQuery(query),
209 showInspector: () => latest.current.showInspector(),
210 toggleAuxiliaryPane: () => latest.current.toggleAuxiliaryPane(),
211 togglePrimarySidebar: () => latest.current.togglePrimarySidebar(),
212 revealPrimarySidebar: () => latest.current.revealPrimarySidebar(),
213 setPrimarySidebarVisible: (visible) => latest.current.setPrimarySidebarVisible(visible),
214 openWorkspaceTab: (tab, options) => latest.current.openWorkspaceTab(tab, options),
215 setAuxiliaryPaneWidth: (width) => latest.current.setAuxiliaryPaneWidth(width),
216 setNativeInspectorWidth: (width) => latest.current.setNativeInspectorWidth(width),
217 }));
218 return stable;
219}
220
221export function AdaptiveWorkspaceLayout(props: {
222 readonly children: ReactNode;
223 readonly pathname: string;
224 readonly workspaceRouteKey: string | undefined;
225 readonly workspaceRouteName: string | undefined;
226}) {
227 const preferencesResult = useAtomValue(mobilePreferencesAtom);
228 if (!AsyncResult.isSuccess(preferencesResult)) {
229 return AsyncResult.isFailure(preferencesResult) ? (
230 <AdaptiveWorkspaceLayoutContent
231 {...props}
232 projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode}
233 />
234 ) : null;
235 }
236 const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value);
237 return (
238 <AdaptiveWorkspaceLayoutContent
239 {...props}
240 projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}
241 savedThreadListFilters={preferencesResult.value.threadListFilters}
242 />
243 );
244}
245
246function AdaptiveWorkspaceLayoutContent(
247 props: {
248 readonly children: ReactNode;
249 readonly pathname: string;
250 readonly workspaceRouteKey: string | undefined;
251 readonly workspaceRouteName: string | undefined;
252 } & {
253 readonly projectGroupingMode: SidebarProjectGroupingMode;
254 readonly savedThreadListFilters?: ThreadListFilters | undefined;
255 },
256) {
257 const floatingTabsEnabled = useAndroidFloatingTabs();
258 const projectGroupingMode = props.projectGroupingMode;
259 const nativeWorkspace = use(NativeWorkspaceModeContext);
260 const usesPresentationHost = use(WorkspacePresentationHostContext);
261 const windowDimensions = useWindowDimensions();
262 const nativeMetrics = useNativeLayoutMetrics();
263 const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions;
264 const pathname = props.pathname;
265 const workspace = useWorkspaceTabs();
266 // Settings and Usage occupy the workspace rather than a list/detail pair.
267 const androidSettingsOpen = useAndroidWorkspaceRoot()?.settingsOpen ?? false;
268 const safeAreaInsets = useSafeAreaInsets();
269 const workspacePage =
270 Platform.OS === "android" && (androidSettingsOpen || workspace?.state.selectedTab === "usage");
271 const navigation = useNavigation();
272 const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true);
273 const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true);
274 const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4);
275 const horizontalSafeArea =
276 (nativeMetrics?.safeArea.left ?? 0) + (nativeMetrics?.safeArea.right ?? 0);
277 const inspectorWidthFor = (fraction: number) =>
278 nativeInspectorColumnWidth({ viewportWidth, horizontalSafeArea, fraction });
279 const nativeInspectorWidth = inspectorWidthFor(nativeInspectorWidthFraction);
280 const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState<number | null>(
281 null,
282 );
283 const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");
284 const [railOverlay, setRailOverlay] = useState(false);
285 // The compiler can't memoize these derivations: they stay live across later hook calls. Fresh
286 // objects here change the workspace context on every render, so every workspace screen
287 // re-renders on any layout state change, and the inspector registration then loops: each
288 // re-register re-renders its screen, which hands over a new inspector renderer.
289 const baseLayout = useMemo(
290 () => deriveLayout({ width: viewportWidth, height, nativeMetrics, nativeWorkspace }),
291 [height, viewportWidth, nativeMetrics, nativeWorkspace],
292 );
293 // The overlay sidebar closes when a page opens from it, and stays open on a list or on the
294 // page a tab switch opened (`keepOpenFor`, set by that switch).
295 const [overlaySidebar, setOverlaySidebar] = useState({
296 active: baseLayout.variant === "medium",
297 pathname,
298 visible: isWorkspaceListPath(pathname),
299 keepOpenFor: null as string | null,
300 });
301 if (
302 overlaySidebar.active !== (baseLayout.variant === "medium") ||
303 (overlaySidebar.active && overlaySidebar.pathname !== pathname)
304 ) {
305 setOverlaySidebar({
306 active: baseLayout.variant === "medium",
307 pathname,
308 visible:
309 isWorkspaceListPath(pathname) ||
310 (overlaySidebar.keepOpenFor !== null &&
311 overlaySidebar.keepOpenFor === props.workspaceRouteKey),
312 keepOpenFor: null,
313 });
314 }
315 const preferredSidebarVisible =
316 baseLayout.variant === "medium" ? overlaySidebar.visible : primarySidebarPreferredVisible;
317 const primarySidebarPinned = isPrimarySidebarPinned({
318 pathname,
319 nativeWorkspace,
320 usesSplitView: baseLayout.usesSplitView,
321 });
322 const showPrimarySidebar = !workspacePage && (primarySidebarPinned || preferredSidebarVisible);
323 // Medium windows float the sidebar over a page; beside a list it keeps its room.
324 const sidebarFloats =
325 baseLayout.variant === "medium" &&
326 (nativeWorkspace || (!primarySidebarPinned && !workspacePage));
327 const shouldRenderNavigationRail =
328 Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled;
329 const enabledTabs = useEnabledNavigationTabs();
330 const { scale, fabSize } = useAndroidControlSizing();
331 const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize);
332 // The rail stays whether or not the sidebar shows; only the list pane comes and goes.
333 const navigationRailWidth = shouldRenderNavigationRail ? ANDROID_WORKSPACE_RAIL_WIDTH : 0;
334 const [hinge, setHinge] = useState<VerticalHinge | null>(null);
335 const [windowHinge, setWindowHinge] = useState<VerticalHinge | null>(null);
336 const handleHingeChange = (next: VerticalHinge | null, nextWindow: VerticalHinge | null) => {
337 setHinge(next);
338 setWindowHinge(nextWindow);
339 };
340 const layout = useMemo(() => {
341 const aligned = alignListPaneToHinge({
342 layout: baseLayout,
343 hinge,
344 viewportWidth,
345 leadingWidth: navigationRailWidth,
346 });
347 if (
348 !floatingTabsEnabled ||
349 !aligned.usesSplitView ||
350 aligned.listPaneGap !== undefined ||
351 aligned.listPaneWidth === null ||
352 aligned.listPaneWidth >= floatingTabs.row ||
353 viewportWidth - floatingTabs.row < 320
354 )
355 return aligned;
356 return { ...aligned, listPaneWidth: floatingTabs.row };
357 }, [
358 baseLayout,
359 hinge,
360 navigationRailWidth,
361 viewportWidth,
362 floatingTabs.row,
363 floatingTabsEnabled,
364 ]);
365 // The rail keeps its room even while the sidebar floats; only the list pane overlays the page.
366 const width = viewportWidth - navigationRailWidth;
367 // The sidebar stays mounted under a workspace page, so its list is ready on the way back.
368 const shouldRenderPrimarySidebar = layout.usesSplitView;
369 const fileInspector = useMemo(
370 () =>
371 deriveFileInspectorPaneLayout({
372 layout,
373 viewportWidth: width,
374 preferredWidth: fileInspectorPreferredWidth ?? undefined,
375 reservedLeadingWidth:
376 shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats
377 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
378 : 0,
379 }),
380 [
381 fileInspectorPreferredWidth,
382 layout,
383 showPrimarySidebar,
384 shouldRenderPrimarySidebar,
385 sidebarFloats,
386 width,
387 ],
388 );
389 const [workspaceInspector, setWorkspaceInspector] = useState<{
390 readonly content: InspectorContent;
391 readonly active: boolean;
392 readonly routeKey: string | undefined;
393 } | null>(null);
394 // Without a column the inspector is a sheet, opened only on request for the route on top, and
395 // shown again when that route returns to the top.
396 const [inspectorSheetRoute, setInspectorSheetRoute] = useState<string | null>(null);
397 const inspectorSheetOpen =
398 !fileInspector.supported &&
399 !workspacePage &&
400 inspectorSheetRoute !== null &&
401 workspaceInspector?.active === true &&
402 workspaceInspector.routeKey === inspectorSheetRoute;
403 const inspectorActive = workspaceInspector?.active ?? false;
404 const panes = useMemo(
405 () =>
406 deriveWorkspacePaneLayout({
407 layout,
408 viewportWidth: width,
409 horizontalSafeArea,
410 nativeWorkspace,
411 primarySidebarFloats: sidebarFloats,
412 primarySidebarPreferredVisible: showPrimarySidebar,
413 // Only a column a screen fills makes room, so a stale preference can't hide the sidebar.
414 auxiliaryPanePreferredVisible:
415 !workspacePage && inspectorActive && fileInspectorPreferredVisible,
416 auxiliaryPanePreferredWidth: fileInspectorPreferredWidth ?? undefined,
417 nativeInspectorWidth,
418 }),
419 [
420 nativeInspectorWidth,
421 horizontalSafeArea,
422 fileInspectorPreferredVisible,
423 fileInspectorPreferredWidth,
424 inspectorActive,
425 layout,
426 nativeWorkspace,
427 showPrimarySidebar,
428 sidebarFloats,
429 width,
430 workspacePage,
431 ],
432 );
433 // Routes still on the stack remember their reveal, so going back to one keeps the user's choice.
434 const revealedInspectors = useRef<ReadonlyMap<string, string>>(new Map());
435 const workspaceInspectorOwner = useRef<symbol | null>(null);
436 const registerWorkspaceInspector = (
437 content: InspectorContent,
438 routeKey: string | undefined,
439 revealKey?: string,
440 ) => {
441 const owner = Symbol("workspace-inspector");
442 workspaceInspectorOwner.current = owner;
443 setWorkspaceInspector({ content, active: true, routeKey });
444 if (
445 routeKey !== undefined &&
446 revealKey !== undefined &&
447 revealedInspectors.current.get(routeKey) !== revealKey
448 ) {
449 const state = rootStackState(navigation);
450 const live = new Set(state?.routes.slice(0, state.index + 1).map((route) => route.key));
451 revealedInspectors.current = new Map(
452 [...revealedInspectors.current, [routeKey, revealKey] as const].filter(
453 ([key]) => live.has(key) || key === routeKey,
454 ),
455 );
456 setFileInspectorPreferredVisible(true);
457 }
458
459 return () => {
460 // Outgoing screens deactivate after incoming registration; only the current owner may deactivate.
461 if (workspaceInspectorOwner.current !== owner) {
462 return;
463 }
464 setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false }));
465 };
466 };
467 const handleWorkspaceInspectorClosed = () => {
468 setWorkspaceInspector((current) => (current !== null && !current.active ? null : current));
469 };
470 const setPrimarySidebarVisible = (visible: boolean) => {
471 if (layout.variant === "medium") {
472 setOverlaySidebar((current) => ({ ...current, visible }));
473 } else {
474 setPrimarySidebarPreferredVisible(visible);
475 }
476 };
477 const togglePrimarySidebar = () => {
478 if (primarySidebarPinned) {
479 return;
480 }
481 if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) {
482 setFileInspectorPreferredVisible(false);
483 setPrimarySidebarPreferredVisible(true);
484 return;
485 }
486 setPrimarySidebarVisible(!preferredSidebarVisible);
487 };
488 const revealPrimarySidebar = () => {
489 if (panes.primarySidebarSuppressedByAuxiliary) {
490 setFileInspectorPreferredVisible(false);
491 }
492 setPrimarySidebarVisible(true);
493 };
494 const handleToggleSidebarCommand = () => {
495 togglePrimarySidebar();
496 return true;
497 };
498 const sidebarCommands = primarySidebarPinned ? [] : (["toggleSidebar"] as const);
499 useHardwareKeyboardCommand(sidebarCommands, handleToggleSidebarCommand);
500 // Resizing into a column closes the sheet without this, so the inspector moves into the column.
501 const dismissInspectorSheet = () => {
502 setFileInspectorPreferredVisible(false);
503 setInspectorSheetRoute(null);
504 };
505 const showInspector = () => {
506 setFileInspectorPreferredVisible(true);
507 if (fileInspector.supported) {
508 setInspectorSheetRoute(null);
509 return;
510 }
511 const state = rootStackState(navigation);
512 const top = workspaceRoutesOf(state?.routes.slice(0, state.index + 1) ?? []).workspace.at(-1);
513 setInspectorSheetRoute(top?.key ?? null);
514 };
515 const handleOpenFilesCommand = () => {
516 const activeThread = parseActiveThreadPath(pathname);
517 if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) {
518 return false;
519 }
520 showInspector();
521 if (props.workspaceRouteName === "ThreadFiles" || props.workspaceRouteName === "ThreadFile") {
522 return true;
523 }
524 navigation.navigate("ThreadFiles", activeThread);
525 return true;
526 };
527 useHardwareKeyboardCommand("files", handleOpenFilesCommand);
528 const toggleAuxiliaryPane = () => {
529 if (fileInspector.supported) {
530 setFileInspectorPreferredVisible((current) => !current);
531 setInspectorSheetRoute(null);
532 } else if (inspectorSheetOpen) dismissInspectorSheet();
533 else showInspector();
534 };
535 /**
536 * The one way to open a workspace tab: replaces the workspace with the tab's list and, when the
537 * window has columns, the page it last showed, keeping any sheet on top. An overlay sidebar
538 * stays open over that page.
539 */
540 const openWorkspaceTab = (
541 tab: WorkspaceTab | "settings",
542 options?: { readonly columns?: boolean },
543 ) => {
544 const state = rootStackState(navigation);
545 if (!state) return;
546 const { workspace, overlays } = workspaceRoutesOf(state.routes.slice(0, state.index + 1));
547 const next = workspaceTabNavigationState(
548 { ...state, routes: workspace },
549 tab,
550 currentWorkspaceSelections(),
551 {
552 columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView),
553 check: workspaceSelectionCheck,
554 overlays,
555 seedPullRequest: withKnownPullRequestSummary,
556 },
557 );
558 if (layout.variant === "medium") {
559 const opened = next.routes.at(-1 - overlays.length)?.key ?? null;
560 setOverlaySidebar((current) => ({ ...current, visible: true, keepOpenFor: opened }));
561 }
562 dispatchRootStackReset(navigation, state, next);
563 };
564 const handleOpenSettings = () => openWorkspaceTab("settings");
565
566 /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */
567 function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) {
568 threadSelectionDeferral.run(() => {
569 if (Platform.OS === "android") Keyboard.dismiss();
570 setFileInspectorPreferredVisible(false);
571 setInspectorSheetRoute(null);
572 if (layout.variant === "medium") setPrimarySidebarVisible(false);
573 workspace?.dispatch({ type: "select", tab: workspaceTabOf(detail.name) });
574 startOpeningDetail(detail);
575 navigation.dispatch(selectWorkspaceDetail(detail, sheet));
576 });
577 }
578 const handleSelectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) =>
579 handleSelectDetail({ name: "Thread", params });
580
581 const projects = useProjects();
582 const compactAndroidSettings =
583 Platform.OS === "android" && androidSettingsOpen && !layout.usesSplitView;
584 // Reads the open page when pressed, so the layout context doesn't change on every navigation.
585 const handleStartNewTask = () => {
586 const state = rootStackState(navigation);
587 const currentRoute = workspaceRoutesOf(
588 state?.routes.slice(0, state.index + 1) ?? [],
589 ).workspace.at(-1);
590 const params =
591 currentRoute?.name === "Thread"
592 ? (currentRoute.params as ReactNavigation.RootParamList["Thread"])
593 : undefined;
594 const first = (value: string | string[] | undefined) =>
595 Array.isArray(value) ? value[0] : value;
596 const environmentId = first(params?.environmentId);
597 const threadId = first(params?.threadId);
598 // The full index, not the sidebar list: subagent and archived threads are current threads too.
599 const projectId =
600 environmentId && threadId
601 ? appAtomRegistry
602 .get(
603 environmentThreadShells.environmentThreadIndexAtom(EnvironmentId.make(environmentId)),
604 )
605 .get(ThreadId.make(threadId))?.projectId
606 : undefined;
607 const preferences = appAtomRegistry.get(mobilePreferencesAtom);
608 const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined;
609 handleSelectThreadRoute(
610 newTaskDestination({
611 projects,
612 requestId: uuidv4(),
613 useLastProject: saved?.useLastProjectForNewThreads,
614 currentProject: environmentId && projectId ? { environmentId, projectId } : undefined,
615 lastProject: saved?.lastThreadProject,
616 }),
617 );
618 };
619
620 useHardwareKeyboardCommand("newTask", () => {
621 handleStartNewTask();
622 return true;
623 });
624
625 const inspectorColumnTargetWidth = panes.auxiliaryPaneVisible
626 ? (panes.auxiliaryPaneWidth ?? 0)
627 : 0;
628 // Android's split content is a rounded card: keep it off the screen edge like the list card.
629 const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0;
630 // Android's split is one strip: a sidebar slot of fixed width, then the detail pane. The
631 // sidebar never resizes; both slide by one offset, so together they always fill the width.
632 const stripSidebar =
633 Platform.OS === "android" &&
634 layout.usesSplitView &&
635 layout.listPaneWidth !== null &&
636 !workspacePage &&
637 !sidebarFloats;
638 const sidebarSlotWidth = (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0);
639 // The pane takes its new width in the commit that starts the sidebar moving, so it rewraps as
640 // the strip starts to slide rather than after it lands.
641 const contentSpansSidebar = stripSidebar && !panes.primarySidebarVisible;
642 const contentTargetWidth = !layout.usesSplitView
643 ? null
644 : Math.max(
645 0,
646 (contentSpansSidebar ? width : panes.contentPaneWidth) -
647 inspectorColumnTargetWidth -
648 contentEndGap,
649 );
650 // A workspace page (Usage) lays out at this width even while hidden behind another tab, so
651 // opening it widens nothing it measured.
652 const workspacePageWidth = !layout.usesSplitView
653 ? null
654 : workspacePage
655 ? contentTargetWidth
656 : Math.max(
657 0,
658 viewportWidth -
659 (shouldRenderNavigationRail ? ANDROID_WORKSPACE_RAIL_WIDTH : 0) -
660 contentEndGap,
661 );
662 const [listPaneSizing, setListPaneSizing] = useState({
663 viewportWidth,
664 workspacePage,
665 sidebarVisible: panes.primarySidebarVisible,
666 listPaneTarget: layout.listPaneWidth,
667 listPaneFloor: layout.listPaneWidth,
668 listPaneCeiling: layout.listPaneWidth,
669 revision: 0,
670 });
671 if (
672 Platform.OS === "android" &&
673 (listPaneSizing.viewportWidth !== viewportWidth ||
674 listPaneSizing.workspacePage !== workspacePage ||
675 listPaneSizing.sidebarVisible !== panes.primarySidebarVisible ||
676 listPaneSizing.listPaneTarget !== layout.listPaneWidth)
677 ) {
678 // A book-posture hinge resizes the visible list pane instead of revealing it.
679 const resizingSidebar =
680 listPaneSizing.viewportWidth === viewportWidth &&
681 listPaneSizing.workspacePage === workspacePage &&
682 listPaneSizing.sidebarVisible &&
683 panes.primarySidebarVisible &&
684 listPaneSizing.listPaneTarget !== layout.listPaneWidth;
685 setListPaneSizing({
686 viewportWidth,
687 workspacePage,
688 sidebarVisible: panes.primarySidebarVisible,
689 listPaneTarget: layout.listPaneWidth,
690 // Retargets widen the list-width span because the animated hinge edge may still be inside it.
691 listPaneFloor: resizingSidebar
692 ? Math.min(listPaneSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0)
693 : layout.listPaneWidth,
694 listPaneCeiling: resizingSidebar
695 ? Math.max(listPaneSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0)
696 : layout.listPaneWidth,
697 revision: listPaneSizing.revision + 1,
698 });
699 }
700 const settleListPane = (revision: number) => {
701 setListPaneSizing((current) =>
702 current.revision !== revision ||
703 (current.listPaneFloor === current.listPaneTarget &&
704 current.listPaneCeiling === current.listPaneTarget)
705 ? current
706 : {
707 ...current,
708 listPaneFloor: current.listPaneTarget,
709 listPaneCeiling: current.listPaneTarget,
710 },
711 );
712 };
713 const listPaneFloor = listPaneSizing.listPaneFloor ?? 0;
714 const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0;
715 const listPaneRevision = listPaneSizing.revision;
716 // Android lays the content out at its new width as soon as a pane toggles, then holds it
717 // against the edge that stays put while the other one slides, so nothing reflows after the
718 // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content
719 // until it is gone.
720 const contentLeftTarget = sidebarFloats
721 ? navigationRailWidth
722 : navigationRailWidth +
723 (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible
724 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
725 : 0);
726 const [contentAnchor, setContentAnchor] = useState({
727 viewportWidth,
728 left: contentLeftTarget,
729 inspector: inspectorColumnTargetWidth,
730 end: false,
731 });
732 if (
733 Platform.OS === "android" &&
734 (contentAnchor.viewportWidth !== viewportWidth ||
735 contentAnchor.left !== contentLeftTarget ||
736 contentAnchor.inspector !== inspectorColumnTargetWidth)
737 ) {
738 setContentAnchor({
739 viewportWidth,
740 left: contentLeftTarget,
741 inspector: inspectorColumnTargetWidth,
742 end:
743 contentAnchor.viewportWidth === viewportWidth &&
744 contentAnchor.inspector === inspectorColumnTargetWidth,
745 });
746 }
747
748 const renderedSidebarWidth = useSharedValue(
749 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,
750 );
751 const renderedRailWidth = useSharedValue(navigationRailWidth);
752 const leavingWorkspacePage = useRef(false);
753 // The slide starts after the commit that lays the pane out, not in the press handler: Reanimated
754 // commits the shadow tree every frame, and that starves a large React commit until it stops.
755 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated rail and sidebar width shared values toward the layout's targets
756 useEffect(() => {
757 if (nativeWorkspace) return;
758 renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING));
759 // A workspace page hides the sidebar through React instead, in the same commit that mounts
760 // the page. Animating it here would hide it a few frames before the page appears.
761 if (workspacePage) {
762 leavingWorkspacePage.current = true;
763 return;
764 }
765 const snap = leavingWorkspacePage.current;
766 leavingWorkspacePage.current = false;
767 const targetWidth = panes.primarySidebarVisible
768 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
769 : 0;
770 // The content beside it already took its final width when the page closed.
771 renderedSidebarWidth.set(
772 withTiming(targetWidth, snap ? { duration: 0 } : WORKSPACE_PANE_TIMING, (finished) => {
773 // Only a hinge resize widens the list's span; a toggle has nothing to settle.
774 if (finished && Platform.OS === "android" && listPaneFloor !== listPaneCeiling) {
775 runOnJS(settleListPane)(listPaneRevision);
776 }
777 }),
778 );
779 }, [
780 nativeWorkspace,
781 navigationRailWidth,
782 layout.listPaneWidth,
783 layout.listPaneGap,
784 panes.primarySidebarVisible,
785 renderedSidebarWidth,
786 renderedRailWidth,
787 settleListPane,
788 listPaneFloor,
789 listPaneCeiling,
790 listPaneRevision,
791 workspacePage,
792 ]);
793 const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value }));
794 const floatingSidebarAnimatedStyle = useAnimatedStyle(() => ({
795 left: sidebarFloats ? renderedRailWidth.value : 0,
796 }));
797 const floatingSidebarOpen = sidebarFloats && panes.primarySidebarVisible;
798 // Its own value, so a sidebar that was beside a list doesn't dim the page as it leaves.
799 const scrimOpacity = useSharedValue(0);
800 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated scrim with the floating sidebar
801 useEffect(() => {
802 scrimOpacity.set(withTiming(floatingSidebarOpen ? 0.32 : 0, WORKSPACE_PANE_TIMING));
803 }, [floatingSidebarOpen, scrimOpacity]);
804 const scrimAnimatedStyle = useAnimatedStyle(() => ({ opacity: scrimOpacity.value }));
805 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Back closes a floating sidebar before it leaves the page
806 useEffect(() => {
807 if (!floatingSidebarOpen || nativeWorkspace) return;
808 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
809 // A sheet or modal over the workspace takes Back first.
810 const state = rootStackState(navigation);
811 if (state && workspaceRoutesOf(state.routes.slice(0, state.index + 1)).overlays.length > 0) {
812 return false;
813 }
814 setPrimarySidebarVisible(false);
815 return true;
816 });
817 return () => subscription.remove();
818 }, [floatingSidebarOpen, nativeWorkspace, navigation, setPrimarySidebarVisible]);
819 // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the
820 // animated edge, so the header actions slide with it rather than jumping or being covered.
821 const listPaneGap = layout.listPaneGap ?? 0;
822 const listPaneAnimatedStyle = useAnimatedStyle(() => ({
823 width: Math.min(
824 listPaneCeiling,
825 Math.max(
826 listPaneFloor,
827 (stripSidebar ? sidebarSlotWidth : renderedSidebarWidth.value) - listPaneGap,
828 ),
829 ),
830 }));
831 // How far the strip has slid: 0 with the sidebar shown, minus its width with it gone.
832 const stripOffset = useDerivedValue(() =>
833 stripSidebar ? renderedSidebarWidth.value - sidebarSlotWidth : 0,
834 );
835 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
836 // Fading a UIKit glass ancestor can leave remounted header effects missing.
837 opacity:
838 stripSidebar || Platform.OS === "ios" || floatingTabsEnabled
839 ? 1
840 : Math.min(1, renderedSidebarWidth.value / 80),
841 width: stripSidebar ? sidebarSlotWidth : renderedSidebarWidth.value,
842 flexShrink: stripSidebar ? 0 : 1,
843 transform: [{ translateX: stripOffset.value }],
844 }));
845
846 // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle.
847 const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);
848 const renderedContentWidth = useDerivedValue(() =>
849 Math.max(
850 0,
851 viewportWidth -
852 renderedRailWidth.value -
853 (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) -
854 renderedInspectorWidth.value,
855 ),
856 );
857 // Android can resize the native row before React removes its split panes on a fold.
858 const androidSplit = Platform.OS === "android" && layout.usesSplitView;
859 // In the strip the column itself slides with the sidebar: Android only delivers touches inside a
860 // parent's bounds, so content may not slide out of its column. Every branch sets every key:
861 // Reanimated keeps a key a branch drops, so a fold mid-slide would leave the column shifted.
862 const androidContentSlotStyle = useAnimatedStyle(() =>
863 !androidSplit
864 ? { width: "100%" as const, marginRight: 0, transform: [{ translateX: 0 }] }
865 : stripSidebar
866 ? {
867 // Wide enough for the pane at either width; the margin gives the sidebar's share
868 // back to the row, so a column after it stays put while the strip slides.
869 width: Math.max(
870 0,
871 viewportWidth - renderedRailWidth.value - renderedInspectorWidth.value,
872 ),
873 marginRight: -sidebarSlotWidth,
874 transform: [{ translateX: stripOffset.value }],
875 }
876 : { width: renderedContentWidth.value, marginRight: 0, transform: [{ translateX: 0 }] },
877 );
878 // While the sidebar animates, clip the settled navigator instead of reflowing its text every frame.
879 // In the strip it lifts entirely: the content outgrows its column, and a cap that follows
880 // React's width would clip the wider layout for the frame it lands.
881 const androidNavigatorLimit = useAnimatedStyle(() => ({
882 maxWidth: stripSidebar
883 ? viewportWidth
884 : androidSplit &&
885 contentTargetWidth !== null &&
886 renderedContentWidth.value < contentTargetWidth
887 ? contentTargetWidth
888 : ("100%" as const),
889 }));
890
891 // Rotating flips the overlay; until the new size reaches JS, predict the reset above.
892 const sidebarOverRotates =
893 nativeWorkspace && nativeMetrics?.hasHinge === true && layout.usesSplitView;
894 // The current orientation's panes already account for an inspector that needs the sidebar's room.
895 const sidebarInLandscape =
896 baseLayout.variant === "medium"
897 ? primarySidebarPinned || primarySidebarPreferredVisible
898 : panes.primarySidebarVisible;
899 const sidebarInPortrait =
900 primarySidebarPinned ||
901 (baseLayout.variant === "medium" ? overlaySidebar.visible : isWorkspaceListPath(pathname));
902 const sidebarByOrientation = useMemo(
903 () =>
904 sidebarOverRotates ? { landscape: sidebarInLandscape, portrait: sidebarInPortrait } : null,
905 [sidebarInLandscape, sidebarInPortrait, sidebarOverRotates],
906 );
907
908 const actions = useStableActions({
909 startNewTask: handleStartNewTask,
910 selectDetail: handleSelectDetail,
911 registerWorkspaceInspector,
912 setPrimarySidebarSearchQuery,
913 showInspector,
914 toggleAuxiliaryPane,
915 togglePrimarySidebar,
916 revealPrimarySidebar,
917 setPrimarySidebarVisible,
918 openWorkspaceTab,
919 setAuxiliaryPaneWidth: setFileInspectorPreferredWidth,
920 setNativeInspectorWidth: (width: number) =>
921 setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth),
922 });
923 const contextValue = useMemo(
924 () => ({ ...actions, layout, nativeInspectorWidth, workspacePageWidth }),
925 [actions, layout, nativeInspectorWidth, workspacePageWidth],
926 );
927 const sidebarToggleable = layout.usesSplitView && !primarySidebarPinned && !workspacePage;
928 const paneState = useMemo(
929 () => ({
930 panes,
931 fileInspector,
932 primarySidebarSearchQuery,
933 sidebarToggleable,
934 sidebarByOrientation,
935 }),
936 [panes, fileInspector, primarySidebarSearchQuery, sidebarToggleable, sidebarByOrientation],
937 );
938
939 const inspectorSheet = (
940 <WorkspaceInspectorSheet
941 open={inspectorSheetOpen}
942 onClose={dismissInspectorSheet}
943 content={workspaceInspector?.content}
944 />
945 );
946 if (nativeWorkspace) {
947 return (
948 <HomeListOptionsProvider
949 projectGroupingMode={projectGroupingMode}
950 savedFilters={props.savedThreadListFilters}
951 >
952 <AdaptiveWorkspaceContext value={contextValue}>
953 <WorkspacePaneStateContext value={paneState}>
954 <NativeWorkspaceInspectorContext
955 value={{
956 // Without a column the sheet shows it; the hidden native column must not mount it too.
957 content: fileInspector.supported ? workspaceInspector?.content : undefined,
958 visible: inspectorColumnTargetWidth > 0,
959 }}
960 >
961 {props.children}
962 {inspectorSheet}
963 </NativeWorkspaceInspectorContext>
964 </WorkspacePaneStateContext>
965 </AdaptiveWorkspaceContext>
966 </HomeListOptionsProvider>
967 );
968 }
969 const renderColumns = (children: ReactNode, sidebar?: ReactNode) => (
970 <View
971 testID="adaptive-workspace-layout"
972 className={Platform.OS === "android" ? "flex-1 flex-row bg-header" : "flex-1 flex-row"}
973 >
974 <AndroidHingeObserver onChange={handleHingeChange} />
975 {shouldRenderNavigationRail ? (
976 <Animated.View className="shrink self-stretch" style={railAnimatedStyle} />
977 ) : null}
978 {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
979 <Animated.View
980 className="self-stretch overflow-hidden android:shrink"
981 accessibilityElementsHidden={workspacePage || !panes.primarySidebarVisible}
982 collapsable={false}
983 importantForAccessibility={
984 !workspacePage && panes.primarySidebarVisible ? "auto" : "no-hide-descendants"
985 }
986 pointerEvents={!workspacePage && panes.primarySidebarVisible ? "auto" : "none"}
987 // Under a workspace page the sidebar leaves the row but keeps its size, so its
988 // lists never lay out at zero size and are ready, unchanged, on the way back. The
989 // content pane, laid out after it, covers it, in the same mount as the page.
990 style={[
991 workspacePage ? { position: "absolute", top: 0, bottom: 0, left: 0 } : null,
992 sidebarFloats
993 ? { position: "absolute", top: 0, bottom: 0, zIndex: 2, elevation: 8 }
994 : null,
995 floatingSidebarAnimatedStyle,
996 sidebarAnimatedStyle,
997 ]}
998 >
999 <Animated.View className="flex-1" style={listPaneAnimatedStyle}>
1000 <PaneContext value="sidebar">
1001 <RenderErrorBoundary
1002 renderFallback={(fallback) => (
1003 <RenderFailureView
1004 {...fallback}
1005 title="The sidebar couldn't be displayed"
1006 exit={{ label: "Open settings", onPress: handleOpenSettings }}
1007 />
1008 )}
1009 >
1010 {/* Without a sidebar route (iPadOS before 26) the columns draw the thread list. */}
1011 {sidebar ?? <WorkspaceThreadSidebar />}
1012 </RenderErrorBoundary>
1013 </PaneContext>
1014 {/* Keep the pill under the content throughout the sidebar's clipping animation. */}
1015 {floatingTabsEnabled && !workspacePage ? (
1016 <FloatingWorkspaceTabs
1017 paneWidth={layout.listPaneWidth}
1018 onNewThread={handleStartNewTask}
1019 onSelect={revealPrimarySidebar}
1020 />
1021 ) : null}
1022 </Animated.View>
1023 </Animated.View>
1024 ) : null}
1025 <Animated.View
1026 className={
1027 Platform.OS === "android"
1028 ? "max-w-full shrink-0 overflow-hidden bg-header"
1029 : "flex-1 overflow-hidden bg-screen"
1030 }
1031 style={Platform.OS === "android" ? androidContentSlotStyle : undefined}
1032 collapsable={false}
1033 importantForAccessibility={floatingSidebarOpen ? "no-hide-descendants" : "auto"}
1034 accessibilityElementsHidden={floatingSidebarOpen}
1035 >
1036 <Animated.View
1037 collapsable={false}
1038 style={[
1039 contentTargetWidth !== null
1040 ? {
1041 flex: 1,
1042 width: contentTargetWidth,
1043 // The strip's column is wider than the pane; the pane sits at its start.
1044 alignSelf: contentAnchor.end && !stripSidebar ? "flex-end" : "flex-start",
1045 marginRight: contentAnchor.end && !stripSidebar ? contentEndGap : 0,
1046 }
1047 : { flex: 1 },
1048 Platform.OS === "android" ? androidNavigatorLimit : undefined,
1049 ]}
1050 >
1051 {/* Android holds the content still, so its floating controls keep its width. */}
1052 <WorkspaceContentWidthContext
1053 value={layout.usesSplitView && Platform.OS !== "android" ? renderedContentWidth : null}
1054 >
1055 <WorkspaceSettledContentWidthContext
1056 value={Platform.OS === "android" && layout.usesSplitView ? contentTargetWidth : null}
1057 >
1058 {/* Compact Settings keeps the bottom navigation under all of its pages. */}
1059 <SafeAreaInsetsContext
1060 value={
1061 floatingTabsEnabled &&
1062 (compactAndroidSettings || (layout.usesSplitView && workspacePage))
1063 ? {
1064 ...safeAreaInsets,
1065 bottom:
1066 safeAreaInsets.bottom +
1067 FLOATING_TABS.bottom +
1068 FLOATING_TABS.edge +
1069 floatingTabs.height,
1070 }
1071 : compactAndroidSettings
1072 ? { ...safeAreaInsets, bottom: 0 }
1073 : safeAreaInsets
1074 }
1075 >
1076 <View className="flex-1">
1077 <PaneContext value="content">
1078 <SettingsWorkspaceLayout>{children}</SettingsWorkspaceLayout>
1079 </PaneContext>
1080 </View>
1081 </SafeAreaInsetsContext>
1082 {floatingTabsEnabled &&
1083 (compactAndroidSettings || (layout.usesSplitView && workspacePage)) ? (
1084 <FloatingWorkspaceTabs
1085 paneWidth={layout.usesSplitView ? (layout.listPaneWidth ?? undefined) : undefined}
1086 fillWidth={compactAndroidSettings}
1087 onNewThread={handleStartNewTask}
1088 />
1089 ) : compactAndroidSettings ? (
1090 <AndroidWorkspaceNavigation onNewThread={handleStartNewTask} />
1091 ) : null}
1092 </WorkspaceSettledContentWidthContext>
1093 </WorkspaceContentWidthContext>
1094 </Animated.View>
1095 </Animated.View>
1096 {sidebarFloats ? (
1097 <Animated.View
1098 className="absolute inset-0 bg-black"
1099 pointerEvents={floatingSidebarOpen ? "auto" : "none"}
1100 style={[{ zIndex: 1 }, scrimAnimatedStyle]}
1101 >
1102 <Pressable
1103 accessibilityLabel="Hide sidebar"
1104 className="flex-1"
1105 onPress={() => setPrimarySidebarVisible(false)}
1106 />
1107 </Animated.View>
1108 ) : null}
1109 <PaneContext value="inspector">
1110 <WorkspaceInspectorPane
1111 renderedInspectorWidth={renderedInspectorWidth}
1112 active={workspaceInspector?.active ?? false}
1113 panes={panes}
1114 content={workspaceInspector?.content}
1115 setAuxiliaryPaneWidth={setFileInspectorPreferredWidth}
1116 onClose={() => setFileInspectorPreferredVisible(false)}
1117 onClosed={handleWorkspaceInspectorClosed}
1118 />
1119 </PaneContext>
1120 {inspectorSheet}
1121 {shouldRenderNavigationRail && navigationRailWidth > 0 ? (
1122 // Its trailing inset clips the old rail as soon as Android fits the workspace to a cover display.
1123 <View
1124 className="absolute inset-y-0 left-0 overflow-hidden"
1125 style={[
1126 railOverlay
1127 ? { right: 0 }
1128 : { right: viewportWidth - navigationRailWidth, maxWidth: navigationRailWidth },
1129 sidebarFloats ? { zIndex: 3, elevation: 8 } : null,
1130 ]}
1131 >
1132 <AndroidWorkspaceNavigation
1133 onNewThread={handleStartNewTask}
1134 rail
1135 onSelect={revealPrimarySidebar}
1136 onOverlayChange={setRailOverlay}
1137 />
1138 </View>
1139 ) : null}
1140 </View>
1141 );
1142
1143 return (
1144 <HomeListOptionsProvider
1145 projectGroupingMode={projectGroupingMode}
1146 savedFilters={props.savedThreadListFilters}
1147 >
1148 <AdaptiveWorkspaceContext value={contextValue}>
1149 <WorkspacePaneStateContext value={paneState}>
1150 <WindowHingeContext value={windowHinge}>
1151 {usesPresentationHost ? (
1152 <WorkspaceColumnsRendererContext value={renderColumns}>
1153 {props.children}
1154 </WorkspaceColumnsRendererContext>
1155 ) : (
1156 renderColumns(props.children)
1157 )}
1158 </WindowHingeContext>
1159 </WorkspacePaneStateContext>
1160 </AdaptiveWorkspaceContext>
1161 </HomeListOptionsProvider>
1162 );
1163}