1import * as Option from "effect/Option";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import {
4 ANTIGRAVITY_DEFAULT_MODEL,
5 type AssetCreateUrlInput,
6 type AssetCreateUrlResult,
7 type ChatFileAttachment,
8 type CommandId,
9 type EnvironmentId,
10 isProviderDriverKind,
11 ProjectId,
12 type MessageId,
13 type ModelSelection,
14 type OrchestrationV2ProjectedTurnItem,
15 type PreviewAnnotationPayload,
16 type ProviderInteractionMode,
17 ProviderDriverKind,
18 type ProviderInstanceId,
19 type ServerProvider,
20 type ScopedProjectRef,
21 type ScopedThreadRef,
22 type ThreadContextRecord,
23 type ThreadId,
24 type ThreadLinkedPullRequest,
25 type RunId,
26 type WorktreeSetupSnapshot,
27} from "@t3tools/contracts";
28import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
29import * as DateTime from "effect/DateTime";
30import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
31import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
32import {
33 squashAtomCommandFailure,
34 type AtomCommandResult,
35} from "@t3tools/client-runtime/state/runtime";
36import { videoMimeType } from "@t3tools/shared/video";
37import {
38 appendCodexArtifactTemplateUsePrompt,
39 codexArtifactTemplateUsePrompt,
40 type CodexArtifactTemplate,
41} from "@t3tools/shared/codexArtifactTemplates";
42import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
43import {
44 type ChatMessage,
45 isImageAttachment,
46 type SessionPhase,
47 type Thread,
48 type TurnDiffSummary,
49} from "../types";
50import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
51import * as Schema from "effect/Schema";
52import { appAtomRegistry } from "../rpc/atomRegistry";
53import { environmentThreadShells, environmentThreadDetails } from "../state/threads";
54import { waitForAtomValue } from "../state/waitForAtomValue";
55import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext";
56import { stripInlineContextReferences } from "~/lib/composerContextReferences";
57import type { DraftThreadEnvMode } from "../composerDraftStore";
58import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic";
59import type { ReviewCommentContext } from "../reviewCommentContext";
60import { derivePhase, type TimelineEntry } from "../session-logic";
61import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore";
62import type { DesktopPreviewOverlay } from "../previewStateStore";
63import type { RightPanelSurface } from "../rightPanelStore";
64import {
65 NO_PROVIDER_MODEL_SELECTION,
66 resolveSelectableProviderInstanceEntry,
67 type ProviderInstanceEntry,
68} from "../providerInstances";
69
70export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
71export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
72
73export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
74
75export function agentControlledBrowserCloseConfirmation(
76 surfaces: readonly RightPanelSurface[],
77 desktopByTabId: Readonly<Record<string, Pick<DesktopPreviewOverlay, "controller"> | undefined>>,
78): string | null {
79 const activeBrowserCount = surfaces.filter(
80 (surface) =>
81 surface.kind === "preview" &&
82 surface.resourceId !== null &&
83 desktopByTabId[surface.resourceId]?.controller === "agent",
84 ).length;
85 if (activeBrowserCount === 0) return null;
86 if (activeBrowserCount === 1) {
87 return [
88 "Close browser while the agent is using it?",
89 "The agent is actively controlling this browser. Closing it may interrupt the current browser action.",
90 ].join("\n");
91 }
92 return [
93 `Close ${activeBrowserCount} browsers while the agent is using them?`,
94 "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.",
95 ].join("\n");
96}
97
98/** The floating player hides only while the same source is rendered in the panel. */
99export function shouldRenderPreviewMiniPlayer(
100 source: PreviewMiniPlayerSource | null,
101 renderedRightPanelSurface: RightPanelSurface | null,
102): boolean {
103 if (source === null) return false;
104 if (source.kind === "browser") {
105 return !(
106 renderedRightPanelSurface?.kind === "preview" &&
107 renderedRightPanelSurface.resourceId === source.tabId
108 );
109 }
110 return !(
111 renderedRightPanelSurface?.kind === "device" &&
112 renderedRightPanelSurface.target?.hostId === source.hostId &&
113 renderedRightPanelSurface.target.deviceId === source.deviceId
114 );
115}
116
117export function shouldOpenProactivePullRequest(
118 previousTargetKey: string | null | undefined,
119 targetKey: string | null,
120): boolean {
121 return targetKey !== null && targetKey !== previousTargetKey;
122}
123
124interface ProactivePanelObservation {
125 threadKey: string;
126 runningTurnId: RunId | null | undefined;
127 targetKey: string | null | undefined;
128 userActionTurnId: RunId | null;
129 userActionRevision: number;
130}
131
132/** Capture user intent before loading or metadata writes can defer panel activation. */
133export function observeProactivePanelUserChoice(
134 previous: ProactivePanelObservation | null,
135 input: { threadKey: string; runningTurnId: RunId | null; userActionRevision: number },
136): ProactivePanelObservation {
137 const sameThread = previous?.threadKey === input.threadKey;
138 const newTurn =
139 sameThread && input.runningTurnId !== null && input.runningTurnId !== previous.userActionTurnId;
140 return {
141 threadKey: input.threadKey,
142 runningTurnId: sameThread ? previous.runningTurnId : undefined,
143 targetKey: sameThread ? previous.targetKey : undefined,
144 userActionTurnId: input.runningTurnId ?? (sameThread ? previous.userActionTurnId : null),
145 userActionRevision:
146 !sameThread || newTurn ? input.userActionRevision : previous.userActionRevision,
147 };
148}
149
150/** Follow a changed server link only when the panel still shows the previous linked PR. */
151export function shouldRetargetThreadPullRequestPanel(
152 previous: ThreadLinkedPullRequest | null,
153 current: ThreadLinkedPullRequest | null,
154 surface: RightPanelSurface | null,
155): boolean {
156 if (previous === null || current === null || surface?.kind !== "pull-request") return false;
157 const previousRepository = previous.repository.toLowerCase();
158 return (
159 (previous.projectId !== current.projectId ||
160 previousRepository !== current.repository.toLowerCase() ||
161 previous.number !== current.number) &&
162 surface.projectId === previous.projectId &&
163 surface.repository.toLowerCase() === previousRepository &&
164 surface.number === previous.number
165 );
166}
167
168export function shouldOpenProactiveTurnDiff(input: {
169 previousRunningTurnId: RunId | null | undefined;
170 runningTurnId: RunId | null;
171 settledTurnId: RunId | null;
172 turnCompleted: boolean;
173}): boolean {
174 return (
175 input.runningTurnId === null &&
176 input.turnCompleted &&
177 input.settledTurnId !== null &&
178 (input.previousRunningTurnId === undefined ||
179 input.settledTurnId === input.previousRunningTurnId)
180 );
181}
182
183export function resolveProactiveTurnDiffAction(input: {
184 checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined;
185 isGitRepo: boolean | undefined;
186 activeSurfaceKind: RightPanelSurface["kind"] | null;
187}): "defer" | "ignore" | "open" {
188 // An open diff already shows the work; reopening it would reset the chosen scope.
189 if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") {
190 return "ignore";
191 }
192 if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";
193 if (input.isGitRepo === undefined) return "defer";
194 if (
195 !input.isGitRepo ||
196 input.checkpoint.status !== "ready" ||
197 input.checkpoint.files.length === 0
198 ) {
199 return "ignore";
200 }
201 const changedLines = input.checkpoint.files.reduce(
202 (total, file) => total + file.additions + file.deletions,
203 0,
204 );
205 return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore";
206}
207
208export function codexArtifactTemplatePromptToAppend(
209 currentDraft: string,
210 template: CodexArtifactTemplate,
211): string | null {
212 return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft
213 ? null
214 : codexArtifactTemplateUsePrompt(template);
215}
216
217export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
218
219export function resolveEffectiveInteractionMode(input: {
220 planModeEnabled: boolean;
221 composerInteractionMode: ProviderInteractionMode | null;
222 threadInteractionMode: ProviderInteractionMode | null | undefined;
223}): ProviderInteractionMode {
224 if (!input.planModeEnabled) return "default";
225 return input.composerInteractionMode ?? input.threadInteractionMode ?? "default";
226}
227
228export function shouldDockDraftHeroForSubmission(input: {
229 isDraftHeroState: boolean;
230 activeThreadKey: string | null;
231 submissionIntent: ComposerSubmissionIntent;
232}): boolean {
233 return (
234 input.submissionIntent === "foreground" &&
235 input.isDraftHeroState &&
236 input.activeThreadKey !== null
237 );
238}
239
240export function shouldReleaseTimelineAnchorForToolActivity(input: {
241 anchorMessageId: MessageId | null;
242 liveFollowEnabled: boolean;
243 runningTurnId: RunId | null;
244 timelineEntries: ReadonlyArray<TimelineEntry>;
245}): boolean {
246 if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) {
247 return false;
248 }
249
250 return input.timelineEntries.some((timelineEntry) => {
251 if (timelineEntry.kind !== "work" || timelineEntry.entry.runId !== input.runningTurnId) {
252 return false;
253 }
254
255 const entry = timelineEntry.entry;
256 return (
257 entry.tone === "tool" ||
258 entry.itemType !== undefined ||
259 entry.requestKind !== undefined ||
260 (entry.command?.trim().length ?? 0) > 0
261 );
262 });
263}
264
265export {
266 findRecordedWorktreeSetup,
267 resolveVisibleWorktreeSetup,
268} from "@t3tools/client-runtime/worktree-setup";
269
270/** Keep setup visible across local dispatch, durable preparation, and the live stream. */
271export function resolveWorktreeSetupProgress(input: {
272 threadId: ThreadId;
273 localPreparing: boolean;
274 runStatus: NonNullable<Thread["latestRun"]>["status"] | undefined;
275 latest: WorktreeSetupSnapshot | null | undefined;
276 held: WorktreeSetupSnapshot | null;
277}) {
278 const latest = input.latest?.threadId === input.threadId ? input.latest : null;
279 const held = input.held?.threadId === input.threadId ? input.held : null;
280 const snapshot = latest && (!held || latest.sequence >= held.sequence) ? latest : held;
281 return {
282 snapshot,
283 isPreparingWorktree:
284 input.localPreparing ||
285 input.runStatus === "preparing" ||
286 (snapshot?.phase === "running" && !worktreeSetupAgentStarted(snapshot)),
287 };
288}
289
290export function resolveDraftHeroState(input: {
291 isLocalDraftThread: boolean;
292 hasTimelineEntries: boolean;
293 isWorking: boolean;
294 draftHeroDockRequested: boolean;
295 backgroundSubmissionPending: boolean;
296 /** A worktree setup card is on the timeline, so the timeline must stay visible. */
297 hasWorktreeSetupCard?: boolean;
298}): boolean {
299 if (input.hasWorktreeSetupCard) {
300 return false;
301 }
302 if (input.backgroundSubmissionPending) {
303 return true;
304 }
305 return (
306 input.isLocalDraftThread &&
307 !input.hasTimelineEntries &&
308 !input.isWorking &&
309 !input.draftHeroDockRequested
310 );
311}
312
313/**
314 * Keep painted timelines on screen across thread jumps. Remounting LegendList
315 * (or handing it an empty first paint) punches a hole through the chat pane —
316 * white in light mode — so cmd+1/2/3 spam flashes even when the destination
317 * is already cached.
318 *
319 * Stored at module scope because ChatView remounts when the thread route
320 * changes (same pattern as the thread-error banner session dismissals).
321 * Remember more than the last thread so jumping back to cmd+1 does not show
322 * cmd+3's messages, and so a cached destination can paint on the first frame.
323 */
324export type HeldThreadTimeline<T extends readonly unknown[]> = {
325 threadKey: string | null;
326 entries: T;
327 markdownCwd?: string | null;
328 workspaceRoot?: string | null;
329};
330
331const MAX_REMEMBERED_THREAD_TIMELINES = 16;
332
333let rememberedThreadTimelines = new Map<string, HeldThreadTimeline<readonly unknown[]>>();
334let rememberedThreadTimelineOrder: string[] = [];
335let lastReadyThreadKey: string | null = null;
336
337function rememberThreadTimelineEntries(held: HeldThreadTimeline<readonly unknown[]>): void {
338 if (held.threadKey === null) {
339 return;
340 }
341 rememberedThreadTimelines.set(held.threadKey, held);
342 rememberedThreadTimelineOrder = [
343 ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey),
344 held.threadKey,
345 ];
346 while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) {
347 const evicted = rememberedThreadTimelineOrder.shift();
348 if (evicted !== undefined) {
349 rememberedThreadTimelines.delete(evicted);
350 }
351 }
352 lastReadyThreadKey = held.threadKey;
353}
354
355export function rememberReadyThreadTimeline<T extends readonly unknown[]>(
356 held: HeldThreadTimeline<T>,
357): void {
358 if (held.threadKey === null || held.entries.length === 0) {
359 return;
360 }
361 rememberThreadTimelineEntries(held);
362}
363
364export function peekRememberedThreadTimeline<T extends readonly unknown[]>(
365 threadKey: string | null,
366): T | null {
367 if (threadKey === null) {
368 return null;
369 }
370 return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;
371}
372
373export function peekHeldThreadTimeline<
374 T extends readonly unknown[],
375>(): HeldThreadTimeline<T> | null {
376 if (lastReadyThreadKey === null) {
377 return null;
378 }
379 const held = rememberedThreadTimelines.get(lastReadyThreadKey);
380 if (held === undefined || held.entries.length === 0) {
381 return null;
382 }
383 return held as HeldThreadTimeline<T>;
384}
385
386export function resetHeldThreadTimeline(): void {
387 rememberedThreadTimelines = new Map();
388 rememberedThreadTimelineOrder = [];
389 lastReadyThreadKey = null;
390}
391
392export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean {
393 if (left === null || right === null) {
394 return false;
395 }
396 const leftRef = parseScopedThreadKey(left);
397 const rightRef = parseScopedThreadKey(right);
398 return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId;
399}
400
401/** True while we still paint another thread's last snapshot. */
402export function isPaintOnlyThreadTimeline(
403 displayThreadKey: string | null,
404 activeThreadKey: string | null,
405): boolean {
406 return (
407 displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey
408 );
409}
410
411export function resolveThreadSwitchTimeline<T extends readonly unknown[]>(input: {
412 loading: boolean;
413 activeThreadKey: string | null;
414 nextEntries: T;
415 rememberedForActive?: T | null;
416 lastReady?: HeldThreadTimeline<T> | null;
417}): { entries: T; displayThreadKey: string | null } {
418 if (input.nextEntries.length > 0) {
419 return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
420 }
421
422 const rememberedForActive =
423 input.rememberedForActive ?? peekRememberedThreadTimeline<T>(input.activeThreadKey);
424 if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) {
425 return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey };
426 }
427
428 const lastReady = input.lastReady ?? peekHeldThreadTimeline<T>();
429 if (
430 input.loading &&
431 lastReady !== null &&
432 lastReady.threadKey !== null &&
433 lastReady.threadKey !== input.activeThreadKey &&
434 lastReady.entries.length > 0 &&
435 threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey)
436 ) {
437 return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey };
438 }
439 return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
440}
441
442export function resolveDraftPromotionNavigationTarget(input: {
443 serverThreadRef: ScopedThreadRef | null;
444 serverThread: Pick<Thread, "latestRun" | "latestUserMessageAt"> | null | undefined;
445 backgroundSubmissionPending: boolean;
446}): ScopedThreadRef | null {
447 if (input.backgroundSubmissionPending) {
448 return null;
449 }
450 const latestRun = input.serverThread?.latestRun ?? null;
451 const runStarted = latestRun?.startedAt != null;
452 const startupStopped =
453 latestRun?.status === "failed" ||
454 latestRun?.status === "interrupted" ||
455 latestRun?.status === "cancelled";
456 // Like main, promote once the server owns the send. The shared chat view
457 // keeps the optimistic message and setup progress mounted through the route swap.
458 const messagePersisted = input.serverThread?.latestUserMessageAt != null;
459 return runStarted || startupStopped || messagePersisted ? input.serverThreadRef : null;
460}
461
462export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
463 const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
464 return () => globalThis.clearTimeout(timeoutId);
465}
466
467export function hasEnvironmentReconnectWarningGraceElapsed(
468 activeEnvironmentId: EnvironmentId | null,
469 elapsedEnvironmentId: EnvironmentId | null,
470): boolean {
471 return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId;
472}
473
474export function startNewThreadForProject(
475 projectRef: ScopedProjectRef | null,
476 handleNewThread: (projectRef: ScopedProjectRef) => Promise<unknown>,
477): boolean {
478 if (projectRef === null) return false;
479 void handleNewThread(projectRef);
480
481 return true;
482}
483
484export function resolveThreadMetadataUpdateForNextTurn(input: {
485 currentModelSelection: ModelSelection;
486 nextModelSelection?: ModelSelection;
487 currentBranch: string | null;
488 nextBranch?: string;
489}): {
490 modelSelection?: ModelSelection;
491 branch?: string;
492 worktreePath?: null;
493} | null {
494 const nextModelSelection = input.nextModelSelection;
495 const modelSelectionChanged =
496 nextModelSelection !== undefined &&
497 (nextModelSelection.model !== input.currentModelSelection.model ||
498 nextModelSelection.instanceId !== input.currentModelSelection.instanceId ||
499 JSON.stringify(nextModelSelection.options ?? null) !==
500 JSON.stringify(input.currentModelSelection.options ?? null));
501 const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch;
502 if (!modelSelectionChanged && !branchChanged) {
503 return null;
504 }
505 return {
506 ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}),
507 ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}),
508 };
509}
510
511export function buildLocalDraftThread(
512 threadId: ThreadId,
513 draftThread: DraftThreadState,
514 fallbackModelSelection: ModelSelection,
515): Thread {
516 const timestamp = DateTime.makeUnsafe(draftThread.createdAt);
517 return presentThreadShell(draftThread.environmentId, {
518 id: threadId,
519 projectId: draftThread.projectId,
520 title: "New thread",
521 providerInstanceId: fallbackModelSelection.instanceId,
522 modelSelection: fallbackModelSelection,
523 runtimeMode: draftThread.runtimeMode,
524 interactionMode: draftThread.interactionMode,
525 branch: draftThread.branch,
526 worktreePath: draftThread.worktreePath,
527 activeProviderThreadId: null,
528 lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null },
529 forkedFrom: null,
530 createdBy: "user",
531 creationSource: "web",
532 latestRunId: null,
533 activeRunId: null,
534 status: "idle",
535 pendingRuntimeRequest: null,
536 latestVisibleMessage: null,
537 latestUserMessageAt: null,
538 hasActionableProposedPlan: false,
539 itemCount: 0,
540 visibleItemCount: 0,
541 createdAt: timestamp,
542 updatedAt: timestamp,
543 archivedAt: null,
544 settledOverride: null,
545 settledAt: null,
546 deletedAt: null,
547 });
548}
549
550export function shouldWriteThreadErrorToCurrentServerThread(input: {
551 serverThread:
552 | {
553 environmentId: EnvironmentId;
554 id: ThreadId;
555 }
556 | null
557 | undefined;
558 routeThreadRef: ScopedThreadRef;
559 targetThreadId: ThreadId;
560}): boolean {
561 return Boolean(
562 input.serverThread &&
563 input.targetThreadId === input.routeThreadRef.threadId &&
564 input.serverThread.environmentId === input.routeThreadRef.environmentId &&
565 input.serverThread.id === input.targetThreadId,
566 );
567}
568
569/** Use the same enabled instance for the composer, provider status, and chat actions. */
570export function resolveComposerProviderSelection(input: {
571 entries: ReadonlyArray<ProviderInstanceEntry>;
572 candidateInstanceIds: ReadonlyArray<ProviderInstanceId | null | undefined>;
573 lockedProvider: ProviderDriverKind | null;
574 lockedInstanceId: ProviderInstanceId | null | undefined;
575}) {
576 const requestedInstanceId = input.candidateInstanceIds.find(
577 (candidate) => candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId,
578 );
579 const requestedDriverKind =
580 input.lockedProvider ??
581 input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ??
582 input.entries[0]?.driverKind ??
583 ProviderDriverKind.make("unconfigured");
584 const lockedContinuationGroupKey = input.lockedProvider
585 ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId)
586 ?.continuationGroupKey ?? null)
587 : null;
588 // Missing metadata must not move Antigravity history into another Google profile.
589 const requiresExactInstance =
590 input.lockedProvider === "antigravity" &&
591 input.lockedInstanceId != null &&
592 lockedContinuationGroupKey === null;
593 const compatibleEntries = input.entries.filter(
594 (entry) =>
595 (!input.lockedProvider || entry.driverKind === input.lockedProvider) &&
596 (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) &&
597 (!requiresExactInstance || entry.instanceId === input.lockedInstanceId),
598 );
599 const selectedProviderEntry =
600 input.candidateInstanceIds
601 .map((candidate) =>
602 compatibleEntries.find(
603 (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,
604 ),
605 )
606 .find((entry) => entry !== undefined) ??
607 resolveSelectableProviderInstanceEntry(
608 compatibleEntries.filter((entry) => entry.driverKind === requestedDriverKind),
609 undefined,
610 ) ??
611 resolveSelectableProviderInstanceEntry(compatibleEntries, undefined);
612 const unavailableProviderInstanceId = selectedProviderEntry
613 ? undefined
614 : input.lockedProvider
615 ? (input.lockedInstanceId ?? requestedInstanceId)
616 : requestedInstanceId;
617 return {
618 selectedProviderEntry,
619 requestedDriverKind,
620 lockedContinuationGroupKey,
621 unavailableProviderInstanceId,
622 };
623}
624
625/** Keep restored drafts and every plan control on the selected instance's supported mode. */
626export function resolveComposerInteractionMode(input: {
627 planModeEnabled: boolean;
628 provider: Pick<ServerProvider, "showInteractionModeToggle"> | null | undefined;
629 interactionMode: ProviderInteractionMode;
630}): { enabled: boolean; interactionMode: ProviderInteractionMode } {
631 const enabled =
632 input.planModeEnabled &&
633 input.provider != null &&
634 input.provider.showInteractionModeToggle !== false;
635 return {
636 enabled,
637 interactionMode: enabled ? input.interactionMode : "default",
638 };
639}
640
641export function getAntigravitySendBlockReason(
642 provider:
643 | Pick<ServerProvider, "driver" | "installed" | "auth" | "models" | "status">
644 | null
645 | undefined,
646 model: string,
647): string | null {
648 if (provider?.driver !== "antigravity") return null;
649 if (!provider.installed) {
650 return "Install Antigravity in provider settings before sending.";
651 }
652 if (provider.auth.status === "unauthenticated") {
653 return "Sign in to Antigravity in provider settings before sending.";
654 }
655 const slug = model.trim();
656 if (slug.length === 0) return "Choose an Antigravity model before sending.";
657 // A restart clears the account status and catalog. Session startup checks
658 // saved credentials and validates the model before sending the prompt.
659 if (provider.auth.status === "unknown") return null;
660 if (provider.models.length === 0) {
661 return "Refresh Antigravity models in provider settings before sending.";
662 }
663 // A saved model that left the catalog is kept in the picker as unavailable
664 // so the user sees what the thread used. The server rejects it at turn
665 // start, so block here unless the provider is in an error state, where a
666 // retry with the same model is the right move.
667 if (
668 provider.status === "ready" &&
669 slug !== ANTIGRAVITY_DEFAULT_MODEL &&
670 !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug))
671 ) {
672 return "That Antigravity model is no longer available. Choose another model.";
673 }
674 return null;
675}
676
677export function reconcileMountedTerminalThreadIds(input: {
678 currentThreadIds: ReadonlyArray<string>;
679 openThreadIds: ReadonlyArray<string>;
680 activeThreadId: string | null;
681 activeThreadTerminalOpen: boolean;
682 maxHiddenThreadCount?: number;
683}): string[] {
684 const openThreadIdSet = new Set(input.openThreadIds);
685 const hiddenThreadIds = input.currentThreadIds.filter(
686 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
687 );
688 const maxHiddenThreadCount = Math.max(
689 0,
690 input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
691 );
692 const nextThreadIds =
693 hiddenThreadIds.length > maxHiddenThreadCount
694 ? hiddenThreadIds.slice(-maxHiddenThreadCount)
695 : hiddenThreadIds;
696
697 if (
698 input.activeThreadId &&
699 input.activeThreadTerminalOpen &&
700 !nextThreadIds.includes(input.activeThreadId)
701 ) {
702 nextThreadIds.push(input.activeThreadId);
703 }
704
705 return nextThreadIds;
706}
707
708export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
709 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
710 return;
711 }
712 URL.revokeObjectURL(previewUrl);
713}
714
715/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */
716export async function resolveFileAttachmentUrl(input: {
717 attachment: ChatFileAttachment;
718 environmentId: EnvironmentId;
719 httpBaseUrl: string;
720 createAssetUrl: (input: {
721 environmentId: EnvironmentId;
722 input: AssetCreateUrlInput;
723 }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>;
724}): Promise<string> {
725 const { attachment } = input;
726 const result = await input.createAssetUrl({
727 environmentId: input.environmentId,
728 input: {
729 resource: {
730 _tag: "attachment",
731 attachmentId: attachment.id,
732 fileName: attachment.name,
733 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
734 },
735 },
736 });
737 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
738 const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
739 if (url === null) throw new Error("The environment returned an invalid attachment URL.");
740 return url;
741}
742
743export async function prepareRevertedMessageAttachments(input: {
744 message: ChatMessage;
745 environmentId: EnvironmentId;
746 httpBaseUrl: string;
747 createAssetUrl: Parameters<typeof resolveFileAttachmentUrl>[0]["createAssetUrl"];
748}): Promise<File[]> {
749 return Promise.all(
750 (input.message.attachments ?? []).map(async (attachment) => {
751 if (attachment.type !== "image" && attachment.type !== "file") {
752 throw new Error("This message has an attachment that cannot be restored.");
753 }
754 const result = await input.createAssetUrl({
755 environmentId: input.environmentId,
756 input: {
757 resource: {
758 _tag: "attachment",
759 attachmentId: attachment.id,
760 fileName: attachment.name,
761 mimeType: attachment.mimeType,
762 },
763 },
764 });
765 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
766 const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
767 if (url === null) throw new Error("The environment returned an invalid attachment URL.");
768 const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
769 if (!response.ok) throw new Error(`Could not restore attachment: ${attachment.name}`);
770 return new File([await response.blob()], attachment.name, { type: attachment.mimeType });
771 }),
772 );
773}
774
775export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
776 if (message.role !== "user" || !message.attachments) {
777 return;
778 }
779 for (const attachment of message.attachments) {
780 if (!isImageAttachment(attachment)) {
781 continue;
782 }
783 revokeBlobPreviewUrl(attachment.previewUrl);
784 }
785}
786
787export function timelineHasEphemeralPreviewUrls(
788 entries: ReadonlyArray<Pick<TimelineEntry, "kind"> & { message?: ChatMessage }>,
789): boolean {
790 return entries.some(
791 (entry) =>
792 entry.kind === "message" &&
793 entry.message !== undefined &&
794 collectUserMessageBlobPreviewUrls(entry.message).length > 0,
795 );
796}
797
798export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {
799 if (message.role !== "user" || !message.attachments) {
800 return [];
801 }
802 const previewUrls: string[] = [];
803 for (const attachment of message.attachments) {
804 if (!isImageAttachment(attachment)) continue;
805 if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;
806 previewUrls.push(attachment.previewUrl);
807 }
808 return previewUrls;
809}
810
811export interface PullRequestDialogState {
812 initialReference: string | null;
813 key: number;
814}
815
816export { readFileAsDataUrl } from "../lib/imageCompression";
817
818export function resolveSendEnvMode(input: {
819 requestedEnvMode: DraftThreadEnvMode;
820 isGitRepo: boolean;
821}): DraftThreadEnvMode {
822 return input.isGitRepo ? input.requestedEnvMode : "local";
823}
824
825export function resolveBackgroundDraftWorkspaceOptions(input: {
826 envMode: DraftThreadEnvMode;
827 branch: string | null;
828 startFromOrigin: boolean;
829}): {
830 envMode: DraftThreadEnvMode;
831 branch: string | null;
832 worktreePath: null;
833 startFromOrigin: boolean;
834} {
835 return {
836 envMode: input.envMode,
837 branch: input.branch,
838 worktreePath: null,
839 startFromOrigin: input.envMode === "worktree" && input.startFromOrigin,
840 };
841}
842
843export function cloneComposerImageForRetry(
844 image: ComposerImageAttachment,
845): ComposerImageAttachment {
846 if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) {
847 return image;
848 }
849 try {
850 return {
851 ...image,
852 previewUrl: URL.createObjectURL(image.file),
853 };
854 } catch {
855 return image;
856 }
857}
858
859export function deriveComposerSendState(options: {
860 prompt: string;
861 imageCount: number;
862 terminalContexts: ReadonlyArray<TerminalContextDraft>;
863 /**
864 * Optional element-pick attachment count. Element contexts contribute to
865 * "sendable content" exactly like images and (text-bearing) terminal
866 * contexts do: a prompt of just element chips is still a valid send.
867 */
868 elementContextCount?: number;
869}): {
870 trimmedPrompt: string;
871 sendableTerminalContexts: TerminalContextDraft[];
872 expiredTerminalContextCount: number;
873 hasSendableContent: boolean;
874} {
875 const trimmedPrompt = stripInlineContextReferences(options.prompt).trim();
876 const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
877 const expiredTerminalContextCount =
878 options.terminalContexts.length - sendableTerminalContexts.length;
879 const elementContextCount = options.elementContextCount ?? 0;
880 return {
881 trimmedPrompt,
882 sendableTerminalContexts,
883 expiredTerminalContextCount,
884 hasSendableContent:
885 trimmedPrompt.length > 0 ||
886 options.imageCount > 0 ||
887 sendableTerminalContexts.length > 0 ||
888 elementContextCount > 0,
889 };
890}
891
892export function buildExpiredTerminalContextToastCopy(
893 expiredTerminalContextCount: number,
894 variant: "omitted" | "empty",
895): { title: string; description: string } {
896 const count = Math.max(1, Math.floor(expiredTerminalContextCount));
897 const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts";
898 if (variant === "empty") {
899 return {
900 title: `${noun} won't be sent`,
901 description: "Remove it or re-add it to include terminal output.",
902 };
903 }
904 return {
905 title: `${noun} omitted from message`,
906 description: "Re-add it if you want that terminal output included.",
907 };
908}
909
910export function branchMismatchKey(
911 threadId: string | null,
912 mismatch: { threadBranch: string; currentBranch: string } | null,
913): string | null {
914 if (!threadId || !mismatch) {
915 return null;
916 }
917 return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`;
918}
919
920// The mismatch banner only matters when the user is about to send: passive
921// reading of an old thread carries no risk (the branch picker tint already
922// covers ambient awareness). Draft content is the intent signal — composer
923// focus is useless here because ChatView autofocuses the composer on every
924// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once
925// revealed so it doesn't flicker away when the draft is cleared.
926export function shouldShowBranchMismatchBanner(input: {
927 hasMismatch: boolean;
928 isDismissed: boolean;
929 composerHasContent: boolean;
930 wasShownForCurrentMismatch: boolean;
931}): boolean {
932 if (!input.hasMismatch || input.isDismissed) {
933 return false;
934 }
935 return input.composerHasContent || input.wasShownForCurrentMismatch;
936}
937
938export function shouldShowPlanFollowUpPrompt(input: {
939 pendingUserInputCount: number;
940 interactionMode: ProviderInteractionMode;
941 latestTurnSettled: boolean;
942 hasActionableProposedPlan: boolean;
943 hasComposerAttachments: boolean;
944}): boolean {
945 return (
946 input.pendingUserInputCount === 0 &&
947 input.interactionMode === "plan" &&
948 input.latestTurnSettled &&
949 input.hasActionableProposedPlan &&
950 !input.hasComposerAttachments
951 );
952}
953
954// Session-scoped (module-level so it survives ChatView remounts, e.g. route
955// changes). Durable cross-device dismissal is planned as a server-side ack.
956const sessionDismissedBranchMismatchKeys = new Set<string>();
957
958export function dismissBranchMismatchForSession(key: string): void {
959 sessionDismissedBranchMismatchKeys.add(key);
960}
961
962export function isBranchMismatchDismissedForSession(key: string | null): boolean {
963 return key !== null && sessionDismissedBranchMismatchKeys.has(key);
964}
965
966// Git status for a checkout arrives after the composer paints, and the branch
967// strip mounts on the assumption that a project is a Git repo. Without a
968// memory, a non-Git project would mount the strip and drop it on every visit.
969// Keyed by environment and checkout for the session; never persisted.
970const sessionCheckoutIsRepo = new Map<string, boolean>();
971
972function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string {
973 return JSON.stringify([environmentId, cwd]);
974}
975
976export function rememberCheckoutIsRepo(
977 environmentId: EnvironmentId,
978 cwd: string,
979 isRepo: boolean,
980): void {
981 sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo);
982}
983
984export function recallCheckoutIsRepo(
985 environmentId: EnvironmentId,
986 cwd: string | null,
987): boolean | undefined {
988 return cwd === null
989 ? undefined
990 : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd));
991}
992
993export function threadHasStarted(thread: Thread | null | undefined): boolean {
994 return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime));
995}
996
997/**
998 * Whether a thread ran at least one turn, judged from its shell alone.
999 *
1000 * `threadHasStarted` needs the detail: a thread whose latest turn was cleared
1001 * still has messages, and the loading shell carries none. The shell records
1002 * when the last user message landed, which every started thread has.
1003 */
1004export function threadShellHasStarted(
1005 shell:
1006 | Pick<EnvironmentThreadShell, "latestRun" | "latestUserMessageAt" | "runtime">
1007 | null
1008 | undefined,
1009): boolean {
1010 return Boolean(
1011 shell &&
1012 (shell.latestRun !== null || shell.latestUserMessageAt !== null || shell.runtime !== null),
1013 );
1014}
1015
1016// Imported history has no session until its first prompt. Resolve its instance
1017// through the environment's provider catalog before locking to a driver.
1018export function deriveLockedProvider(input: {
1019 thread: Thread | null | undefined;
1020 selectedProvider: string | null;
1021 threadProvider: string | null;
1022 providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "driver">>;
1023}): ProviderDriverKind | null {
1024 if (!threadHasStarted(input.thread)) {
1025 return null;
1026 }
1027 const sessionProvider = input.thread?.runtime?.providerName ?? null;
1028 if (sessionProvider && isProviderDriverKind(sessionProvider)) {
1029 return sessionProvider;
1030 }
1031 // Preserve the existing lock while an instance is missing from the catalog;
1032 // a started thread must not silently fall back to a different driver.
1033 const threadProvider =
1034 input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ??
1035 input.threadProvider;
1036 const selectedProvider =
1037 input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ??
1038 input.selectedProvider;
1039 const narrowedThreadProvider =
1040 threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null;
1041 const narrowedSelectedProvider =
1042 selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null;
1043 return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;
1044}
1045
1046export function getStartedThreadModelChangeBlockReason(input: {
1047 providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "requiresNewThreadForModelChange">>;
1048 hasStartedSession: boolean;
1049 supportsProviderSwitchingViaHandoff?: boolean;
1050 currentModelSelection: ModelSelection;
1051 currentProviderInstanceId?: ModelSelection["instanceId"] | null | undefined;
1052 nextModelSelection: ModelSelection;
1053}): { title: string; description: string } | null {
1054 if (!input.hasStartedSession) {
1055 return null;
1056 }
1057 const currentModelSelection = {
1058 ...input.currentModelSelection,
1059 instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId,
1060 };
1061 if (
1062 currentModelSelection.instanceId === input.nextModelSelection.instanceId &&
1063 currentModelSelection.model === input.nextModelSelection.model
1064 ) {
1065 return null;
1066 }
1067 if (currentModelSelection.instanceId !== input.nextModelSelection.instanceId) {
1068 if (input.supportsProviderSwitchingViaHandoff === true) {
1069 return null;
1070 }
1071 return {
1072 title: "Start a new chat to switch providers",
1073 description: "This thread does not support switching providers after it has started.",
1074 };
1075 }
1076 const currentProvider = input.providers.find(
1077 (snapshot) => snapshot.instanceId === currentModelSelection.instanceId,
1078 );
1079 const nextProvider = input.providers.find(
1080 (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId,
1081 );
1082 if (
1083 currentProvider?.requiresNewThreadForModelChange !== true &&
1084 nextProvider?.requiresNewThreadForModelChange !== true
1085 ) {
1086 return null;
1087 }
1088 return {
1089 title: "Start a new chat to change models",
1090 description: "This provider does not allow switching models after a conversation has started.",
1091 };
1092}
1093
1094export async function waitForStartedServerThread(
1095 threadRef: ScopedThreadRef,
1096 timeoutMs = 1_000,
1097): Promise<boolean> {
1098 const threadAtom = environmentThreadShells.threadShellAtom(threadRef);
1099 return waitForAtomValue({
1100 registry: appAtomRegistry,
1101 atom: threadAtom,
1102 predicate: threadHasStarted,
1103 timeoutMs,
1104 });
1105}
1106
1107/**
1108 * Runs `revert` (the rollback command `requestId`) and resolves once the
1109 * message's run is rolled back. Rejects with the server's reason as soon as
1110 * the thread records that this rollback failed.
1111 */
1112export async function waitForRevertedMessage(
1113 threadRef: ScopedThreadRef,
1114 messageId: MessageId,
1115 turnCount: number,
1116 requestId: CommandId,
1117 revert: () => Promise<void>,
1118 timeoutMs = 120_000,
1119): Promise<void> {
1120 const threadAtom = environmentThreadDetails.stateAtom(threadRef);
1121 const readProjection = () => Option.getOrNull(appAtomRegistry.get(threadAtom).data);
1122 const initial = readProjection();
1123 if (!initial?.messages.some((message) => message.id === messageId)) {
1124 throw new Error("The message to rewind is no longer available.");
1125 }
1126 const messageRunId = initial.messages.find((message) => message.id === messageId)?.runId;
1127 return new Promise<void>((resolve, reject) => {
1128 let settled = false;
1129 let accepted = false;
1130 let unsubscribe = () => {};
1131 let timeout: ReturnType<typeof globalThis.setTimeout> | undefined;
1132 const finish = (error?: unknown) => {
1133 if (settled) return;
1134 settled = true;
1135 if (timeout !== undefined) globalThis.clearTimeout(timeout);
1136 unsubscribe();
1137 if (error !== undefined) reject(error);
1138 else resolve();
1139 };
1140 const inspect = () => {
1141 const thread = readProjection();
1142 if (!thread) return;
1143 const failure = thread.thread.rollbackFailure;
1144 if (failure?.requestId === requestId) {
1145 finish(new Error(failure.message));
1146 return;
1147 }
1148 if (
1149 accepted &&
1150 thread.runs.some(
1151 (run) =>
1152 run.id === messageRunId && run.ordinal > turnCount && run.status === "rolled_back",
1153 )
1154 )
1155 finish();
1156 };
1157 unsubscribe = appAtomRegistry.subscribe(threadAtom, inspect);
1158 timeout = globalThis.setTimeout(() => {
1159 finish(new Error("Timed out waiting for the thread to rewind."));
1160 }, timeoutMs);
1161 Promise.resolve()
1162 .then(revert)
1163 .then(() => {
1164 accepted = true;
1165 inspect();
1166 }, finish);
1167 });
1168}
1169
1170export interface LocalDispatchSnapshot {
1171 startedAt: string;
1172 preparingWorktree: boolean;
1173 submissionIntent: ComposerSubmissionIntent;
1174 latestUserMessageId: ChatMessage["id"] | null;
1175 latestRunId: RunId | null;
1176 latestRunRequestedAt: string | null;
1177 latestRunStartedAt: string | null;
1178 latestRunCompletedAt: string | null;
1179 runtimeStatus: NonNullable<Thread["runtime"]>["status"] | null;
1180 runtimeUpdatedAt: string | null;
1181}
1182
1183export function createLocalDispatchSnapshot(
1184 activeThread: Thread | undefined,
1185 options?: {
1186 preparingWorktree?: boolean;
1187 latestUserMessageId?: ChatMessage["id"] | null;
1188 submissionIntent?: ComposerSubmissionIntent;
1189 },
1190): LocalDispatchSnapshot {
1191 const latestRun = activeThread?.latestRun ?? null;
1192 const runtime = activeThread?.runtime ?? null;
1193 return {
1194 startedAt: new Date().toISOString(),
1195 preparingWorktree: Boolean(options?.preparingWorktree),
1196 submissionIntent: options?.submissionIntent ?? "foreground",
1197 latestUserMessageId: options?.latestUserMessageId ?? null,
1198 latestRunId: latestRun?.runId ?? null,
1199 latestRunRequestedAt: latestRun?.requestedAt ?? null,
1200 latestRunStartedAt: latestRun?.startedAt ?? null,
1201 latestRunCompletedAt: latestRun?.completedAt ?? null,
1202 runtimeStatus: runtime?.status ?? null,
1203 runtimeUpdatedAt: runtime?.updatedAt ?? null,
1204 };
1205}
1206
1207/**
1208 * The timeline renders committed user rows from `visibleTurnItems`, but
1209 * `message.updated` can land in `projection.messages` one event earlier than
1210 * the matching `turn-item.updated`. Basing optimistic eviction on visible user
1211 * turn items avoids dropping steer rows in that gap.
1212 */
1213export function deriveCommittedServerUserMessageIds(
1214 visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
1215): ReadonlySet<ChatMessage["id"]> {
1216 return new Set(
1217 visibleTurnItems.flatMap((row) =>
1218 row.item.type === "user_message" ? [row.item.messageId] : [],
1219 ),
1220 );
1221}
1222
1223export function hasServerAcknowledgedLocalDispatch(input: {
1224 localDispatch: LocalDispatchSnapshot | null;
1225 phase: SessionPhase;
1226 latestRun: Thread["latestRun"] | null;
1227 latestUserMessageId?: ChatMessage["id"] | null;
1228 runtime: Thread["runtime"] | null;
1229 hasPendingApproval: boolean;
1230 hasPendingUserInput: boolean;
1231 threadError: string | null | undefined;
1232}): boolean {
1233 if (!input.localDispatch) {
1234 return false;
1235 }
1236 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
1237 return true;
1238 }
1239 // The thread shell can report a preparing or starting run before the detail
1240 // projection behind `phase` loads, so either source still connecting holds
1241 // the send.
1242 if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") {
1243 return false;
1244 }
1245
1246 const latestRun = input.latestRun ?? null;
1247 const runtime = input.runtime ?? null;
1248 const latestUserMessageChanged =
1249 input.localDispatch.latestUserMessageId !== (input.latestUserMessageId ?? null);
1250 const latestRunChanged =
1251 input.localDispatch.latestRunId !== (latestRun?.runId ?? null) ||
1252 input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) ||
1253 input.localDispatch.latestRunStartedAt !== (latestRun?.startedAt ?? null) ||
1254 input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null);
1255
1256 if (input.phase === "running") {
1257 if (latestUserMessageChanged) {
1258 return true;
1259 }
1260 if (!latestRunChanged) {
1261 return false;
1262 }
1263 if (latestRun?.startedAt === null || latestRun === null) {
1264 return false;
1265 }
1266 if (
1267 runtime?.activeRunId !== null &&
1268 runtime?.activeRunId !== undefined &&
1269 latestRun?.runId !== runtime.activeRunId
1270 ) {
1271 return false;
1272 }
1273 return true;
1274 }
1275
1276 return (
1277 latestRunChanged ||
1278 input.localDispatch.runtimeStatus !== (runtime?.status ?? null) ||
1279 input.localDispatch.runtimeUpdatedAt !== (runtime?.updatedAt ?? null)
1280 );
1281}
1282
1283// Returning to the window should land the caret in the composer, so the reader can type right
1284// away. The exceptions are places where focus is deliberate: another text field, a terminal in
1285// the drawer or the right panel, or an open dialog or popup. A focused button outside those is
1286// not one of them, so it yields to the composer.
1287export function shouldRefocusComposerOnWindowFocus(
1288 activeElement:
1289 | (Pick<Element, "tagName" | "closest" | "getAttribute"> & { isContentEditable?: boolean })
1290 | null,
1291): boolean {
1292 if (activeElement === null || activeElement.tagName === "BODY") return true;
1293 if (
1294 activeElement.tagName === "INPUT" ||
1295 activeElement.tagName === "TEXTAREA" ||
1296 activeElement.tagName === "SELECT" ||
1297 activeElement.tagName === "IFRAME" ||
1298 activeElement.tagName === "WEBVIEW" ||
1299 activeElement.isContentEditable === true ||
1300 activeElement.getAttribute("role") === "textbox"
1301 ) {
1302 return false;
1303 }
1304 return (
1305 activeElement.closest(
1306 '[role="dialog"], [role="alertdialog"], [data-slot$="-popup"], [data-terminal-owner]',
1307 ) === null
1308 );
1309}
1310
1311export interface PlanFollowUpComposerSnapshot {
1312 readonly prompt: string;
1313 readonly terminalContexts: ReadonlyArray<TerminalContextDraft>;
1314 readonly reviewComments: ReadonlyArray<ReviewCommentContext>;
1315 readonly previewAnnotations: ReadonlyArray<PreviewAnnotationPayload>;
1316 readonly threadContexts: ReadonlyArray<ThreadContextRecord>;
1317}
1318
1319/**
1320 * Puts back everything a plan follow-up send cleared when the send fails. The
1321 * caller clears the composer before awaiting the send, so every field it held
1322 * has to be written back here: a dropped field silently discards user context.
1323 */
1324export function restorePlanFollowUpComposer(input: {
1325 readonly snapshot: PlanFollowUpComposerSnapshot;
1326 readonly writePrompt: (prompt: string) => void;
1327 readonly writeTerminalContexts: (contexts: ReadonlyArray<TerminalContextDraft>) => void;
1328 readonly writeReviewComments: (comments: ReadonlyArray<ReviewCommentContext>) => void;
1329 readonly writePreviewAnnotations: (annotations: ReadonlyArray<PreviewAnnotationPayload>) => void;
1330 readonly writeThreadContexts: (records: ReadonlyArray<ThreadContextRecord>) => void;
1331 readonly resetCursor: (options: {
1332 cursor: number;
1333 prompt: string;
1334 detectTrigger: boolean;
1335 }) => void;
1336}): void {
1337 input.writePrompt(input.snapshot.prompt);
1338 input.writeTerminalContexts(input.snapshot.terminalContexts);
1339 input.writeReviewComments(input.snapshot.reviewComments);
1340 input.writePreviewAnnotations(input.snapshot.previewAnnotations);
1341 input.writeThreadContexts(input.snapshot.threadContexts);
1342 input.resetCursor({
1343 cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length),
1344 prompt: input.snapshot.prompt,
1345 detectTrigger: true,
1346 });
1347}