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, StackActions, useNavigation,} from "@react-navigation/native";import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ReactNode,} from "react";import { useWindowDimensions, View } from "react-native";import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, withTiming,} from "react-native-reanimated";import { AsyncResult } from "effect/unstable/reactivity"; import { deriveFileInspectorPaneLayout, deriveLayout, deriveWorkspacePaneLayout, type FileInspectorPaneLayout, type Layout, type WorkspaceAuxiliaryPaneRole, type WorkspacePaneLayout,} from "../../lib/layout";import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";import { scopedThreadKey } from "../../lib/scopedEntities";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 { HomeListOptionsProvider } from "../home/home-list-options";import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";import { WorkspaceInspectorPane } from "./workspace-inspector-pane";import { WorkspaceContentWidthContext } from "./workspace-content-width"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; readonly panes: WorkspacePaneLayout; readonly fileInspector: FileInspectorPaneLayout; readonly primarySidebarSearchQuery: string; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; /** * Route screens hand their inspector pane content to the workspace so it * renders BESIDE the navigator (outside the native stack header) instead of * inside the route. Returns a deactivate callback: the pane animates closed * (content kept mounted for the exit transition) unless a newer * registration already took over — stale deactivates never clobber it. * Prefer useRegisterWorkspaceInspector over calling this directly. */ readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; readonly toggleAuxiliaryPane: () => void; readonly togglePrimarySidebar: () => void; readonly setAuxiliaryPaneWidth: (width: number) => void;} const compactLayout = deriveLayout({ width: 0, height: 0 });const compactPanes = deriveWorkspacePaneLayout({ layout: compactLayout, viewportWidth: 0, primarySidebarPreferredVisible: true, auxiliaryPanePreferredVisible: true,});const compactFileInspector = deriveFileInspectorPaneLayout({ layout: compactLayout, viewportWidth: 0,});const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({ layout: compactLayout, panes: compactPanes, fileInspector: compactFileInspector, primarySidebarSearchQuery: "", activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, showAuxiliaryPane: () => undefined, toggleAuxiliaryPane: () => undefined, togglePrimarySidebar: () => undefined, setAuxiliaryPaneWidth: () => undefined,}); export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { return use(AdaptiveWorkspaceContext);} export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); useFocusEffect( useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), );} /** * Register this screen's inspector pane content with the workspace column. * * The column renders BESIDE the navigator — outside any screen — so the * registering screen's navigation and route contexts are captured here and * re-provided around the portal content. Without them, useNavigation/useRoute * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw * "Couldn't find a route object". * * Registration is FOCUS-scoped, driven by navigation events rather than the * screen's own render cycle: react-native-screens freezes blurred screens, so * a cleanup that depends on the blurred subtree re-rendering never runs and * would leak the pane into the next route. Blur deactivates the pane (it * animates closed, or is replaced seamlessly when the next route registers in * the same commit); focus re-registers it. */export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); // Raw context values (not the useNavigation/useRoute wrappers) so the // portal re-provides exactly what this screen sees. const navigation = use(NavigationContext); const route = use(NavigationRouteContext); const wrappedRender = useMemo(() => { if (render === undefined) { return undefined; } return () => ( <NavigationContext.Provider value={navigation}> <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider> </NavigationContext.Provider> ); }, [navigation, render, route]); const wrappedRenderRef = useRef(wrappedRender); wrappedRenderRef.current = wrappedRender; const focusedRef = useRef(false); const deactivateRef = useRef<(() => void) | null>(null); const syncRegistration = useCallback(() => { if (!focusedRef.current || wrappedRenderRef.current === undefined) { deactivateRef.current?.(); return; } deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); }, [registerWorkspaceInspector]); // Focus lifecycle. Blur/focus events fire even when the blurred subtree is // frozen (events are navigation-driven, renders are not). useFocusEffect( useCallback(() => { focusedRef.current = true; syncRegistration(); return () => { focusedRef.current = false; syncRegistration(); }; }, [syncRegistration]), ); // Content changes while focused re-register in place. useEffect(() => { if (focusedRef.current) { syncRegistration(); } }, [syncRegistration, wrappedRender]); // Unmount: hand the pane back (owner-guarded, so a route that already // took over is unaffected). useEffect( () => () => { deactivateRef.current?.(); deactivateRef.current = null; }, [], );} export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string;}) { const preferencesResult = useAtomValue(mobilePreferencesAtom); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( <AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode} /> ) : null; } const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); return ( <AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode={groupingSettings.sidebarProjectGroupingMode} /> );} function AdaptiveWorkspaceLayoutContent( props: { readonly children: ReactNode; readonly pathname: string; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; },) { const projectGroupingMode = props.projectGroupingMode; const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); const activeRoleOwner = useRef<symbol | null>(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<number | null>( null, ); const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState<WorkspaceAuxiliaryPaneRole | null>(null); const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); const layout = baseLayout; // In split layouts the sidebar IS the thread list — it renders on every // route, including Home (which shows an empty-detail pane instead of the // compact list). const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( () => deriveFileInspectorPaneLayout({ layout, viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: shouldRenderPrimarySidebar && primarySidebarPreferredVisible ? (layout.listPaneWidth ?? 0) : 0, }), [ fileInspectorPreferredWidth, layout, primarySidebarPreferredVisible, shouldRenderPrimarySidebar, width, ], ); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); const auxiliaryPanePreferredVisible = auxiliaryPaneRole === "inspector" ? fileInspectorPreferredVisible : supplementaryPanePreferredVisible; const auxiliaryPanePreferredWidth = auxiliaryPaneRole === "inspector" ? fileInspectorPreferredWidth : supplementaryPanePreferredWidth; const panes = useMemo( () => deriveWorkspacePaneLayout({ layout, viewportWidth: width, primarySidebarPreferredVisible, auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, }), [ auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth, layout, primarySidebarPreferredVisible, width, ], ); const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; const selectedThreadKey = useMemo(() => { if (environmentId === null || threadId === null) { return null; } try { return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); } catch { return null; } }, [environmentId, threadId]); // Wrapped in an object: bare functions in useState would be treated as // lazy initializers/updaters. `active: false` keeps the outgoing route's // content mounted so the pane can animate closed (or be replaced // seamlessly by the next route's registration in the same commit). const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; } | null>(null); const workspaceInspectorOwner = useRef<symbol | null>(null); const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; setWorkspaceInspector({ render, active: true }); return () => { // During a push/replace the outgoing screen deactivates AFTER the // incoming screen registered — only the current owner may deactivate. if (workspaceInspectorOwner.current !== owner) { return; } setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); }; }, []); // Once the close animation settles, drop the stale content entirely. const handleWorkspaceInspectorClosed = useCallback(() => { setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); }, []); const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { const owner = Symbol(role); activeRoleOwner.current = owner; setFocusedAuxiliaryPaneRole(role); return () => { if (activeRoleOwner.current !== owner) { return; } activeRoleOwner.current = null; setFocusedAuxiliaryPaneRole(null); }; }, []); const togglePrimarySidebar = useCallback(() => { if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); return; } setPrimarySidebarPreferredVisible((current) => !current); }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]); const revealPrimarySidebar = useCallback(() => { if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } setPrimarySidebarPreferredVisible(true); }, [panes.primarySidebarSuppressedByAuxiliary]); const handleToggleSidebarCommand = useCallback(() => { togglePrimarySidebar(); return true; }, [togglePrimarySidebar]); useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand); const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { if (role === "inspector") { setFocusedAuxiliaryPaneRole("inspector"); setFileInspectorPreferredVisible(true); return; } setFocusedAuxiliaryPaneRole("supplementary"); setSupplementaryPanePreferredVisible(true); }, []); const handleOpenFilesCommand = useCallback(() => { 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; }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]); useHardwareKeyboardCommand("files", handleOpenFilesCommand); const toggleAuxiliaryPane = useCallback(() => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredVisible((current) => !current); return; } setSupplementaryPanePreferredVisible((current) => !current); }, [auxiliaryPaneRole]); const setAuxiliaryPaneWidth = useCallback( (nextWidth: number) => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredWidth(nextWidth); return; } setSupplementaryPanePreferredWidth(nextWidth); }, [auxiliaryPaneRole], ); const contextValue = useMemo( () => ({ layout, panes, fileInspector, primarySidebarSearchQuery, activateAuxiliaryPaneRole, registerWorkspaceInspector, setPrimarySidebarSearchQuery, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar, setAuxiliaryPaneWidth, }), [ activateAuxiliaryPaneRole, fileInspector, layout, panes, primarySidebarSearchQuery, registerWorkspaceInspector, showAuxiliaryPane, setPrimarySidebarSearchQuery, setAuxiliaryPaneWidth, toggleAuxiliaryPane, togglePrimarySidebar, ], ); const handleOpenSettings = useCallback(() => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "Settings" }, }); }, [navigation]); const handleStartNewTask = useCallback(() => { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); }, [navigation]); // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "SettingsEnvironments" }, }); }, [navigation]); const handleNewThreadOnBranch = useCallback( (thread: EnvironmentThreadShell) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", params: { environmentId: String(thread.environmentId), projectId: String(thread.projectId), branch: thread.branch, worktreePath: thread.worktreePath, }, }); }, [navigation], ); const handleNewThreadInProject = useCallback( (project: EnvironmentProject) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", params: { environmentId: String(project.environmentId), projectId: String(project.id), title: project.title, }, }); }, [navigation], ); const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); useEffect(() => { const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, })); Lines 1–500 of 615. Load more
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, StackActions, useNavigation, } from "@react-navigation/native"; import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useWindowDimensions, View } from "react-native"; import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; import { AsyncResult } from "effect/unstable/reactivity"; import { deriveFileInspectorPaneLayout, deriveLayout, deriveWorkspacePaneLayout, type FileInspectorPaneLayout, type Layout, type WorkspaceAuxiliaryPaneRole, type WorkspacePaneLayout, } from "../../lib/layout"; import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; 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 { HomeListOptionsProvider } from "../home/home-list-options"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; readonly panes: WorkspacePaneLayout; readonly fileInspector: FileInspectorPaneLayout; readonly primarySidebarSearchQuery: string; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; /** * Route screens hand their inspector pane content to the workspace so it * renders BESIDE the navigator (outside the native stack header) instead of * inside the route. Returns a deactivate callback: the pane animates closed * (content kept mounted for the exit transition) unless a newer * registration already took over — stale deactivates never clobber it. * Prefer useRegisterWorkspaceInspector over calling this directly. */ readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; readonly toggleAuxiliaryPane: () => void; readonly togglePrimarySidebar: () => void; readonly setAuxiliaryPaneWidth: (width: number) => void; } const compactLayout = deriveLayout({ width: 0, height: 0 }); const compactPanes = deriveWorkspacePaneLayout({ layout: compactLayout, viewportWidth: 0, primarySidebarPreferredVisible: true, auxiliaryPanePreferredVisible: true, }); const compactFileInspector = deriveFileInspectorPaneLayout({ layout: compactLayout, viewportWidth: 0, }); const AdaptiveWorkspaceContext = createContext<AdaptiveWorkspaceContextValue>({ layout: compactLayout, panes: compactPanes, fileInspector: compactFileInspector, primarySidebarSearchQuery: "", activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, showAuxiliaryPane: () => undefined, toggleAuxiliaryPane: () => undefined, togglePrimarySidebar: () => undefined, setAuxiliaryPaneWidth: () => undefined, }); export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { return use(AdaptiveWorkspaceContext); } export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); useFocusEffect( useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), ); } /** * Register this screen's inspector pane content with the workspace column. * * The column renders BESIDE the navigator — outside any screen — so the * registering screen's navigation and route contexts are captured here and * re-provided around the portal content. Without them, useNavigation/useRoute * inside the pane (e.g. GitOverviewSheet via useThreadSelection) throw * "Couldn't find a route object". * * Registration is FOCUS-scoped, driven by navigation events rather than the * screen's own render cycle: react-native-screens freezes blurred screens, so * a cleanup that depends on the blurred subtree re-rendering never runs and * would leak the pane into the next route. Blur deactivates the pane (it * animates closed, or is replaced seamlessly when the next route registers in * the same commit); focus re-registers it. */ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); // Raw context values (not the useNavigation/useRoute wrappers) so the // portal re-provides exactly what this screen sees. const navigation = use(NavigationContext); const route = use(NavigationRouteContext); const wrappedRender = useMemo(() => { if (render === undefined) { return undefined; } return () => ( <NavigationContext.Provider value={navigation}> <NavigationRouteContext.Provider value={route}>{render()}</NavigationRouteContext.Provider> </NavigationContext.Provider> ); }, [navigation, render, route]); const wrappedRenderRef = useRef(wrappedRender); wrappedRenderRef.current = wrappedRender; const focusedRef = useRef(false); const deactivateRef = useRef<(() => void) | null>(null); const syncRegistration = useCallback(() => { if (!focusedRef.current || wrappedRenderRef.current === undefined) { deactivateRef.current?.(); return; } deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); }, [registerWorkspaceInspector]); // Focus lifecycle. Blur/focus events fire even when the blurred subtree is // frozen (events are navigation-driven, renders are not). useFocusEffect( useCallback(() => { focusedRef.current = true; syncRegistration(); return () => { focusedRef.current = false; syncRegistration(); }; }, [syncRegistration]), ); // Content changes while focused re-register in place. useEffect(() => { if (focusedRef.current) { syncRegistration(); } }, [syncRegistration, wrappedRender]); // Unmount: hand the pane back (owner-guarded, so a route that already // took over is unaffected). useEffect( () => () => { deactivateRef.current?.(); deactivateRef.current = null; }, [], ); } export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( <AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode} /> ) : null; } const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); return ( <AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode={groupingSettings.sidebarProjectGroupingMode} /> ); } function AdaptiveWorkspaceLayoutContent( props: { readonly children: ReactNode; readonly pathname: string; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; }, ) { const projectGroupingMode = props.projectGroupingMode; const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); const activeRoleOwner = useRef<symbol | null>(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<number | null>( null, ); const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState<WorkspaceAuxiliaryPaneRole | null>(null); const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]); const layout = baseLayout; // In split layouts the sidebar IS the thread list — it renders on every // route, including Home (which shows an empty-detail pane instead of the // compact list). const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( () => deriveFileInspectorPaneLayout({ layout, viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: shouldRenderPrimarySidebar && primarySidebarPreferredVisible ? (layout.listPaneWidth ?? 0) : 0, }), [ fileInspectorPreferredWidth, layout, primarySidebarPreferredVisible, shouldRenderPrimarySidebar, width, ], ); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); const auxiliaryPanePreferredVisible = auxiliaryPaneRole === "inspector" ? fileInspectorPreferredVisible : supplementaryPanePreferredVisible; const auxiliaryPanePreferredWidth = auxiliaryPaneRole === "inspector" ? fileInspectorPreferredWidth : supplementaryPanePreferredWidth; const panes = useMemo( () => deriveWorkspacePaneLayout({ layout, viewportWidth: width, primarySidebarPreferredVisible, auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, }), [ auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth, layout, primarySidebarPreferredVisible, width, ], ); const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; const selectedThreadKey = useMemo(() => { if (environmentId === null || threadId === null) { return null; } try { return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); } catch { return null; } }, [environmentId, threadId]); // Wrapped in an object: bare functions in useState would be treated as // lazy initializers/updaters. `active: false` keeps the outgoing route's // content mounted so the pane can animate closed (or be replaced // seamlessly by the next route's registration in the same commit). const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; } | null>(null); const workspaceInspectorOwner = useRef<symbol | null>(null); const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; setWorkspaceInspector({ render, active: true }); return () => { // During a push/replace the outgoing screen deactivates AFTER the // incoming screen registered — only the current owner may deactivate. if (workspaceInspectorOwner.current !== owner) { return; } setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false })); }; }, []); // Once the close animation settles, drop the stale content entirely. const handleWorkspaceInspectorClosed = useCallback(() => { setWorkspaceInspector((current) => (current !== null && !current.active ? null : current)); }, []); const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { const owner = Symbol(role); activeRoleOwner.current = owner; setFocusedAuxiliaryPaneRole(role); return () => { if (activeRoleOwner.current !== owner) { return; } activeRoleOwner.current = null; setFocusedAuxiliaryPaneRole(null); }; }, []); const togglePrimarySidebar = useCallback(() => { if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); return; } setPrimarySidebarPreferredVisible((current) => !current); }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]); const revealPrimarySidebar = useCallback(() => { if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } setPrimarySidebarPreferredVisible(true); }, [panes.primarySidebarSuppressedByAuxiliary]); const handleToggleSidebarCommand = useCallback(() => { togglePrimarySidebar(); return true; }, [togglePrimarySidebar]); useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand); const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { if (role === "inspector") { setFocusedAuxiliaryPaneRole("inspector"); setFileInspectorPreferredVisible(true); return; } setFocusedAuxiliaryPaneRole("supplementary"); setSupplementaryPanePreferredVisible(true); }, []); const handleOpenFilesCommand = useCallback(() => { 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; }, [fileInspector.supported, layout.usesSplitView, pathname, navigation, showAuxiliaryPane]); useHardwareKeyboardCommand("files", handleOpenFilesCommand); const toggleAuxiliaryPane = useCallback(() => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredVisible((current) => !current); return; } setSupplementaryPanePreferredVisible((current) => !current); }, [auxiliaryPaneRole]); const setAuxiliaryPaneWidth = useCallback( (nextWidth: number) => { if (auxiliaryPaneRole === "inspector") { setFileInspectorPreferredWidth(nextWidth); return; } setSupplementaryPanePreferredWidth(nextWidth); }, [auxiliaryPaneRole], ); const contextValue = useMemo( () => ({ layout, panes, fileInspector, primarySidebarSearchQuery, activateAuxiliaryPaneRole, registerWorkspaceInspector, setPrimarySidebarSearchQuery, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar, setAuxiliaryPaneWidth, }), [ activateAuxiliaryPaneRole, fileInspector, layout, panes, primarySidebarSearchQuery, registerWorkspaceInspector, showAuxiliaryPane, setPrimarySidebarSearchQuery, setAuxiliaryPaneWidth, toggleAuxiliaryPane, togglePrimarySidebar, ], ); const handleOpenSettings = useCallback(() => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "Settings" }, }); }, [navigation]); const handleStartNewTask = useCallback(() => { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); }, [navigation]); // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { navigation.navigate("SettingsSheet", { screen: "SettingsContent", params: { screen: "SettingsEnvironments" }, }); }, [navigation]); const handleNewThreadOnBranch = useCallback( (thread: EnvironmentThreadShell) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", params: { environmentId: String(thread.environmentId), projectId: String(thread.projectId), branch: thread.branch, worktreePath: thread.worktreePath, }, }); }, [navigation], ); const handleNewThreadInProject = useCallback( (project: EnvironmentProject) => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", params: { environmentId: String(project.environmentId), projectId: String(project.id), title: project.title, }, }); }, [navigation], ); const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, ); useEffect(() => { const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0; renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); }, [layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ opacity: Math.min(1, renderedSidebarWidth.value / 80), width: renderedSidebarWidth.value, }));