Open History to see the last commit
1import * as Dialog from "../../components/ui/Dialog";
2import { useRememberThreadProject } from "./useRememberThreadProject";
3import { useOpenPullRequest } from "../../native/useOpenPullRequest";
4import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
5import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
6import { useWorktreeSetup } from "./use-worktree-setup";
7import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
8import { AppBar } from "../../components/ui/AppBar";
9import {
10 StackActions,
11 useNavigation,
12 useRoute,
13 type StaticScreenProps,
14} from "@react-navigation/native";
15import {
16 createContext,
17 use,
18 useEffect,
19 useEffectEvent,
20 useRef,
21 useState,
22 type ReactNode,
23} from "react";
24import * as Option from "effect/Option";
25import {
26 AuthOrchestrationOperateScope,
27 AuthTerminalOperateScope,
28 AuthTerminalReadScope,
29 DEFAULT_SERVER_SETTINGS,
30 EnvironmentId,
31 isProviderNativeSubagentThread,
32 MessageId,
33 ThreadId,
34 type ProjectScript,
35} from "@t3tools/contracts";
36import {
37 projectScriptCwd,
38 projectScriptRuntimeEnv,
39 projectScriptTerminalInput,
40 resolveProjectScripts,
41} from "@t3tools/shared/projectScripts";
42import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
43import { useConnectionsReady } from "../../state/workspace";
44import { useEnvironmentShellReadiness } from "../../state/shell";
45import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
46import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
47import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft";
48import { useEnvironmentQuery } from "../../state/query";
49import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
50import { vcsEnvironment } from "../../state/vcs";
51import { EmptyState } from "../../components/ui/EmptyState";
52import { LoadingScreen } from "../../components/LoadingScreen";
53import { scopedThreadKey } from "../../lib/scopedEntities";
54import { useDelayedStatus } from "../../lib/useDelayedStatus";
55import { scopeThreadRef } from "@t3tools/client-runtime/environment";
56import { useThreadShell } from "../../state/entities";
57import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
58import { connectionTone } from "../connection/connectionTone";
59import {
60 useRemoteConnections,
61 useRemoteConnectionStatus,
62 useRemoteEnvironmentRuntime,
63} from "../../state/use-remote-environment-registry";
64import { useKnownTerminalSessions } from "../../state/use-terminal-session";
65import { uuidv4 } from "../../lib/uuid";
66import {
67 isAtomCommandInterrupted,
68 squashAtomCommandFailure,
69} from "@t3tools/client-runtime/state/runtime";
70import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
71import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
72import { useThreadSelection } from "../../state/use-thread-selection";
73import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
74import {
75 buildTerminalMenuSessions,
76 nextOpenTerminalId,
77 resolveProjectScriptTerminalId,
78} from "../terminal/terminalMenu";
79import {
80 resolvePreferredThreadWorktreePath,
81 stagePendingTerminalLaunch,
82} from "../terminal/terminalLaunchContext";
83import type { ThreadDetailScreenProps } from "./ThreadDetailScreen";
84import { ThreadScreenBinding, ThreadScreenFallback, ThreadScreenHost } from "./ThreadScreenHost";
85import { draftRouteIdentity } from "./thread-route-state";
86import { NewTaskDraftRouteScreen, type NewTaskDraftRouteParams } from "./NewTaskDraftRouteScreen";
87import { GitOverviewSheet } from "./git/GitOverviewSheet";
88import { useAtomCommand } from "../../state/use-atom-command";
89import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
90import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
91import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
92import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
93import { useThreadComposerState } from "../../state/use-thread-composer-state";
94import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
95import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
96import { threadEnvironment } from "../../state/threads";
97import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads";
98import { useProjects } from "../../state/entities";
99import {
100 projectThreadContentPresentation,
101 threadSyncStatusLabel,
102} from "./threadContentPresentation";
103import {
104 Inspector,
105 useAdaptiveWorkspaceLayout,
106 useWorkspacePaneState,
107} from "../layout/AdaptiveWorkspaceLayout";
108import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
109import { InspectorSheetContext } from "../layout/workspace-inspector-sheet";
110import type { InspectorContent } from "../layout/inspector-panes";
111import {
112 threadDetailErrorIsCurrent,
113 threadRouteIsHydrating,
114 threadRouteOpensDraft,
115} from "./thread-route-hydration";
116import { ThreadHeader } from "./ThreadHeader";
117import { resumeThread } from "./resume-thread";
118import {
119 useAndroidWorkspaceBack,
120 useWorkspaceBackNavigation,
121} from "../layout/workspace/useAndroidWorkspaceNavigation";
122import { openSettings } from "../settings/settings-navigation";
123import {
124 InspectorContentScope,
125 ThreadInspectorHeader,
126 ThreadInspectorPreview,
127 type ThreadInspectorPresentation,
128} from "./thread-inspector-preview";
129import { useStartSideChat } from "./useStartSideChat";
130import { useThreadStatusAnnouncements } from "./useThreadStatusAnnouncements";
131import type { ThreadInspectorMode, ThreadInspectorTarget } from "./thread-inspector-navigation";
132import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail";
133
134function firstRouteParam(value: string | string[] | undefined): string | null {
135 const first = Array.isArray(value) ? value[0] : value;
136 return first === undefined || first.trim().length === 0 ? null : first;
137}
138
139// The inspector pane's bar belongs to ThreadInspectorHeader; the fallback must not draw its own.
140const InspectorHeaderShownContext = createContext(false);
141
142function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
143 const goBack = useWorkspaceBackNavigation();
144 const inspectorHeaderShown = use(InspectorHeaderShownContext);
145 return (
146 <ThreadScreenFallback
147 header={
148 inspectorHeaderShown ? undefined : (
149 <AppBar title="Thread" onBackToList={goBack} hideBottomBorder />
150 )
151 }
152 >
153 {props.children}
154 </ThreadScreenFallback>
155 );
156}
157
158function OpeningThreadLoadingScreen() {
159 return (
160 <ThreadRouteFallbackLayout>
161 <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" embedded />
162 </ThreadRouteFallbackLayout>
163 );
164}
165
166type ThreadRouteScreenRouteProps = StaticScreenProps<{
167 readonly environmentId?: string;
168 readonly threadId?: string;
169 readonly draft?: NewTaskDraftRouteParams;
170 readonly inspector?: ThreadInspectorTarget;
171 readonly prompt?: string | string[];
172}>;
173
174interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
175 readonly inspectorPresentation?: ThreadInspectorPresentation;
176 readonly onReturnToThread?: () => void;
177 readonly renderInspector?: () => ReactNode;
178}
179
180function ThreadUnavailableScreen(props: {
181 readonly actionLabel: string;
182 readonly onAction: () => void;
183}) {
184 return (
185 <ThreadRouteFallbackLayout>
186 <ScrollView
187 contentInsetAdjustmentBehavior="automatic"
188 contentContainerStyle={{
189 flexGrow: 1,
190 justifyContent: "center",
191 paddingHorizontal: 24,
192 paddingVertical: 32,
193 }}
194 className="bg-screen flex-1 android:bg-transparent"
195 >
196 <EmptyState
197 title="Thread unavailable"
198 detail="This thread is not available in the current mobile snapshot."
199 actionLabel={props.actionLabel}
200 onAction={props.onAction}
201 />
202 </ScrollView>
203 </ThreadRouteFallbackLayout>
204 );
205}
206
207/** A thread that can't load opens a new task in its environment instead of a dead end. */
208function UnavailableThreadDraftRedirect(props: { readonly environmentId: EnvironmentId }) {
209 const navigation = useNavigation();
210 const projects = useProjects();
211 const openDraft = useEffectEvent(() => {
212 const project = projects.find((candidate) => candidate.environmentId === props.environmentId);
213 navigation.dispatch(
214 StackActions.replace("Thread", {
215 draft: project
216 ? {
217 requestId: uuidv4(),
218 environmentId: String(project.environmentId),
219 projectId: String(project.id),
220 }
221 : { requestId: uuidv4() },
222 }),
223 );
224 });
225 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- replaces this route with a new draft thread route for the environment's project on mount
226 useEffect(() => {
227 openDraft();
228 }, []);
229 return <OpeningThreadLoadingScreen />;
230}
231
232export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
233 return (
234 <ThreadScreenHost>
235 {props.route.params?.draft ? (
236 <NewTaskDraftRouteScreen
237 key={draftRouteIdentity(props.route.params.draft)}
238 route={{ ...props.route, params: props.route.params.draft }}
239 />
240 ) : (
241 <LiveThreadRouteController {...props} />
242 )}
243 </ThreadScreenHost>
244 );
245}
246
247function LiveThreadRouteController(props: ThreadRouteScreenProps) {
248 useAndroidWorkspaceBack();
249 const connectionsReady = useConnectionsReady();
250 const { connectionState } = useRemoteConnectionStatus();
251 const { selectedThread } = useThreadSelection();
252 const params = props.route.params ?? {};
253 const environmentIdRaw = firstRouteParam(params.environmentId);
254 const threadIdRaw = firstRouteParam(params.threadId);
255 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
256 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
257 const routeEnvironmentShellState = useEnvironmentShellReadiness(environmentId);
258 const { onReconnectEnvironment } = useRemoteConnections();
259 const navigation = useNavigation();
260 const routeName = useRoute().name;
261 const routeConnectionState =
262 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
263 const routeThreadKey =
264 environmentId !== null && threadIdRaw !== null
265 ? scopedThreadKey(environmentId, ThreadId.make(threadIdRaw))
266 : null;
267 const selectedThreadKey =
268 selectedThread === null
269 ? null
270 : scopedThreadKey(selectedThread.environmentId, selectedThread.id);
271 const selectedThreadDetailState = useSelectedThreadDetailState();
272 const detailError = selectedThreadDetailState.error;
273 const [errorBeforeConnected, setErrorBeforeConnected] = useState(() =>
274 routeConnectionState === "connected" ? Option.none<string>() : detailError,
275 );
276 if (routeConnectionState !== "connected" && errorBeforeConnected !== detailError) {
277 setErrorBeforeConnected(detailError);
278 }
279 const withInspectorHeader = (content: ReactNode) =>
280 props.inspectorPresentation ? (
281 <InspectorHeaderShownContext value>
282 <ThreadInspectorHeader
283 title={selectedThread?.title ?? "Thread"}
284 {...props.inspectorPresentation}
285 />
286 {content}
287 </InspectorHeaderShownContext>
288 ) : (
289 content
290 );
291
292 if (environmentId === null || threadIdRaw === null) {
293 return withInspectorHeader(<OpeningThreadLoadingScreen />);
294 }
295
296 if (selectedThread !== null && selectedThreadKey === routeThreadKey) {
297 // ThreadRouteContent draws its own inspector header so it can show the sync status.
298 return (
299 <InspectorHeaderShownContext value={props.inspectorPresentation !== undefined}>
300 <ThreadRouteContent {...props} selectedThreadDetailState={selectedThreadDetailState} />
301 </InspectorHeaderShownContext>
302 );
303 }
304
305 const stillHydrating = threadRouteIsHydrating({
306 isLoadingConnections: !connectionsReady,
307 environmentRegistered: routeEnvironmentRuntime !== null,
308 localCacheRead:
309 routeEnvironmentShellState.cacheRead &&
310 selectedThreadDetailState !== EMPTY_ENVIRONMENT_THREAD_STATE,
311 connectionState: routeConnectionState,
312 shellStatus: routeEnvironmentShellState.status,
313 shellHasError: routeEnvironmentShellState.hasError,
314 detailStatus: selectedThreadDetailState.status,
315 detailHasError: threadDetailErrorIsCurrent({
316 connectionState: routeConnectionState,
317 error: detailError,
318 errorBeforeConnected,
319 }),
320 });
321
322 if (stillHydrating) {
323 return withInspectorHeader(<OpeningThreadLoadingScreen />);
324 }
325
326 if (
327 routeName === "Thread" &&
328 !props.inspectorPresentation &&
329 threadRouteOpensDraft({
330 connectionState: routeConnectionState,
331 detailStatus: selectedThreadDetailState.status,
332 })
333 ) {
334 return <UnavailableThreadDraftRedirect environmentId={environmentId} />;
335 }
336
337 return withInspectorHeader(
338 <ThreadUnavailableScreen
339 actionLabel={
340 routeEnvironmentRuntime === null ? "Manage environments" : "Reconnect environment"
341 }
342 onAction={() => {
343 if (routeEnvironmentRuntime !== null) {
344 onReconnectEnvironment(environmentId);
345 return;
346 }
347 openSettings(navigation, "SettingsEnvironments");
348 }}
349 />,
350 );
351}
352
353function ThreadRouteContent(
354 props: ThreadRouteScreenProps & {
355 readonly selectedThreadDetailState: ReturnType<typeof useSelectedThreadDetailState>;
356 },
357) {
358 const { layout, showInspector, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
359 const { connectionState } = useRemoteConnectionStatus();
360 const { onReconnectEnvironment } = useRemoteConnections();
361 const {
362 selectedThread,
363 selectedThreadCreation,
364 selectedThreadDetailRef,
365 selectedThreadProject,
366 selectedEnvironmentConnection,
367 } = useThreadSelection();
368 useRememberThreadProject(selectedThreadProject);
369 const canOperateThread = useEnvironmentScope(
370 selectedThread?.environmentId ?? null,
371 AuthOrchestrationOperateScope,
372 );
373 const canReadTerminal = useEnvironmentScope(
374 selectedThread?.environmentId ?? null,
375 AuthTerminalReadScope,
376 );
377 const canOperateTerminal = useEnvironmentScope(
378 selectedThread?.environmentId ?? null,
379 AuthTerminalOperateScope,
380 );
381 const selectedThreadDetailState = props.selectedThreadDetailState;
382 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
383 const { selectedThreadCwd } = useSelectedThreadWorktree();
384 const startSideChat = useStartSideChat();
385 const composer = useThreadComposerState({ startSideChat });
386 const gitState = useSelectedThreadGitState();
387 const gitActions = useSelectedThreadGitActions();
388 const requests = useSelectedThreadRequests();
389 useThreadStatusAnnouncements({
390 threadKey:
391 selectedThread === null || isProviderNativeSubagentThread(selectedThread.source)
392 ? null
393 : scopedThreadKey(selectedThread.environmentId, selectedThread.id),
394 // A preview beside its host names its thread, so the two threads' statuses can't be confused.
395 subject:
396 props.inspectorPresentation !== undefined && "onMaximize" in props.inspectorPresentation
397 ? (selectedThread?.title ?? null)
398 : null,
399 approvalRequestId: requests.activePendingApproval?.requestId ?? null,
400 userInputRequestId: requests.activePendingUserInput?.requestId ?? null,
401 shellRequestId: selectedThread?.source.pendingRuntimeRequest?.id ?? null,
402 shellPendingUserInput: selectedThread?.hasPendingUserInput === true,
403 working: composer.activeWorkStartedAt !== null || composer.isCompacting,
404 run: composer.selectedThreadAnnouncedRun,
405 detailBehind: composer.selectedThreadDetailBehindShell,
406 });
407 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
408 const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, {
409 label: "resume queue",
410 reportFailure: false,
411 });
412 const continueThreadRun = useAtomCommand(threadEnvironment.startTurn, {
413 label: "continue thread",
414 reportFailure: false,
415 });
416 const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
417 label: "set thread runtime mode",
418 reportFailure: false,
419 });
420 const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
421 label: "set thread interaction mode",
422 reportFailure: false,
423 });
424 const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
425 label: "load earlier thread history",
426 reportFailure: false,
427 });
428 const historyControls = (() => {
429 const history = selectedThreadDetailState.history;
430 if (!selectedThread) {
431 return undefined;
432 }
433 if (!history.hasMoreHistory && history.error === null) {
434 return undefined;
435 }
436 return {
437 hasMoreHistory: history.hasMoreHistory,
438 loading: history.loading,
439 error: history.error,
440 onLoadEarlier: () => {
441 void loadEarlierHistory({
442 environmentId: selectedThread.environmentId,
443 input: { threadId: selectedThread.id },
444 });
445 },
446 onLoadThrough: (throughEntryId: string) => {
447 void loadEarlierHistory({
448 environmentId: selectedThread.environmentId,
449 input: { threadId: selectedThread.id, throughEntryId },
450 });
451 },
452 };
453 })();
454 const navigation = useNavigation();
455 const selectDetail = useSelectWorkspaceDetail();
456 const openPullRequest = useOpenPullRequest();
457 const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back");
458 const mergeBackTargetThreadId = resolveMergeBackTargetThreadId(selectedThreadDetail);
459 const mergeBackRun =
460 selectedThreadDetail === null ? null : resolveLatestMergeBackRun(selectedThreadDetail);
461 const mergeBackBusyRef = useRef(false);
462 const handleMergeBack = async () => {
463 if (
464 mergeBackBusyRef.current ||
465 !selectedThread ||
466 mergeBackTargetThreadId === null ||
467 mergeBackRun === null
468 ) {
469 return;
470 }
471 mergeBackBusyRef.current = true;
472 const result = await mergeBack({
473 environmentId: selectedThread.environmentId,
474 input: {
475 sourceThreadId: selectedThread.id,
476 targetThreadId: mergeBackTargetThreadId,
477 runId: mergeBackRun.id,
478 creationSource: "mobile",
479 },
480 });
481 mergeBackBusyRef.current = false;
482 if (result._tag !== "Success") return;
483 selectDetail({
484 name: "Thread",
485 params: { environmentId: selectedThread.environmentId, threadId: mergeBackTargetThreadId },
486 });
487 };
488 const params = props.route.params ?? {};
489 const environmentIdRaw = firstRouteParam(params.environmentId);
490 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
491 const threadId = firstRouteParam(params.threadId);
492 const routeThreadIdentity =
493 environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null;
494 // The chosen mode survives thread switches; "route" only applies while the route
495 // supplies an inspector, which is also the default whenever it does.
496 const [chosenInspectorMode, setChosenInspectorMode] = useState<ThreadInspectorMode | null>(null);
497 const previewTarget = props.inspectorPresentation ? undefined : params.inspector;
498 const inspectorMode = (() => {
499 if (previewTarget) return "preview";
500 const mode =
501 props.renderInspector === undefined
502 ? chosenInspectorMode === "route"
503 ? null
504 : chosenInspectorMode
505 : chosenInspectorMode === null || chosenInspectorMode === "route"
506 ? "route"
507 : chosenInspectorMode;
508 return mode === "files" && selectedThreadCwd === null ? null : mode;
509 })();
510 const revealPreview = useEffectEvent(() => showInspector());
511 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the workspace navigator's inspector pane when route params bring a preview target
512 useEffect(() => {
513 if (previewTarget) revealPreview();
514 }, [previewTarget]);
515 const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId);
516 const routeConnectionState =
517 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
518 const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;
519 const selectedThreadWithDraftSettings = selectedThread
520 ? {
521 ...selectedThread,
522 modelSelection: composer.modelSelection ?? selectedThread.modelSelection,
523 runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,
524 interactionMode: composer.interactionMode ?? selectedThread.interactionMode,
525 }
526 : null;
527
528 const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED;
529 const delegatedFrom = selectedThread?.delegatedFrom ?? null;
530 const delegatedFromRef =
531 delegatedFrom === null
532 ? null
533 : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId);
534 // The title it had at delegation, until this client sees the parent itself.
535 const delegatedFromThread = useThreadShell(delegatedFromRef);
536 const headerSubtitle = [
537 selectedThreadProject?.title ?? null,
538 selectedEnvironmentConnection?.environmentLabel ?? null,
539 // A linked environment's delegated task names its parent; other linked work its origin.
540 delegatedFrom !== null
541 ? `Subagent of ${delegatedFromThread?.title ?? delegatedFrom.title}`
542 : selectedThread?.linkOrigin
543 ? `From ${selectedThread.linkOrigin.label}`
544 : null,
545 ]
546 .filter(Boolean)
547 .join(" · ");
548 const gitStatus = useEnvironmentQuery(
549 selectedThread !== null && selectedThreadCwd !== null
550 ? vcsEnvironment.status({
551 environmentId: selectedThread.environmentId,
552 input: { cwd: selectedThreadCwd },
553 })
554 : null,
555 );
556 const { sessions: knownTerminalSessions } = useKnownTerminalSessions({
557 environmentId: selectedThread?.environmentId ?? null,
558 threadId: selectedThread?.id ?? null,
559 });
560 const terminalMenuSessions = buildTerminalMenuSessions({
561 knownSessions: knownTerminalSessions ?? [],
562 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
563 });
564 const selectedThreadDetailWorktreePath = selectedThreadDetail?.thread.worktreePath ?? null;
565 const handleReconnectEnvironment = () => {
566 if (!environmentId) {
567 return;
568 }
569 onReconnectEnvironment(environmentId);
570 };
571
572 const gitActionProgressTarget = {
573 environmentId: selectedThread?.environmentId ?? null,
574 cwd: selectedThreadCwd,
575 };
576 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
577
578 const handleOpenGitInspector = () => {
579 navigation.setParams({ inspector: undefined });
580 setChosenInspectorMode("git");
581 showInspector();
582 };
583 const handleOpenFilesInspector = () => {
584 if (selectedThread === null || selectedThreadCwd === null) {
585 return;
586 }
587 navigation.setParams({ inspector: undefined });
588 setChosenInspectorMode(props.renderInspector === undefined ? "files" : "route");
589 showInspector();
590 };
591 const handleSelectInspectorFile = (path: string) => {
592 if (selectedThread === null) {
593 return;
594 }
595 const params = {
596 environmentId: String(selectedThread.environmentId),
597 threadId: String(selectedThread.id),
598 path: path.split("/").filter((segment) => segment.length > 0),
599 };
600 navigation.navigate("ThreadFile", params);
601 };
602 const renderGitInspector = () => (
603 <GitOverviewSheet
604 presentation="inspector"
605 route={{ params: { environmentId: environmentId!, threadId: threadId! } }}
606 />
607 );
608 const renderFilesInspector = () =>
609 selectedThread !== null && selectedThreadCwd !== null ? (
610 <InspectorSheetContext.Consumer>
611 {(closeInspectorSheet) => (
612 <ThreadFileNavigatorPane
613 cwd={selectedThreadCwd}
614 environmentId={selectedThread.environmentId}
615 projectName={selectedThreadProject?.title ?? "Files"}
616 selectedPath={null}
617 onSelectFile={(path) =>
618 closeInspectorSheet
619 ? closeInspectorSheet(() => handleSelectInspectorFile(path))
620 : handleSelectInspectorFile(path)
621 }
622 />
623 )}
624 </InspectorSheetContext.Consumer>
625 ) : null;
626 const renderPreviewInspector = () =>
627 previewTarget ? (
628 <ThreadInspectorPreviewWhileVisible
629 target={previewTarget}
630 renderThread={(screen) => <ThreadRouteScreen {...screen} />}
631 onClose={toggleAuxiliaryPane}
632 onOpen={(inspector) => {
633 if (
634 inspector.type === "thread" &&
635 inspector.environmentId === environmentId &&
636 inspector.threadId === threadId
637 ) {
638 toggleAuxiliaryPane();
639 return;
640 }
641 navigation.setParams({ inspector });
642 }}
643 />
644 ) : null;
645 const inspectorContent: InspectorContent | null =
646 inspectorMode === null
647 ? null
648 : {
649 active: inspectorMode,
650 resetKeys: [routeThreadIdentity, selectedThreadCwd],
651 panes: [
652 ...(inspectorMode === "preview"
653 ? [
654 {
655 key: "preview",
656 render: renderPreviewInspector,
657 },
658 ]
659 : []),
660 { key: "files", render: renderFilesInspector, prewarm: true },
661 { key: "git", render: renderGitInspector, prewarm: true },
662 ...(props.renderInspector ? [{ key: "route", render: props.renderInspector }] : []),
663 ],
664 };
665
666 const handleOpenConnectionEditor = () => {
667 void navigation.navigate("Connections");
668 };
669 const handleStopThread = () => {
670 if (
671 !selectedThread ||
672 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) ||
673 composer.interruptibleRunId === null
674 ) {
675 return;
676 }
677 return interruptThreadTurn({
678 environmentId: selectedThread.environmentId,
679 input: {
680 threadId: selectedThread.id,
681 runId: composer.interruptibleRunId,
682 },
683 });
684 };
685
686 const handleResumeQueue = async () => {
687 if (!selectedThread) return;
688 const result = await resumeThread({
689 thread: selectedThread,
690 resumableRunId: composer.resumableRunId,
691 queueHeld: composer.queueHeld,
692 runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,
693 interactionMode: composer.interactionMode ?? selectedThread.interactionMode,
694 modelSelection: composer.modelSelection,
695 messageId: MessageId.make(uuidv4()),
696 createdAt: new Date().toISOString(),
697 setRuntimeMode: setThreadRuntimeMode,
698 setInteractionMode: setThreadInteractionMode,
699 startTurn: continueThreadRun,
700 resumeQueue: resumeThreadQueue,
701 });
702 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
703 const error = squashAtomCommandFailure(result);
704 Dialog.alert(
705 "Could not resume thread",
706 error instanceof Error ? error.message : "Try again.",
707 );
708 }
709 };
710
711 const handleOpenTerminal = (nextTerminalId?: string | null) => {
712 if (
713 !selectedThread ||
714 !selectedThreadProject?.workspaceRoot ||
715 (!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) &&
716 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope))
717 ) {
718 return;
719 }
720
721 void navigation.navigate("ThreadTerminal", {
722 environmentId: String(selectedThread.environmentId),
723 threadId: String(selectedThread.id),
724 ...(nextTerminalId ? { terminalId: nextTerminalId } : {}),
725 });
726 };
727
728 const handleOpenNewTerminal = () => {
729 if (
730 !selectedThread ||
731 !selectedThreadProject?.workspaceRoot ||
732 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
733 ) {
734 return;
735 }
736
737 const nextId = nextOpenTerminalId({
738 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
739 ...(knownTerminalSessions === null ||
740 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
741 ? { uniqueSuffix: uuidv4() }
742 : {}),
743 });
744 void navigation.navigate("ThreadTerminal", {
745 environmentId: String(selectedThread.environmentId),
746 threadId: String(selectedThread.id),
747 terminalId: nextId,
748 });
749 };
750
751 const handleRunProjectScript = async (script: ProjectScript) => {
752 if (
753 !selectedThread ||
754 !selectedThreadProject?.workspaceRoot ||
755 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
756 ) {
757 return;
758 }
759
760 const targetTerminalId = resolveProjectScriptTerminalId({
761 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
762 ...(knownTerminalSessions === null ||
763 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
764 ? { uniqueSuffix: uuidv4() }
765 : {}),
766 hasRunningTerminal: terminalMenuSessions.some(
767 (session) => session.status === "running" || session.status === "starting",
768 ),
769 });
770 const preferredWorktreePath = resolvePreferredThreadWorktreePath({
771 threadShellWorktreePath: selectedThread.worktreePath ?? null,
772 threadDetailWorktreePath: selectedThreadDetailWorktreePath,
773 });
774 const cwd = projectScriptCwd({
775 project: { cwd: selectedThreadProject.workspaceRoot },
776 worktreePath: preferredWorktreePath,
777 });
778 const env = projectScriptRuntimeEnv({
779 project: { cwd: selectedThreadProject.workspaceRoot },
780 worktreePath: preferredWorktreePath,
781 });
782 stagePendingTerminalLaunch({
783 target: {
784 environmentId: selectedThread.environmentId,
785 threadId: selectedThread.id,
786 terminalId: targetTerminalId,
787 },
788 launch: {
789 cwd,
790 worktreePath: preferredWorktreePath,
791 env,
792 initialInput: projectScriptTerminalInput(script.command),
793 stopOnSettle: script.stopOnSettle ?? false,
794 },
795 });
796
797 void navigation.navigate("ThreadTerminal", {
798 environmentId: String(selectedThread.environmentId),
799 threadId: String(selectedThread.id),
800 terminalId: targetTerminalId,
801 });
802 };
803 const threadGitControlProps = {
804 environmentId: environmentIdRaw ?? "",
805 threadId: threadId ?? "",
806 onOpenFilesInspector: selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
807 onOpenGitInspector: handleOpenGitInspector,
808 onMergeBack:
809 mergeBackTargetThreadId !== null && mergeBackRun !== null
810 ? () => void handleMergeBack()
811 : undefined,
812 currentBranch: selectedThread?.branch ?? null,
813 gitStatus: gitStatus.data,
814 gitOperationLabel: gitState.gitOperationLabel,
815 canOpenTerminal:
816 Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
817 canOperateTerminal,
818 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
819 projectScripts: selectedThreadProject
820 ? resolveProjectScripts(
821 routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
822 selectedThreadProject,
823 )
824 : [],
825 terminalSessions: terminalMenuSessions,
826 onOpenTerminal: handleOpenTerminal,
827 onOpenNewTerminal: handleOpenNewTerminal,
828 onRunProjectScript: handleRunProjectScript,
829 onPull: gitActions.onPullSelectedThreadBranch,
830 onRunAction: gitActions.onRunSelectedThreadGitAction,
831 };
832 const handleEditFailedCreation = async () => {
833 const creation = selectedThreadCreation?.message;
834 if (!creation?.creation || routeThreadIdentity === null) {
835 return;
836 }
837 // Open recovery by draft id or the sheet creates an empty draft and strands restored content.
838 try {
839 await recoverFailedThreadDraft(creation);
840 } catch (error) {
841 Dialog.alert(
842 "Could not restore draft",
843 error instanceof Error ? error.message : String(error),
844 );
845 return;
846 }
847 clearPendingThreadCreationOutcome(routeThreadIdentity);
848 navigation.dispatch(
849 StackActions.replace("Thread", {
850 draft: {
851 draftId: restoredNewTaskDraftKey(creation.messageId),
852 environmentId: String(creation.environmentId),
853 projectId: String(creation.creation.projectId),
854 ...(selectedThreadProject ? { title: selectedThreadProject.title } : {}),
855 },
856 }),
857 );
858 };
859 const setupTurnStartedAt = composer.selectedThreadActivityRun?.startedAt ?? null;
860 const { snapshot: worktreeSetupSnapshot, visible: worktreeSetup } = useWorktreeSetup({
861 environmentId: selectedThread?.environmentId ?? null,
862 threadId: selectedThread?.id ?? null,
863 preparing:
864 composer.selectedThreadActivityRun?.status === "preparing" ||
865 selectedThread?.runtime?.status === "preparing" ||
866 selectedThread?.worktreePath != null ||
867 (selectedThreadCreation?.message.creation?.workspaceMode === "worktree" &&
868 selectedThreadCreation.outcome == null),
869 runs: selectedThreadDetail?.runs ?? [],
870 followUpSent:
871 composer.selectedThreadFeed.filter(
872 (entry) => entry.type === "message" && entry.message.role === "user",
873 ).length +
874 composer.selectedThreadQueueCount >
875 1,
876 });
877 const awaitingBootstrapTurn =
878 worktreeSetup !== null
879 ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
880 : (selectedThreadDetail?.runs.some((run) => run.status === "preparing") ?? false);
881 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup);
882 const handleCancelWorktreeSetup = () => {
883 if (!selectedThread) return;
884 void cancelWorktreeSetup({
885 environmentId: selectedThread.environmentId,
886 input: { threadId: selectedThread.id },
887 });
888 };
889 const startLocalThread = useAtomCommand(threadEnvironment.startTurn, "work locally");
890 const localResendBusy = useRef(false);
891 const setupMessage = selectedThreadDetail?.messages.find((message) => message.role === "user");
892 const handleWorkLocally = async () => {
893 if (!selectedThread || !selectedThreadProject || !setupMessage || localResendBusy.current)
894 return;
895 localResendBusy.current = true;
896 const result = await cancelWorktreeSetup({
897 environmentId: selectedThread.environmentId,
898 input: { threadId: selectedThread.id },
899 });
900 if (result._tag !== "Success" || !result.value.cancelled) {
901 localResendBusy.current = false;
902 return;
903 }
904 // V2 accepts launch before setup; cancellation cannot reject that delivery, so reuse server-owned prompt and uploads.
905 const metadata = makeTurnCommandMetadata();
906 const launched = await startLocalThread({
907 environmentId: selectedThread.environmentId,
908 input: buildProjectThreadStartTurnInput({
909 ...metadata,
910 projectId: selectedThread.projectId,
911 projectCwd: selectedThreadProject.workspaceRoot,
912 text: setupMessage.text,
913 titleText: selectedThread.title,
914 ...(setupMessage.context ? { context: setupMessage.context } : {}),
915 uploadedAttachments: setupMessage.attachments,
916 modelSelection: selectedThread.modelSelection,
917 runtimeMode: selectedThread.runtimeMode,
918 interactionMode: selectedThread.interactionMode,
919 workspaceMode: "local",
920 branch: null,
921 worktreePath: null,
922 startFromOrigin: false,
923 worktreeBranchName: "",
924 }),
925 });
926 localResendBusy.current = false;
927 if (launched._tag !== "Success") return;
928 navigation.dispatch(
929 StackActions.replace("Thread", {
930 environmentId: String(selectedThread.environmentId),
931 threadId: metadata.threadId,
932 }),
933 );
934 };
935 const creationState = ((): ThreadDetailScreenProps["creationState"] => {
936 if (selectedThreadCreation === null) {
937 return awaitingBootstrapTurn
938 ? { kind: "preparing", preparingWorktree: true, serverOwned: true }
939 : null;
940 }
941 if (selectedThreadCreation.outcome?.kind === "failed") {
942 return {
943 kind: "failed",
944 reason: selectedThreadCreation.outcome.reason,
945 onEditTask: handleEditFailedCreation,
946 };
947 }
948 return {
949 kind: "preparing",
950 preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
951 serverOwned: false,
952 };
953 })();
954 // Queued creation displays its prompt before server creation; temporary not-found is expected.
955 const contentPresentation =
956 creationState !== null
957 ? { kind: "ready" as const }
958 : projectThreadContentPresentation({
959 hasDetail: selectedThreadDetail !== null,
960 detailError: Option.getOrNull(selectedThreadDetailState.error),
961 detailDeleted: selectedThreadDetailState.status === "deleted",
962 connectionState: routeConnectionState,
963 });
964 // Shown in the header subtitle so the composer's status slot doesn't jump while history syncs.
965 const threadSyncLabel = useDelayedStatus(
966 selectedThread === null ? "" : scopedThreadKey(selectedThread.environmentId, selectedThread.id),
967 threadSyncStatusLabel(selectedThreadDetailState.status, contentPresentation.kind),
968 );
969 const inspectorHeader = props.inspectorPresentation ? (
970 <ThreadInspectorHeader
971 title={selectedThread?.title ?? "Thread"}
972 subtitle={threadSyncLabel ?? undefined}
973 {...props.inspectorPresentation}
974 />
975 ) : null;
976 if (!environmentId || !threadId || !selectedThread) {
977 return (
978 <>
979 {inspectorHeader}
980 <OpeningThreadLoadingScreen />
981 </>
982 );
983 }
984
985 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
986 const renderThreadRouteBody = () => (
987 <>
988 <GitActionProgressOverlay
989 progress={gitActionProgress}
990 onDismiss={dismissGitActionResult}
991 onOpenPullRequest={openPullRequest}
992 />
993
994 <>
995 <ThreadScreenBinding
996 header={
997 props.inspectorPresentation ? null : (
998 <ThreadHeader
999 title={selectedThread.title}
1000 subtitle={threadSyncLabel ?? headerSubtitle}
1001 gitControls={threadGitControlProps}
1002 hasThreadCwd={selectedThreadCwd !== null}
1003 inspectorMode={inspectorMode}
1004 onOpenFilesInspector={handleOpenFilesInspector}
1005 onReturnToThread={props.onReturnToThread}
1006 />
1007 )
1008 }
1009 canOperateThread={canOperateThread}
1010 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
1011 selectedThreadDetailRef={selectedThreadDetailRef}
1012 contentPresentation={contentPresentation}
1013 screenTone={connectionTone(
1014 routeConnectionState,
1015 routeEnvironmentRuntime?.connectionAwaitingApproval === true,
1016 )}
1017 connectionError={routeConnectionError}
1018 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
1019 feedbackSubmissions={composer.feedbackSubmissions}
1020 onDismissFeedback={composer.dismissFeedback}
1021 selectedThreadFeed={composer.selectedThreadFeed}
1022 pendingHandoff={composer.pendingHandoff}
1023 activityRun={composer.selectedThreadActivityRun}
1024 activeWorkStartedAt={
1025 creationState?.kind === "preparing" ||
1026 (worktreeSetup !== null && setupTurnStartedAt === null)
1027 ? null
1028 : composer.activeWorkStartedAt
1029 }
1030 isCompacting={composer.isCompacting}
1031 compactBeforeSend={composer.compactBeforeSend}
1032 runlessWorkActive={composer.runlessWorkActive}
1033 extensionStatuses={composer.extensionStatuses}
1034 providerSubagentStatus={composer.providerSubagentStatus}
1035 creationState={creationState}
1036 setupWorkingStartedAt={
1037 composer.activeWorkStartedAt !== null &&
1038 worktreeSetupSnapshot !== null &&
1039 composer.selectedThreadFeed.filter(
1040 (entry) => entry.type === "message" && entry.message.role === "user",
1041 ).length <= 1
1042 ? composer.activeWorkStartedAt
1043 : null
1044 }
1045 worktreeSetup={
1046 worktreeSetup
1047 ? {
1048 snapshot: worktreeSetup,
1049 turnStartedAt: setupTurnStartedAt,
1050 working: composer.activeWorkStartedAt !== null,
1051 turnStarted: setupTurnStartedAt !== null,
1052 onCancel: handleCancelWorktreeSetup,
1053 onWorkLocally: setupMessage && selectedThreadProject ? handleWorkLocally : null,
1054 }
1055 : null
1056 }
1057 activePendingApproval={requests.activePendingApproval}
1058 activePendingApprovals={requests.activePendingApprovals}
1059 respondingApprovalId={requests.respondingApprovalId}
1060 activePendingUserInput={requests.activePendingUserInput}
1061 activePendingUserInputDrafts={requests.activePendingUserInputDrafts}
1062 activePendingUserInputAnswers={requests.activePendingUserInputAnswers}
1063 respondingUserInputId={requests.respondingUserInputId}
1064 connectionStateLabel={routeConnectionState}
1065 connectionAwaitingApproval={routeEnvironmentRuntime?.connectionAwaitingApproval === true}
1066 historyControls={historyControls}
1067 activeThreadBusy={composer.activeThreadBusy}
1068 canStopThread={awaitingBootstrapTurn || composer.interruptibleRunId !== null}
1069 queuedRunEdit={composer.queuedRunEdit}
1070 composerDraftKey={composer.composerDraftKey}
1071 followUpBehavior={composer.followUpBehavior}
1072 canSteerActiveTurn={composer.canSteerActiveTurn}
1073 isSavingQueuedEdit={composer.isSavingQueuedEdit}
1074 onCancelQueuedRunEdit={composer.cancelQueuedRunEdit}
1075 onRemoveQueuedEditAttachment={composer.onRemoveQueuedEditAttachment}
1076 environmentId={selectedThread.environmentId}
1077 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
1078 threadCwd={selectedThreadCwd}
1079 selectedThreadQueueCount={composer.selectedThreadQueueCount}
1080 queuedMessages={
1081 selectedThreadCreation && selectedThreadCreation.outcome?.kind !== "delivered"
1082 ? [selectedThreadCreation.message, ...composer.selectedThreadQueuedMessages]
1083 : composer.selectedThreadQueuedMessages
1084 }
1085 dispatchingMessageId={composer.dispatchingQueuedMessageId}
1086 layoutVariant={layout.variant}
1087 usesAutomaticContentInsets={usesNativeHeaderGlass}
1088 onOpenConnectionEditor={handleOpenConnectionEditor}
1089 onChangeDraftMessage={composer.onChangeDraftMessage}
1090 onPickDraftMedia={composer.onPickDraftMedia}
1091 onPickDraftFiles={composer.onPickDraftFiles}
1092 onNativePasteImages={composer.onNativePasteImages}
1093 onNativePasteText={composer.onNativePasteText}
1094 onRemoveDraftImage={composer.onRemoveDraftImage}
1095 serverConfig={serverConfig}
1096 onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
1097 onResumeQueue={composer.canResume ? handleResumeQueue : null}
1098 onSendMessage={composer.onSendMessage}
1099 offersSideChat={composer.sideChatUnavailableReason === null}
1100 onReconnectEnvironment={handleReconnectEnvironment}
1101 canSwitchThreadProvider={composer.canSwitchThreadProvider}
1102 onRespondToApproval={requests.onRespondToApproval}
1103 onSelectUserInputOption={requests.onSelectUserInputOption}
1104 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
1105 onSubmitUserInput={requests.onSubmitUserInput}
1106 onDismissUserInput={requests.onDismissUserInput}
1107 />
1108 </>
1109 </>
1110 );
1111
1112 return (
1113 <>
1114 {inspectorHeader}
1115 {!props.inspectorPresentation && inspectorContent ? (
1116 <Inspector
1117 // A route's own inspector (files) opens once per thread; going back keeps the user's choice.
1118 revealKey={
1119 props.renderInspector !== undefined && selectedThreadCwd !== null
1120 ? (routeThreadIdentity ?? undefined)
1121 : undefined
1122 }
1123 content={inspectorContent}
1124 />
1125 ) : null}
1126
1127 {renderThreadRouteBody()}
1128 </>
1129 );
1130}
1131
1132export function FullscreenThreadRouteScreen(
1133 props: StaticScreenProps<{
1134 readonly environmentId: string;
1135 readonly threadId: string;
1136 }>,
1137) {
1138 const navigation = useNavigation();
1139 return (
1140 <InspectorContentScope>
1141 <ThreadRouteScreen
1142 route={props.route}
1143 inspectorPresentation={{ onClose: () => navigation.goBack() }}
1144 />
1145 </InspectorContentScope>
1146 );
1147}
1148
1149/** Reads the pane state itself, so a sidebar toggle doesn't re-render the whole thread screen. */
1150function ThreadInspectorPreviewWhileVisible(props: Parameters<typeof ThreadInspectorPreview>[0]) {
1151 const { panes } = useWorkspacePaneState();
1152 return panes.auxiliaryPaneVisible ? <ThreadInspectorPreview {...props} /> : null;
1153}