1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
6import { useAtomValue } from "@effect/atom-react";
7import { useFocusEffect } from "@react-navigation/native";
8import {
9 CommonActions,
10 NavigationContext,
11 NavigationRouteContext,
12 StackActions,
13 useNavigation,
14} from "@react-navigation/native";
15import {
16 createContext,
17 use,
18 useCallback,
19 useEffect,
20 useMemo,
21 useRef,
22 useState,
23 type ReactNode,
24} from "react";
25import { Platform, useWindowDimensions, View } from "react-native";
26import Animated, {
27 runOnJS,
28 useAnimatedStyle,
29 useDerivedValue,
30 useSharedValue,
31 withTiming,
32} from "react-native-reanimated";
33import { AsyncResult } from "effect/reactivity";
34
35import {
36 deriveFileInspectorPaneLayout,
37 deriveLayout,
38 deriveWorkspacePaneLayout,
39 type FileInspectorPaneLayout,
40 type Layout,
41 type WorkspaceAuxiliaryPaneRole,
42 type WorkspacePaneLayout,
43} from "../../lib/layout";
44import {
45 resolveThreadSelectionNavigationAction,
46 resolveThreadSelectionOverlayState,
47} from "../../lib/adaptive-navigation";
48import { scopedThreadKey } from "../../lib/scopedEntities";
49import type { ThreadListFilters } from "../../persistence/mobile-preferences";
50import { mobilePreferencesAtom } from "../../state/preferences";
51import {
52 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
53 resolveMobileProjectGroupingSettings,
54} from "../../state/project-grouping";
55import {
56 parseActiveThreadPath,
57 useHardwareKeyboardCommand,
58} from "../keyboard/hardwareKeyboardCommands";
59import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
60import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
61import {
62 AndroidWorkspaceNavigation,
63 ANDROID_WORKSPACE_RAIL_WIDTH,
64} from "../workspace/AndroidWorkspaceNavigation";
65import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar";
66import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
67import { HomeListOptionsProvider } from "../home/home-list-options";
68import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
69import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
70import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
71import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
72import { WorkspaceContentWidthContext } from "./workspace-content-width";
73import {
74 NativeWorkspaceModeContext,
75 NativeWorkspaceInspectorContext,
76} from "../../native/v5-workspace-context";
77
78import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
79
80interface 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
103const compactLayout = deriveLayout({ width: 0, height: 0 });
104const compactPanes = deriveWorkspacePaneLayout({
105 layout: compactLayout,
106 viewportWidth: 0,
107 primarySidebarPreferredVisible: true,
108 auxiliaryPanePreferredVisible: true,
109});
110const compactFileInspector = deriveFileInspectorPaneLayout({
111 layout: compactLayout,
112 viewportWidth: 0,
113});
114const 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
129export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue {
130 return use(AdaptiveWorkspaceContext);
131}
132
133export 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 */
157export 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
219export 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
244function 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}