import { WorkspaceColumnsRendererContext, WorkspacePresentationHostContext, } from "./workspace-presentation-context"; import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; 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 { 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, useMemo, useRef, useState, type ReactNode } from "react"; import { BackHandler, Keyboard, Platform, Pressable, 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, nativeInspectorColumnWidth, } from "../../lib/layout"; 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 { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs, ANDROID_WORKSPACE_RAIL_WIDTH, } from "../workspace/AndroidWorkspaceNavigation"; 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 { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { PaneContext } from "../../components/ui/Pane"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceInspectorSheet } from "./workspace-inspector-sheet"; 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 { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset"; import { currentWorkspaceSelections, workspaceSelectionCheck, } from "../workspace/workspace-selection-store"; import { workspaceTabColumns, 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"; import { WorkspaceThreadSidebar } from "../workspace/WorkspaceThreadSidebar"; export { useAdaptiveWorkspaceLayout }; /** * Shows its children in the workspace's inspector column while this screen is focused. Register on * focus events because frozen blurred screens may never clean up. */ export function Inspector(props: { readonly children: ReactNode; /** Opens the column the first time this key registers while its route stays on the stack. */ readonly revealKey?: string; }) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); const navigation = use(NavigationContext); const route = use(NavigationRouteContext); const render = () => ( {props.children} ); // useFocusEffect re-runs when the content changes while focused, so the column always shows this // render's content, and its cleanup deactivates on blur and unmount. useFocusEffect(() => registerWorkspaceInspector(render, route?.key, props.revealKey)); return null; } export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; readonly workspaceRouteName: string | undefined; }) { 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 workspaceRouteName: string | undefined; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; readonly savedThreadListFilters?: ThreadListFilters | undefined; }, ) { const floatingTabsEnabled = useAndroidFloatingTabs(); const projectGroupingMode = props.projectGroupingMode; const nativeWorkspace = use(NativeWorkspaceModeContext); const usesPresentationHost = use(WorkspacePresentationHostContext); 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 [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4); const nativeInspectorWidth = nativeInspectorColumnWidth( viewportWidth, nativeInspectorWidthFraction, ); const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( null, ); const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [railOverlay, setRailOverlay] = useState(false); // 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.variant === "medium", pathname, visible: isWorkspaceListPath(pathname), keepOpenFor: null as string | null, }); if ( overlaySidebar.active !== (baseLayout.variant === "medium") || (overlaySidebar.active && overlaySidebar.pathname !== pathname) ) { setOverlaySidebar({ active: baseLayout.variant === "medium", pathname, visible: isWorkspaceListPath(pathname) || (overlaySidebar.keepOpenFor !== null && overlaySidebar.keepOpenFor === props.workspaceRouteKey), keepOpenFor: null, }); } const preferredSidebarVisible = baseLayout.variant === "medium" ? 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)); // Medium windows float the sidebar over a page; beside a list it keeps its room. const sidebarFloats = baseLayout.variant === "medium" && (nativeWorkspace || (!primarySidebarPinned && !workspacePage)); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; const enabledTabs = useEnabledNavigationTabs(); const { scale, fabSize } = useAndroidControlSizing(); const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); 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(() => { const aligned = alignListPaneToHinge({ layout: baseLayout, hinge, viewportWidth, leadingWidth: navigationRailWidth, }); if ( !floatingTabsEnabled || !aligned.usesSplitView || aligned.listPaneGap !== undefined || aligned.listPaneWidth === null || aligned.listPaneWidth >= floatingTabs.row || viewportWidth - floatingTabs.row < 320 ) return aligned; return { ...aligned, listPaneWidth: floatingTabs.row }; }, [ baseLayout, hinge, navigationRailWidth, viewportWidth, floatingTabs.row, floatingTabsEnabled, ]); // A floating sidebar brings the rail with it. const width = viewportWidth - (sidebarFloats ? 0 : 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 && !sidebarFloats ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, }), [ fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, sidebarFloats, width, ], ); const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; readonly routeKey: string | undefined; } | null>(null); // Without a column the inspector is a sheet, opened only on request for the route on top, and // shown again when that route returns to the top. const [inspectorSheetRoute, setInspectorSheetRoute] = useState(null); const inspectorSheetOpen = !fileInspector.supported && !workspacePage && inspectorSheetRoute !== null && workspaceInspector?.active === true && workspaceInspector.routeKey === inspectorSheetRoute; const inspectorActive = workspaceInspector?.active ?? false; const panes = useMemo( () => deriveWorkspacePaneLayout({ layout, viewportWidth: width, nativeWorkspace, primarySidebarFloats: sidebarFloats, primarySidebarPreferredVisible: showPrimarySidebar, // Only a column a screen fills makes room, so a stale preference can't hide the sidebar. auxiliaryPanePreferredVisible: !workspacePage && inspectorActive && fileInspectorPreferredVisible, auxiliaryPanePreferredWidth: fileInspectorPreferredWidth ?? undefined, nativeInspectorWidth, }), [ nativeInspectorWidth, fileInspectorPreferredVisible, fileInspectorPreferredWidth, inspectorActive, layout, nativeWorkspace, showPrimarySidebar, sidebarFloats, width, workspacePage, ], ); // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. const revealedInspectors = useRef>(new Map()); const workspaceInspectorOwner = useRef(null); const registerWorkspaceInspector = ( render: () => ReactNode, routeKey: string | undefined, revealKey?: string, ) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; setWorkspaceInspector({ render, active: true, routeKey }); if ( routeKey !== undefined && revealKey !== undefined && revealedInspectors.current.get(routeKey) !== revealKey ) { const state = rootStackState(navigation); const live = new Set(state?.routes.slice(0, state.index + 1).map((route) => route.key)); revealedInspectors.current = new Map( [...revealedInspectors.current, [routeKey, revealKey] as const].filter( ([key]) => live.has(key) || key === routeKey, ), ); setFileInspectorPreferredVisible(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 setPrimarySidebarVisible = (visible: boolean) => { if (layout.variant === "medium") { 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 (preferredSidebarVisible) 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 showInspector = () => { if (fileInspector.supported) { setFileInspectorPreferredVisible(true); setInspectorSheetRoute(null); return; } const state = rootStackState(navigation); const top = workspaceRoutesOf(state?.routes.slice(0, state.index + 1) ?? []).workspace.at(-1); setInspectorSheetRoute(top?.key ?? null); }; const handleOpenFilesCommand = () => { const activeThread = parseActiveThreadPath(pathname); if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) { return false; } showInspector(); if (props.workspaceRouteName === "ThreadFiles" || props.workspaceRouteName === "ThreadFile") { return true; } navigation.navigate("ThreadFiles", activeThread); return true; }; useHardwareKeyboardCommand("files", handleOpenFilesCommand); const toggleAuxiliaryPane = () => { if (fileInspector.supported) { setFileInspectorPreferredVisible((current) => !current); setInspectorSheetRoute(null); } else if (inspectorSheetOpen) setInspectorSheetRoute(null); else showInspector(); }; /** * 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: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, }, ); if (layout.variant === "medium") { 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); setInspectorSheetRoute(null); if (layout.variant === "medium") 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 environmentId = first(params?.environmentId); const threadId = first(params?.threadId); // The full index, not the sidebar list: subagent and archived threads are current threads too. const projectId = environmentId && threadId ? appAtomRegistry .get( environmentThreadShells.environmentThreadIndexAtom(EnvironmentId.make(environmentId)), ) .get(ThreadId.make(threadId))?.projectId : undefined; const preferences = appAtomRegistry.get(mobilePreferencesAtom); const saved = AsyncResult.isSuccess(preferences) ? preferences.value : undefined; handleSelectThreadRoute( newTaskDestination({ projects, requestId: uuidv4(), useLastProject: saved?.useLastProjectForNewThreads, currentProject: environmentId && projectId ? { environmentId, projectId } : undefined, lastProject: saved?.lastThreadProject, }), ); }; useHardwareKeyboardCommand("newTask", () => { handleStartNewTask(); return true; }); const inspectorColumnTargetWidth = panes.auxiliaryPaneVisible ? (panes.auxiliaryPaneWidth ?? 0) : 0; // Android's split content is a rounded card: keep it off the screen edge like the list card. const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0; const contentTargetWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth - contentEndGap) : 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 = sidebarFloats ? 0 : 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); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated rail and sidebar width shared values toward the layout's targets 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 })); const floatingSidebarAnimatedStyle = useAnimatedStyle(() => ({ left: renderedRailWidth.value })); const floatingSidebarOpen = sidebarFloats && panes.primarySidebarVisible; // Its own value, so a sidebar that was beside a list doesn't dim the page as it leaves. const scrimOpacity = useSharedValue(0); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates the Reanimated scrim with the floating sidebar useEffect(() => { scrimOpacity.set(withTiming(floatingSidebarOpen ? 0.32 : 0, WORKSPACE_PANE_TIMING)); }, [floatingSidebarOpen, scrimOpacity]); const scrimAnimatedStyle = useAnimatedStyle(() => ({ opacity: scrimOpacity.value })); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Back closes a floating sidebar before it leaves the page useEffect(() => { if (!floatingSidebarOpen || nativeWorkspace) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { // A sheet or modal over the workspace takes Back first. const state = rootStackState(navigation); if (state && workspaceRoutesOf(state.routes.slice(0, state.index + 1)).overlays.length > 0) { return false; } setPrimarySidebarVisible(false); return true; }); return () => subscription.remove(); }, [floatingSidebarOpen, nativeWorkspace, navigation, setPrimarySidebarVisible]); // 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" || floatingTabsEnabled ? 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, ), ); // 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.variant === "medium" ? primarySidebarPinned || primarySidebarPreferredVisible : panes.primarySidebarVisible; const sidebarInPortrait = primarySidebarPinned || (baseLayout.variant === "medium" ? 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, registerWorkspaceInspector, setPrimarySidebarSearchQuery, showInspector, toggleAuxiliaryPane, sidebarToggleable: layout.usesSplitView && !primarySidebarPinned && !workspacePage, togglePrimarySidebar, revealPrimarySidebar, setPrimarySidebarVisible, openWorkspaceTab, setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, nativeInspectorWidth, setNativeInspectorWidth: (width: number) => setNativeInspectorWidthFraction( nativeInspectorColumnWidth(viewportWidth, width / viewportWidth) / viewportWidth, ), composerKeyboardActive, setComposerEditing, sidebarByOrientation, }; const inspectorSheet = ( setInspectorSheetRoute(null)}> {workspaceInspector?.render()} ); if (nativeWorkspace) { return ( ); } const renderColumns = (children: ReactNode, sidebar?: ReactNode) => ( {shouldRenderNavigationRail ? ( ) : null} {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( ( )} > {/* Without a sidebar route (iPadOS before 26) the columns draw the thread list. */} {sidebar ?? } {/* Keep the pill under the content throughout the sidebar's clipping animation. */} {floatingTabsEnabled && !workspacePage ? ( ) : 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. */} {children} {floatingTabsEnabled && (compactAndroidSettings || (layout.usesSplitView && workspacePage)) ? ( ) : compactAndroidSettings ? ( ) : null} {sidebarFloats ? ( setPrimarySidebarVisible(false)} /> ) : null} setFileInspectorPreferredVisible(false)} onClosed={handleWorkspaceInspectorClosed} /> {inspectorSheet} {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} ); return ( {usesPresentationHost ? ( {props.children} ) : ( renderColumns(props.children) )} ); }