import { NativeStackScreenOptions } from "../../native/StackHeader";import { StackActions, useFocusEffect, useNavigation, type StaticScreenProps,} from "@react-navigation/native";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";import * as Option from "effect/Option";import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";import { Platform, ScrollView, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useWorkspaceState } from "../../state/workspace";import { useEnvironmentQuery } from "../../state/query";import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState";import { AndroidScreenHeader, type AndroidHeaderAction,} from "../../components/AndroidScreenHeader";import { LoadingScreen } from "../../components/LoadingScreen";import { scopedThreadKey } from "../../lib/scopedEntities";import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";import { connectionTone } from "../connection/connectionTone"; import { useRemoteConnections, useRemoteConnectionStatus, useRemoteEnvironmentRuntime,} from "../../state/use-remote-environment-registry";import { useKnownTerminalSessions } from "../../state/use-terminal-session";import { useSelectedThreadDetailState } from "../../state/use-thread-detail";import { useThreadSelection } from "../../state/use-thread-selection";import { GitActionProgressOverlay } from "./GitActionProgressOverlay";import { buildTerminalMenuSessions, nextOpenTerminalId, resolveProjectScriptTerminalId,} from "../terminal/terminalMenu";import { resolvePreferredThreadWorktreePath, stagePendingTerminalLaunch,} from "../terminal/terminalLaunchContext";import { terminalDebugLog } from "../terminal/terminalDebugLog";import { ThreadDetailScreen } from "./ThreadDetailScreen";import { ThreadGitControls, useThreadGitCenterHeaderItems, useThreadGitRightHeaderItems,} from "./ThreadGitControls";import { GitOverviewSheet } from "./git/GitOverviewSheet";import { useAtomCommand } from "../../state/use-atom-command";import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";import { useThreadComposerState } from "../../state/use-thread-composer-state";import { threadEnvironment } from "../../state/threads";import { projectThreadContentPresentation } from "./threadContentPresentation";import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector,} from "../layout/AdaptiveWorkspaceLayout";import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";import { ThreadInspectorContentStack, type ThreadInspectorMode,} from "./thread-inspector-content-stack"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; readonly mode: ThreadInspectorMode;} type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>; function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null;} function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { return value[0] ?? null; } return value ?? null;} function OpeningThreadLoadingScreen() { return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;} type ThreadRouteScreenRouteProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string;}>; interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly onReturnToThread?: () => void; readonly renderInspector?: (headerInset: number) => ReactNode;} function ThreadUnavailableScreen() { return ( <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ flexGrow: 1, justifyContent: "center", paddingHorizontal: 24, paddingVertical: 32, }} className="bg-screen flex-1" > <EmptyState title="Thread unavailable" detail="This thread is not available in the current mobile snapshot." /> </ScrollView> );} export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const { state: workspaceState } = useWorkspaceState(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeThreadKey = environmentId !== null && threadIdRaw !== null ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw)) : null; const selectedThreadKey = selectedThread === null ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); const selectedThreadDetailState = useSelectedThreadDetailState(); if (environmentId === null || threadIdRaw === null) { return <OpeningThreadLoadingScreen />; } // Render the full thread chrome (header, feed, composer) as soon as the // thread SHELL is known — no blocking on message detail. The feed shows a // loading placeholder while messages fetch, and the composer's connection // pill reports connecting/reconnecting/syncing status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; } const stillHydrating = workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; if (stillHydrating) { return <OpeningThreadLoadingScreen />; } return <ThreadUnavailableScreen />;} function ThreadRouteContent( props: ThreadRouteScreenProps & { readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; },) { const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar, } = useAdaptiveWorkspaceLayout(); const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const threadId = firstRouteParam(params.threadId); const routeThreadIdentity = environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); const inspectorMode = (() => { if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { return null; } return inspectorSelection.mode; } return null; })(); useEffect(() => { if ( fileInspector.supported && selectedThreadCwd === null && inspectorMode === null && panes.auxiliaryPaneVisible ) { toggleAuxiliaryPane(); } }, [ fileInspector.supported, inspectorMode, panes.auxiliaryPaneVisible, selectedThreadCwd, toggleAuxiliaryPane, ]); useEffect(() => { setInspectorSelection((current) => { if (props.renderInspector === undefined) { if (current === null || current.mode === "route") { return null; } return { ...current, routeThreadIdentity }; } if (current === null || current.mode === "route") { return { routeThreadIdentity, mode: "route" }; } return { ...current, routeThreadIdentity }; }); }, [props.renderInspector, routeThreadIdentity]); useFocusEffect( useCallback(() => { return () => { if (props.renderInspector === undefined) { // Inspectors are contextual to this chat destination. Clear the // hidden chat copy after a native push so returning from Files, // Review, or Terminal cannot reserve an empty trailing pane. setInspectorSelection(null); } }; }, [props.renderInspector]), ); const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; const selectedThreadWithDraftSettings = useMemo( () => selectedThread ? { ...selectedThread, modelSelection: composer.modelSelection ?? selectedThread.modelSelection, runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, interactionMode: composer.interactionMode ?? selectedThread.interactionMode, } : null, [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], ); /* ─── Native header theming ──────────────────────────────────────── */ const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED; const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, ] .filter(Boolean) .join(" · "); /* ─── Git status for native header trigger ───────────────────────── */ const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ environmentId: selectedThread.environmentId, input: { cwd: selectedThreadCwd }, }) : null, ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); const terminalMenuSessions = useMemo( () => buildTerminalMenuSessions({ knownSessions: knownTerminalSessions, workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, }), [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; const handleReconnectEnvironment = useCallback(() => { if (!environmentId) { return; } onReconnectEnvironment(environmentId); }, [environmentId, onReconnectEnvironment]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( () => ({ environmentId: selectedThread?.environmentId ?? null, cwd: selectedThreadCwd, }), [selectedThread?.environmentId, selectedThreadCwd], ); const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenGitInspector = useCallback(() => { if (!fileInspector.supported) { if (selectedThread === null) { return; } navigation.navigate("GitOverview", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), }); return; } setInspectorSelection({ routeThreadIdentity, mode: "git" }); showAuxiliaryPane("inspector"); }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]); const handleOpenFilesInspector = useCallback(() => { if (selectedThread === null || selectedThreadCwd === null) { return; } if (!fileInspector.supported) { navigation.navigate("ThreadFiles", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), }); return; } setInspectorSelection({ routeThreadIdentity, mode: props.renderInspector === undefined ? "files" : "route", }); showAuxiliaryPane("inspector"); }, [ fileInspector.supported, navigation, props.renderInspector, routeThreadIdentity, selectedThread, selectedThreadCwd, showAuxiliaryPane, ]); const inspectorToggleActionRef = useRef({ inspectorMode, openFilesInspector: handleOpenFilesInspector, toggleAuxiliaryPane, }); inspectorToggleActionRef.current = { inspectorMode, openFilesInspector: handleOpenFilesInspector, toggleAuxiliaryPane, }; const handleToggleInspector = useCallback(() => { const action = inspectorToggleActionRef.current; if (action.inspectorMode === null) { action.openFilesInspector(); return; } action.toggleAuxiliaryPane(); }, []); const handleSelectInspectorFile = useCallback( (path: string) => { if (selectedThread === null) { return; } const params = { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), path: path.split("/").filter((segment) => segment.length > 0), }; if (fileInspector.supported) { navigation.navigate("ThreadFile", params); return; } navigation.navigate("ThreadFile", params); }, [fileInspector.supported, navigation, selectedThread], ); // The workspace inspector column spans the full window height. On iOS the // panes bring their own nested native headers (which underlap the status // bar); elsewhere the pane content pads itself below the top inset. const safeAreaInsets = useSafeAreaInsets(); const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; const GitInspector = useCallback( () => ( <GitOverviewSheet headerInset={inspectorHeaderInset} presentation="inspector" route={{ params: props.route.params }} /> ), [inspectorHeaderInset, props.route.params], ); const FilesInspector = useCallback( () => selectedThread !== null && selectedThreadCwd !== null ? ( <ThreadFileNavigatorPane cwd={selectedThreadCwd} environmentId={selectedThread.environmentId} headerInset={inspectorHeaderInset} projectName={selectedThreadProject?.title ?? "Files"} selectedPath={null} onSelectFile={handleSelectInspectorFile} /> ) : null, [ handleSelectInspectorFile, inspectorHeaderInset, selectedThread, selectedThreadCwd, selectedThreadProject?.title, ], ); const RouteInspector = useCallback( () => props.renderInspector?.(inspectorHeaderInset), [inspectorHeaderInset, props.renderInspector], ); const renderInspectorStack = useCallback( () => inspectorMode === null ? null : ( <ThreadInspectorContentStack Files={FilesInspector} Git={GitInspector} mode={inspectorMode} Route={props.renderInspector ? RouteInspector : undefined} /> ), [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, // outside this screen's native header — the terminal/git/files toolbar // stays anchored to the chat pane instead of floating above the inspector. useRegisterWorkspaceInspector(activeInspectorRenderer); const handleOpenConnectionEditor = useCallback(() => { void navigation.navigate("Connections"); }, [navigation]); const handleStopThread = useCallback(() => { if ( !selectedThread || (selectedThread.session?.status !== "running" && selectedThread.session?.status !== "starting") ) { return; } return interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, ...(selectedThread.session.activeTurnId ? { turnId: selectedThread.session.activeTurnId } : {}), }, }); }, [interruptThreadTurn, selectedThread]); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { terminalDebugLog("terminal-menu:open-existing", { terminalId: nextTerminalId ?? null, hasThread: Boolean(selectedThread), hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), }); if (!selectedThread || !selectedThreadProject?.workspaceRoot) { return; } void navigation.navigate("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), ...(nextTerminalId ? { terminalId: nextTerminalId } : {}), }); }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot],Lines 1–500 of 852. Load more
import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useFocusEffect, useNavigation, type StaticScreenProps, } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { AndroidScreenHeader, type AndroidHeaderAction, } from "../../components/AndroidScreenHeader"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { connectionTone } from "../connection/connectionTone"; import { useRemoteConnections, useRemoteConnectionStatus, useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { buildTerminalMenuSessions, nextOpenTerminalId, resolveProjectScriptTerminalId, } from "../terminal/terminalMenu"; import { resolvePreferredThreadWorktreePath, stagePendingTerminalLaunch, } from "../terminal/terminalLaunchContext"; import { terminalDebugLog } from "../terminal/terminalDebugLog"; import { ThreadDetailScreen } from "./ThreadDetailScreen"; import { ThreadGitControls, useThreadGitCenterHeaderItems, useThreadGitRightHeaderItems, } from "./ThreadGitControls"; import { GitOverviewSheet } from "./git/GitOverviewSheet"; import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; import { ThreadInspectorContentStack, type ThreadInspectorMode, } from "./thread-inspector-content-stack"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; readonly mode: ThreadInspectorMode; } type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>; function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; } function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { return value[0] ?? null; } return value ?? null; } function OpeningThreadLoadingScreen() { return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />; } type ThreadRouteScreenRouteProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; }>; interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly onReturnToThread?: () => void; readonly renderInspector?: (headerInset: number) => ReactNode; } function ThreadUnavailableScreen() { return ( <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ flexGrow: 1, justifyContent: "center", paddingHorizontal: 24, paddingVertical: 32, }} className="bg-screen flex-1" > <EmptyState title="Thread unavailable" detail="This thread is not available in the current mobile snapshot." /> </ScrollView> ); } export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const { state: workspaceState } = useWorkspaceState(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeThreadKey = environmentId !== null && threadIdRaw !== null ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw)) : null; const selectedThreadKey = selectedThread === null ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); const selectedThreadDetailState = useSelectedThreadDetailState(); if (environmentId === null || threadIdRaw === null) { return <OpeningThreadLoadingScreen />; } // Render the full thread chrome (header, feed, composer) as soon as the // thread SHELL is known — no blocking on message detail. The feed shows a // loading placeholder while messages fetch, and the composer's connection // pill reports connecting/reconnecting/syncing status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />; } const stillHydrating = workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; if (stillHydrating) { return <OpeningThreadLoadingScreen />; } return <ThreadUnavailableScreen />; } function ThreadRouteContent( props: ThreadRouteScreenProps & { readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>; }, ) { const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar, } = useAdaptiveWorkspaceLayout(); const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const threadId = firstRouteParam(params.threadId); const routeThreadIdentity = environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); const inspectorMode = (() => { if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { return null; } return inspectorSelection.mode; } return null; })(); useEffect(() => { if ( fileInspector.supported && selectedThreadCwd === null && inspectorMode === null && panes.auxiliaryPaneVisible ) { toggleAuxiliaryPane(); } }, [ fileInspector.supported, inspectorMode, panes.auxiliaryPaneVisible, selectedThreadCwd, toggleAuxiliaryPane, ]); useEffect(() => { setInspectorSelection((current) => { if (props.renderInspector === undefined) { if (current === null || current.mode === "route") { return null; } return { ...current, routeThreadIdentity }; } if (current === null || current.mode === "route") { return { routeThreadIdentity, mode: "route" }; } return { ...current, routeThreadIdentity }; }); }, [props.renderInspector, routeThreadIdentity]); useFocusEffect( useCallback(() => { return () => { if (props.renderInspector === undefined) { // Inspectors are contextual to this chat destination. Clear the // hidden chat copy after a native push so returning from Files, // Review, or Terminal cannot reserve an empty trailing pane. setInspectorSelection(null); } }; }, [props.renderInspector]), ); const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; const selectedThreadWithDraftSettings = useMemo( () => selectedThread ? { ...selectedThread, modelSelection: composer.modelSelection ?? selectedThread.modelSelection, runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, interactionMode: composer.interactionMode ?? selectedThread.interactionMode, } : null, [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], ); /* ─── Native header theming ──────────────────────────────────────── */ const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED; const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, ] .filter(Boolean) .join(" · "); /* ─── Git status for native header trigger ───────────────────────── */ const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ environmentId: selectedThread.environmentId, input: { cwd: selectedThreadCwd }, }) : null, ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); const terminalMenuSessions = useMemo( () => buildTerminalMenuSessions({ knownSessions: knownTerminalSessions, workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, }), [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; const handleReconnectEnvironment = useCallback(() => { if (!environmentId) { return; } onReconnectEnvironment(environmentId); }, [environmentId, onReconnectEnvironment]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( () => ({ environmentId: selectedThread?.environmentId ?? null, cwd: selectedThreadCwd, }), [selectedThread?.environmentId, selectedThreadCwd], ); const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenGitInspector = useCallback(() => { if (!fileInspector.supported) { if (selectedThread === null) { return; } navigation.navigate("GitOverview", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), }); return; } setInspectorSelection({ routeThreadIdentity, mode: "git" }); showAuxiliaryPane("inspector"); }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]); const handleOpenFilesInspector = useCallback(() => { if (selectedThread === null || selectedThreadCwd === null) { return; } if (!fileInspector.supported) { navigation.navigate("ThreadFiles", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), }); return; } setInspectorSelection({ routeThreadIdentity, mode: props.renderInspector === undefined ? "files" : "route", }); showAuxiliaryPane("inspector"); }, [ fileInspector.supported, navigation, props.renderInspector, routeThreadIdentity, selectedThread, selectedThreadCwd, showAuxiliaryPane, ]); const inspectorToggleActionRef = useRef({ inspectorMode, openFilesInspector: handleOpenFilesInspector, toggleAuxiliaryPane, }); inspectorToggleActionRef.current = { inspectorMode, openFilesInspector: handleOpenFilesInspector, toggleAuxiliaryPane, }; const handleToggleInspector = useCallback(() => { const action = inspectorToggleActionRef.current; if (action.inspectorMode === null) { action.openFilesInspector(); return; } action.toggleAuxiliaryPane(); }, []); const handleSelectInspectorFile = useCallback( (path: string) => { if (selectedThread === null) { return; } const params = { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), path: path.split("/").filter((segment) => segment.length > 0), }; if (fileInspector.supported) { navigation.navigate("ThreadFile", params); return; } navigation.navigate("ThreadFile", params); }, [fileInspector.supported, navigation, selectedThread], ); // The workspace inspector column spans the full window height. On iOS the // panes bring their own nested native headers (which underlap the status // bar); elsewhere the pane content pads itself below the top inset. const safeAreaInsets = useSafeAreaInsets(); const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top; const GitInspector = useCallback( () => ( <GitOverviewSheet headerInset={inspectorHeaderInset} presentation="inspector" route={{ params: props.route.params }} /> ), [inspectorHeaderInset, props.route.params], ); const FilesInspector = useCallback( () => selectedThread !== null && selectedThreadCwd !== null ? ( <ThreadFileNavigatorPane cwd={selectedThreadCwd} environmentId={selectedThread.environmentId} headerInset={inspectorHeaderInset} projectName={selectedThreadProject?.title ?? "Files"} selectedPath={null} onSelectFile={handleSelectInspectorFile} /> ) : null, [ handleSelectInspectorFile, inspectorHeaderInset, selectedThread, selectedThreadCwd, selectedThreadProject?.title, ], ); const RouteInspector = useCallback( () => props.renderInspector?.(inspectorHeaderInset), [inspectorHeaderInset, props.renderInspector], ); const renderInspectorStack = useCallback( () => inspectorMode === null ? null : ( <ThreadInspectorContentStack Files={FilesInspector} Git={GitInspector} mode={inspectorMode} Route={props.renderInspector ? RouteInspector : undefined} /> ), [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, // outside this screen's native header — the terminal/git/files toolbar // stays anchored to the chat pane instead of floating above the inspector. useRegisterWorkspaceInspector(activeInspectorRenderer); const handleOpenConnectionEditor = useCallback(() => { void navigation.navigate("Connections"); }, [navigation]); const handleStopThread = useCallback(() => { if ( !selectedThread || (selectedThread.session?.status !== "running" && selectedThread.session?.status !== "starting") ) { return; } return interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, ...(selectedThread.session.activeTurnId ? { turnId: selectedThread.session.activeTurnId } : {}), }, }); }, [interruptThreadTurn, selectedThread]); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { terminalDebugLog("terminal-menu:open-existing", { terminalId: nextTerminalId ?? null, hasThread: Boolean(selectedThread), hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), }); if (!selectedThread || !selectedThreadProject?.workspaceRoot) { return; } void navigation.navigate("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), ...(nextTerminalId ? { terminalId: nextTerminalId } : {}), }); }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot],