1import { useOpenPullRequest } from "../../native/useOpenPullRequest";
2import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
3import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
4import { useWorktreeSetup } from "./use-worktree-setup";
5import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
6import { ScreenHeader } from "../../components/ScreenHeader";
7import {
8 StackActions,
9 useFocusEffect,
10 useNavigation,
11 type StaticScreenProps,
12} from "@react-navigation/native";
13import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
14import * as Option from "effect/Option";
15import {
16 AuthOrchestrationOperateScope,
17 AuthTerminalOperateScope,
18 AuthTerminalReadScope,
19 DEFAULT_SERVER_SETTINGS,
20 EnvironmentId,
21 ThreadId,
22 type ProjectScript,
23} from "@t3tools/contracts";
24import {
25 projectScriptCwd,
26 projectScriptRuntimeEnv,
27 resolveProjectScripts,
28} from "@t3tools/shared/projectScripts";
29import { Alert, Platform, ScrollView, View } from "react-native";
30import { useSafeAreaInsets } from "react-native-safe-area-context";
31import { useConnectionsReady } from "../../state/workspace";
32import { useEnvironmentShellReadiness } from "../../state/shell";
33import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
34import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
35import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft";
36import { useEnvironmentQuery } from "../../state/query";
37import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
38import { vcsEnvironment } from "../../state/vcs";
39import { EmptyState } from "../../components/EmptyState";
40import { LoadingScreen } from "../../components/LoadingScreen";
41import { scopedThreadKey } from "../../lib/scopedEntities";
42import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
43import { connectionTone } from "../connection/connectionTone";
44import {
45 useRemoteConnections,
46 useRemoteConnectionStatus,
47 useRemoteEnvironmentRuntime,
48} from "../../state/use-remote-environment-registry";
49import { useKnownTerminalSessions } from "../../state/use-terminal-session";
50import { uuidv4 } from "../../lib/uuid";
51import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
52import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
53import { useThreadSelection } from "../../state/use-thread-selection";
54import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
55import {
56 buildTerminalMenuSessions,
57 nextOpenTerminalId,
58 resolveProjectScriptTerminalId,
59} from "../terminal/terminalMenu";
60import {
61 resolvePreferredThreadWorktreePath,
62 stagePendingTerminalLaunch,
63} from "../terminal/terminalLaunchContext";
64import { terminalDebugLog } from "../terminal/terminalDebugLog";
65import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen";
66import { GitOverviewSheet } from "./git/GitOverviewSheet";
67import { useAtomCommand } from "../../state/use-atom-command";
68import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
69import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
70import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
71import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
72import { useThreadComposerState } from "../../state/use-thread-composer-state";
73import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
74import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
75import { threadEnvironment } from "../../state/threads";
76import { projectThreadContentPresentation } from "./threadContentPresentation";
77import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
78import {
79 useAdaptiveWorkspaceLayout,
80 useAdaptiveWorkspacePaneRole,
81 useRegisterWorkspaceInspector,
82} from "../layout/AdaptiveWorkspaceLayout";
83import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
84import {
85 ThreadInspectorContentStack,
86 type ThreadInspectorMode,
87} from "./thread-inspector-content-stack";
88import { threadRouteIsHydrating } from "./thread-route-hydration";
89import { useThreadPromptLink } from "../prompt-links/useThreadPromptLink";
90import { ThreadHeader } from "./ThreadHeader";
91import {
92 useAndroidWorkspaceBack,
93 useWorkspaceListNavigation,
94} from "../workspace/useAndroidWorkspaceNavigation";
95
96interface ThreadInspectorSelection {
97 readonly routeThreadIdentity: string | null;
98 readonly mode: ThreadInspectorMode;
99}
100
101function InspectorPaneRoleActivation() {
102 useAdaptiveWorkspacePaneRole("inspector");
103 return null;
104}
105
106// A blank param (a hand-typed deep link) is treated as missing, since branded
107// IDs reject whitespace-only values.
108function firstRouteParam(value: string | string[] | undefined): string | null {
109 const first = Array.isArray(value) ? value[0] : value;
110 return first === undefined || first.trim().length === 0 ? null : first;
111}
112
113function ThreadPane(props: { readonly children: ReactNode }) {
114 return (
115 <View className="flex-1 bg-screen android:overflow-hidden android:rounded-t-[28px] android:bg-thread-canvas">
116 {props.children}
117 </View>
118 );
119}
120
121function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
122 const returnToList = useWorkspaceListNavigation("threads");
123 const { layout } = useAdaptiveWorkspaceLayout();
124 const { themeVariables } = useAppearancePreferences();
125 if (Platform.OS !== "android") return props.children;
126
127 return (
128 <>
129 <ScreenHeader
130 title="Thread"
131 options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }}
132 onBack={layout.usesSplitView ? undefined : returnToList}
133 hideBottomBorder
134 />
135 <ThreadPane>{props.children}</ThreadPane>
136 </>
137 );
138}
139
140function OpeningThreadLoadingScreen() {
141 return (
142 <ThreadRouteFallbackLayout>
143 <LoadingScreen
144 message="Opening thread…"
145 messagePlacement="above-spinner"
146 embedded={Platform.OS === "android"}
147 />
148 </ThreadRouteFallbackLayout>
149 );
150}
151
152type ThreadRouteScreenRouteProps = StaticScreenProps<{
153 readonly environmentId: string;
154 readonly threadId: string;
155 /** A prompt link's draft text. See useThreadPromptLink. */
156 readonly prompt?: string | string[];
157}>;
158
159interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
160 readonly onReturnToThread?: () => void;
161 readonly renderInspector?: (headerInset: number) => ReactNode;
162}
163
164/** Shows recovery only after the target route has reached a terminal unavailable state. */
165function ThreadUnavailableScreen(props: {
166 readonly actionLabel: string;
167 readonly onAction: () => void;
168}) {
169 return (
170 <ThreadRouteFallbackLayout>
171 <ScrollView
172 contentInsetAdjustmentBehavior="automatic"
173 contentContainerStyle={{
174 flexGrow: 1,
175 justifyContent: "center",
176 paddingHorizontal: 24,
177 paddingVertical: 32,
178 }}
179 className="bg-screen flex-1 android:bg-transparent"
180 >
181 <EmptyState
182 title="Thread unavailable"
183 detail="This thread is not available in the current mobile snapshot."
184 actionLabel={props.actionLabel}
185 onAction={props.onAction}
186 />
187 </ScrollView>
188 </ThreadRouteFallbackLayout>
189 );
190}
191
192export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
193 useAndroidWorkspaceBack("threads");
194 const connectionsReady = useConnectionsReady();
195 const { connectionState } = useRemoteConnectionStatus();
196 const { selectedThread } = useThreadSelection();
197 const params = props.route.params;
198 const environmentIdRaw = firstRouteParam(params.environmentId);
199 const threadIdRaw = firstRouteParam(params.threadId);
200 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
201 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
202 const routeEnvironmentShellState = useEnvironmentShellReadiness(environmentId);
203 const { onReconnectEnvironment } = useRemoteConnections();
204 const navigation = useNavigation();
205 const routeConnectionState =
206 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
207 const routeThreadKey =
208 environmentId !== null && threadIdRaw !== null
209 ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw))
210 : null;
211 const selectedThreadKey =
212 selectedThread === null
213 ? null
214 : scopedThreadKey(selectedThread.environmentId, selectedThread.id);
215 const selectedThreadDetailState = useSelectedThreadDetailState();
216 useThreadPromptLink(params, routeThreadKey);
217
218 if (environmentId === null || threadIdRaw === null) {
219 return <OpeningThreadLoadingScreen />;
220 }
221
222 // Render the full thread chrome (header, feed, composer) as soon as the
223 // thread SHELL is known — no blocking on message detail. The feed shows a
224 // loading placeholder while messages fetch, the floating pill above the
225 // composer reports loading/syncing, and the composer's connection pill
226 // reports connecting/reconnecting status.
227 if (selectedThread !== null && selectedThreadKey === routeThreadKey) {
228 return <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />;
229 }
230
231 const stillHydrating = threadRouteIsHydrating({
232 isLoadingConnections: !connectionsReady,
233 connectionState: routeConnectionState,
234 shellStatus: routeEnvironmentShellState.status,
235 shellHasError: routeEnvironmentShellState.hasError,
236 detailStatus: selectedThreadDetailState.status,
237 detailHasError: Option.isSome(selectedThreadDetailState.error),
238 });
239
240 if (stillHydrating) {
241 return <OpeningThreadLoadingScreen />;
242 }
243
244 return (
245 <ThreadUnavailableScreen
246 actionLabel={
247 routeEnvironmentRuntime === null ? "Manage environments" : "Reconnect environment"
248 }
249 onAction={() => {
250 if (routeEnvironmentRuntime !== null) {
251 onReconnectEnvironment(environmentId);
252 return;
253 }
254 navigation.navigate("SettingsSheet", {
255 screen: "SettingsContent",
256 params: { screen: "SettingsEnvironments" },
257 });
258 }}
259 />
260 );
261}
262
263function ThreadRouteContent(
264 props: ThreadRouteScreenProps & {
265 readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
266 },
267) {
268 const { themeVariables } = useAppearancePreferences();
269 const headerColor = themeVariables["--color-header"];
270 const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane } =
271 useAdaptiveWorkspaceLayout();
272 const { connectionState } = useRemoteConnectionStatus();
273 const { onReconnectEnvironment } = useRemoteConnections();
274 const {
275 selectedThread,
276 selectedThreadCreation,
277 selectedThreadProject,
278 selectedEnvironmentConnection,
279 } = useThreadSelection();
280 const canOperateThread = useEnvironmentScope(
281 selectedThread?.environmentId ?? null,
282 AuthOrchestrationOperateScope,
283 );
284 const canReadTerminal = useEnvironmentScope(
285 selectedThread?.environmentId ?? null,
286 AuthTerminalReadScope,
287 );
288 const canOperateTerminal = useEnvironmentScope(
289 selectedThread?.environmentId ?? null,
290 AuthTerminalOperateScope,
291 );
292 const selectedThreadDetailState = props.selectedThreadDetailState;
293 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
294 const { selectedThreadCwd } = useSelectedThreadWorktree();
295 const composer = useThreadComposerState();
296 const gitState = useSelectedThreadGitState();
297 const gitActions = useSelectedThreadGitActions();
298 const requests = useSelectedThreadRequests();
299 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
300 const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
301 label: "load earlier thread history",
302 reportFailure: false,
303 });
304 const historyControls = useMemo(() => {
305 const history = selectedThreadDetailState.history;
306 if (!selectedThread) {
307 return undefined;
308 }
309 if (!history.hasMoreHistory && history.error === null) {
310 return undefined;
311 }
312 return {
313 hasMoreHistory: history.hasMoreHistory,
314 loading: history.loading,
315 error: history.error,
316 onLoadEarlier: () => {
317 void loadEarlierHistory({
318 environmentId: selectedThread.environmentId,
319 input: { threadId: selectedThread.id },
320 });
321 },
322 };
323 }, [loadEarlierHistory, selectedThread, selectedThreadDetailState.history]);
324 const navigation = useNavigation();
325 const openPullRequest = useOpenPullRequest();
326 const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back");
327 const mergeBackTargetThreadId = resolveMergeBackTargetThreadId(selectedThreadDetail);
328 const mergeBackRun =
329 selectedThreadDetail === null ? null : resolveLatestMergeBackRun(selectedThreadDetail);
330 const mergeBackBusyRef = useRef(false);
331 const handleMergeBack = useCallback(async () => {
332 if (
333 mergeBackBusyRef.current ||
334 !selectedThread ||
335 mergeBackTargetThreadId === null ||
336 mergeBackRun === null
337 ) {
338 return;
339 }
340 mergeBackBusyRef.current = true;
341 try {
342 const result = await mergeBack({
343 environmentId: selectedThread.environmentId,
344 input: {
345 sourceThreadId: selectedThread.id,
346 targetThreadId: mergeBackTargetThreadId,
347 runId: mergeBackRun.id,
348 creationSource: "mobile",
349 },
350 });
351 if (result._tag !== "Success") return;
352 navigation.navigate("Thread", {
353 environmentId: selectedThread.environmentId,
354 threadId: mergeBackTargetThreadId,
355 });
356 } finally {
357 mergeBackBusyRef.current = false;
358 }
359 }, [mergeBack, mergeBackRun, mergeBackTargetThreadId, navigation, selectedThread]);
360 const params = props.route.params;
361 const environmentIdRaw = firstRouteParam(params.environmentId);
362 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
363 const threadId = firstRouteParam(params.threadId);
364 const routeThreadIdentity =
365 environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null;
366 const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>(
367 () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null),
368 );
369 const inspectorMode = (() => {
370 if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) {
371 if (inspectorSelection.mode === "files" && selectedThreadCwd === null) {
372 return null;
373 }
374 return inspectorSelection.mode;
375 }
376 return null;
377 })();
378 useEffect(() => {
379 if (
380 fileInspector.supported &&
381 selectedThreadCwd === null &&
382 inspectorMode === null &&
383 panes.auxiliaryPaneVisible
384 ) {
385 toggleAuxiliaryPane();
386 }
387 }, [
388 fileInspector.supported,
389 inspectorMode,
390 panes.auxiliaryPaneVisible,
391 selectedThreadCwd,
392 toggleAuxiliaryPane,
393 ]);
394
395 useEffect(() => {
396 setInspectorSelection((current) => {
397 if (props.renderInspector === undefined) {
398 if (current === null || current.mode === "route") {
399 return null;
400 }
401 return { ...current, routeThreadIdentity };
402 }
403
404 if (current === null || current.mode === "route") {
405 return { routeThreadIdentity, mode: "route" };
406 }
407
408 return { ...current, routeThreadIdentity };
409 });
410 }, [props.renderInspector, routeThreadIdentity]);
411
412 useFocusEffect(
413 useCallback(() => {
414 return () => {
415 if (props.renderInspector === undefined) {
416 // Inspectors are contextual to this chat destination. Clear the
417 // hidden chat copy after a native push so returning from Files,
418 // Review, or Terminal cannot reserve an empty trailing pane.
419 setInspectorSelection(null);
420 }
421 };
422 }, [props.renderInspector]),
423 );
424 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
425 const routeConnectionState =
426 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
427 const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;
428 const selectedThreadWithDraftSettings = useMemo(
429 () =>
430 selectedThread
431 ? {
432 ...selectedThread,
433 modelSelection: composer.modelSelection ?? selectedThread.modelSelection,
434 runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,
435 interactionMode: composer.interactionMode ?? selectedThread.interactionMode,
436 }
437 : null,
438 [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread],
439 );
440
441 /* ─── Native header theming ──────────────────────────────────────── */
442 const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED;
443 const headerSubtitle = [
444 selectedThreadProject?.title ?? null,
445 selectedEnvironmentConnection?.environmentLabel ?? null,
446 ]
447 .filter(Boolean)
448 .join(" · ");
449 /* ─── Git status for native header trigger ───────────────────────── */
450 const gitStatus = useEnvironmentQuery(
451 selectedThread !== null && selectedThreadCwd !== null
452 ? vcsEnvironment.status({
453 environmentId: selectedThread.environmentId,
454 input: { cwd: selectedThreadCwd },
455 })
456 : null,
457 );
458 const { sessions: knownTerminalSessions } = useKnownTerminalSessions({
459 environmentId: selectedThread?.environmentId ?? null,
460 threadId: selectedThread?.id ?? null,
461 });
462 const terminalMenuSessions = useMemo(
463 () =>
464 buildTerminalMenuSessions({
465 knownSessions: knownTerminalSessions ?? [],
466 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
467 }),
468 [knownTerminalSessions, selectedThreadProject?.workspaceRoot],
469 );
470 const selectedThreadDetailWorktreePath = selectedThreadDetail?.thread.worktreePath ?? null;
471 const handleReconnectEnvironment = useCallback(() => {
472 if (!environmentId) {
473 return;
474 }
475 onReconnectEnvironment(environmentId);
476 }, [environmentId, onReconnectEnvironment]);
477
478 /* ─── Git action progress (for overlay banner) ──────────────────── */
479 const gitActionProgressTarget = useMemo(
480 () => ({
481 environmentId: selectedThread?.environmentId ?? null,
482 cwd: selectedThreadCwd,
483 }),
484 [selectedThread?.environmentId, selectedThreadCwd],
485 );
486 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
487
488 const handleOpenGitInspector = useCallback(() => {
489 if (!fileInspector.supported) {
490 if (selectedThread === null) {
491 return;
492 }
493 navigation.navigate("GitOverview", {
494 environmentId: String(selectedThread.environmentId),
495 threadId: String(selectedThread.id),
496 });
497 return;
498 }
499 setInspectorSelection({ routeThreadIdentity, mode: "git" });
500 showAuxiliaryPane("inspector");
501 }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]);
502 const handleOpenFilesInspector = useCallback(() => {
503 if (selectedThread === null || selectedThreadCwd === null) {
504 return;
505 }
506 if (!fileInspector.supported) {
507 navigation.navigate("ThreadFiles", {
508 environmentId: String(selectedThread.environmentId),
509 threadId: String(selectedThread.id),
510 });
511 return;
512 }
513 setInspectorSelection({
514 routeThreadIdentity,
515 mode: props.renderInspector === undefined ? "files" : "route",
516 });
517 showAuxiliaryPane("inspector");
518 }, [
519 fileInspector.supported,
520 navigation,
521 props.renderInspector,
522 routeThreadIdentity,
523 selectedThread,
524 selectedThreadCwd,
525 showAuxiliaryPane,
526 ]);
527 const inspectorToggleActionRef = useRef({
528 inspectorMode,
529 openFilesInspector: handleOpenFilesInspector,
530 toggleAuxiliaryPane,
531 });
532 inspectorToggleActionRef.current = {
533 inspectorMode,
534 openFilesInspector: handleOpenFilesInspector,
535 toggleAuxiliaryPane,
536 };
537 const handleToggleInspector = useCallback(() => {
538 const action = inspectorToggleActionRef.current;
539 if (action.inspectorMode === null) {
540 action.openFilesInspector();
541 return;
542 }
543 action.toggleAuxiliaryPane();
544 }, []);
545 const handleSelectInspectorFile = useCallback(
546 (path: string) => {
547 if (selectedThread === null) {
548 return;
549 }
550 const params = {
551 environmentId: String(selectedThread.environmentId),
552 threadId: String(selectedThread.id),
553 path: path.split("/").filter((segment) => segment.length > 0),
554 };
555 if (fileInspector.supported) {
556 navigation.navigate("ThreadFile", params);
557 return;
558 }
559 navigation.navigate("ThreadFile", params);
560 },
561 [fileInspector.supported, navigation, selectedThread],
562 );
563 // The workspace inspector column spans the full window height. On iOS the
564 // panes bring their own nested native headers (which underlap the status
565 // bar); elsewhere the pane content pads itself below the top inset.
566 const safeAreaInsets = useSafeAreaInsets();
567 const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
568 const GitInspector = useCallback(
569 () => (
570 <GitOverviewSheet
571 headerInset={inspectorHeaderInset}
572 presentation="inspector"
573 route={{ params: props.route.params }}
574 />
575 ),
576 [inspectorHeaderInset, props.route.params],
577 );
578 const FilesInspector = useCallback(
579 () =>
580 selectedThread !== null && selectedThreadCwd !== null ? (
581 <ThreadFileNavigatorPane
582 cwd={selectedThreadCwd}
583 environmentId={selectedThread.environmentId}
584 headerInset={inspectorHeaderInset}
585 projectName={selectedThreadProject?.title ?? "Files"}
586 selectedPath={null}
587 onSelectFile={handleSelectInspectorFile}
588 />
589 ) : null,
590 [
591 handleSelectInspectorFile,
592 inspectorHeaderInset,
593 selectedThread,
594 selectedThreadCwd,
595 selectedThreadProject?.title,
596 ],
597 );
598 const RouteInspector = useCallback(
599 () => props.renderInspector?.(inspectorHeaderInset),
600 [inspectorHeaderInset, props.renderInspector],
601 );
602 const renderInspectorStack = useCallback(
603 () =>
604 inspectorMode === null ? null : (
605 <ThreadInspectorContentStack
606 renderFiles={FilesInspector}
607 renderGit={GitInspector}
608 mode={inspectorMode}
609 resetKeys={[routeThreadIdentity, selectedThreadCwd]}
610 renderRoute={props.renderInspector ? RouteInspector : undefined}
611 />
612 ),
613 [
614 FilesInspector,
615 GitInspector,
616 RouteInspector,
617 inspectorMode,
618 props.renderInspector,
619 routeThreadIdentity,
620 selectedThreadCwd,
621 ],
622 );
623 const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;
624 // Hand the inspector to the workspace so it renders beside the navigator,
625 // outside this screen's native header — the terminal/git/files toolbar
626 // stays anchored to the chat pane instead of floating above the inspector.
627 useRegisterWorkspaceInspector(activeInspectorRenderer);
628
629 const handleOpenConnectionEditor = useCallback(() => {
630 void navigation.navigate("Connections");
631 }, [navigation]);
632 const handleStopThread = useCallback(() => {
633 if (
634 !selectedThread ||
635 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) ||
636 composer.interruptibleRunId === null
637 ) {
638 return;
639 }
640 return interruptThreadTurn({
641 environmentId: selectedThread.environmentId,
642 input: {
643 threadId: selectedThread.id,
644 runId: composer.interruptibleRunId,
645 },
646 });
647 }, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]);
648
649 const handleOpenTerminal = useCallback(
650 (nextTerminalId?: string | null) => {
651 terminalDebugLog("terminal-menu:open-existing", {
652 terminalId: nextTerminalId ?? null,
653 hasThread: Boolean(selectedThread),
654 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
655 });
656
657 if (
658 !selectedThread ||
659 !selectedThreadProject?.workspaceRoot ||
660 (!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) &&
661 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope))
662 ) {
663 return;
664 }
665
666 void navigation.navigate("ThreadTerminal", {
667 environmentId: String(selectedThread.environmentId),
668 threadId: String(selectedThread.id),
669 ...(nextTerminalId ? { terminalId: nextTerminalId } : {}),
670 });
671 },
672 [navigation, selectedThread, selectedThreadProject?.workspaceRoot],
673 );
674
675 const handleOpenNewTerminal = useCallback(() => {
676 terminalDebugLog("terminal-menu:open-new", {
677 hasThread: Boolean(selectedThread),
678 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
679 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
680 });
681
682 if (
683 !selectedThread ||
684 !selectedThreadProject?.workspaceRoot ||
685 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
686 ) {
687 return;
688 }
689
690 const nextId = nextOpenTerminalId({
691 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
692 ...(knownTerminalSessions === null ||
693 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
694 ? { uniqueSuffix: uuidv4() }
695 : {}),
696 });
697 void navigation.navigate("ThreadTerminal", {
698 environmentId: String(selectedThread.environmentId),
699 threadId: String(selectedThread.id),
700 terminalId: nextId,
701 });
702 }, [
703 knownTerminalSessions,
704 navigation,
705 selectedThread,
706 selectedThreadProject?.workspaceRoot,
707 terminalMenuSessions,
708 ]);
709
710 const handleRunProjectScript = useCallback(
711 async (script: ProjectScript) => {
712 terminalDebugLog("project-script:press", {
713 scriptId: script.id,
714 command: script.command,
715 hasThread: Boolean(selectedThread),
716 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
717 });
718
719 if (
720 !selectedThread ||
721 !selectedThreadProject?.workspaceRoot ||
722 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
723 ) {
724 terminalDebugLog("project-script:abort", {
725 scriptId: script.id,
726 reason: "no-thread-workspace-or-terminal-access",
727 });
728 return;
729 }
730
731 const targetTerminalId = resolveProjectScriptTerminalId({
732 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
733 ...(knownTerminalSessions === null ||
734 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
735 ? { uniqueSuffix: uuidv4() }
736 : {}),
737 hasRunningTerminal: terminalMenuSessions.some(
738 (session) => session.status === "running" || session.status === "starting",
739 ),
740 });
741 const preferredWorktreePath = resolvePreferredThreadWorktreePath({
742 threadShellWorktreePath: selectedThread.worktreePath ?? null,
743 threadDetailWorktreePath: selectedThreadDetailWorktreePath,
744 });
745 const cwd = projectScriptCwd({
746 project: { cwd: selectedThreadProject.workspaceRoot },
747 worktreePath: preferredWorktreePath,
748 });
749 const env = projectScriptRuntimeEnv({
750 project: { cwd: selectedThreadProject.workspaceRoot },
751 worktreePath: preferredWorktreePath,
752 });
753 stagePendingTerminalLaunch({
754 target: {
755 environmentId: selectedThread.environmentId,
756 threadId: selectedThread.id,
757 terminalId: targetTerminalId,
758 },
759 launch: {
760 cwd,
761 worktreePath: preferredWorktreePath,
762 env,
763 initialInput: `${script.command}\r`,
764 },
765 });
766 terminalDebugLog("project-script:staged", {
767 scriptId: script.id,
768 terminalId: targetTerminalId,
769 cwd,
770 worktreePath: preferredWorktreePath,
771 });
772
773 void navigation.navigate("ThreadTerminal", {
774 environmentId: String(selectedThread.environmentId),
775 threadId: String(selectedThread.id),
776 terminalId: targetTerminalId,
777 });
778 },
779 [
780 navigation,
781 selectedThread,
782 selectedThreadDetailWorktreePath,
783 selectedThreadProject,
784 terminalMenuSessions,
785 knownTerminalSessions,
786 ],
787 );
788 const threadGitControlProps = {
789 environmentId: environmentIdRaw ?? "",
790 threadId: threadId ?? "",
791 auxiliaryPaneControl:
792 !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null
793 ? {
794 accessibilityLabel: "Toggle inspector",
795 onPress: handleToggleInspector,
796 }
797 : undefined,
798 onOpenFilesInspector:
799 fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
800 onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
801 onMergeBack:
802 mergeBackTargetThreadId !== null && mergeBackRun !== null
803 ? () => void handleMergeBack()
804 : undefined,
805 currentBranch: selectedThread?.branch ?? null,
806 gitStatus: gitStatus.data,
807 gitOperationLabel: gitState.gitOperationLabel,
808 canOpenTerminal:
809 Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
810 canOperateTerminal,
811 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
812 projectScripts: selectedThreadProject
813 ? resolveProjectScripts(
814 routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
815 selectedThreadProject,
816 )
817 : [],
818 terminalSessions: terminalMenuSessions,
819 showDirectFileControl: layout.usesSplitView,
820 onOpenTerminal: handleOpenTerminal,
821 onOpenNewTerminal: handleOpenNewTerminal,
822 onRunProjectScript: handleRunProjectScript,
823 onPull: gitActions.onPullSelectedThreadBranch,
824 onRunAction: gitActions.onRunSelectedThreadGitAction,
825 };
826 const handleEditFailedCreation = useCallback(async () => {
827 const creation = selectedThreadCreation?.message;
828 if (!creation?.creation || routeThreadIdentity === null) {
829 return;
830 }
831 // The drain restored the prompt and attachments into the recovery draft
832 // the rejected creation owns. Open that draft by id: without it the sheet
833 // mints a fresh empty one and the restored content is unreachable.
834 try {
835 await recoverFailedThreadDraft(creation);
836 } catch (error) {
837 Alert.alert(
838 "Could not restore draft",
839 error instanceof Error ? error.message : String(error),
840 );
841 return;
842 }
843 clearPendingThreadCreationOutcome(routeThreadIdentity);
844 navigation.dispatch(
845 StackActions.replace("NewTaskSheet", {
846 screen: "NewTaskDraft",
847 params: {
848 draftId: restoredNewTaskDraftKey(creation.messageId),
849 environmentId: String(creation.environmentId),
850 projectId: String(creation.creation.projectId),
851 ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}),
852 },
853 }),
854 );
855 }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]);
856 const setupTurnStartedAt = composer.selectedThreadActivityRun?.startedAt ?? null;
857 const { snapshot: worktreeSetupSnapshot, visible: worktreeSetup } = useWorktreeSetup({
858 environmentId: selectedThread?.environmentId ?? null,
859 threadId: selectedThread?.id ?? null,
860 preparing:
861 composer.selectedThreadActivityRun?.status === "preparing" ||
862 selectedThread?.runtime?.status === "preparing" ||
863 selectedThread?.worktreePath != null ||
864 (selectedThreadCreation?.message.creation?.workspaceMode === "worktree" &&
865 selectedThreadCreation.outcome == null),
866 turnStarted: setupTurnStartedAt !== null,
867 followUpSent:
868 composer.selectedThreadFeed.filter(
869 (entry) => entry.type === "message" && entry.message.role === "user",
870 ).length +
871 composer.selectedThreadQueueCount >
872 1,
873 });
874 const awaitingBootstrapTurn =
875 worktreeSetup !== null
876 ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
877 : (selectedThreadDetail?.runs.some((run) => run.status === "preparing") ?? false);
878 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup);
879 const handleCancelWorktreeSetup = useCallback(() => {
880 if (!selectedThread) return;
881 void cancelWorktreeSetup({
882 environmentId: selectedThread.environmentId,
883 input: { threadId: selectedThread.id },
884 });
885 }, [cancelWorktreeSetup, selectedThread]);
886 const startLocalThread = useAtomCommand(threadEnvironment.startTurn, "work locally");
887 const localResendBusy = useRef(false);
888 const setupMessage = selectedThreadDetail?.messages.find((message) => message.role === "user");
889 const handleWorkLocally = useCallback(async () => {
890 if (!selectedThread || !selectedThreadProject || !setupMessage || localResendBusy.current)
891 return;
892 localResendBusy.current = true;
893 try {
894 const result = await cancelWorktreeSetup({
895 environmentId: selectedThread.environmentId,
896 input: { threadId: selectedThread.id },
897 });
898 if (result._tag !== "Success" || !result.value.cancelled) return;
899 // V2 accepts the launch before setup runs, so cancellation never rejects
900 // the original outbox delivery. Reuse the server-owned prompt and uploads.
901 const metadata = makeTurnCommandMetadata();
902 const launched = await startLocalThread({
903 environmentId: selectedThread.environmentId,
904 input: buildProjectThreadStartTurnInput({
905 ...metadata,
906 projectId: selectedThread.projectId,
907 projectCwd: selectedThreadProject.workspaceRoot,
908 text: setupMessage.text,
909 ...(setupMessage.context ? { context: setupMessage.context } : {}),
910 uploadedAttachments: setupMessage.attachments,
911 modelSelection: selectedThread.modelSelection,
912 runtimeMode: selectedThread.runtimeMode,
913 interactionMode: selectedThread.interactionMode,
914 workspaceMode: "local",
915 branch: null,
916 worktreePath: null,
917 startFromOrigin: false,
918 worktreeBranchName: "",
919 }),
920 });
921 if (launched._tag !== "Success") return;
922 navigation.dispatch(
923 StackActions.replace("Thread", {
924 environmentId: String(selectedThread.environmentId),
925 threadId: metadata.threadId,
926 }),
927 );
928 } finally {
929 localResendBusy.current = false;
930 }
931 }, [
932 cancelWorktreeSetup,
933 navigation,
934 selectedThread,
935 selectedThreadProject,
936 setupMessage,
937 startLocalThread,
938 ]);
939 const creationState = ((): ThreadDetailScreenProps["creationState"] => {
940 if (selectedThreadCreation === null) {
941 return awaitingBootstrapTurn
942 ? { kind: "preparing", preparingWorktree: true, serverOwned: true }
943 : null;
944 }
945 if (selectedThreadCreation.outcome?.kind === "failed") {
946 return {
947 kind: "failed",
948 reason: selectedThreadCreation.outcome.reason,
949 onEditTask: handleEditFailedCreation,
950 };
951 }
952 return {
953 kind: "preparing",
954 preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
955 serverOwned: false,
956 };
957 })();
958 if (!environmentId || !threadId) {
959 return <OpeningThreadLoadingScreen />;
960 }
961
962 if (!selectedThread) {
963 return <OpeningThreadLoadingScreen />;
964 }
965
966 // A queued creation renders as ready content: its prompt is the whole
967 // conversation until the server creates the thread. The subscription's
968 // not-found error for that window is expected, not a load failure.
969 const contentPresentation =
970 creationState !== null
971 ? { kind: "ready" as const }
972 : projectThreadContentPresentation({
973 hasDetail: selectedThreadDetail !== null,
974 detailError: Option.getOrNull(selectedThreadDetailState.error),
975 detailDeleted: selectedThreadDetailState.status === "deleted",
976 connectionState: routeConnectionState,
977 });
978 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
979 const renderThreadRouteBody = () => (
980 <>
981 <GitActionProgressOverlay
982 progress={gitActionProgress}
983 onDismiss={dismissGitActionResult}
984 onOpenPullRequest={openPullRequest}
985 />
986
987 <ThreadPane>
988 <ThreadDetailScreen
989 canOperateThread={canOperateThread}
990 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
991 contentPresentation={contentPresentation}
992 screenTone={connectionTone(routeConnectionState)}
993 connectionError={routeConnectionError}
994 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
995 feedbackSubmissions={composer.feedbackSubmissions}
996 onDismissFeedback={composer.dismissFeedback}
997 selectedThreadFeed={composer.selectedThreadFeed}
998 pendingHandoff={composer.pendingHandoff}
999 activityRun={composer.selectedThreadActivityRun}
1000 activeWorkStartedAt={
1001 creationState?.kind === "preparing" ||
1002 (worktreeSetup !== null && setupTurnStartedAt === null)
1003 ? null
1004 : composer.activeWorkStartedAt
1005 }
1006 isCompacting={composer.isCompacting}
1007 runlessWorkActive={composer.runlessWorkActive}
1008 providerSubagentStatus={composer.providerSubagentStatus}
1009 creationState={creationState}
1010 setupWorkingStartedAt={
1011 composer.activeWorkStartedAt !== null &&
1012 worktreeSetupSnapshot !== null &&
1013 composer.selectedThreadFeed.filter(
1014 (entry) => entry.type === "message" && entry.message.role === "user",
1015 ).length <= 1
1016 ? composer.activeWorkStartedAt
1017 : null
1018 }
1019 worktreeSetup={
1020 worktreeSetup
1021 ? {
1022 snapshot: worktreeSetup,
1023 turnStartedAt: setupTurnStartedAt,
1024 working: composer.activeWorkStartedAt !== null,
1025 turnStarted: setupTurnStartedAt !== null,
1026 onCancel: handleCancelWorktreeSetup,
1027 onWorkLocally: setupMessage && selectedThreadProject ? handleWorkLocally : null,
1028 }
1029 : null
1030 }
1031 activePendingApproval={requests.activePendingApproval}
1032 respondingApprovalId={requests.respondingApprovalId}
1033 activePendingUserInput={requests.activePendingUserInput}
1034 activePendingUserInputDrafts={requests.activePendingUserInputDrafts}
1035 activePendingUserInputAnswers={requests.activePendingUserInputAnswers}
1036 respondingUserInputId={requests.respondingUserInputId}
1037 draftMessage={composer.draftMessage}
1038 draftAttachments={composer.draftAttachments}
1039 connectionStateLabel={routeConnectionState}
1040 threadSyncStatus={selectedThreadDetailState.status}
1041 historyControls={historyControls}
1042 activeThreadBusy={composer.activeThreadBusy}
1043 canStopThread={awaitingBootstrapTurn || composer.interruptibleRunId !== null}
1044 queuedRunEdit={composer.queuedRunEdit}
1045 composerDraftKey={composer.composerDraftKey}
1046 followUpBehavior={composer.followUpBehavior}
1047 canSteerActiveTurn={composer.canSteerActiveTurn}
1048 isSavingQueuedEdit={composer.isSavingQueuedEdit}
1049 onCancelQueuedRunEdit={composer.cancelQueuedRunEdit}
1050 onRemoveQueuedEditAttachment={composer.onRemoveQueuedEditAttachment}
1051 environmentId={selectedThread.environmentId}
1052 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
1053 threadCwd={selectedThreadCwd}
1054 selectedThreadQueueCount={composer.selectedThreadQueueCount}
1055 queuedMessages={composer.selectedThreadQueuedMessages}
1056 dispatchingMessageId={composer.dispatchingQueuedMessageId}
1057 layoutVariant={layout.variant}
1058 usesAutomaticContentInsets={usesNativeHeaderGlass}
1059 onOpenConnectionEditor={handleOpenConnectionEditor}
1060 onChangeDraftMessage={composer.onChangeDraftMessage}
1061 onPickDraftMedia={composer.onPickDraftMedia}
1062 onPickDraftFiles={composer.onPickDraftFiles}
1063 onNativePasteImages={composer.onNativePasteImages}
1064 onNativePasteText={composer.onNativePasteText}
1065 onRemoveDraftImage={composer.onRemoveDraftImage}
1066 serverConfig={serverConfig}
1067 onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
1068 onSendMessage={composer.onSendMessage}
1069 onReconnectEnvironment={handleReconnectEnvironment}
1070 canSwitchThreadProvider={composer.canSwitchThreadProvider}
1071 onUpdateThreadModelSelection={composer.onUpdateModelSelection}
1072 onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode}
1073 onUpdateThreadInteractionMode={composer.onUpdateInteractionMode}
1074 onRespondToApproval={requests.onRespondToApproval}
1075 onSelectUserInputOption={requests.onSelectUserInputOption}
1076 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
1077 onSubmitUserInput={requests.onSubmitUserInput}
1078 onDismissUserInput={requests.onDismissUserInput}
1079 />
1080 </ThreadPane>
1081 </>
1082 );
1083
1084 return (
1085 <>
1086 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}
1087 <ThreadHeader
1088 title={selectedThread.title}
1089 subtitle={headerSubtitle}
1090 headerColor={headerColor}
1091 usesNativeHeaderGlass={usesNativeHeaderGlass}
1092 gitControls={threadGitControlProps}
1093 hasThreadCwd={selectedThreadCwd !== null}
1094 hasWorkspaceRoot={Boolean(selectedThreadProject?.workspaceRoot)}
1095 fileInspectorSupported={fileInspector.supported}
1096 inspectorMode={inspectorMode}
1097 onToggleInspector={handleToggleInspector}
1098 onOpenGitInspector={handleOpenGitInspector}
1099 onOpenFilesInspector={handleOpenFilesInspector}
1100 onReturnToThread={props.onReturnToThread}
1101 />
1102
1103 {renderThreadRouteBody()}
1104 </>
1105 );
1106}