import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; import { threadSelectionDeferral } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { use, useDeferredValue, useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode, } from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; import { AsyncResult } from "effect/reactivity"; import { deriveFileInspectorPaneLayout, alignListPaneToHinge, deriveLayout, type VerticalHinge, deriveWorkspacePaneLayout, isPrimarySidebarPinned, isWorkspaceListPath, type WorkspaceAuxiliaryPaneRole, } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { mobilePreferencesAtom } from "../../state/preferences"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, resolveMobileProjectGroupingSettings, } from "../../state/project-grouping"; import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { AndroidWorkspaceNavigation, ANDROID_WORKSPACE_RAIL_WIDTH, } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; import { selectWorkspaceDetail, type WorkspaceDetail, type WorkspaceDetailSheet, } from "../workspace/workspace-detail-selection"; import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext, WorkspaceSettledContentWidthContext, } from "./workspace-content-width"; import { NativeWorkspaceModeContext, NativeWorkspaceInspectorContext, } from "../../native/v5-workspace-context"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { useAndroidWorkspaceRoot } from "../workspace/AndroidWorkspaceTabsProvider"; import { openSettings } from "../settings/settings-navigation"; import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset"; import { currentWorkspaceSelections, workspaceSelectionCheck, } from "../workspace/workspace-selection-store"; import { workspaceTabNavigationState } from "../workspace/workspace-tab-navigation"; import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; export { useAdaptiveWorkspaceLayout }; export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); useFocusEffect(() => activateAuxiliaryPaneRole(role)); } /** Capture inspector navigation contexts. Register on focus events because frozen blurred screens may never clean up. */ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); const navigation = use(NavigationContext); const route = use(NavigationRouteContext); const wrappedRender = (() => { if (render === undefined) { return undefined; } return () => ( {render()} ); })(); const focusedRef = useRef(false); const deactivateRef = useRef<(() => void) | null>(null); const syncRegistration = useEffectEvent(() => { if (!focusedRef.current || wrappedRender === undefined) { deactivateRef.current?.(); return; } deactivateRef.current = registerWorkspaceInspector(wrappedRender); }); useFocusEffect(() => { focusedRef.current = true; syncRegistration(); return () => { focusedRef.current = false; syncRegistration(); }; }); useEffect(() => { if (focusedRef.current) { syncRegistration(); } // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. }, [wrappedRender]); useEffect( () => () => { deactivateRef.current?.(); deactivateRef.current = null; }, [], ); } export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; readonly selectedPullRequestKey?: string | null; }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( ) : null; } const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); return ( ); } function AdaptiveWorkspaceLayoutContent( props: { readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; readonly selectedPullRequestKey?: string | null; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; readonly savedThreadListFilters?: ThreadListFilters | undefined; }, ) { const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); const windowDimensions = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; const workspace = useWorkspaceTabs(); // Settings and Usage occupy the workspace rather than a list/detail pair. const androidSettingsOpen = useAndroidWorkspaceRoot()?.settingsOpen ?? false; const safeAreaInsets = useSafeAreaInsets(); const workspacePage = Platform.OS === "android" && (androidSettingsOpen || workspace?.state.selectedTab === "usage"); const navigation = useNavigation(); const activeRoleOwner = useRef(null); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< number | null >(null); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( null, ); const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [railOverlay, setRailOverlay] = useState(false); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState(null); // The compiler can't memoize these derivations: they stay live across later hook calls. Fresh // objects here change the workspace context on every render, so every workspace screen // re-renders on any layout state change, and the inspector registration then loops: each // re-register re-renders its screen, which hands over a new inspector renderer. const baseLayout = useMemo( () => deriveLayout({ width: viewportWidth, height, nativeMetrics, nativeWorkspace }), [height, viewportWidth, nativeMetrics, nativeWorkspace], ); // The overlay sidebar closes when a page opens from it, and stays open on a list or on the // page a tab switch opened (`keepOpenFor`, set by that switch). const [overlaySidebar, setOverlaySidebar] = useState({ active: baseLayout.primarySidebarOverlaysContent, pathname, visible: isWorkspaceListPath(pathname), keepOpenFor: null as string | null, }); if ( overlaySidebar.active !== baseLayout.primarySidebarOverlaysContent || (overlaySidebar.active && overlaySidebar.pathname !== pathname) ) { setOverlaySidebar({ active: baseLayout.primarySidebarOverlaysContent, pathname, visible: isWorkspaceListPath(pathname) || (overlaySidebar.keepOpenFor !== null && overlaySidebar.keepOpenFor === props.workspaceRouteKey), keepOpenFor: null, }); } const preferredSidebarVisible = baseLayout.primarySidebarOverlaysContent ? overlaySidebar.visible : primarySidebarPreferredVisible; const primarySidebarPinned = isPrimarySidebarPinned({ pathname, nativeWorkspace, usesSplitView: baseLayout.usesSplitView, }); const [composerEditing, setComposerEditing] = useState(false); const softKeyboardVisible = useKeyboardState((state) => state.isVisible); // Keyed on the soft keyboard, not focus: a hardware keyboard leaves room for the sidebar, and // Back closes the keyboard while the editor keeps focus. Deferred so the composer commits its // keyboard render first; relaying out in the same commit held it behind the keyboard. const composerKeyboardActive = useDeferredValue( Platform.OS === "android" && baseLayout.usesSplitView && composerEditing && softKeyboardVisible, ); const showPrimarySidebar = !workspacePage && (primarySidebarPinned || (preferredSidebarVisible && !composerKeyboardActive)); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) ? ANDROID_WORKSPACE_RAIL_WIDTH : 0; const [hinge, setHinge] = useState(null); const [windowHinge, setWindowHinge] = useState(null); const handleHingeChange = (next: VerticalHinge | null, nextWindow: VerticalHinge | null) => { setHinge(next); setWindowHinge(nextWindow); }; const layout = useMemo( () => alignListPaneToHinge({ layout: baseLayout, hinge, viewportWidth, leadingWidth: navigationRailWidth, }), [baseLayout, hinge, navigationRailWidth, viewportWidth], ); const width = viewportWidth - navigationRailWidth; // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( () => deriveFileInspectorPaneLayout({ layout, viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !layout.primarySidebarOverlaysContent ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, }), [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], ); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); const auxiliaryPanePreferredVisible = !workspacePage && (auxiliaryPaneRole === "inspector" ? fileInspectorPreferredVisible : supplementaryPanePreferredVisible); const auxiliaryPanePreferredWidth = auxiliaryPaneRole === "inspector" ? fileInspectorPreferredWidth : supplementaryPanePreferredWidth; const panes = useMemo( () => deriveWorkspacePaneLayout({ layout, viewportWidth: width, nativeWorkspace, primarySidebarPreferredVisible: showPrimarySidebar, auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, }), [ auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth, layout, nativeWorkspace, showPrimarySidebar, width, ], ); const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; const selectedThreadKey = (() => { if (environmentId === null || threadId === null) { return null; } try { return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); } catch { return null; } })(); const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; } | null>(null); const workspaceInspectorOwner = useRef(null); const registerWorkspaceInspector = (render: () => ReactNode) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; setWorkspaceInspector({ render, active: true }); return () => { // Outgoing screens deactivate after incoming registration; only the current owner may deactivate. if (workspaceInspectorOwner.current !== owner) { return; } setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); }; }; const handleWorkspaceInspectorClosed = () => { setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); }; const activateAuxiliaryPaneRole = (role: WorkspaceAuxiliaryPaneRole) => { const owner = Symbol(role); activeRoleOwner.current = owner; setFocusedAuxiliaryPaneRole(role); return () => { if (activeRoleOwner.current !== owner) { return; } activeRoleOwner.current = null; setFocusedAuxiliaryPaneRole(null); }; }; const setPrimarySidebarVisible = (visible: boolean) => { if (layout.primarySidebarOverlaysContent) { setOverlaySidebar((current) => ({ ...current, visible })); } else { setPrimarySidebarPreferredVisible(visible); } }; const togglePrimarySidebar = () => { if (primarySidebarPinned) { return; } // A sidebar the keyboard set aside returns with the keyboard; the user's preference stays // intact. One the user had hidden still opens. if (composerKeyboardActive) { void KeyboardController.dismiss(); if (primarySidebarPreferredVisible) return; } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); return; } setPrimarySidebarVisible(!preferredSidebarVisible); }; const revealPrimarySidebar = () => { if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } setPrimarySidebarVisible(true); }; const handleToggleSidebarCommand = () => { togglePrimarySidebar(); return true; }; const sidebarCommands = primarySidebarPinned ? [] : (["toggleSidebar"] as const); useHardwareKeyboardCommand(sidebarCommands, handleToggleSidebarCommand); const showAuxiliaryPane = (role: WorkspaceAuxiliaryPaneRole) => { if (role === "inspector") { setFocusedAuxiliaryPaneRole("inspector"); setFileInspectorPreferredVisible(true); return; } setFocusedAuxiliaryPaneRole("supplementary"); setSupplementaryPanePreferredVisible(true); }; const handleOpenFilesCommand = () => { const activeThread = parseActiveThreadPath(pathname); if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { return false; } showAuxiliaryPane("inspector"); if (/\/files(?:\/|$)/.test(pathname)) { return true; } navigation.navigate("ThreadFiles", activeThread); return true; }; useHardwareKeyboardCommand("files", handleOpenFilesCommand); const toggleAuxiliaryPane = () => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredVisible((current) => !current); return; } setSupplementaryPanePreferredVisible((current) => !current); }; const setAuxiliaryPaneWidth = (nextWidth: number) => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredWidth(nextWidth); return; } setSupplementaryPanePreferredWidth(nextWidth); }; /** * The one way to open a workspace tab: replaces the workspace with the tab's list and, when the * window has columns, the page it last showed, keeping any sheet on top. An overlay sidebar * stays open over that page. */ const openWorkspaceTab = ( tab: WorkspaceTab | "settings", options?: { readonly columns?: boolean }, ) => { const state = rootStackState(navigation); if (!state) return; const { workspace, overlays } = workspaceRoutesOf(state.routes.slice(0, state.index + 1)); const next = workspaceTabNavigationState( { ...state, routes: workspace }, tab, currentWorkspaceSelections(), { columns: Platform.OS === "ios" && (options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, }, ); if (layout.primarySidebarOverlaysContent) { const opened = next.routes.at(-1 - overlays.length)?.key ?? null; setOverlaySidebar((current) => ({ ...current, visible: true, keepOpenFor: opened })); } dispatchRootStackReset(navigation, state, next); }; const handleOpenSettings = () => openWorkspaceTab("settings"); /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { threadSelectionDeferral.run(() => { if (Platform.OS === "android") Keyboard.dismiss(); setFileInspectorPreferredVisible(false); if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); workspace?.dispatch({ type: "select", tab: detail.name === "Thread" ? "threads" : "pullRequests", }); startOpeningDetail(detail); navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); } const handleSelectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => handleSelectDetail({ name: "Thread", params }); const projects = useProjects(); const compactAndroidSettings = Platform.OS === "android" && androidSettingsOpen && !layout.usesSplitView; // Reads the open page when pressed, so the layout context doesn't change on every navigation. const handleStartNewTask = () => { const state = rootStackState(navigation); const currentRoute = workspaceRoutesOf( state?.routes.slice(0, state.index + 1) ?? [], ).workspace.at(-1); const params = currentRoute?.name === "Thread" ? (currentRoute.params as ReactNavigation.RootParamList["Thread"]) : undefined; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; const thread = appAtomRegistry .get(environmentThreadShells.navigationThreadShellsAtom) .find( (candidate) => candidate.environmentId === first(params?.environmentId) && candidate.id === first(params?.threadId), ); const draft = params?.draft; const draftEnvironmentId = first(draft?.environmentId); const draftProjectId = first(draft?.projectId); const currentProject = thread ? { environmentId: thread.environmentId, id: thread.projectId } : projects.find( (project) => project.environmentId === draftEnvironmentId && project.id === draftProjectId, ); handleSelectThreadRoute( newTaskDestination(layout.usesSplitView, projects, currentProject, uuidv4()), ); }; useHardwareKeyboardCommand("newTask", () => { handleStartNewTask(); return true; }); // Create at root: the iOS sidebar renders in an independent navigation tree. const handleOpenEnvironmentSettings = () => { openSettings(navigation, "SettingsEnvironments"); }; const handleOpenThreadPullRequests = useOpenThreadPullRequests(); const { openPendingTask: handleOpenPendingTask } = usePendingTaskListActions(handleSelectThreadRoute); const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => { handleSelectThreadRoute({ draft: { requestId: uuidv4(), environmentId: String(thread.environmentId), projectId: String(thread.projectId), branch: thread.branch, worktreePath: thread.worktreePath, }, }); }; const handleNewThreadInProject = (project: EnvironmentProject) => { handleSelectThreadRoute({ draft: { requestId: uuidv4(), environmentId: String(project.environmentId), projectId: String(project.id), title: project.title, }, }); }; const inspectorColumnTargetWidth = workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible ? (panes.auxiliaryPaneWidth ?? 0) : 0; const contentTargetWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; const [listPaneSizing, setListPaneSizing] = useState({ viewportWidth, workspacePage, sidebarVisible: panes.primarySidebarVisible, listPaneTarget: layout.listPaneWidth, listPaneFloor: layout.listPaneWidth, listPaneCeiling: layout.listPaneWidth, revision: 0, }); if ( Platform.OS === "android" && (listPaneSizing.viewportWidth !== viewportWidth || listPaneSizing.workspacePage !== workspacePage || listPaneSizing.sidebarVisible !== panes.primarySidebarVisible || listPaneSizing.listPaneTarget !== layout.listPaneWidth) ) { // A book-posture hinge resizes the visible list pane instead of revealing it. const resizingSidebar = listPaneSizing.viewportWidth === viewportWidth && listPaneSizing.workspacePage === workspacePage && listPaneSizing.sidebarVisible && panes.primarySidebarVisible && listPaneSizing.listPaneTarget !== layout.listPaneWidth; setListPaneSizing({ viewportWidth, workspacePage, sidebarVisible: panes.primarySidebarVisible, listPaneTarget: layout.listPaneWidth, // Retargets widen the list-width span because the animated hinge edge may still be inside it. listPaneFloor: resizingSidebar ? Math.min(listPaneSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0) : layout.listPaneWidth, listPaneCeiling: resizingSidebar ? Math.max(listPaneSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0) : layout.listPaneWidth, revision: listPaneSizing.revision + 1, }); } const settleListPane = (revision: number) => { setListPaneSizing((current) => current.revision !== revision || (current.listPaneFloor === current.listPaneTarget && current.listPaneCeiling === current.listPaneTarget) ? current : { ...current, listPaneFloor: current.listPaneTarget, listPaneCeiling: current.listPaneTarget, }, ); }; const listPaneFloor = listPaneSizing.listPaneFloor ?? 0; const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0; const listPaneRevision = listPaneSizing.revision; // Android lays the content out at its new width as soon as a pane toggles, then holds it // against the edge that stays put while the other one slides, so nothing reflows after the // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content // until it is gone. const contentLeftTarget = navigationRailWidth + (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0); const [contentAnchor, setContentAnchor] = useState({ viewportWidth, left: contentLeftTarget, inspector: inspectorColumnTargetWidth, end: false, }); if ( Platform.OS === "android" && (contentAnchor.viewportWidth !== viewportWidth || contentAnchor.left !== contentLeftTarget || contentAnchor.inspector !== inspectorColumnTargetWidth) ) { setContentAnchor({ viewportWidth, left: contentLeftTarget, inspector: inspectorColumnTargetWidth, end: contentAnchor.viewportWidth === viewportWidth && contentAnchor.inspector === inspectorColumnTargetWidth, }); } const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); const renderedRailWidth = useSharedValue(navigationRailWidth); const leavingWorkspacePage = useRef(false); useEffect(() => { if (nativeWorkspace) return; renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); // A workspace page hides the sidebar through React instead, in the same commit that mounts // the page. Animating it here would hide it a few frames before the page appears. if (workspacePage) { leavingWorkspacePage.current = true; return; } const snap = leavingWorkspacePage.current; leavingWorkspacePage.current = false; const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0; // The content beside it already took its final width when the page closed. renderedSidebarWidth.set( withTiming(targetWidth, snap ? { duration: 0 } : WORKSPACE_PANE_TIMING, (finished) => { if (finished && Platform.OS === "android") runOnJS(settleListPane)(listPaneRevision); }), ); }, [ nativeWorkspace, navigationRailWidth, layout.listPaneWidth, layout.listPaneGap, panes.primarySidebarVisible, renderedSidebarWidth, renderedRailWidth, settleListPane, listPaneRevision, workspacePage, ]); const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the // animated edge, so the header actions slide with it rather than jumping or being covered. const listPaneGap = layout.listPaneGap ?? 0; const listPaneAnimatedStyle = useAnimatedStyle(() => ({ width: Math.min( listPaneCeiling, Math.max(listPaneFloor, renderedSidebarWidth.value - listPaneGap), ), })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, })); // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); const renderedContentWidth = useDerivedValue(() => Math.max( 0, viewportWidth - renderedRailWidth.value - renderedSidebarWidth.value - renderedInspectorWidth.value, ), ); const handleSelectThread = (thread: EnvironmentThreadShell) => { handleSelectThreadRoute({ environmentId: String(thread.environmentId), threadId: String(thread.id), }); }; // Rotating flips the overlay; until the new size reaches JS, predict the reset above. const sidebarOverRotates = nativeWorkspace && nativeMetrics?.hasHinge === true && layout.usesSplitView; // The current orientation's panes already account for an inspector that needs the sidebar's room. const sidebarInLandscape = baseLayout.primarySidebarOverlaysContent ? primarySidebarPinned || primarySidebarPreferredVisible : panes.primarySidebarVisible; const sidebarInPortrait = primarySidebarPinned || (baseLayout.primarySidebarOverlaysContent ? overlaySidebar.visible : isWorkspaceListPath(pathname)); const sidebarByOrientation = useMemo( () => sidebarOverRotates ? { landscape: sidebarInLandscape, portrait: sidebarInPortrait } : null, [sidebarInLandscape, sidebarInPortrait, sidebarOverRotates], ); const contextValue = { layout, panes, fileInspector, primarySidebarSearchQuery, startNewTask: handleStartNewTask, selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, setPrimarySidebarSearchQuery, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar, setPrimarySidebarVisible, openWorkspaceTab, setAuxiliaryPaneWidth, composerKeyboardActive, setComposerEditing, sidebarByOrientation, }; if (nativeWorkspace) { return ( 0 }} > {props.children} ); } return ( {shouldRenderNavigationRail ? ( ) : null} {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( ( )} > ) : null} {/* Android holds the content still, so its floating controls keep its width. */} {/* Compact Settings keeps the bottom navigation under all of its pages. */} {props.children} {compactAndroidSettings ? ( ) : null} {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( // The rail reserves its slot above and draws over the panes, so it can widen over // the list in place. It takes the whole workspace only while it is open. ) : null} ); }