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 (
0,
}}
>
{props.children}
{inspectorSheet}
);
}
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)
)}
);
}