返回 DeepSeek-Reasonix
useController.ts
根目录 / desktop / frontend / src / lib / useController.ts
1 import { isShellToolName } from "./shellToolIdentity";
2 // useController is the frontend's state machine over the agent event stream. It keeps
3 // per-tab output, tool state, and approvals while the user switches tabs; components
4 // render the active tab's state.
5 import { resetTurnTiming, confirmPendingUser, installTranscriptRecords, startLocalSubmission, submissionBindingCurrent } from "./submissionReducer";
6 import { runtimeStatusSnapshotIsStale } from "./runtimeStatusFreshness";
7 import { useRuntimeSession } from "./useRuntimeState";
8 import { acceptSessionRuntimeSnapshot, type RuntimeState } from "./runtimeStateStore";
9 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10 import { asArray } from "./array";
11 import { createControllerModelCommands } from "./controllerModelCommands";
12 import { compactArchivedToolItems } from "./archivedToolItems";
13 import { addBreadcrumb } from "./breadcrumbs";
14 import { desktopHost } from "./desktopHost";
15 import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge";
16 import { startControllerEventRecovery } from "./controllerEventRecovery";
17 import { metaFromTab } from "./controllerTabMeta";
18 import { tokensFromQuarters, unbilledOutputTokens } from "./turnMetrics";
19 import { normalizeToolApprovalMode } from "./types";
20 export { metaFromTab } from "./controllerTabMeta";
21 import { invalidateCache } from "./composerHistory";
22 import { formatInboxCancelError } from "./inboxError";
23 import type { MessageActionScope, MessageActionState } from "./messageActions";
24 import { mergeRateBand, type AggregatedRateBand } from "./costRateBand";
25 import { requestSessionCancel, type CancelOutcome } from "./inboxCancel";
26 import { normalizeTurnSubmit, resolveActiveTurnId } from "./inboxSubmit";
27 import { findTabAfterSubmitFailure, reduceManagementConfirmation, reduceSubmitFailure } from "./turnSubmissionFailure";
28 import {
29 checkpointLocalSubmission,
30 settleLocalSubmissions,
31 updateLocalSubmission,
32 canonicalUserConfirmations,
33 isUnknownSubmissionError,
34 type CanonicalUserConfirmation,
35 type LocalSubmission,
36 } from "./localSubmissionState";
37 import { formatContextMaintenanceNotice, isNewMaintenanceOperation, rememberMaintenanceOperation } from "./contextMaintenanceTypes";
38 import { formatGuardianAssessmentNotice } from "./guardianEvents";
39 import { normalizeCompletionSummary } from "./completionSummary";
40 import { withRunningChecks, withTurnResult } from "./completionResultState";
41 import { applyTurnCheckpoint, historyMessagesToItems, historyPageItems } from "./historyItems";
42 import { mergeTurnResult } from "./turnResult";
43 import { invalidateSharedQuery } from "./queryCoalesce";
44 import { replayPendingPromptsForActiveTab } from "./promptReplay";
45 import { createRafBatch } from "./rafBatch";
46 import { foregroundRunningFromRuntimeMeta, type RuntimeMetaSnapshot } from "./runtimeMeta";
47 import {
48 aliasActivationRequest,
49 beginResumeHistory,
50 noteActivationRequested,
51 noteActivationSettled,
52 noteActivationStarted,
53 noteNavigationHistoryReadable,
54 noteNavigationHistoryRequested,
55 noteNavigationIdentityPublished,
56 noteNavigationRequested,
57 noteNavigationRuntimeReady,
58 noteTranscriptFollowSwitch,
59 } from "./sessionDiagnostics";
60 import { applyLiveSegments, coalesceStreamDeltas, completeLiveReasoning, type StreamDeltaEntry, type StreamSegment } from "./streamDeltaBatch";
61 import { assistantHasContent, ensureActiveAssistant, ensureAssistant, removeEmptyAssistantItems } from "./assistantItems";
62 import { setTranscriptBindingIdentity } from "./canonicalTranscriptBackend";
63 import { getTranscriptStore } from "./transcriptStore";
64 import { TranscriptSessionFollower } from "./transcriptSessionFollower";
65 import { historyReplaceAction, historyRevisionIsOlder } from "./sessionTranscriptMode";
66 import { matchingSnapshotItem, transcriptPageState, transcriptSnapshotState } from "./transcriptSnapshotState";
67 import type { TranscriptSnapshot } from "./transcriptProtocol";
68 import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge";
69 import { uiPerfTracker } from "./uiPerf";
70 import { getLocale, t } from "./i18n";
71 import {
72 appendNoticeItem,
73 deliveryReadinessDetail,
74 errorMessage,
75 readinessMissingIds,
76 } from "./controllerNotices";
77 import { applyReadStatusEvent, type ReadStatusHost } from "./readStatus";
78 import { upsertReadPause } from "./readPause";
79 import { applyHydrateErrorState, hydratePlaceholderItems as resolveHydratePlaceholders } from "./hydrateErrorState";
80 import { isHostRecoveryGuidance } from "./hostRecoverySteer";
81 import { activeTabHydrationPlan, canAdoptUnboundLiveSurface, hasCachedLiveTurn, hasReusableCachedTranscript, sameSessionHydrateIdentity, sameSessionPlaceholderItems, type HydrateSurfacePolicy } from "./hydrateHistoryApply";
82 import { useSessionCatalogActions } from "./useSessionCatalogActions";
83 import { hydrateIdentityCurrent, sessionIdentityFields, sessionIdentityStableKey, type SessionHydrationOptions } from "./sessionIdentity";
84 import { loadHistoryWindow } from "./historyWindowController";
85 import { reduceHistoryWindowState } from "./historyWindowState";
86 import { withRemoteProviderUnreachable, withRemoteTurnInterrupted } from "./remoteTurnState";
87 import type { NavigationResult, SurfaceDataCommit, SurfaceDataOutcome } from "./navigationSurfaceTransition";
88 import { sameTodoList } from "./todoVisibility";
89 import type { InteractionKind, InteractionTarget } from "./interactionTarget";
90 import { interactionTargetFromState, promptInstanceKeyForState, stateOwnsInteraction } from "./interactionOwnership";
91 import { acceptsExtensionGeneration, applyExtensionForm, extensionSurfaceKey, type ExtensionFormState, type ExtensionNotificationEntry, type ExtensionStatusEntry } from "./extensionFormState";
92 export { acceptsExtensionGeneration, type ExtensionFormState, type ExtensionStatusEntry } from "./extensionFormState";
93 import { resolveSnapshotTurnStartedAt, resolveTurnStartedAt, snapshotPredatesTurnLifecycle } from "./turnTiming";
94 import { useRemoteTabSwitch } from "./useRemoteTabSwitch";
95 import { useNavigationIntentFence } from "./useNavigationIntentFence";
96 import { useGoalControllerActions } from "./useGoalControllerActions";
97 import type { SearchSource } from "./searchSources";
98 import { attachWebSearchOutput } from "./searchTranscript";
99 import { initialForkTurnState, reduceForkTurn, settleForkTurnForTab, type ForkTurnAction, type ForkTurnState } from "./forkTurn";
100 import { createTurnBoundaryReads } from "./turnBoundaryReads";
101 import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
102 import type { QualityFloor } from "./types";
103 import type { SessionClearResult } from "./historyTypes";
104 import type {
105 BalanceInfo,
106 CheckpointMeta,
107 CollaborationMode,
108 ContextInfo,
109 DeliveryWorktreeOpenResult,
110 EffortInfo,
111 HistoryMessage,
112 HistoryPage,
113 JobView,
114 MemoryCitation,
115 MemoryView,
116 Meta,
117 Mode,
118 QuestionAnswer,
119 RewindResultView,
120 SessionMeta,
121 TabMeta,
122 ToolApprovalMode,
123 TopicActivationEvent,
124 WireApproval,
125 WireAsk,
126 WireMCPInteraction,
127 WireCompletionSummary,
128 WireDecisionReceipt,
129 WireEvent,
130 WireExtensionCard,
131 WireExtensionStatus,
132 WireExtensionSurface,
133 WireTool,
134 TurnUsage,
135 WireUsage,
136 WireShellExecution,
137 } from "./types";
138
139 function resolvePromptForSession(target: InteractionTarget, answer: Record<string, unknown>): Promise<void> {
140 return import("./exactPromptSubmit").then(({ resolvePromptForSession: submit }) => submit(app, target, answer));
141 }
142
143 export { foregroundRunningFromRuntimeMeta } from "./runtimeMeta";
144 export { historyMessagesToItems, historyToolError, isReadOnlyTool } from "./historyItems";
145 export {
146 deliveryReadinessDetail,
147 localizedBackendNoticeText,
148 localizedNoticeText,
149 quietTranscriptNoticeKey,
150 readinessMissingIds,
151 } from "./controllerNotices";
152 export type ToolStatus = "running" | "done" | "error" | "stopped" | "unknown";
153 // Reserved ToolProgress channel names for sub-agent progress previews (the Go
154 // tracker emits these; ordinary tool progress must never use them).
155 export const SUBAGENT_PROGRESS_STATUS = "reasonix.subagent.status";
156 export const SUBAGENT_PROGRESS_REASONING = "reasonix.subagent.reasoning";
157 export const SUBAGENT_PROGRESS_TEXT = "reasonix.subagent.text";
158 export const SUBAGENT_PROGRESS_NOTICE = "reasonix.subagent.notice";
159 // Reserved names are matched by prefix so a future channel never falls back
160 // to ordinary tool output on older frontends.
161 const SUBAGENT_PROGRESS_PREFIX = "reasonix.subagent.";
162 const SUBAGENT_PROGRESS_PHASES = new Set(["queued", "running", "reasoning", "responding", "tool", "retrying", "completed", "partial", "failed", "cancelled"]);
163 // Tool names that initialize a sub-agent progress card. parallel_tasks/fleet
164 // are group cards: they settle when their whole child progress tree is
165 // terminal, since they never receive a terminal status of their own.
166 const SUBAGENT_PROGRESS_TOOLS = new Set(["task", "read_only_task", "parallel_tasks", "fleet"]);
167 // Per-channel preview retention. The backend already bounds what it sends
168 // (8 KiB pending per child); these caps keep one hot card from dominating the
169 // live conversation memory.
170 const SUBAGENT_PREVIEW_REASONING_LIMIT = 8 << 10;
171 const SUBAGENT_PREVIEW_TEXT_LIMIT = 8 << 10;
172 const SUBAGENT_PREVIEW_NOTICE_LIMIT = 2 << 10;
173 const RUNTIME_STATUS_ONLY = { hydrateSessionData: false } as const;
174 export type SubagentPhase = "queued" | "running" | "reasoning" | "responding" | "tool" | "retrying" | "completed" | "partial" | "failed" | "cancelled";
175 // In-memory-only sub-agent progress preview. Never persisted: history
176 // hydration rebuilds tool items from the transcript without these fields, and
177 // the full sub-agent transcript stays the source of truth after a restart.
178 export type SubagentProgress = {
179 phase: SubagentPhase;
180 reasoning: string;
181 text: string;
182 notice: string;
183 lastActivityAt: number;
184 truncated: boolean;
185 durationMs?: number;
186 startedAt: number;
187 };
188 export function isSubagentProgressName(name: string | undefined): boolean {
189 return !!name && name.startsWith(SUBAGENT_PROGRESS_PREFIX);
190 }
191 export function isTerminalSubagentPhase(phase: string | undefined): boolean {
192 return phase === "completed" || phase === "partial" || phase === "failed" || phase === "cancelled";
193 }
194 function isGroupSubagentTool(name: string): boolean {
195 return name === "parallel_tasks" || name === "fleet";
196 }
197 function terminalStatusOf(phase: string): ToolStatus {
198 switch (phase) {
199 case "completed": return "done";
200 case "partial": return "error";
201 case "failed": return "error";
202 case "cancelled": return "stopped";
203 }
204 return "running";
205 }
206 function freshSubagentProgress(): SubagentProgress {
207 const now = Date.now();
208 return { phase: "running", reasoning: "", text: "", notice: "", lastActivityAt: now, truncated: false, startedAt: now };
209 }
210 /** Keeps the most recent `limit` code points; surrogate pairs stay intact. */
211 function tailPreview(text: string, limit: number): string {
212 if (text.length <= limit) return text;
213 const pts = Array.from(text);
214 return pts.slice(pts.length - limit).join("");
215 }
216 // --- Sub-agent progress reducer helpers --------------------------------------
217 // Applies one reserved ToolProgress event to the target card's in-memory
218 // preview. The card must exist and be dispatch-initialized; never writes
219 // tool.output, the parent LiveStream, or history data.
220 function applySubagentProgress(s: State, t: WireTool): State {
221 if (!t.id) return s;
222 const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === t.id);
223 if (idx < 0) return s;
224 const next = [...s.items];
225 const it = next[idx];
226 if (it.kind !== "tool" || !it.subagentProgress) return s;
227 const sp: SubagentProgress = { ...it.subagentProgress, lastActivityAt: Date.now() };
228 switch (t.name) {
229 case SUBAGENT_PROGRESS_STATUS: {
230 const phase = t.output ?? "";
231 if (!SUBAGENT_PROGRESS_PHASES.has(phase)) return s; // unknown phase: ignore
232 sp.phase = phase as SubagentPhase;
233 if (isTerminalSubagentPhase(phase) && typeof t.durationMs === "number") sp.durationMs = t.durationMs;
234 break;
235 }
236 case SUBAGENT_PROGRESS_REASONING:
237 sp.reasoning = tailPreview(sp.reasoning + (t.output ?? ""), SUBAGENT_PREVIEW_REASONING_LIMIT);
238 sp.truncated = sp.truncated || !!t.truncated;
239 break;
240 case SUBAGENT_PROGRESS_TEXT:
241 sp.text = tailPreview(sp.text + (t.output ?? ""), SUBAGENT_PREVIEW_TEXT_LIMIT);
242 sp.truncated = sp.truncated || !!t.truncated;
243 break;
244 case SUBAGENT_PROGRESS_NOTICE:
245 sp.notice = tailPreview(sp.notice + (t.output ?? ""), SUBAGENT_PREVIEW_NOTICE_LIMIT);
246 sp.truncated = sp.truncated || !!t.truncated;
247 break;
248 default:
249 return s;
250 }
251 const status = isTerminalSubagentPhase(sp.phase) ? terminalStatusOf(sp.phase) : it.status;
252 next[idx] = { ...it, subagentProgress: sp, status };
253 return { ...s, items: next };
254 }
255 // Nested real tool activity refreshes its sub-agent parent's recent activity
256 // and switches the phase to "tool". Terminal parents are left untouched.
257 function touchSubagentParent(next: Item[], parentId: string): void {
258 const idx = next.findIndex((it) => it.kind === "tool" && it.id === parentId && it.subagentProgress);
259 if (idx < 0) return;
260 const it = next[idx];
261 if (it.kind !== "tool" || !it.subagentProgress || isTerminalSubagentPhase(it.subagentProgress.phase)) return;
262 next[idx] = { ...it, subagentProgress: { ...it.subagentProgress, phase: "tool", lastActivityAt: Date.now() } };
263 }
264 export type LiveStream = {
265 id: string;
266 text: string;
267 reasoning: string;
268 reasoningComplete: boolean;
269 reasoningStartedAt?: number;
270 reasoningCompletedAt?: number;
271 };
272 /** Speculative journal for one sampling attempt — rolled back on discard. */
273 type StreamAttemptJournal = {
274 id: string;
275 baselineLive?: LiveStream;
276 baselineTurnArgChars: number;
277 /** Tool cards created by this attempt (running, no result yet). */
278 createdToolIds: string[];
279 /** Prior state of tools that existed before this attempt and were patched. */
280 priorTools: Record<string, Extract<Item, { kind: "tool" }>>;
281 };
282 export type ControllerLiveStore = {
283 subscribe: (tabId: string | undefined, listener: () => void) => () => void;
284 getSnapshot: (tabId: string | undefined) => LiveStream | undefined;
285 getModelActiveAt?: (tabId: string | undefined) => number | undefined;
286 };
287 export type HistoryMutationKind = "replace" | "prepend" | "append" | "patch";
288 export type HistoryMutation = { seq: number; kind: HistoryMutationKind };
289 export type HistoryLoadTrigger = "viewport-user" | "question-jump" | "retry" | "auto-fill";
290
291 /** Alias kept for call sites that read as a type name rather than a trigger. */
292 export type HistoryLoadType = HistoryLoadTrigger;
293
294 /**
295 * What one older-history request produced. `stale` is deliberately distinct
296 * from `empty`: a recycled snapshot is not the same as running out of history,
297 * and a navigation jump has to report it rather than silently swap the body.
298 */
299 export type HistoryLoadOutcome = "loaded" | "empty" | "stale";
300
301 /** Marks an older-history failure the reader can resolve by retrying. */
302 export const STALE_HISTORY_ERROR = "history snapshot expired";
303 export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup" | "rewind" | "session-changed";
304 type SyncActiveTabOptions = { preserveCachedHistory?: boolean; navigationIntentSeq?: number; surfacePolicy?: HydrateSurfacePolicy; deferHydration?: boolean };
305 // A ticketed StartTopicActivation in flight. Only the latest one is tracked:
306 // superseded requests get "cancelled" from the backend and are ignored.
307 type PendingTopicActivation = {
308 requestId: string;
309 navigationSeq: number;
310 tabId?: string;
311 runtimeInitiallyReady?: boolean;
312 placeholderItems?: Item[];
313 /** Terminal event that arrived before the ticket resolved. */
314 terminal?: TopicActivationEvent;
315 };
316 type ModelSwitchQueueResult = "applied" | "superseded";
317 type ModelSwitchQueueRequest = {
318 name: string;
319 resolve: (result: ModelSwitchQueueResult) => void;
320 reject: (err: unknown) => void;
321 };
322 type ModelSwitchQueueState = {
323 running: boolean;
324 pending?: ModelSwitchQueueRequest;
325 fallbackBalance?: BalanceInfo;
326 };
327
328 export type TurnPhaseName = "working" | "checking" | "verifying" | "reviewing" | string;
329 export type Item = { turnId?: string } & (
330 | { kind: "user"; id: string; messageId?: string; submissionId?: string; submissionState?: "sending" | "confirmed" | "failed" | "unknown"; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number; historyTurn?: number }
331 | { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; turnFinal?: boolean; samplingCount?: number; toolCount?: number; wasStreamed?: true; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; turnDurationMs?: number; turnUsage?: TurnUsage; tokensPerSecond?: number; createdAt?: number; memoryCitations?: MemoryCitation[]; searchSources?: SearchSource[] }
332 | { kind: "phase"; id: string; text: string }
333 | { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
334 | {
335 kind: "compaction";
336 id: string;
337 pending: boolean;
338 trigger: string;
339 messages: number;
340 summary: string;
341 archive: string;
342 }
343 | {
344 kind: "tool";
345 id: string;
346 messageId?: string;
347 name: string;
348 args: string;
349 readOnly: boolean;
350 resolvedName?: string;
351 capabilityId?: string; subagentOutcome?: import("./subagentOutcome").SubagentOutcome;
352 status: ToolStatus;
353 resultMissing?: boolean; contentState?: "unloaded" | "loading" | "ready" | "failed";
354 output?: string; searchSources?: SearchSource[]; searchSourcesStatus?: "available" | "not_provided"; searchSummary?: string; // display-only provider search results; replay data stays in output/serverSearch
355 error?: string;
356 truncated?: boolean;
357 dataArchived?: boolean; // args/output trimmed for memory; full data available via backend
358 durationMs?: number; startedAt?: number; // Date.now() at dispatch; in-memory only, so hydrated cards show no live elapsed
359 subject?: string; // stable collapsed subject from archived history payloads
360 summary?: string; // stable collapsed readout kept even after args/output archive
361 fileDiff?: ToolFileDiff; // previewed whole-file diff from writer dispatch
362 isShell?: boolean; // bash tool or !command — structured shell card presentation
363 execution?: WireShellExecution; // local shell metadata
364 presentedFiles?: import("./types").PresentedFile[];
365 parentId?: string; // a sub-agent call nests under the `task` call with this id
366 profile?: { model?: string; effort?: string }; // subagent model/effort from tool event
367 argChars?: number; // args still streaming from the model: cumulative chars received
368 subagentProgress?: SubagentProgress; // in-memory-only preview, never hydrated from history
369 verifying?: boolean; // Host-confirmed check execution; never inferred from prose.
370 }
371 | {
372 kind: "extension";
373 id: string;
374 // surfaceKey is "<pluginId>:<surfaceId>"; a re-published card replaces the
375 // previous one in place instead of appending a duplicate transcript entry.
376 surfaceKey: string;
377 pluginId: string;
378 surfaceId: string;
379 generation?: number;
380 card: WireExtensionCard;
381 });
382
383 type ToolItem = Extract<Item, { kind: "tool" }>;
384 export type ExtensionItem = Extract<Item, { kind: "extension" }>;
385 // Extension UI surfaces (stage 8b2) — per-tab state fed by extension_surface /
386 // extension_status wire events. Statuses and generations key on
387 // "<pluginId>:<surfaceId>"; the form is the single pending form surface (a new
388 // form replaces the old, matching the backend's one-blocking-prompt model);
389 // notifications queue until the App drains them into the toast system.
390
391 // Mid-turn steer messages are recorded as info notices carrying this prefix —
392 // both live (the "steer" event below) and in replayed history (desktop/app.go
393 // prefixes persisted steers the same way). The prefix is the only durable
394 // marker, so display code identifies steers by it.
395 export const STEER_NOTICE_PREFIX = "↪ ";
396
397 function isStalePromptError(error: unknown): boolean {
398 return /active turn|runtime changed|stale/i.test(errorMessage(error));
399 }
400
401 function handlePromptFailure(dispatchTo: (tabId: string, action: Action) => void, target: InteractionTarget, epoch: number, error: unknown) {
402 if (isStalePromptError(error)) dispatchTo(target.tabId, { type: "expire_prompt", target, epoch });
403 else dispatchTo(target.tabId, { type: "submit_prompt_failed", target, epoch });
404 replayPendingPromptsForActiveTab(target.tabId);
405 }
406
407 export function isSteerNoticeText(text: string): boolean {
408 return text.startsWith(STEER_NOTICE_PREFIX);
409 }
410 export interface State extends ReadStatusHost, ForkTurnState {
411 /** Active sample overlay while the reader owns an older contiguous window. */
412 offscreenItems?: Item[];
413 transcriptProtocol?: 1 | 2;
414 /** Authoritative snapshot owner; reconnects retain it, rebinding replaces it. */
415 transcriptSessionId?: string;
416 transcriptRuntime?: import("../generated/desktopContract.generated").Runtime;
417 transcriptConnection?: "syncing" | "connected" | "disconnected";
418 transcriptConnectionError?: string;
419 transcriptItemOrder?: Record<string, number>;
420 items: Item[];
421 /** Browser-owned prompt echoes. Durable transcript rows never live here. */
422 localSubmissions: Record<string, LocalSubmission>;
423 visibleSubmissionHandoffs: Record<string, { submissionId: string }>;
424 localSubmissionOrder: string[];
425 /** Advances only for an explicit user send, never for history reconciliation. */
426 localSubmissionSendRevision: number;
427 /** Exact backend-owned turn targeted by Stop/Ask. */
428 activeTurnId?: string;
429 running: boolean;
430 turnActive: boolean;
431 pendingPrompt: boolean;
432 backgroundJobs: number;
433 cancelRequested: boolean;
434 cancellable: boolean;
435 /** Host turn phase from turn_phase events (working|checking|verifying|reviewing). */
436 turnPhase?: TurnPhaseName;
437 /** Latest content-free turn quality summary, shown on demand in the change panel. */
438 completionSummary?: WireCompletionSummary;
439 approval?: WireApproval;
440 ask?: WireAsk;
441 mcpInteraction?: WireMCPInteraction;
442 usage?: WireUsage;
443 context: ContextInfo;
444 meta?: Meta;
445 balance?: BalanceInfo;
446 effort?: EffortInfo;
447 jobs: JobView[];
448 checkpoints: CheckpointMeta[];
449 hydrating: boolean;
450 hydrateReason?: HydrateReason;
451 hydrateError?: string;
452 hydrateHistoryLoaded?: boolean;
453 hydratePlaceholderItems?: Item[];
454 historyStartTurn: number;
455 historyEndTurn: number;
456 historyTotalTurns: number;
457 historyHasOlder: boolean;
458 historyHasNewer: boolean;
459 historyOlderLoading: boolean;
460 historyOlderError?: string;
461 historyNewerLoading: boolean;
462 historyNewerError?: string;
463 historyRevision?: number;
464 historyDigest?: string;
465 /** Number of leading items owned by the persisted transcript projection. */
466 historyPrefixCount: number;
467 /** Bumped when lazy history content can change already-estimated row sizes. */
468 historyLayoutRevision: number;
469 historyMutation: HistoryMutation;
470 backendActivationPending: boolean;
471 messageAction?: MessageActionState;
472 currentAssistant?: string;
473 /** Next assistant sampling-segment ordinal within activeTurnId. */
474 assistantSegmentOrdinal: number;
475 pendingSearchSources?: SearchSource[];
476 live?: LiveStream;
477 pendingUser?: string;
478 pendingSubmissionId?: string;
479 deliveryRecoveryActive: boolean;
480 discardTurn?: boolean;
481 turnStartAt: number;
482 turnDoneAt: number;
483 turnLifecycleObservedAt?: number;
484 /** Last runtime snapshot sequence accepted for this tab/epoch. */
485 runtimeStatusEpoch?: string; runtimeStatusSeq?: number; runtimeStatusSnapshotAt?: number;
486 // Completion tokens accumulated across executor usage events within the
487 // current turn. ReasoningTokens is a subset of CompletionTokens.
488 turnOutputTokens: number;
489 turnOutputChars: number;
490 // Live text/reasoning characters already covered by the accumulated usage.
491 // This lets the composer estimate only the in-flight provider request.
492 turnOutputCharsAtUsage: number;
493 // True when any output-token count in the current turn is estimated.
494 turnOutputEstimated: boolean;
495 // Active provider-output intervals for the current turn. Tool execution and
496 // gaps between provider requests are intentionally excluded from TPS.
497 turnModelActiveAt?: number;
498 turnModelActiveMs: number;
499 // Time spent waiting on the user (approval/ask) within the current turn.
500 // Closed intervals accumulate here; an open interval uses promptWaitStartedAt
501 // so background tabs keep counting while not rendered by Composer.
502 turnWaitAccumMs: number;
503 // Last completed turn's values — preserved across turn boundaries so the
504 // status bar can display the most recent completed turn's TPS and token
505 // counts until the current turn finishes and overwrites them.
506 lastTurnOutputTokens: number;
507 lastTurnStartAt: number;
508 lastTurnDoneAt: number;
509 lastTurnWaitAccumMs: number;
510 lastTurnModelMs: number;
511 lastTurnOutputEstimated: boolean;
512 // Per-request rate (null when unmeasurable) and pending interval are tab-local.
513 lastRequestTps?: number | null;
514 pendingRequestModelMs?: number;
515 promptWaitStartedAt?: number;
516 // promptEventClock() at the CURRENT prompt's first arrival; not advanced by
517 // a same-id replay. Orders the prompt against reconciliation snapshots so a
518 // snapshot cannot clear a prompt it never knew about (#6429, #6432).
519 promptArrivedAt?: number;
520 // Id of the prompt promptArrivedAt is anchored to. A replay re-emitting the
521 // same id keeps the original arrival time; only a genuinely new prompt id
522 // (backend ids are monotonic within a controller) re-anchors it.
523 promptArrivedId?: string;
524 // Id of the most recently user-resolved approval/ask (explicit answer,
525 // cancel-through-mode-switch, etc). A replay carrying this same id is a
526 // stale re-delivery of an already-answered prompt, not a new one — arming
527 // it would resurrect a zombie no downstream snapshot may ever get a chance
528 // to reject (#6432 round 2: idle-applied-before-replay, and
529 // running=true/pendingPrompt=false snapshots that never clear approval/ask).
530 resolvedPromptId?: string;
531 resolvedPromptKey?: string;
532 // Monotonic per-tab prompt-id namespace generation. Approval/ask ids restart
533 // from "1" whenever the backend controller is rebuilt, so any id captured
534 // before the bump (an in-flight prompt answer or mode-switch RPC) must not
535 // touch bookkeeping written after it. Late callbacks from the old controller
536 // otherwise act on a different prompt that reused the same numeric id.
537 promptEpoch: number;
538 turnTokens: number;
539 turnTotalTokens: number;
540 /** Per-request usage folded into the active UI turn for the answer footer. */
541 turnUsage?: TurnUsage;
542 turnCost: number;
543 turnRateBand?: AggregatedRateBand;
544 // Cumulative argument characters of the tool call currently streaming its
545 // args (partial dispatch progress). Folded into the composer pill as an
546 // estimated-token tail; cleared when the round's usage arrives (which then
547 // includes those tokens for real) and on turn start.
548 turnArgChars: number;
549 sessionTokens: number;
550 sessionCost: number;
551 sessionCurrency: string;
552 retry?: { attempt: number; max: number; observedAt: number; recovery?: WireEvent["recovery"] };
553 seq: number;
554 sessionGen: number;
555 // Per-session counter bumped after hydration ancillary data (context, effort,
556 // jobs) arrives. ContextPanel reads this (merged into refreshKey) so the
557 // right-side panel re-fetches after a session rebind instead of showing stale
558 // RequestCount / ElapsedMs / SessionCost from before the swap.
559 contextPanelSeq: number;
560 // Monotonic count of usage events from ANY source (executor, subagent,
561 // title…). Drives right-panel snapshot refreshes so sub-agent activity keeps
562 // the session metrics live; state.usage stays executor-gated for the gauge.
563 usageSeq: number;
564 // Bounded set of context_maintenance operationIds already shown as notices
565 // so reconnect/replay does not insert duplicate timeline cards.
566 seenMaintenanceOps: string[];
567 // Extension UI surfaces (stage 8b2). See the ExtensionStatusEntry block
568 // above for the keying/lifecycle rules.
569 extensionStatuses: Record<string, ExtensionStatusEntry>;
570 extensionForm?: ExtensionFormState;
571 extensionNotifications: ExtensionNotificationEntry[];
572 // Last accepted generation per extension surface key; guards against
573 // re-ordered publications (acceptsExtensionGeneration).
574 extensionGenerations: Record<string, number>;
575 /** Last binding-validated runtime snapshot accepted from Meta or runtime sync. */
576 runtimeStateSnapshot?: RuntimeState;
577 // Speculative sampling-attempt journal for Codex-style stream replay.
578 // Host-local only; never hydrated from history.
579 streamAttemptJournal?: StreamAttemptJournal;
580 // Most recent discarded sampling attempt's failure reason within this turn
581 // (idle_timeout | premature_eof | connection_reset). Host-local; used to
582 // explain an interrupted turn whose stream had already been failing (#9560).
583 lastStreamInterrupt?: { reason: string; attempt: number; at: number };
584 // True after the agent emitted the safe terminal stream-failure notice. The
585 // following turn_done carries the same failure in err; suppress that duplicate
586 // while keeping the agent notice available to non-Desktop event consumers.
587 streamInterruptNoticeShown?: boolean;
588 }
589
590 type NavigationSourceSnapshot = {
591 tabId?: string;
592 state?: State;
593 tab?: TabMeta;
594 tabPromise?: Promise<TabMeta | undefined>;
595 };
596 export const initialState: State = {
597 items: [],
598 localSubmissions: {},
599 visibleSubmissionHandoffs: {},
600 localSubmissionOrder: [],
601 localSubmissionSendRevision: 0,
602 running: false,
603 turnActive: false,
604 pendingPrompt: false,
605 backgroundJobs: 0,
606 cancelRequested: false,
607 cancellable: false,
608 activeTurnId: undefined,
609 assistantSegmentOrdinal: 0,
610 context: { used: 0, window: 0, sessionTokens: 0 },
611 jobs: [],
612 checkpoints: [], ...initialForkTurnState,
613 hydrating: false,
614 historyStartTurn: 0,
615 historyEndTurn: 0,
616 historyTotalTurns: 0,
617 historyHasOlder: false,
618 historyHasNewer: false,
619 historyOlderLoading: false,
620 historyNewerLoading: false,
621 historyLayoutRevision: 0,
622 historyPrefixCount: 0,
623 historyMutation: { seq: 0, kind: "replace" },
624 backendActivationPending: false,
625 deliveryRecoveryActive: false,
626 promptEpoch: 0,
627 turnStartAt: 0,
628 turnDoneAt: 0,
629 turnOutputTokens: 0,
630 turnOutputChars: 0,
631 turnOutputCharsAtUsage: 0,
632 turnOutputEstimated: false,
633 turnModelActiveMs: 0,
634 turnWaitAccumMs: 0,
635 lastTurnOutputTokens: 0,
636 lastTurnStartAt: 0,
637 lastTurnDoneAt: 0,
638 lastTurnWaitAccumMs: 0,
639 lastTurnModelMs: 0,
640 lastTurnOutputEstimated: false,
641 turnTokens: 0,
642 turnTotalTokens: 0,
643 turnCost: 0,
644 turnRateBand: undefined,
645 turnArgChars: 0,
646 sessionTokens: 0,
647 sessionCost: 0,
648 sessionCurrency: "¥",
649 seq: 0,
650 sessionGen: 0,
651 contextPanelSeq: 0,
652 usageSeq: 0,
653 seenMaintenanceOps: [],
654 extensionStatuses: {},
655 extensionNotifications: [],
656 extensionGenerations: {},
657 };
658 function usageTotalTokens(usage?: WireUsage): number {
659 if (!usage) return 0;
660 if (usage.totalTokens > 0) return usage.totalTokens;
661 const promptTokens = usage.promptTokens || usage.cacheHitTokens + usage.cacheMissTokens;
662 return Math.max(0, promptTokens + usage.completionTokens);
663 }
664
665 function mergeChatTurnUsage(current: TurnUsage | undefined, usage: WireUsage | undefined): TurnUsage | undefined {
666 if (!usage) return current;
667 const route = usage.costQuote?.modelRef?.trim();
668 const routes = current?.routes ? [...current.routes] : [];
669 if (route && !routes.includes(route)) routes.push(route);
670 const hasCacheBuckets = usage.cacheHitTokens > 0 || usage.cacheMissTokens > 0;
671 return {
672 uncachedInputTokens: (current?.uncachedInputTokens ?? 0) + (hasCacheBuckets ? usage.cacheMissTokens : usage.promptTokens),
673 outputTokens: (current?.outputTokens ?? 0) + usage.completionTokens,
674 totalTokens: (current?.totalTokens ?? 0) + usageTotalTokens(usage),
675 cacheReadTokens: (current?.cacheReadTokens ?? 0) + usage.cacheHitTokens,
676 reasoningTokens: (current?.reasoningTokens ?? 0) + (usage.reasoningTokens ?? 0),
677 routes: routes.length ? routes : undefined,
678 };
679 }
680 // Clock used to order live prompt events against runtime snapshot fetches.
681 // Monotonic (immune to wall-clock jumps) with sub-millisecond resolution, so
682 // an event and a snapshot initiated in the same millisecond still order
683 // correctly. Only ever compared against itself.
684 export function promptEventClock(): number {
685 return typeof performance !== "undefined" ? performance.now() : Date.now();
686 }
687 // True when a runtime snapshot was fetched before the tab's live approval/ask
688 // event arrived. Such a snapshot reports the tab idle only because it predates
689 // the prompt (pre-attach ListTabs, activation-time metas); applying it would
690 // clear the only UI able to answer the prompt — and, since it also carries
691 // pendingPrompt=false, skip the compensating replay (#6429, #5561, #5481).
692 // Ties count as stale: keeping a prompt one extra round is recoverable, while
693 // clearing a live prompt is the bug this guards against.
694 export function runtimeSnapshotPredatesPrompt(
695 state: { approval?: unknown; ask?: unknown; promptArrivedAt?: number } | undefined,
696 snapshotAt: number | undefined,
697 ): boolean {
698 if (!state || (!state.approval && !state.ask)) return false;
699 if (snapshotAt === undefined || state.promptArrivedAt === undefined) return false;
700 return snapshotAt <= state.promptArrivedAt;
701 }
702 function runtimeSnapshotPredatesRetry(
703 state: Pick<State, "retry"> | undefined,
704 snapshotAt: number | undefined,
705 ): boolean {
706 if (snapshotAt === undefined || state?.retry?.observedAt === undefined) return false;
707 return snapshotAt <= state.retry.observedAt;
708 }
709 function updatesContextGauge(usage?: WireUsage): boolean {
710 const source = usage?.source?.trim();
711 return !source || source === "executor";
712 }
713 function countsTowardCurrentTurn(state: State): boolean {
714 return state.turnActive || state.running;
715 }
716 export function sameMeta(a?: Meta, b?: Meta): boolean {
717 if (a === b) return true;
718 if (!a || !b) return false;
719 return (
720 a.label === b.label &&
721 a.ready === b.ready &&
722 a.runtime?.phase === b.runtime?.phase &&
723 a.runtime?.epoch === b.runtime?.epoch &&
724 a.runtime?.issue?.code === b.runtime?.issue?.code &&
725 a.runtime?.issue?.message === b.runtime?.issue?.message &&
726 a.runtime?.issue?.retryable === b.runtime?.issue?.retryable &&
727 a.runtime?.issue?.holderPid === b.runtime?.issue?.holderPid &&
728 a.runtime?.issue?.holderHost === b.runtime?.issue?.holderHost &&
729 a.runtime?.issue?.acquiredAt === b.runtime?.issue?.acquiredAt &&
730 a.startupErr === b.startupErr &&
731 a.historicalSource?.path === b.historicalSource?.path &&
732 a.historicalSource?.headId === b.historicalSource?.headId &&
733 a.eventChannel === b.eventChannel &&
734 a.cwd === b.cwd &&
735 a.workspaceRoot === b.workspaceRoot &&
736 a.workspaceName === b.workspaceName &&
737 a.workspacePath === b.workspacePath &&
738 sessionIdentityStableKey(a) === sessionIdentityStableKey(b) &&
739 a.sessionGeneration === b.sessionGeneration &&
740 a.sessionRevision === b.sessionRevision &&
741 a.sessionDigest === b.sessionDigest &&
742 a.gitBranch === b.gitBranch &&
743 a.imageInputEnabled === b.imageInputEnabled &&
744 a.visionFallbackEnabled === b.visionFallbackEnabled &&
745 a.autoApproveTools === b.autoApproveTools &&
746 a.bypass === b.bypass &&
747 a.collaborationMode === b.collaborationMode &&
748 a.toolApprovalMode === b.toolApprovalMode &&
749
750 a.tokenMode === b.tokenMode &&
751 a.agentPreset === b.agentPreset &&
752 a.qualityFloor === b.qualityFloor &&
753 a.floorInferred === b.floorInferred &&
754 a.goal === b.goal &&
755 a.goalStatus === b.goalStatus &&
756 sameTodoList(a.canonicalTodos, b.canonicalTodos)
757 );
758 }
759
760 export function runtimeReadyForSubmit(meta?: Meta): boolean {
761 if (!meta || meta.ready !== true || meta.startupErr) return false;
762 return !meta.runtime || meta.runtime.phase === "ready";
763 }
764
765 export { normalizeTurnSubmit } from "./inboxSubmit";
766
767 const frontendSubmissionEpoch = typeof globalThis.crypto?.randomUUID === "function"
768 ? globalThis.crypto.randomUUID()
769 : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
770 export function createTurnSubmissionId(tabId: string, sessionGen: number, seq: number, runtimeEpoch?: string): string {
771 return JSON.stringify([frontendSubmissionEpoch, tabId, sessionGen, runtimeEpoch ?? "", seq]);
772 }
773
774 export function acceptsRuntimeEventEpoch(acceptedEpoch: string | undefined, eventEpoch: string | undefined): boolean {
775 return !eventEpoch || !acceptedEpoch || acceptedEpoch === eventEpoch;
776 }
777
778 export function composerProfileApplicationKey(
779 runtimeEpoch: string | undefined,
780 collaborationMode: CollaborationMode,
781 toolApprovalMode: ToolApprovalMode,
782 goal: string,
783 ): string {
784 return JSON.stringify([runtimeEpoch ?? "", collaborationMode, toolApprovalMode, goal]);
785 }
786
787 function metaWithoutCanonicalTodos(meta?: Meta): Meta | undefined {
788 if (!meta || meta.canonicalTodos === undefined) return meta;
789 return { ...meta, canonicalTodos: undefined };
790 }
791
792 const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
793 // After a stale runtime snapshot is rejected (its fetch predates the live
794 // prompt), refetch authoritative backend state once. Short enough to be barely
795 // perceptible, long enough to let any other in-flight replay events land first
796 // so the refetch reflects settled backend truth (#6432).
797 const STALE_PROMPT_RECONCILE_MS = 150;
798 const STARTUP_READY_META_RECONCILE_MS = 250;
799 const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
800
801 export { isBatchedReadOnlyTool } from "./searchTranscript";
802 export type Action =
803 | { type: "transcript_connection"; status: "syncing" | "connected" | "disconnected"; error?: string }
804 | { type: "transcript_v2_snapshot"; snapshot: TranscriptSnapshot; projection: import("./transcriptStore").TranscriptProjection; remote?: boolean }
805 | { type: "transcript_records"; projection: import("./transcriptStore").AppendEntriesResult; confirmedUsers: readonly CanonicalUserConfirmation[] }
806 | { type: "submission_verified"; submissionId: string; messageId: string }
807 | { type: "transcript_runtime"; runtime: import("../generated/desktopContract.generated").Runtime }
808 | { type: "event"; e: WireEvent; remote?: boolean }
809 | { type: "stream_batch"; segments: StreamSegment[] }
810 | { type: "user"; text: string; submitText?: string; seq: number; submissionId: string; deliveryRecovery?: boolean }
811 | { type: "unsend" }
812 | { type: "send_confirmed"; submissionId: string }
813 | { type: "management_confirmed"; submissionId: string }
814 | { type: "turn_admitted"; turnId: string; submissionId: string }
815 | { type: "turn_submit_rejected"; submissionId: string; error: string }
816 | { type: "turn_submit_unknown"; submissionId: string; error: string }
817 | { type: "send_failed"; submissionId: string; error: string }
818 | { type: "turn_interrupted" }
819 | { type: "backend_status"; running: boolean; turnStartedAt?: number; pendingPrompt?: boolean; backgroundJobs?: number; cancelRequested?: boolean; cancellable?: boolean; turnId?: string; turnStatus?: string; snapshotAt?: number; runtimeEpoch?: string; turnEventSeq?: number }
820 | { type: "cancel_requested" }
821 | { type: "meta"; meta: Meta }
822 | { type: "optimistic_meta"; meta: Meta }
823 | { type: "runtime_snapshot"; snapshot: RuntimeState }
824 | { type: "context"; context: ContextInfo }
825 | { type: "balance"; balance: BalanceInfo }
826 | { type: "effort"; effort: EffortInfo }
827 | { type: "jobs"; jobs: JobView[] }
828 | { type: "checkpoints"; checkpoints: CheckpointMeta[] } | ForkTurnAction
829 | { type: "hydrate_start"; reason: HydrateReason; placeholderItems?: Item[] }
830 | { type: "hydrate_done" }
831 | { type: "hydrate_error"; reason: HydrateReason; error: string }
832 | { type: "backend_activation_start"; backendPendingPrompt?: boolean }
833 | { type: "backend_activation_done" }
834 | { type: "message_action_start"; action: MessageActionState }
835 | { type: "message_action_done" }
836 | { type: "history"; messages: HistoryMessage[]; remote?: boolean }
837 | { type: "transcript_snapshot"; snapshot: TranscriptSnapshot; remote?: boolean }
838 | { type: "transcript_page"; snapshot: TranscriptSnapshot }
839 | { type: "history_page"; page: HistoryPage; mode: "replace" | "prepend" }
840 // TranscriptStore-driven history actions (windowed HistorySliceForTab flow).
841 // Items carry stable entryId-derived ids; prepend also lists existing item
842 // ids superseded by cross-page tool call/result merges.
843 | { type: "history_replace"; items: Item[]; startTurn: number; endTurn?: number; totalTurns: number; hasOlder: boolean; hasNewer?: boolean; revision?: number; digest?: string }
844 | { type: "history_rebase"; items: Item[]; startTurn: number; endTurn?: number; totalTurns: number; hasOlder: boolean; hasNewer?: boolean; revision?: number; digest?: string }
845 | { type: "history_prepend"; items: Item[]; removeIds: string[]; startTurn: number; endTurn?: number; totalTurns: number; hasOlder: boolean; hasNewer?: boolean; revision?: number; digest?: string }
846 | { type: "history_append"; items: Item[]; startTurn: number; endTurn: number; totalTurns: number; hasOlder: boolean; hasNewer: boolean; revision?: number; digest?: string }
847 | { type: "history_items_patch"; patches: Record<string, Item>; expected?: Record<string, Item> }
848 | { type: "history_older_start" }
849 | { type: "history_older_error"; error?: string }
850 | { type: "history_newer_start" }
851 | { type: "history_newer_error"; error?: string }
852 | { type: "local_notice"; level: "info" | "warn"; text: string; preserveRuntime?: boolean }
853 | { type: "clearApproval"; target?: InteractionTarget }
854 | { type: "clearAsk" }
855 | { type: "expire_prompt"; target: InteractionTarget; epoch: number }
856 | { type: "clearExtensionForm"; identity?: Pick<ExtensionFormState, "pluginId" | "surfaceId" | "formInstanceId"> }
857 | { type: "extension_notifications_drained" }
858 | { type: "approval_drained"; ids: string[]; epoch: number }
859 | { type: "ask_submit_succeeded"; target: InteractionTarget; epoch: number }
860 | { type: "submit_prompt_failed"; target: InteractionTarget; epoch: number }
861 | { type: "controller_rebuilt" }
862 | { type: "reset" }
863 | { type: "context_panel_refresh" };
864
865 function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
866 const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
867 return {
868 type: "backend_status",
869 running: foregroundRunning,
870 turnStartedAt: meta.turnStartedAt,
871 pendingPrompt: Boolean(meta.pendingPrompt),
872 backgroundJobs: meta.backgroundJobs ?? 0,
873 cancelRequested: Boolean(meta.cancelRequested),
874 cancellable: foregroundRunning,
875 turnId: meta.turnId,
876 turnStatus: meta.turnStatus,
877 runtimeEpoch: meta.runtime?.epoch, turnEventSeq: meta.turnEventSeq,
878 };
879 }
880
881 // ---- reducer helpers (unchanged logic) ----
882
883
884 /** End the compatibility-path segment before a committed tool dispatch. */
885 function settleCurrentAssistant(s: State, now = Date.now()): State {
886 const settled = endTurnModelActivity(s, now, true);
887 if (!s.currentAssistant) return settled;
888 const current = settled.items.find((item) => item.id === s.currentAssistant) as Extract<Item, { kind: "assistant" }> | undefined;
889 const live = s.live?.id === s.currentAssistant ? s.live : undefined;
890 if (!assistantHasContent(current, live) && s.transcriptProtocol !== 2) {
891 return { ...settled, items: current ? settled.items.filter((item) => item.id !== current.id) : settled.items, live: undefined, currentAssistant: undefined };
892 }
893 const completedLive = live ? completeLiveReasoning(live, now) : undefined;
894 const items = settled.items.map((item) => item.kind === "assistant" && item.id === s.currentAssistant
895 ? {
896 ...item, text: completedLive?.text ?? item.text, reasoning: completedLive?.reasoning ?? item.reasoning, streaming: false,
897 reasoningComplete: Boolean(completedLive?.reasoning || item.reasoning || completedLive?.reasoningComplete || item.reasoningComplete),
898 reasoningDurationMs: liveReasoningDurationMs(completedLive) ?? item.reasoningDurationMs,
899 }
900 : item);
901 return { ...settled, items, live: undefined, currentAssistant: undefined };
902 }
903
904 function liveReasoningDurationMs(live?: LiveStream): number | undefined {
905 if (!live?.reasoningStartedAt || !live.reasoning) return undefined;
906 const completedAt = live.reasoningCompletedAt;
907 if (!completedAt || completedAt < live.reasoningStartedAt) return undefined;
908 return completedAt - live.reasoningStartedAt;
909 }
910
911 // applyDeltaSegments folds ordered stream segments into the assistant's live
912 // stream in one state transition. Assumes applyEvent's preamble already ran.
913 function applyDeltaSegments(s: State, segments: StreamSegment[]): State {
914 const active = ensureActiveAssistant(s);
915 const base = active.live!;
916 const now = Date.now();
917 const deltaChars = segments.reduce((total, segment) => total + segment.delta.length, 0);
918 const next = { ...active, live: applyLiveSegments(base, segments, now), turnOutputChars: active.turnOutputChars + deltaChars };
919 return deltaChars > 0 ? beginTurnModelActivity(next, now) : next;
920 }
921
922 // applyStreamBatch is the stream_batch action: one frame's deltas, one reducer
923 // pass, one notification. Mirrors applyEvent's preamble for delta events.
924 function applyStreamBatch(s: State, segments: StreamSegment[]): State {
925 if (s.discardTurn) return s;
926 if (s.retry) s = { ...s, retry: undefined };
927 return applyDeltaSegments(s, segments);
928 }
929
930 /** Closed + open user-wait ms for the active turn (approval/ask). */
931 export function currentTurnWaitMs(
932 s: Pick<State, "turnWaitAccumMs" | "promptWaitStartedAt">,
933 now = Date.now(),
934 ): number {
935 const closed = Math.max(0, s.turnWaitAccumMs || 0);
936 const open = s.promptWaitStartedAt && s.promptWaitStartedAt > 0
937 ? Math.max(0, now - s.promptWaitStartedAt)
938 : 0;
939 return closed + open;
940 }
941
942 function currentTurnDurationMs(
943 s: Pick<State, "turnStartAt" | "turnWaitAccumMs" | "promptWaitStartedAt">,
944 now = Date.now(),
945 ): number | undefined {
946 if (!Number.isFinite(s.turnStartAt) || s.turnStartAt <= 0 || now < s.turnStartAt) return undefined;
947 return Math.max(1, now - s.turnStartAt - currentTurnWaitMs(s, now));
948 }
949
950 function beginPromptWait(s: State, now = Date.now()): State {
951 if (s.promptWaitStartedAt && s.promptWaitStartedAt > 0) return s;
952 return { ...s, promptWaitStartedAt: now };
953 }
954
955 function endPromptWait(s: State, now = Date.now()): State {
956 if (!s.promptWaitStartedAt || s.promptWaitStartedAt <= 0) {
957 return s.promptWaitStartedAt === undefined ? s : { ...s, promptWaitStartedAt: undefined };
958 }
959 const delta = Math.max(0, now - s.promptWaitStartedAt);
960 return {
961 ...s,
962 turnWaitAccumMs: Math.max(0, s.turnWaitAccumMs || 0) + delta,
963 promptWaitStartedAt: undefined,
964 };
965 }
966
967 // An MCP interaction is a user wait like any other prompt: closing the interval
968 // while one is outstanding would drop that wait from turnWaitAccumMs entirely,
969 // because the later answer's endPromptWait finds no open interval to close.
970 function endPromptWaitIfIdle(s: State, now = Date.now()): State {
971 if (s.approval || s.ask || s.mcpInteraction) return s;
972 return endPromptWait(s, now);
973 }
974
975
976 function beginTurnModelActivity(s: State, now = Date.now()): State {
977 return s.turnModelActiveAt && s.turnModelActiveAt > 0
978 ? s
979 : { ...s, turnModelActiveAt: now };
980 }
981
982 function endTurnModelActivity(s: State, now = Date.now(), stashForUsage = false): State {
983 if (!s.turnModelActiveAt || s.turnModelActiveAt <= 0) return s;
984 const closedMs = Math.max(0, now - s.turnModelActiveAt);
985 return { ...s, turnModelActiveAt: undefined, turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + closedMs,
986 pendingRequestModelMs: stashForUsage ? closedMs : s.pendingRequestModelMs };
987 }
988
989 function snapshotCompletedTurnTelemetry(s: State, now = Date.now()): State {
990 if (!s.turnStartAt || s.turnDoneAt > 0) return s;
991 const settled = endPromptWait(endTurnModelActivity(s, now), now);
992 // `turnOutputChars` is a bare count with no buffer to weight, so the rolled
993 // back-attempt branch stays ASCII-priced.
994 const estimatedInFlightTokens = settled.turnOutputTokens > 0
995 ? unbilledOutputTokens(settled.live, settled.turnOutputCharsAtUsage, settled.turnArgChars)
996 : tokensFromQuarters(settled.turnOutputChars + settled.turnArgChars);
997 return {
998 ...settled,
999 turnDoneAt: now,
1000 lastTurnOutputTokens: settled.turnOutputTokens + estimatedInFlightTokens,
1001 lastTurnStartAt: settled.turnStartAt,
1002 lastTurnDoneAt: now,
1003 lastTurnWaitAccumMs: settled.turnWaitAccumMs,
1004 lastTurnModelMs: settled.turnModelActiveMs,
1005 lastTurnOutputEstimated: settled.turnOutputEstimated || estimatedInFlightTokens > 0 || (settled.turnOutputTokens === 0 && settled.turnOutputChars > 0),
1006 };
1007 }
1008
1009 // applyExtensionSurfaceEvent reduces one extension_surface / extension_status
1010 // wire event. Every publication passes the per-surface generation fence first
1011 // (withAcceptedExtensionGeneration); the per-tab runtime-epoch fence in the
1012 // onEvent handler has already dropped anything from an older runtime
1013 // generation.
1014 function applyExtensionSurfaceEvent(s: State, surface: WireExtensionSurface | undefined): State {
1015 if (!surface) return s;
1016 const gated = withAcceptedExtensionGeneration(s, surface);
1017 if (gated === null) return s;
1018 s = gated;
1019 const kind = surface.kind || (surface.status ? "status" : "");
1020 switch (kind) {
1021 case "status":
1022 return applyExtensionStatus(s, surface);
1023 case "card":
1024 return applyExtensionCard(s, surface);
1025 case "form":
1026 return applyExtensionForm(s, surface);
1027 case "notification":
1028 return applyExtensionNotification(s, surface);
1029 default:
1030 return s;
1031 }
1032 }
1033
1034 // withAcceptedExtensionGeneration applies the per-surface generation fence.
1035 // Returns null when the event is a stale re-ordering and must be dropped;
1036 // otherwise returns state with the accepted generation recorded.
1037 function withAcceptedExtensionGeneration(s: State, surface: WireExtensionSurface): State | null {
1038 const key = extensionSurfaceKey(surface);
1039 if (!acceptsExtensionGeneration(s.extensionGenerations[key], surface.generation)) return null;
1040 if (surface.generation === undefined || s.extensionGenerations[key] === surface.generation) return s;
1041 return { ...s, extensionGenerations: { ...s.extensionGenerations, [key]: surface.generation } };
1042 }
1043
1044 function applyExtensionStatus(s: State, surface: WireExtensionSurface): State {
1045 const status: WireExtensionStatus | undefined = surface.status;
1046 if (!status) return s;
1047 const entry: ExtensionStatusEntry = {
1048 pluginId: surface.pluginId,
1049 surfaceId: surface.surfaceId,
1050 label: status.label,
1051 detail: status.detail,
1052 severity: status.severity,
1053 progress: status.progress,
1054 generation: surface.generation,
1055 };
1056 return { ...s, extensionStatuses: { ...s.extensionStatuses, [extensionSurfaceKey(surface)]: entry } };
1057 }
1058
1059 function applyExtensionCard(s: State, surface: WireExtensionSurface): State {
1060 const card: WireExtensionCard | undefined = surface.card;
1061 if (!card) return s;
1062 const key = extensionSurfaceKey(surface);
1063 const idx = s.items.findIndex((it) => it.kind === "extension" && it.surfaceKey === key);
1064 if (idx >= 0) {
1065 const next = [...s.items];
1066 const prev = next[idx];
1067 if (prev.kind === "extension") next[idx] = { ...prev, generation: surface.generation, card };
1068 return { ...s, items: next };
1069 }
1070 return {
1071 ...s,
1072 seq: s.seq + 1,
1073 items: [
1074 ...s.items,
1075 { kind: "extension", id: `x${s.seq}`, surfaceKey: key, pluginId: surface.pluginId, surfaceId: surface.surfaceId, generation: surface.generation, card },
1076 ],
1077 };
1078 }
1079
1080 // streamInterruptReasonText localizes the closed host enum a stream-attempt
1081 // discard carries (idle_timeout | premature_eof | connection_reset).
1082 function streamInterruptReasonText(reason: string): string {
1083 switch (reason) {
1084 case "idle_timeout": return t("notice.streamInterruptReason.idleTimeout");
1085 case "premature_eof": return t("notice.streamInterruptReason.prematureEof");
1086 case "connection_reset": return t("notice.streamInterruptReason.connectionReset");
1087 default: return t("notice.streamInterruptReason.unknown");
1088 }
1089 }
1090
1091 function applyStreamAttempt(s: State, e: WireEvent): State {
1092 const sa = e.streamAttempt;
1093 if (!sa?.id || !sa.action) return s;
1094 switch (sa.action) {
1095 case "begin": {
1096 const active = ensureActiveAssistant(s);
1097 // Snapshot only what this attempt may replace in the visible stream.
1098 // Provider activity timing is closed at discard but remains accumulated so
1099 // retry backoff is not counted in the completed TPS denominator.
1100 const baselineLive = { ...active.live! };
1101 return {
1102 ...active,
1103 running: true,
1104 turnActive: true,
1105 cancellable: true,
1106 turnStartAt: s.turnStartAt || Date.now(),
1107 streamAttemptJournal: {
1108 id: sa.id,
1109 baselineLive,
1110 baselineTurnArgChars: active.turnArgChars,
1111 createdToolIds: [],
1112 priorTools: {},
1113 },
1114 };
1115 }
1116 case "discard": {
1117 const journal = s.streamAttemptJournal;
1118 if (e.messageId) {
1119 const id = `m:${e.messageId}`;
1120 const ownsCurrent = s.currentAssistant === id;
1121 const ownsJournal = journal?.id === sa.id;
1122 return {
1123 ...s,
1124 items: s.items.filter((item) => item.id !== id && !(item.kind === "tool" &&
1125 (item.messageId === e.messageId || (ownsJournal && !item.messageId && journal.createdToolIds.includes(item.id))))),
1126 live: s.live?.id === id ? undefined : s.live,
1127 currentAssistant: ownsCurrent ? undefined : s.currentAssistant,
1128 streamAttemptJournal: ownsJournal ? undefined : journal,
1129 turnArgChars: ownsJournal ? journal.baselineTurnArgChars : s.turnArgChars,
1130 lastStreamInterrupt: sa.reason ? { reason: sa.reason, attempt: sa.attempt ?? 0, at: promptEventClock() } : s.lastStreamInterrupt,
1131 };
1132 }
1133 if (!journal || journal.id !== sa.id) {
1134 // Stale/out-of-order discard for an older attempt — leave the current
1135 // journal (and live speculative UI) untouched.
1136 return s;
1137 }
1138 const remove = new Set(journal.createdToolIds);
1139 const discardedMessageId = e.messageId ? `m:${e.messageId}` : undefined;
1140 const items = s.items
1141 .filter((it) => !(it.kind === "tool" && remove.has(it.id)) && it.id !== discardedMessageId)
1142 .map((it) => {
1143 if (it.kind !== "tool") return it;
1144 const prior = journal.priorTools[it.id];
1145 return prior ? { ...prior } : it;
1146 });
1147 // Restore live to the pre-attempt snapshot so partial text/reasoning is
1148 // replaced, not concatenated with the next attempt.
1149 const live = journal.baselineLive
1150 ? { ...journal.baselineLive }
1151 : s.live
1152 ? { ...s.live, text: "", reasoning: "", reasoningComplete: false, reasoningStartedAt: undefined, reasoningCompletedAt: undefined }
1153 : undefined;
1154 return {
1155 ...endTurnModelActivity(s),
1156 items,
1157 live: discardedMessageId ? undefined : live,
1158 currentAssistant: discardedMessageId ? undefined : s.currentAssistant,
1159 turnArgChars: journal.baselineTurnArgChars,
1160 streamAttemptJournal: undefined,
1161 lastStreamInterrupt: sa.reason
1162 ? { reason: sa.reason, attempt: sa.attempt ?? 0, at: promptEventClock() }
1163 : s.lastStreamInterrupt,
1164 running: true,
1165 turnActive: true,
1166 cancellable: true,
1167 };
1168 }
1169 case "commit": {
1170 if (s.streamAttemptJournal && s.streamAttemptJournal.id !== sa.id) return s;
1171 // Tool-only samples do not emit a message event. Remove their empty
1172 // placeholder now so the next sampling round is allocated after the
1173 // committed tool cards instead of reusing a bubble above them.
1174 const current = s.items.find((item) => item.id === s.currentAssistant) as Extract<Item, { kind: "assistant" }> | undefined;
1175 if (s.currentAssistant && !assistantHasContent(current, s.live?.id === s.currentAssistant ? s.live : undefined)) {
1176 return { ...s, items: current ? s.items.filter((item) => item.id !== current.id) : s.items, live: undefined, currentAssistant: undefined, streamAttemptJournal: undefined };
1177 }
1178 return { ...s, streamAttemptJournal: undefined };
1179 }
1180 default:
1181 return s;
1182 }
1183 }
1184
1185 /** Record a tool card mutation against the active sampling-attempt journal.
1186 * Only parent-sampling partials with a matching attemptId are journaled —
1187 * background sub-agent tools (parentId) and committed full dispatches are not.
1188 */
1189 function noteToolInJournal(
1190 s: State,
1191 toolId: string,
1192 existedBefore: boolean,
1193 prior: Extract<Item, { kind: "tool" }> | undefined,
1194 meta?: { attemptId?: string; parentId?: string; partial?: boolean },
1195 ): State {
1196 const journal = s.streamAttemptJournal;
1197 if (!journal || !toolId) return s;
1198 // Require explicit attempt membership — do not journal by arrival time alone.
1199 if (!meta?.attemptId || meta.attemptId !== journal.id) return s;
1200 if (meta.parentId) return s;
1201 if (meta.partial === false) return s;
1202 if (!existedBefore) {
1203 if (journal.createdToolIds.includes(toolId)) return s;
1204 return {
1205 ...s,
1206 streamAttemptJournal: {
1207 ...journal,
1208 createdToolIds: [...journal.createdToolIds, toolId],
1209 },
1210 };
1211 }
1212 if (prior && !journal.priorTools[toolId] && !journal.createdToolIds.includes(toolId)) {
1213 return {
1214 ...s,
1215 streamAttemptJournal: {
1216 ...journal,
1217 priorTools: { ...journal.priorTools, [toolId]: { ...prior } },
1218 },
1219 };
1220 }
1221 return s;
1222 }
1223
1224 function applyExtensionNotification(s: State, surface: WireExtensionSurface): State {
1225 const notification = surface.notification;
1226 if (!notification) return s;
1227 const entry: ExtensionNotificationEntry = {
1228 id: `xn${s.seq}`,
1229 pluginId: surface.pluginId,
1230 title: notification.title,
1231 body: notification.body,
1232 severity: notification.severity,
1233 };
1234 return { ...s, seq: s.seq + 1, extensionNotifications: [...s.extensionNotifications, entry] };
1235 }
1236
1237 function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State {
1238 if (s.discardTurn) {
1239 if (e.kind === "turn_done") {
1240 return {
1241 ...s,
1242 items: applyTurnCheckpoint(s.items, e.submissionId, e.checkpointTurn),
1243 discardTurn: false,
1244 running: false,
1245 turnActive: false,
1246 pendingPrompt: false,
1247 cancelRequested: false,
1248 cancellable: false,
1249 turnLifecycleObservedAt: promptEventClock(),
1250 currentAssistant: undefined,
1251 assistantSegmentOrdinal: 0,
1252 activeTurnId: undefined,
1253 live: undefined,
1254 };
1255 }
1256 return s;
1257 }
1258 s = confirmPendingUser(s, e.submissionId);
1259 if (e.kind === "user_message") {
1260 if (e.source && e.source !== "executor") return s;
1261 if (!e.messageId) return s;
1262 if (s.transcriptProtocol === 2) {
1263 const local = e.submissionId ? s.localSubmissions[e.submissionId] : undefined;
1264 if (local?.messageId && local.messageId !== e.messageId) {
1265 recordFrontendDiagnostic("transcript", "submission.identity-conflict", {
1266 submissionId: e.submissionId, boundMessageId: local.messageId, incomingMessageId: e.messageId,
1267 });
1268 return s;
1269 }
1270 return settleLocalSubmissions(updateLocalSubmission(s, e.submissionId, { messageId: e.messageId, turnId: e.turnId ?? local?.turnId,
1271 status: local?.status === "failed" ? "failed" : "accepted" }), s.items);
1272 }
1273 const id = `m:${e.messageId}`;
1274 const incoming: Item = { kind: "user", id, messageId: e.messageId, submissionId: e.submissionId, text: e.text ?? "" };
1275 const existing = matchingSnapshotItem(s.items, incoming);
1276 if (existing) {
1277 const next = { ...s, items: s.items.map((item) => item === existing ? { ...existing, ...incoming, id } : item) };
1278 return settleLocalSubmissions(next, next.items, canonicalUserConfirmations([incoming]));
1279 }
1280 const items = [...s.items, incoming];
1281 return settleLocalSubmissions({ ...s, items }, items, canonicalUserConfirmations([incoming]));
1282 }
1283 if (e.kind === "mcp_surface_ready") {
1284 // Background readiness remains a no-op unless the sink explicitly
1285 // correlates it to this submit in the common preamble above.
1286 return s;
1287 }
1288 if (e.kind === "extension_surface" || e.kind === "extension_status") {
1289 // Sidecar publications without exact sink correlation remain background-
1290 // only and must not clear the retry indicator.
1291 return applyExtensionSurfaceEvent(s, e.extension);
1292 }
1293 if (e.kind === "retrying") {
1294 // Recovery keeps Stop/Escape available despite stale idle snapshots.
1295 return {
1296 ...s,
1297 retry: {
1298 recovery: e.recovery,
1299 attempt: e.retryAttempt ?? 0,
1300 max: e.retryMax ?? 0,
1301 observedAt: promptEventClock(),
1302 },
1303 running: true,
1304 turnActive: true,
1305 cancellable: true,
1306 turnStartAt: s.turnStartAt || Date.now(),
1307 };
1308 }
1309 if (e.kind === "provider_unreachable") {
1310 const detail = typeof e.text === "string" ? e.text : "";
1311 return withRemoteProviderUnreachable(s, detail);
1312 }
1313 if (e.kind === "stream_attempt") {
1314 if (e.streamAttempt?.action === "begin" && e.messageId) {
1315 if (s.currentAssistant && s.currentAssistant !== `m:${e.messageId}`) s = settleCurrentAssistant(s);
1316 s = ensureAssistant({ ...s, items: s.transcriptProtocol === 2 ? s.items : removeEmptyAssistantItems(s.items) }, e.messageId);
1317 }
1318 return applyStreamAttempt(s, e);
1319 }
1320 if (e.messageId && (e.kind === "text" || e.kind === "reasoning" || e.kind === "message" || (e.kind === "tool_dispatch" && e.tool?.partial && !e.tool.parentId))) {
1321 if (s.currentAssistant && s.currentAssistant !== `m:${e.messageId}`) {
1322 s = settleCurrentAssistant(s);
1323 s = { ...s, items: s.transcriptProtocol === 2 ? s.items : removeEmptyAssistantItems(s.items) };
1324 }
1325 s = ensureAssistant(s, e.messageId);
1326 }
1327 if (s.retry) s = { ...s, retry: undefined };
1328 switch (e.kind) {
1329 case "turn_started": {
1330 // Pre-create an empty assistant bubble
1331 // immediately so the user sees their message + a blinking cursor the
1332 // instant the backend acknowledges the turn — no dead gap waiting for
1333 // the first text/reasoning token.
1334 const startsNewTurn = s.assistantSegmentOrdinal === 0
1335 || !s.turnActive
1336 || (Boolean(e.turnId) && e.turnId !== s.activeTurnId);
1337 const fresh = {
1338 ...s,
1339 // A new turn starts from no live read status: the previous turn's
1340 // progress is history, not this turn's state.
1341 readStatuses: undefined,
1342 readStatusClosed: false,
1343 activeTurnId: e.turnId ?? s.activeTurnId,
1344 assistantSegmentOrdinal: startsNewTurn ? 0 : s.assistantSegmentOrdinal,
1345 pendingSearchSources: undefined,
1346 meta: s.meta ? { ...s.meta, canonicalTodos: [] } : s.meta,
1347 };
1348 if (fresh.items.some((it) => it.id === "provider-unreachable")) {
1349 fresh.items = fresh.items.filter((it) => it.id !== "provider-unreachable");
1350 }
1351 const active = startsNewTurn || fresh.currentAssistant ? ensureActiveAssistant(fresh) : fresh;
1352 return {
1353 ...active,
1354 running: true,
1355 turnActive: true,
1356 turnPhase: "working",
1357 completionSummary: undefined,
1358 pendingPrompt: false,
1359 cancelRequested: false,
1360 cancellable: true,
1361 lastStreamInterrupt: undefined,
1362 streamInterruptNoticeShown: undefined,
1363 turnLifecycleObservedAt: promptEventClock(),
1364 ...resetTurnTiming(resolveTurnStartedAt(fresh.running || fresh.turnActive ? fresh.turnStartAt : 0, e.turnStartedAt)),
1365 };
1366 }
1367 case "turn_phase": {
1368 if (e.turnId && s.activeTurnId && e.turnId !== s.activeTurnId) return s;
1369 const phase = (e.phase ?? e.text ?? "").trim();
1370 if (!phase) return s;
1371 const next = { ...s, turnPhase: phase, running: true, turnActive: true, cancellable: true };
1372 return withRunningChecks(next);
1373 }
1374 case "turn_status": {
1375 if (e.turnId && s.activeTurnId && e.turnId !== s.activeTurnId) return s;
1376 switch (e.status) {
1377 case "queued":
1378 return {
1379 ...s,
1380 activeTurnId: e.turnId ?? s.activeTurnId,
1381 running: true,
1382 turnActive: true,
1383 pendingPrompt: false,
1384 cancelRequested: false,
1385 cancellable: true,
1386 };
1387 case "cancelling":
1388 return endPromptWait({ ...s, cancelRequested: true, pendingPrompt: false, approval: undefined, ask: undefined, mcpInteraction: undefined, cancellable: true });
1389 case "waiting_user":
1390 return { ...s, running: true, turnActive: true, pendingPrompt: true, cancellable: true };
1391 case "in_progress":
1392 return endPromptWait({ ...s, running: true, turnActive: true, pendingPrompt: false, cancelRequested: false, cancellable: true });
1393 default:
1394 return s;
1395 }
1396 }
1397 case "prompt_answered": {
1398 if (e.turnId && s.activeTurnId && e.turnId !== s.activeTurnId) return s;
1399 if (e.itemId && s.approval?.id !== e.itemId && s.ask?.id !== e.itemId && s.mcpInteraction?.id !== e.itemId) return s;
1400 return endPromptWait({
1401 ...s,
1402 approval: undefined,
1403 ask: undefined,
1404 mcpInteraction: undefined,
1405 pendingPrompt: false,
1406 running: true,
1407 turnActive: true,
1408 cancellable: true,
1409 resolvedPromptId: e.itemId ?? s.resolvedPromptId,
1410 });
1411 }
1412 case "completion_summary": {
1413 if (!e.completion) return s;
1414 if (e.turnId && s.activeTurnId && e.turnId !== s.activeTurnId) return s;
1415 return withTurnResult(s, normalizeCompletionSummary({ ...s.completionSummary, ...e.completion, turnId: e.turnId ?? s.activeTurnId }));
1416 }
1417 case "text":
1418 case "reasoning": {
1419 return applyDeltaSegments(s, [{ kind: e.kind, delta: e.text ?? e.reasoning ?? "" }]);
1420 }
1421 case "message": {
1422 const existingAssistant =
1423 s.currentAssistant === undefined
1424 ? undefined
1425 : s.items.find((it): it is Extract<Item, { kind: "assistant" }> => it.kind === "assistant" && it.id === s.currentAssistant);
1426 const text = e.text ?? s.live?.text ?? existingAssistant?.text ?? "";
1427 const reasoning = e.reasoning ?? s.live?.reasoning ?? existingAssistant?.reasoning ?? "";
1428 if (text.trim() === "" && reasoning.trim() === "") {
1429 const keepEmpty =
1430 s.transcriptProtocol === 2 || Boolean(existingAssistant?.memoryCitations?.length) || Boolean(existingAssistant?.searchSources?.length);
1431 const items =
1432 existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !keepEmpty
1433 ? s.items.filter((it) => !(it.kind === "assistant" && it.id === existingAssistant.id))
1434 : s.items;
1435 return { ...endTurnModelActivity(s, Date.now(), true), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
1436 }
1437 const now = Date.now();
1438 const settled = endTurnModelActivity(s, now, true);
1439 const active = ensureAssistant(settled);
1440 const id = active.currentAssistant!;
1441 const streamedChars = active.live?.id === id ? active.live.text.length + active.live.reasoning.length : 0;
1442 const turnOutputChars = Math.max(0, settled.turnOutputChars - streamedChars + text.length + reasoning.length);
1443 const completedLive = active.live?.id === id ? completeLiveReasoning({ ...active.live, text, reasoning }, now) : undefined;
1444 const reasoningDurationMs = liveReasoningDurationMs(completedLive);
1445 const workDurationMs = currentTurnDurationMs(settled, now);
1446 const next = active.items.map((it) =>
1447 it.kind === "assistant" && it.id === id
1448 ? (() => {
1449 const memoryCitations = asArray<MemoryCitation>(e.memoryCitations ?? it.memoryCitations);
1450 return {
1451 ...it,
1452 text,
1453 reasoning,
1454 streaming: false,
1455 reasoningComplete: reasoning !== "" || it.reasoningComplete,
1456 reasoningDurationMs: reasoningDurationMs ?? it.reasoningDurationMs,
1457 workDurationMs: Math.max(it.workDurationMs ?? 0, workDurationMs ?? 0) || undefined,
1458 memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
1459 };
1460 })()
1461 : it,
1462 );
1463 return { ...active, items: next, live: undefined, currentAssistant: undefined, turnOutputChars, turnOutputCharsAtUsage: 0 };
1464 }
1465 case "tool_dispatch": {
1466 const t = e.tool;
1467 if (!t) return s;
1468 // A partial dispatch (args still streaming from the model) upserts a
1469 // lightweight "receiving" card immediately. Dropping it entirely — the
1470 // old behavior — left a 30KB write_file body streaming for a minute with
1471 // zero visible activity, indistinguishable from a hang. The full
1472 // dispatch that follows merges by ID and fills in args/summary.
1473 if (t.partial) {
1474 const samplingState = t.parentId || s.currentAssistant ? s : ensureActiveAssistant(s);
1475 const activeState = t.parentId ? samplingState : beginTurnModelActivity(samplingState);
1476 const turnArgChars = t.argChars && t.argChars > 0 ? t.argChars : s.turnArgChars;
1477 // Some OpenAI-compatible streams surface the call name before its ID.
1478 // Without a stable ID the card could never be merged with the full
1479 // dispatch (a synthetic `tool${seq}` id would orphan it as a forever-
1480 // running duplicate), so count the progress but wait for the ID before
1481 // creating the card.
1482 if (!t.id) return { ...activeState, turnArgChars };
1483 const id = t.id;
1484 const idx = activeState.items.findIndex((it) => it.kind === "tool" && it.id === id);
1485 if (idx >= 0) {
1486 const next = [...activeState.items];
1487 const it = next[idx];
1488 if (it.kind === "tool" && it.status === "running" && !it.args) {
1489 const prior = it;
1490 next[idx] = { ...it, argChars: t.argChars || it.argChars };
1491 return noteToolInJournal({ ...activeState, items: next, turnArgChars }, id, true, prior, {
1492 attemptId: t.attemptId, parentId: t.parentId, partial: true,
1493 });
1494 }
1495 return { ...activeState, turnArgChars };
1496 }
1497 return noteToolInJournal({
1498 ...activeState,
1499 turnArgChars,
1500 seq: activeState.seq + 1,
1501 items: [...activeState.items, { kind: "tool", id, name: t.name, args: "", readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", startedAt: Date.now(), argChars: t.argChars || undefined, parentId: t.parentId, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined }],
1502 }, id, false, undefined, { attemptId: t.attemptId, parentId: t.parentId, partial: true });
1503 }
1504 const settled = t.parentId ? s : settleCurrentAssistant(s);
1505 const id = t.id || `tool${s.seq}`;
1506 const idx = settled.items.findIndex((it) => it.kind === "tool" && it.id === id);
1507 if (idx >= 0) {
1508 const next = [...settled.items];
1509 const it = next[idx];
1510 if (it.kind === "tool") {
1511 const args = t.args ? t.args : it.args;
1512 const fileDiff = fileDiffFromWire(t);
1513 const summary = summarizeFileDiff(fileDiff) || summarize(t.name, args) || (t.name === it.name && args === it.args ? it.summary : undefined);
1514 next[idx] = { ...it, name: t.name, args, readOnly: t.readOnly, resolvedName: t.resolvedName ?? it.resolvedName, capabilityId: t.capabilityId ?? it.capabilityId, profile: t.profile ?? it.profile, summary, fileDiff, argChars: undefined, isShell: it.isShell || isShellToolName(t.name) || id.startsWith("shell-"), execution: t.execution ?? it.execution, subagentProgress: it.subagentProgress ?? (SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined) };
1515 }
1516 if (t.parentId) touchSubagentParent(next, t.parentId);
1517 return { ...settled, items: next };
1518 }
1519 const args = t.args ?? "";
1520 const fileDiff = fileDiffFromWire(t);
1521 const created: ToolItem = { kind: "tool", id, name: t.name, args, readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", startedAt: Date.now(), summary: summarizeFileDiff(fileDiff) || summarize(t.name, args), fileDiff, isShell: isShellToolName(t.name) || id.startsWith("shell-"), execution: t.execution, parentId: t.parentId, profile: t.profile, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined };
1522 const items = [...settled.items, created];
1523 // A sub-agent call nested under a task card refreshes that card's
1524 // recent activity and switches its phase to "tool".
1525 if (t.parentId) touchSubagentParent(items, t.parentId);
1526 return { ...settled, seq: settled.seq + 1, items };
1527 }
1528 case "tool_result_preview": case "tool_result": {
1529 const t = e.tool;
1530 if (!t) return s;
1531 const next = [...s.items];
1532 let idx = t.id ? next.findIndex((it) => it.kind === "tool" && it.id === t.id) : -1;
1533 if (idx < 0) {
1534 for (let i = next.length - 1; i >= 0; i--) {
1535 const it = next[i];
1536 if (it.kind === "tool" && it.status === "running") { idx = i; break; }
1537 }
1538 }
1539 if (idx >= 0) {
1540 const it = next[idx];
1541 if (it.kind === "tool") {
1542 // Archive immediately: collapsed cards only show tool name + command
1543 // subject (from args). Drop output entirely; full data is loaded on
1544 // demand via app.ToolResultForTab when the card is expanded.
1545 const existing = it;
1546 const summary = t.err ? undefined : existing.summary || summarize(existing.name, existing.args, t.output);
1547 let status: ToolStatus = t.err ? "error" : "done";
1548 if (existing.subagentProgress) {
1549 // Sub-agent progress owns the card's final visual: a background
1550 // call that returned a job id stays running while the child
1551 // works; a cancelled child keeps its stopped semantics even when
1552 // the aggregate result carries an error. Group cards
1553 // (parallel_tasks/fleet) settle only from their own lifecycle
1554 // terminal event — the backend emits running at start and exactly
1555 // one terminal at the end (including validation failures and
1556 // zero-child cancellation) — never from inferring the children
1557 // observed so far, since a background group's children dispatch
1558 // asynchronously and a fast child can finish before later ones
1559 // even appear.
1560 if (isGroupSubagentTool(existing.name)) {
1561 status = isTerminalSubagentPhase(existing.subagentProgress.phase)
1562 ? terminalStatusOf(existing.subagentProgress.phase)
1563 : "running";
1564 } else if (!isTerminalSubagentPhase(existing.subagentProgress.phase)) {
1565 status = "running";
1566 } else {
1567 status = terminalStatusOf(existing.subagentProgress.phase);
1568 }
1569 }
1570 next[idx] = {
1571 ...existing,
1572 readOnly: t.readOnly,
1573 resolvedName: t.resolvedName ?? existing.resolvedName,
1574 capabilityId: t.capabilityId ?? existing.capabilityId,
1575 status,
1576 output: t.output,
1577 error: t.err,
1578 truncated: t.truncated,
1579 durationMs: t.durationMs,
1580 summary,
1581 isShell: existing.isShell || isShellToolName(existing.name) || isShellToolName(t.name),
1582 execution: t.execution ?? existing.execution,
1583 presentedFiles: t.presentedFiles ?? existing.presentedFiles,
1584 subagentOutcome: t.subagentRef || t.subagentStatus
1585 ? [t.subagentRef, t.subagentStatus, t.subagentErrorCode, t.subagentRetryable] as const
1586 : existing.subagentOutcome,
1587 };
1588 }
1589 }
1590 // A nested result refreshes its sub-agent parent's recent activity.
1591 if (t.parentId) touchSubagentParent(next, t.parentId);
1592 const items = preserveToolPayloads ? next : compactArchivedToolItems(next);
1593 const committedTodos = e.kind === "tool_result" && !t.err && t.todoWritten && Array.isArray(t.todos)
1594 ? t.todos.map((todo) => ({ content: todo.content, status: todo.status }))
1595 : undefined;
1596 const updated = committedTodos !== undefined && s.meta
1597 ? { ...s, items, meta: { ...s.meta, canonicalTodos: committedTodos } }
1598 : { ...s, items };
1599 return withRunningChecks(attachWebSearchOutput(updated, t.name, t.output, t.err, idx >= 0 && next[idx]?.kind === "tool" ? next[idx].id : t.id));
1600 }
1601 case "tool_progress": {
1602 const t = e.tool;
1603 if (!t?.id) return s;
1604 // Reserved sub-agent progress channels update the card's in-memory
1605 // preview; they never touch tool.output or the parent's live stream.
1606 if (isSubagentProgressName(t.name)) {
1607 return applySubagentProgress(s, t);
1608 }
1609 const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === t.id);
1610 if (idx < 0) return s;
1611 const next = [...s.items];
1612 const it = next[idx];
1613 if (it.kind === "tool") next[idx] = { ...it, output: (it.output ?? "") + (t.output ?? ""), verifying: it.verifying || (t.verifying && it.status === "running") };
1614 // Streaming output of a sub-agent's real tool refreshes its card.
1615 if (t.parentId) touchSubagentParent(next, t.parentId);
1616 return withRunningChecks({ ...s, items: next });
1617 }
1618 case "usage": {
1619 if (!countsTowardCurrentTurn(s)) return s;
1620 const updateContextGauge = updatesContextGauge(e.usage);
1621 // Only executor usage belongs to the foreground model stream. Planner,
1622 // subagent, and auxiliary usage still contributes to session totals and
1623 // usageSeq, but must not close or inflate the executor TPS interval.
1624 const settled = updateContextGauge ? endTurnModelActivity(s, Date.now(), true) : s;
1625 const hasRequestCompletion = (e.usage?.contextCompletionTokens ?? 0) > 0;
1626 const requestModelMs = updateContextGauge ? (settled.pendingRequestModelMs ?? 0) : 0;
1627 const requestTokens = updateContextGauge ? (hasRequestCompletion ? (e.usage?.contextCompletionTokens ?? 0) : (e.usage?.completionTokens ?? 0)) : 0;
1628 const lastRequestTps = updateContextGauge ? (requestTokens > 0 && requestModelMs >= 500 ? requestTokens / (requestModelMs / 1000) : null) : s.lastRequestTps;
1629 // Context* is the latest sampling attempt; other token fields are billable aggregates.
1630 let used = settled.context.used;
1631 if (e.usage && settled.context.window && updateContextGauge) used = (e.usage.contextPromptTokens ?? 0) > 0
1632 ? (e.usage.contextPromptTokens ?? 0)
1633 : (e.usage.promptTokens ?? 0);
1634 const turnTokens = settled.turnTokens + (e.usage?.completionTokens ?? 0);
1635 const turnOutputTokens = updateContextGauge
1636 ? settled.turnOutputTokens + (e.usage?.completionTokens ?? 0)
1637 : settled.turnOutputTokens;
1638 const turnOutputCharsAtUsage = updateContextGauge
1639 ? (settled.live?.text.length ?? 0) + (settled.live?.reasoning.length ?? 0)
1640 : settled.turnOutputCharsAtUsage;
1641 const turnOutputEstimated = updateContextGauge
1642 ? settled.turnOutputEstimated || Boolean(e.usage?.estimated)
1643 : settled.turnOutputEstimated;
1644 const usageTokens = usageTotalTokens(e.usage);
1645 const turnTotalTokens = settled.turnTotalTokens + usageTokens;
1646 const sessionTokens = settled.sessionTokens + usageTokens;
1647 const usageCost = e.usage?.cost ?? e.usage?.costUsd ?? 0;
1648 const turnCost = settled.turnCost + usageCost;
1649 const turnRateBand = mergeRateBand(settled.turnRateBand, e.usage?.costQuote?.rateBand);
1650 const sessionCost = settled.sessionCost + usageCost;
1651 const sessionCurrency = e.usage?.currency || settled.sessionCurrency || "¥";
1652 const usage = updateContextGauge ? e.usage : settled.usage;
1653 const turnUsage = mergeChatTurnUsage(settled.turnUsage, e.usage);
1654 // The completed round's usage now accounts for the streamed tool-call
1655 // arguments, so drop the live estimate rather than double-count it.
1656 return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnUsage, turnCost, turnRateBand, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1, lastRequestTps, pendingRequestModelMs: updateContextGauge ? undefined : settled.pendingRequestModelMs };
1657 }
1658 case "read_status":
1659 return applyReadStatusEvent(s, e);
1660 case "notice": {
1661 const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt);
1662 return e.code?.startsWith("stream_interrupted_") ? { ...next, streamInterruptNoticeShown: true } : next;
1663 }
1664 case "context_maintenance": {
1665 const m = e.maintenance;
1666 if (!m || m.status === "noop") return s;
1667 if (!isNewMaintenanceOperation(s.seenMaintenanceOps, m.operationId)) return s;
1668 const next = appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason);
1669 return { ...next, seenMaintenanceOps: rememberMaintenanceOperation(s.seenMaintenanceOps, m.operationId) };
1670 }
1671 case "phase":
1672 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
1673 case "compaction_started":
1674 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "compaction", id: `c${s.seq}`, pending: true, trigger: e.compaction?.trigger ?? "", messages: 0, summary: "", archive: "" }] };
1675 case "compaction_done": {
1676 const c = e.compaction;
1677 const idx = [...s.items].reverse().findIndex((it) => it.kind === "compaction" && it.pending);
1678 const at = idx < 0 ? -1 : s.items.length - 1 - idx;
1679 if (!c?.summary) {
1680 const items = at < 0 ? s.items : s.items.filter((_, i) => i !== at);
1681 return { ...s, running: s.turnActive ? s.running : false, items };
1682 }
1683 const filled: Item = { kind: "compaction", id: at < 0 ? `c${s.seq}` : (s.items[at] as Extract<Item, { kind: "compaction" }>).id, pending: false, trigger: c.trigger ?? "", messages: c.messages ?? 0, summary: c.summary, archive: c.archive ?? "" };
1684 const items = at < 0 ? [...s.items, filled] : s.items.map((it, i) => (i === at ? filled : it));
1685 return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items };
1686 }
1687 case "steer":
1688 if (isHostRecoveryGuidance(e.text ?? "")) return s;
1689 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `s${s.seq}`, level: "info", text: `${STEER_NOTICE_PREFIX}${e.text ?? ""}`, inboxItemId: e.itemId }] };
1690 case "approval_request": {
1691 if (s.cancelRequested) return s;
1692 const approval = e.approval ? { ...e.approval, turnId: e.turnId ?? e.approval.turnId, runtimeEpoch: e.runtimeEpoch ?? e.approval.runtimeEpoch } : undefined;
1693 const approvalKind: InteractionKind = approval?.kind === "recovery" || approval?.recovery
1694 ? "recovery" : approval?.tool === "exit_plan_mode" ? "plan" : "approval";
1695 // A delayed re-delivery of a prompt the user already answered locally
1696 // (clearApproval) must not resurrect it — no downstream snapshot is
1697 // guaranteed to ever reject it again (#6432 round 2).
1698 if (approval && (promptInstanceKeyForState(s, approval, approvalKind) === s.resolvedPromptKey || (!s.resolvedPromptKey && approval.id === s.resolvedPromptId))) return s;
1699 return beginPromptWait({
1700 ...s,
1701 activeTurnId: e.turnId ?? s.activeTurnId,
1702 approval,
1703 // A replay of the SAME prompt (post-answer delayed delivery, or the
1704 // #6429 re-arm after activation) keeps the original arrival time; only
1705 // a genuinely new prompt id re-anchors it (#6432 reverse race).
1706 promptArrivedAt: e.approval?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
1707 promptArrivedId: e.approval?.id,
1708 pendingPrompt: true,
1709 running: true,
1710 turnActive: true,
1711 cancellable: true,
1712 });
1713 }
1714 case "ask_request": {
1715 if (s.cancelRequested) return s;
1716 const ask = e.ask ? { ...e.ask, turnId: e.turnId ?? e.ask.turnId, runtimeEpoch: e.runtimeEpoch ?? e.ask.runtimeEpoch } : undefined;
1717 if (ask && (promptInstanceKeyForState(s, ask, "ask") === s.resolvedPromptKey || (!s.resolvedPromptKey && ask.id === s.resolvedPromptId))) return s;
1718 return beginPromptWait({
1719 ...s,
1720 activeTurnId: e.turnId ?? s.activeTurnId,
1721 ask,
1722 promptArrivedAt: e.ask?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
1723 promptArrivedId: e.ask?.id,
1724 pendingPrompt: true,
1725 running: true,
1726 turnActive: true,
1727 cancellable: true,
1728 });
1729 }
1730 case "mcp_interaction": {
1731 if (s.cancelRequested) return s;
1732 const interaction = e.mcpInteraction ? { ...e.mcpInteraction, turnId: e.turnId ?? e.mcpInteraction.turnId, runtimeEpoch: e.runtimeEpoch ?? e.mcpInteraction.runtimeEpoch } : undefined;
1733 if (interaction && (promptInstanceKeyForState(s, interaction, "mcp") === s.resolvedPromptKey || (!s.resolvedPromptKey && interaction.id === s.resolvedPromptId))) return s;
1734 return beginPromptWait({
1735 ...s,
1736 activeTurnId: e.turnId ?? s.activeTurnId,
1737 mcpInteraction: interaction,
1738 promptArrivedAt: e.mcpInteraction?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
1739 promptArrivedId: e.mcpInteraction?.id,
1740 pendingPrompt: true,
1741 running: true,
1742 turnActive: true,
1743 cancellable: true,
1744 });
1745 }
1746 case "guardian_assessment": {
1747 if (!e.guardian) return s;
1748 const level = e.guardian.outcome === "deny" ? "warn" : "info";
1749 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `g${s.seq}`, level, text: formatGuardianAssessmentNotice(e.guardian) }] };
1750 }
1751 case "turn_done": {
1752 if (e.turnId && s.activeTurnId && e.turnId !== s.activeTurnId) return s;
1753 s = checkpointLocalSubmission(s, e.submissionId, e.checkpointTurn);
1754 s = { ...s, readStatuses: undefined, readStatusClosed: true };
1755 const now = Date.now();
1756 s = snapshotCompletedTurnTelemetry(s, now);
1757 const workDurationMs = s.turnDoneAt ? Math.max(1, s.turnDoneAt - s.turnStartAt - (s.lastTurnWaitAccumMs ?? 0)) : undefined;
1758 const turnDurationMs = s.turnDoneAt && s.turnStartAt > 0 ? Math.max(1, s.turnDoneAt - s.turnStartAt) : undefined;
1759 const tokensPerSecond = s.lastTurnOutputTokens > 0 && s.lastTurnModelMs > 0
1760 ? s.lastTurnOutputTokens / (s.lastTurnModelMs / 1000)
1761 : undefined;
1762 const settleItems = s.items.map((it) => {
1763 if (it.kind === "assistant") {
1764 const completedLive = s.live?.id === it.id ? completeLiveReasoning(s.live, now) : undefined;
1765 return {
1766 ...it,
1767 text: completedLive?.text ?? it.text,
1768 reasoning: completedLive?.reasoning ?? it.reasoning,
1769 streaming: false,
1770 reasoningComplete: completedLive?.reasoningComplete ?? it.reasoningComplete,
1771 reasoningDurationMs: liveReasoningDurationMs(completedLive) ?? it.reasoningDurationMs,
1772 };
1773 }
1774 if (it.kind === "tool" && it.status === "running") return { ...it, status: "stopped" as const, resultMissing: false };
1775 return it;
1776 });
1777 const completedItems = s.transcriptProtocol === 2 ? settleItems : removeEmptyAssistantItems(settleItems);
1778 let lastAssistantIndex = -1;
1779 for (let i = completedItems.length - 1; s.transcriptProtocol !== 2 && i >= 0; i -= 1) {
1780 if (completedItems[i].kind === "user") break;
1781 if (completedItems[i].kind === "assistant") { lastAssistantIndex = i; break; }
1782 }
1783 const finalized = completedItems.map((it, index) =>
1784 it.kind === "assistant" && index === lastAssistantIndex
1785 ? {
1786 ...it,
1787 workDurationMs: Math.max(it.workDurationMs ?? 0, workDurationMs ?? 0) || undefined,
1788 turnDurationMs: Math.max(it.turnDurationMs ?? 0, turnDurationMs ?? 0) || undefined,
1789 turnUsage: s.turnUsage,
1790 tokensPerSecond,
1791 createdAt: it.createdAt ?? now,
1792 }
1793 : it,
1794 );
1795 let items: Item[] = finalized;
1796 if (s.deliveryRecoveryActive && !e.err) {
1797 items = finalized.filter((item) => item.kind !== "notice" || item.variant !== "delivery");
1798 }
1799 if (e.outcome === "incomplete_read") {
1800 items = upsertReadPause(items, e.readPause, `read-pause-${e.turnId ?? s.seq}`);
1801 } else if (e.outcome === "final_readiness") {
1802 const previous = items.map((item) => item.kind === "notice" && item.variant === "delivery"
1803 ? { ...item, action: undefined }
1804 : item);
1805 items = [...previous, {
1806 kind: "notice",
1807 id: `e${s.seq}`,
1808 level: "info",
1809 variant: "delivery",
1810 title: t("notice.deliveryIncompleteTitle"),
1811 text: t("notice.deliveryIncompleteBody"),
1812 detail: deliveryReadinessDetail(e.readiness, e.err),
1813 action: "continue_delivery",
1814 missing: readinessMissingIds(e.readiness),
1815 }];
1816 } else if (e.outcome === "recovery_paused") {
1817 // Informational pause — not a send failure. Composer is immediately free.
1818 items = [...finalized, {
1819 kind: "notice",
1820 id: `e${s.seq}`,
1821 level: "info",
1822 title: t("notice.recoveryPausedTitle"),
1823 text: t("notice.recoveryPausedBody"),
1824 }];
1825 } else if (e.outcome === "completion_uncertain") {
1826 items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "info", title: t("notice.completionUncertainTitle"), text: t("notice.completionUncertainBody") }];
1827 } else if (e.status === "interrupted" || e.status === "recovery_required") {
1828 const interruptItems: Item[] = [{
1829 kind: "notice",
1830 id: `e${s.seq}`,
1831 level: "info",
1832 text: t("notice.cancelledTurnDisplay"),
1833 }];
1834 // A stop during a broken provider stream would otherwise look like an
1835 // unexplained silence; surface the last known failure reason (#9560).
1836 if (s.lastStreamInterrupt?.reason) {
1837 interruptItems.push({
1838 kind: "notice",
1839 id: `e${s.seq + 1}`,
1840 level: "warn",
1841 text: t("notice.streamInterruptReason", { reason: streamInterruptReasonText(s.lastStreamInterrupt.reason) }),
1842 });
1843 }
1844 items = [...finalized, ...interruptItems];
1845 } else if (e.err && !s.streamInterruptNoticeShown) {
1846 items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail }];
1847 }
1848 if (e.protocolRecovery?.id && e.status !== "interrupted" && !s.cancelRequested) {
1849 items = items.map(item => item.kind==="notice" && item.action==="recover_context" ? {...item,action:undefined} : item);
1850 items.push({kind:"notice",id:`e${s.seq}-protocol`,level:"info",code:"protocol_recovery",text:t("notice.protocolRecoveryBody"),action:"recover_context",recoveryId:e.protocolRecovery.id});
1851 }
1852 // Plan approval can arrive before turn_done on some bridge event paths.
1853 // Keep that gate visible instead of clearing the only UI that can answer it.
1854 const keepPlanApproval = s.transcriptProtocol !== 2 && s.approval?.tool === "exit_plan_mode";
1855 let next: State = {
1856 ...s,
1857 items: applyTurnCheckpoint(items, e.submissionId, e.checkpointTurn),
1858 live: undefined,
1859 streamAttemptJournal: undefined,
1860 running: keepPlanApproval,
1861 turnActive: keepPlanApproval,
1862 turnPhase: keepPlanApproval ? s.turnPhase : undefined,
1863 pendingPrompt: keepPlanApproval,
1864 cancelRequested: false,
1865 cancellable: keepPlanApproval,
1866 currentAssistant: undefined,
1867 assistantSegmentOrdinal: 0,
1868 activeTurnId: undefined,
1869 approval: keepPlanApproval ? s.approval : undefined,
1870 ask: undefined,
1871 mcpInteraction: undefined,
1872 deliveryRecoveryActive: false,
1873 turnLifecycleObservedAt: promptEventClock(),
1874 seq: s.seq + Math.max(items.length - finalized.length, 1),
1875 lastStreamInterrupt: undefined,
1876 streamInterruptNoticeShown: undefined,
1877 };
1878 // Close user-wait unless the plan approval gate remains open.
1879 next = keepPlanApproval ? beginPromptWait(next, now) : endPromptWait(next, now);
1880 if (e.receipt || s.completionSummary) {
1881 const summary = mergeTurnResult(s.completionSummary, e.receipt, e.turnId, e.checkpointTurn);
1882 return withTurnResult(next, { ...summary, checking: false });
1883 }
1884 return next;
1885 }
1886 default: return s;
1887 }
1888 }
1889
1890 export function reducer(s: State, a: Action): State {
1891 const next = reduceState(s, a);
1892 return next.items !== s.items ? settleLocalSubmissions(next, next.items) : next;
1893 }
1894
1895 function reduceState(s: State, a: Action): State {
1896 switch (a.type) {
1897 case "submission_verified": {
1898 const local = s.localSubmissions[a.submissionId];
1899 if (!local || local.messageId !== a.messageId) return s;
1900 return settleLocalSubmissions(s, s.items, [{ messageId: a.messageId, submissionId: a.submissionId }]);
1901 }
1902 case "transcript_connection": return s.transcriptConnection === a.status && s.transcriptConnectionError === a.error
1903 ? s : { ...s, transcriptConnection: a.status, transcriptConnectionError: a.error };
1904 case "transcript_runtime": {
1905 const runtime = a.runtime;
1906 const active = runtime.status === "queued" || runtime.status === "in_progress" || runtime.status === "waiting_user" || runtime.status === "cancelling";
1907 const authoritative = Boolean(runtime.status) && (!s.pendingSubmissionId || s.pendingSubmissionId === runtime.submissionId || runtime.turnId === s.activeTurnId);
1908 const finalId = runtime.finalMessageId ? `m:${runtime.finalMessageId}` : undefined;
1909 const usage = runtime.turnUsage ? { ...runtime.turnUsage,
1910 cacheReadTokens: runtime.turnUsage.cacheReadTokens ?? undefined,
1911 reasoningTokens: runtime.turnUsage.reasoningTokens ?? undefined } : undefined;
1912 return { ...s, ...(authoritative ? { running: active, turnActive: active, cancellable: active,
1913 cancelRequested: runtime.status === "cancelling", activeTurnId: active ? runtime.turnId : undefined,
1914 turnPhase: active ? runtime.phase : undefined } : {}), transcriptRuntime: runtime, items: s.items.map(item =>
1915 item.kind === "assistant" && item.id === finalId && runtime.durationMs
1916 ? { ...item, turnFinal: true, turnDurationMs: runtime.durationMs, turnUsage: usage,
1917 samplingCount: runtime.samplingCount || runtime.toolCount ? runtime.samplingCount : item.samplingCount,
1918 toolCount: runtime.samplingCount || runtime.toolCount ? runtime.toolCount : item.toolCount } : item) };
1919 }
1920 case "transcript_v2_snapshot": {
1921 const next = transcriptSnapshotState(s, a.snapshot, historyMessagesToItems, (state, event) => applyEvent(state, event, a.remote), promptEventClock(), a.projection.items);
1922 return { ...next, transcriptProtocol: 2, historyHasOlder: a.projection.hasOlder, historyHasNewer: a.projection.hasNewer,
1923 historyRevision: a.projection.revision, historyDigest: a.projection.digest };
1924 }
1925 case "transcript_records": return installTranscriptRecords(s, a);
1926 case "transcript_snapshot": return transcriptSnapshotState(s, a.snapshot, historyMessagesToItems, (state, event) => applyEvent(state, event, a.remote), promptEventClock());
1927 case "transcript_page": return transcriptPageState(s, a.snapshot, historyMessagesToItems);
1928 case "user": return startLocalSubmission(s, a, promptEventClock());
1929 case "unsend": {
1930 const cleared = endPromptWait(updateLocalSubmission({
1931 ...s,
1932 pendingUser: undefined,
1933 pendingSubmissionId: undefined,
1934 discardTurn: true,
1935 running: false,
1936 pendingPrompt: false,
1937 cancelRequested: true,
1938 cancellable: false,
1939 approval: undefined,
1940 ask: undefined,
1941 mcpInteraction: undefined,
1942 promptArrivedAt: undefined,
1943 promptArrivedId: undefined,
1944 live: undefined,
1945 turnLifecycleObservedAt: promptEventClock(),
1946 }, s.pendingSubmissionId, { status: "unknown" }));
1947 return cleared;
1948 }
1949 case "cancel_requested": {
1950 return endPromptWait({
1951 ...s,
1952 readStatuses: undefined,
1953 readStatusClosed: true,
1954 pendingPrompt: false,
1955 cancelRequested: true,
1956 approval: undefined,
1957 ask: undefined,
1958 mcpInteraction: undefined,
1959 promptArrivedAt: undefined,
1960 promptArrivedId: undefined,
1961 cancellable: s.running || s.turnActive,
1962 });
1963 }
1964 case "send_confirmed": return confirmPendingUser(s, a.submissionId);
1965 case "management_confirmed": return reduceManagementConfirmation(s, a.submissionId, promptEventClock());
1966 case "turn_admitted":
1967 return s.localSubmissions[a.submissionId] && a.turnId
1968 ? updateLocalSubmission(s.pendingSubmissionId === a.submissionId ? { ...s, activeTurnId: a.turnId } : s, a.submissionId,
1969 { turnId: a.turnId, status: s.localSubmissions[a.submissionId].status === "failed" ? "failed" : "accepted" })
1970 : s;
1971 case "turn_submit_rejected":
1972 case "send_failed": return reduceSubmitFailure(s, a.submissionId, a.error, a.type === "turn_submit_rejected", promptEventClock());
1973 case "turn_submit_unknown": {
1974 const local = s.localSubmissions[a.submissionId];
1975 if (!local || local.settled || local.status === "failed") return s;
1976 const ownsRequest = s.pendingSubmissionId === a.submissionId;
1977 const ownsTurn = !s.pendingSubmissionId && s.activeTurnId && local.turnId === s.activeTurnId;
1978 return updateLocalSubmission(ownsRequest || ownsTurn ? {
1979 ...s, transcriptConnection: "disconnected", transcriptConnectionError: a.error,
1980 } : s, a.submissionId, { status: "unknown" });
1981 }
1982 case "turn_interrupted": {
1983 return withRemoteTurnInterrupted(s);
1984 }
1985 case "backend_status": {
1986 if (s.transcriptProtocol) {
1987 if (a.runtimeEpoch && s.runtimeStatusEpoch && a.runtimeEpoch !== s.runtimeStatusEpoch) return s;
1988 const backgroundJobs = Math.max(0, a.backgroundJobs ?? s.backgroundJobs ?? 0);
1989 return backgroundJobs === s.backgroundJobs ? s : { ...s, backgroundJobs };
1990 }
1991 const incomingEpoch = a.runtimeEpoch?.trim();
1992 const storedEpoch = s.runtimeStatusEpoch?.trim();
1993 if (runtimeStatusSnapshotIsStale(s, a)) return s;
1994 // Reject snapshots that began before newer prompt or turn lifecycle evidence.
1995 if (runtimeSnapshotPredatesPrompt(s, a.snapshotAt) || snapshotPredatesTurnLifecycle(s.turnLifecycleObservedAt, a.snapshotAt)) return s;
1996 const runtimeStatus = { runtimeStatusEpoch: incomingEpoch ?? storedEpoch, runtimeStatusSeq: a.turnEventSeq ?? s.runtimeStatusSeq, runtimeStatusSnapshotAt: a.snapshotAt };
1997 const pendingPrompt = Boolean(a.pendingPrompt);
1998 const backgroundJobs = Math.max(0, a.backgroundJobs ?? s.backgroundJobs ?? 0);
1999 const cancelRequested = Boolean(a.cancelRequested);
2000 const foregroundRunning = foregroundRunningFromRuntimeMeta({ running: a.running, pendingPrompt, backgroundJobs, cancellable: a.cancellable });
2001 const turnStartedAt = foregroundRunning ? resolveSnapshotTurnStartedAt(s.running || s.turnActive ? s.turnStartAt : 0, a.turnStartedAt) : s.turnStartAt;
2002 const activeTurnId = foregroundRunning ? a.turnId ?? s.activeTurnId : undefined;
2003 // A retry event is newer evidence of foreground activity than an idle
2004 // snapshot whose fetch started earlier. Keep the turn cancellable until
2005 // a snapshot started after the retry confirms that it is actually idle.
2006 if (!foregroundRunning && runtimeSnapshotPredatesRetry(s, a.snapshotAt)) return s;
2007 const cancellable = foregroundRunning;
2008 const clearsRetry = !foregroundRunning && s.retry !== undefined;
2009 if (
2010 foregroundRunning === s.running &&
2011 pendingPrompt === s.pendingPrompt &&
2012 backgroundJobs === s.backgroundJobs &&
2013 cancelRequested === s.cancelRequested &&
2014 cancellable === s.cancellable &&
2015 turnStartedAt === s.turnStartAt &&
2016 activeTurnId === s.activeTurnId &&
2017 !clearsRetry
2018 ) return incomingEpoch || a.turnEventSeq !== undefined
2019 ? { ...s, ...runtimeStatus } : s;
2020 if (foregroundRunning) {
2021 return {
2022 ...s,
2023 ...(s.turnDoneAt > 0 && turnStartedAt !== s.turnStartAt ? resetTurnTiming(turnStartedAt) : {}),
2024 ...runtimeStatus,
2025 running: true,
2026 turnActive: true,
2027 pendingPrompt,
2028 backgroundJobs,
2029 cancelRequested,
2030 cancellable,
2031 activeTurnId,
2032 turnStartAt: turnStartedAt,
2033 };
2034 }
2035 const telemetry = snapshotCompletedTurnTelemetry(s);
2036 const finalized = removeEmptyAssistantItems(telemetry.items.map((it) => {
2037 if (it.kind === "assistant" && telemetry.live && it.id === telemetry.live.id) return { ...it, text: telemetry.live.text, reasoning: telemetry.live.reasoning, streaming: false };
2038 if (it.kind === "assistant" && it.streaming) return { ...it, streaming: false };
2039 if (it.kind === "tool" && it.status === "running") return { ...it, status: "stopped" as const };
2040 return it;
2041 }));
2042 return endPromptWait({
2043 ...telemetry,
2044 ...runtimeStatus,
2045 items: finalized,
2046 running: false,
2047 turnActive: false,
2048 pendingPrompt,
2049 backgroundJobs,
2050 cancelRequested,
2051 cancellable,
2052 activeTurnId: undefined,
2053 live: undefined,
2054 currentAssistant: undefined,
2055 assistantSegmentOrdinal: 0,
2056 streamAttemptJournal: undefined,
2057 approval: undefined,
2058 ask: undefined,
2059 mcpInteraction: undefined,
2060 retry: undefined,
2061 });
2062 }
2063 case "meta": {
2064 const meta = a.meta.sessionPath === undefined && s.meta?.sessionPath !== undefined ? { ...a.meta, sessionPath: s.meta.sessionPath } : a.meta;
2065 const runtimeStateSnapshot = meta.runtimeStateSnapshot
2066 ? acceptSessionRuntimeSnapshot(s.runtimeStateSnapshot, meta.runtimeStateSnapshot, true)
2067 : s.runtimeStateSnapshot;
2068 const acceptedMeta = runtimeStateSnapshot?.todos !== undefined
2069 ? { ...meta, canonicalTodos: runtimeStateSnapshot.todos }
2070 : meta;
2071 return sameMeta(s.meta, acceptedMeta) && runtimeStateSnapshot === s.runtimeStateSnapshot
2072 ? s
2073 : { ...s, meta: acceptedMeta, runtimeStateSnapshot };
2074 }
2075 case "optimistic_meta": return sameMeta(s.meta, a.meta) ? s : { ...s, meta: a.meta, hydrateError: undefined };
2076 case "runtime_snapshot": {
2077 const runtimeStateSnapshot = acceptSessionRuntimeSnapshot(s.runtimeStateSnapshot, a.snapshot);
2078 if (runtimeStateSnapshot === s.runtimeStateSnapshot) return s;
2079 const meta = runtimeStateSnapshot.todos !== undefined && s.meta
2080 ? { ...s.meta, runtimeStateSnapshot, canonicalTodos: runtimeStateSnapshot.todos }
2081 : s.meta;
2082 return { ...s, meta, runtimeStateSnapshot };
2083 }
2084 case "context": {
2085 const sessionTokens = typeof a.context.sessionTokens === "number"
2086 ? Math.max(0, a.context.sessionTokens)
2087 : s.sessionTokens;
2088 const sessionCost = typeof a.context.sessionCost === "number" && a.context.sessionCost > 0
2089 ? a.context.sessionCost
2090 : s.sessionCost;
2091 const sessionCurrency = a.context.sessionCurrency || s.sessionCurrency;
2092 // Mid-turn snapshot refreshes can race a rebuilt executor whose
2093 // LastUsage is still nil: the backend then reports used=0 for a session
2094 // that visibly holds tokens, and the gauge collapses to "0/1M" until the
2095 // next executor usage arrives. Keep the last known fill while a turn is
2096 // live; genuine resets flow through the "reset" action or land when the
2097 // session is idle.
2098 const context =
2099 a.context.used === 0 && s.context.used > 0 && (s.running || s.turnActive) && a.context.window === s.context.window
2100 ? { ...a.context, used: s.context.used }
2101 : a.context;
2102 return { ...s, context, sessionTokens, sessionCost, sessionCurrency };
2103 }
2104 case "balance": return { ...s, balance: a.balance };
2105 case "effort": return { ...s, effort: a.effort };
2106 case "jobs": return { ...s, jobs: a.jobs };
2107 case "checkpoints": return { ...s, checkpoints: a.checkpoints };
2108 case "fork_targets": case "fork_creating": return { ...s, ...reduceForkTurn(s, a) };
2109 case "hydrate_start": return {
2110 ...s,
2111 hydrating: true,
2112 hydrateReason: a.reason,
2113 hydrateError: undefined,
2114 hydrateHistoryLoaded: false,
2115 hydratePlaceholderItems: a.placeholderItems?.length ? a.placeholderItems : undefined,
2116 };
2117 case "hydrate_done": return s.hydrating || s.hydrateReason || s.hydrateError || s.hydrateHistoryLoaded || s.hydratePlaceholderItems
2118 ? { ...s, hydrating: false, hydrateReason: undefined, hydrateError: undefined, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined }
2119 : s;
2120 case "hydrate_error": return applyHydrateErrorState(s, a.reason, a.error);
2121 case "backend_activation_start": {
2122 // Backend metadata makes a cached background prompt safe to preserve.
2123 // Otherwise retain the compatibility reset for stale/untagged events.
2124 const preservePrompt = Boolean(a.backendPendingPrompt && (s.approval || s.ask));
2125 return {
2126 ...s,
2127 backendActivationPending: true,
2128 pendingPrompt: preservePrompt,
2129 approval: preservePrompt ? s.approval : undefined,
2130 ask: preservePrompt ? s.ask : undefined,
2131 // A confirmed cached prompt keeps its original freshness boundary.
2132 promptArrivedAt: preservePrompt ? s.promptArrivedAt : undefined,
2133 promptArrivedId: preservePrompt ? s.promptArrivedId : undefined,
2134 running: preservePrompt,
2135 turnActive: preservePrompt,
2136 cancellable: preservePrompt,
2137 };
2138 }
2139 case "backend_activation_done": return s.backendActivationPending ? { ...s, backendActivationPending: false } : s;
2140 case "message_action_start": return { ...s, messageAction: a.action };
2141 case "message_action_done": return { ...s, messageAction: undefined };
2142 case "history": {
2143 const { items, seq } = historyMessagesToItems(a.messages, "h", s.seq);
2144 // Remote cards have no local ToolResultForTab fallback; retain expansion data.
2145 return { ...s, items: a.remote ? items : compactArchivedToolItems(items), historyPrefixCount: items.length, pendingSubmissionId: undefined, seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyEndTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyHasNewer: false, historyOlderLoading: false, historyOlderError: undefined, historyNewerLoading: false, historyNewerError: undefined, historyRevision: undefined, historyDigest: undefined, historyMutation: { seq: s.historyMutation.seq + 1, kind: "replace" } };
2146 }
2147 case "history_page": {
2148 if (historyRevisionIsOlder(s.historyRevision, a.page.revision)) return s;
2149 const { items, seq, firstTurn } = historyPageItems(a.page);
2150 const nextItems = a.mode === "prepend" ? [...items, ...s.items] : items;
2151 return {
2152 ...s,
2153 items: compactArchivedToolItems(nextItems),
2154 historyPrefixCount: a.mode === "prepend" ? items.length + s.historyPrefixCount : items.length,
2155 pendingSubmissionId: a.mode === "replace" ? undefined : s.pendingSubmissionId,
2156 seq: Math.max(s.seq, seq),
2157 hydrateHistoryLoaded: true,
2158 hydratePlaceholderItems: undefined,
2159 historyStartTurn: firstTurn,
2160 historyEndTurn: a.page.endTurn,
2161 historyTotalTurns: a.page.totalTurns,
2162 historyHasOlder: a.page.hasOlder,
2163 historyHasNewer: false,
2164 historyOlderLoading: false,
2165 historyOlderError: undefined,
2166 historyNewerLoading: false,
2167 historyNewerError: undefined,
2168 historyRevision: a.page.revision,
2169 historyDigest: a.page.digest,
2170 historyMutation: { seq: s.historyMutation.seq + 1, kind: a.mode },
2171 };
2172 }
2173 case "history_older_start": return s.historyOlderLoading && !s.historyOlderError ? s : { ...s, historyOlderLoading: true, historyOlderError: undefined };
2174 case "history_older_error": return { ...s, historyOlderLoading: false, historyOlderError: a.error };
2175 case "history_newer_start": return s.historyNewerLoading && !s.historyNewerError ? s : { ...s, historyNewerLoading: true, historyNewerError: undefined };
2176 case "history_newer_error": return { ...s, historyNewerLoading: false, historyNewerError: a.error };
2177 case "history_replace":
2178 case "history_rebase":
2179 case "history_prepend":
2180 case "history_append":
2181 {
2182 const next = reduceHistoryWindowState(s, a);
2183 if (next.transcriptProtocol !== 2) return next;
2184 if (next.historyHasNewer) return { ...next, offscreenItems: s.offscreenItems ?? s.items.filter(item => item.id === s.live?.id) };
2185 const active = s.offscreenItems?.find(item => item.id === s.live?.id);
2186 const items = active ? next.items.some(item => item.id === active.id)
2187 ? next.items.map(item => item.id === active.id ? active : item)
2188 : [...next.items, active] : next.items;
2189 return { ...next, items, offscreenItems: undefined };
2190 }
2191 // Ref-resolved full content landed for history items already on screen:
2192 // patch by stable item id so the live tail and untouched items keep their
2193 // identity.
2194 case "history_items_patch": {
2195 let changed = false;
2196 const next = s.items.map((item) => {
2197 const patch = a.patches[item.id];
2198 if (!patch) return item;
2199 if (a.expected?.[item.id] && a.expected[item.id] !== item) return item;
2200 changed = true;
2201 if (item.kind === "assistant" && patch.kind === "assistant" && item.turnFinal) {
2202 return { ...patch, turnFinal: true, turnDurationMs: item.turnDurationMs, turnUsage: item.turnUsage,
2203 samplingCount: item.samplingCount, toolCount: item.toolCount };
2204 }
2205 return patch;
2206 });
2207 return changed ? { ...s, items: next, historyLayoutRevision: s.historyLayoutRevision + 1, historyMutation: { seq: s.historyMutation.seq + 1, kind: "patch" } } : s;
2208 }
2209 case "local_notice": return { ...s, running: a.preserveRuntime || s.transcriptProtocol === 2 ? s.running : false, turnActive: a.preserveRuntime || s.transcriptProtocol === 2 ? s.turnActive : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, local: true, level: a.level, text: a.text }] };
2210 case "clearApproval": {
2211 if (a.target && !stateOwnsInteraction(s, a.target)) return s;
2212 const next = {
2213 ...s,
2214 approval: undefined,
2215 pendingPrompt: Boolean(s.ask || s.mcpInteraction),
2216 resolvedPromptId: s.approval?.id ?? s.resolvedPromptId,
2217 resolvedPromptKey: a.target?.instanceKey ?? s.resolvedPromptKey,
2218 };
2219 return endPromptWaitIfIdle(next);
2220 }
2221 case "clearAsk": {
2222 const next = {
2223 ...s,
2224 ask: undefined,
2225 mcpInteraction: undefined,
2226 pendingPrompt: Boolean(s.approval),
2227 resolvedPromptId: s.ask?.id ?? s.mcpInteraction?.id ?? s.resolvedPromptId,
2228 };
2229 return endPromptWaitIfIdle(next);
2230 }
2231 case "expire_prompt": {
2232 if (s.promptEpoch !== a.epoch) return s;
2233 if (!stateOwnsInteraction(s, a.target)) return s;
2234 if (a.target.kind === "approval" || a.target.kind === "plan" || a.target.kind === "recovery") {
2235 return endPromptWaitIfIdle({ ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey });
2236 }
2237 if (a.target.kind === "ask") {
2238 return endPromptWaitIfIdle({ ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey });
2239 }
2240 return endPromptWaitIfIdle({ ...s, mcpInteraction: undefined, pendingPrompt: Boolean(s.approval || s.ask), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey });
2241 }
2242 case "clearExtensionForm": {
2243 if (!s.extensionForm) return s;
2244 if (a.identity && (s.extensionForm.pluginId !== a.identity.pluginId || s.extensionForm.surfaceId !== a.identity.surfaceId ||
2245 s.extensionForm.formInstanceId !== a.identity.formInstanceId)) return s;
2246 return { ...s, extensionForm: undefined };
2247 }
2248 case "extension_notifications_drained": return s.extensionNotifications.length > 0 ? { ...s, extensionNotifications: [] } : s;
2249 // A tool-approval posture switch auto-allowed exactly these prompt ids on
2250 // the backend. Hide + tombstone the visible approval only when it is one
2251 // of them; anything else (plan/memory/sandbox-escape, ask-rule approvals
2252 // under auto) is still genuinely pending there and must stay visible —
2253 // tombstoning it would filter every future replay and strand the turn. The
2254 // drain result must also belong to this controller's prompt-id epoch.
2255 case "approval_drained": {
2256 if (s.promptEpoch !== a.epoch || !s.approval || !a.ids.includes(s.approval.id)) return s;
2257 const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: s.approval.id };
2258 return endPromptWaitIfIdle(next);
2259 }
2260 case "ask_submit_succeeded": {
2261 if (s.promptEpoch !== a.epoch || !stateOwnsInteraction(s, a.target)) return s;
2262 const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.target.promptId, resolvedPromptKey: a.target.instanceKey };
2263 return endPromptWaitIfIdle(next);
2264 }
2265 // The optimistic clearApproval/clearAsk tombstone was wrong: the backend
2266 // call that was supposed to actually resolve this id failed, so the
2267 // prompt is still genuinely pending there. Undo the tombstone so the next
2268 // replay (proactively requested by the caller) can re-arm it instead of
2269 // being silently swallowed forever. Only for the epoch the RPC was issued
2270 // in: after a controller rebuild the same numeric id names a DIFFERENT
2271 // prompt, and a late failure from the old controller must not erase the
2272 // new controller's tombstone.
2273 case "submit_prompt_failed":
2274 return s.resolvedPromptKey === a.target.instanceKey && s.promptEpoch === a.epoch
2275 ? { ...s, resolvedPromptId: undefined, resolvedPromptKey: undefined }
2276 : s;
2277 // A controller rebuild (model/effort/token-mode switch) replaces the
2278 // backend controller in place and its approval/ask ids restart from "1"
2279 // (per-controller counters, see sound.ts). Any id-anchored bookkeeping
2280 // from the OLD controller is meaningless for the new one and must be
2281 // dropped, or a genuinely new prompt reusing an old id would be misread
2282 // as a stale replay of an already-answered prompt and silently ignored.
2283 case "controller_rebuilt":
2284 // A rebuild restarts the runtime's extension sidecars too, so extension
2285 // surface state (and the per-surface generation fence) from the old
2286 // runtime is meaningless for the new one and is dropped with the rest of
2287 // the id-anchored bookkeeping.
2288 return {
2289 ...s,
2290 localSubmissions: Object.fromEntries(Object.entries(s.localSubmissions).map(([submissionId, submission]) => [
2291 submissionId,
2292 { ...submission, status: submission.status === "sending" ? "unknown" as const : submission.status, settled: true },
2293 ])),
2294 promptEpoch: s.promptEpoch + 1,
2295 pendingSubmissionId: undefined,
2296 resolvedPromptId: undefined,
2297 resolvedPromptKey: undefined,
2298 promptArrivedId: undefined,
2299 promptArrivedAt: undefined,
2300 extensionStatuses: {},
2301 extensionForm: undefined,
2302 extensionNotifications: [],
2303 extensionGenerations: {},
2304 };
2305 case "reset": return { ...initialState, meta: metaWithoutCanonicalTodos(s.meta), context: { used: 0, window: s.context.window, sessionTokens: 0, compactRatio: s.context.compactRatio }, balance: s.balance, effort: s.effort, jobs: s.jobs, hydrating: s.hydrating, hydrateReason: s.hydrateReason, hydrateError: s.hydrateError, hydrateHistoryLoaded: s.hydrateHistoryLoaded, hydratePlaceholderItems: s.hydratePlaceholderItems, backendActivationPending: s.backendActivationPending, sessionGen: s.sessionGen + 1, promptEpoch: s.promptEpoch + 1 };
2306 case "context_panel_refresh": return { ...s, contextPanelSeq: s.contextPanelSeq + 1 };
2307 case "event": {
2308 if (s.transcriptProtocol === 2 && s.historyHasNewer) {
2309 const next = reducer({ ...s, historyHasNewer: false, items: s.offscreenItems ?? [] }, a);
2310 return settleLocalSubmissions({ ...next, items: s.items, visibleSubmissionHandoffs: s.visibleSubmissionHandoffs, historyHasNewer: true, offscreenItems: next.items.slice(-96) }, s.items);
2311 }
2312 let next = applyEvent(s, a.e, a.remote);
2313 if (a.e.turnId && next.items !== s.items) {
2314 const prior = new Set(s.items);
2315 next = { ...next, items: next.items.map(item => prior.has(item) || item.turnId ? item : { ...item, turnId: a.e.turnId }) };
2316 }
2317 if (a.e.messageId && a.e.tool?.id && next.items !== s.items) {
2318 const toolId = a.e.tool.id;
2319 const prior = s.items.find((item) => item.kind === "tool" && item.id === toolId);
2320 next = { ...next, items: next.items.map((item) => item.kind === "tool" && item.id === toolId && item !== prior
2321 ? { ...item, messageId: a.e.messageId } : item) };
2322 }
2323 return next.items.length > s.items.length
2324 ? { ...next, historyMutation: { seq: s.historyMutation.seq + 1, kind: "append" } }
2325 : next;
2326 }
2327 case "stream_batch": {
2328 if (s.transcriptProtocol === 2 && s.historyHasNewer) {
2329 const next = applyStreamBatch({ ...s, items: s.offscreenItems ?? [] }, a.segments);
2330 return { ...next, items: s.items, offscreenItems: next.items.slice(-96) };
2331 }
2332 const next = applyStreamBatch(s, a.segments);
2333 return next.items.length > s.items.length
2334 ? { ...next, historyMutation: { seq: s.historyMutation.seq + 1, kind: "append" } }
2335 : next;
2336 }
2337 default: return s;
2338 }
2339 }
2340
2341 // ---- per-tab state map ----
2342
2343 type TabStates = Map<string, State>;
2344
2345 function getOrCreateState(states: TabStates, tabId: string): State {
2346 if (!states.has(tabId)) states.set(tabId, { ...initialState });
2347 return states.get(tabId)!;
2348 }
2349
2350 function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): State {
2351 const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail, code, decisionReceipt);
2352 return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
2353 }
2354
2355 export { replayPendingPromptsForActiveTab } from "./promptReplay";
2356
2357 export function useController() {
2358 const followers = useRef(new Map<string, TranscriptSessionFollower>());
2359 const statesRef = useRef<TabStates>(getTranscriptStore().states);
2360 const liveListenersByTabRef = useRef(new Map<string, Set<() => void>>());
2361 const balanceRefreshSeqByTab = useRef(new Map<string, number>());
2362 const modelSwitchSeqByTab = useRef(new Map<string, number>());
2363 const modelSwitchSuccessVersionByTab = useRef(new Map<string, number>());
2364 const modelSwitchQueueByTab = useRef(new Map<string, ModelSwitchQueueState>());
2365 const lastTurnActivityAtByTab = useRef(new Map<string, number>());
2366 const runtimeEpochByTabRef = useRef(new Map<string, string>());
2367 const listedSessionIdentityByTabRef = useRef(new Map<string, TabMeta>());
2368 const appliedComposerProfileByTabRef = useRef(new Map<string, string>());
2369 const composerProfileInFlightByTabRef = useRef(new Map<string, { key: string; promise: Promise<boolean> }>());
2370 const composerProfileQueueByTabRef = useRef(new Map<string, Promise<void>>());
2371 const composerProfileLifecycleByTabRef = useRef(new Map<string, number>());
2372 useEffect(() => desktopHost().native.onServiceState((service) => {
2373 addBreadcrumb("service", `phase=${service.phase} generation=${service.generation || "unknown"}`);
2374 if (service.phase !== "stopping" && service.phase !== "exited") return;
2375 // Each follower owns its service-stop fence, including remote followers.
2376 // Retire only the controller's references here so late hydration cannot
2377 // mistake a stopped follower for an active subscription.
2378 followers.current.clear();
2379 }), []);
2380 const cancelReconcileTimers = useRef(new Map<string, number>());
2381 const stalePromptReconcileTimers = useRef(new Map<string, number>());
2382 // Indirection so dispatchRuntimeStatusForTab (defined above reconcileTabRuntime)
2383 // can schedule an authoritative refetch after it rejects a stale snapshot.
2384 const scheduleStalePromptReconcileRef = useRef<(tabId: string) => void>(() => {});
2385 const [activeTabId, setActiveTabId] = useState<string | undefined>();
2386 const activeTabIdRef = useRef<string | undefined>(undefined);
2387 // Invalidates async navigation completions even for ABA switches where the
2388 // visible tab ID eventually returns to the original value.
2389 const activeNavigationSeqRef = useRef(0);
2390 const navigationSourcesRef = useRef(new Map<number, NavigationSourceSnapshot>());
2391 const { registerNavigationIntent, registeredNavigationIntent } = useNavigationIntentFence();
2392 // A render-triggering counter so that mutations to a non-active tab's state still
2393 // cause a re-render when that tab becomes active.
2394 const [, setVersion] = useState(0);
2395 const bump = useCallback(() => setVersion((v) => v + 1), []);
2396 const notifyLiveListeners = useCallback((tabId: string) => {
2397 for (const listener of liveListenersByTabRef.current.get(tabId) ?? []) listener();
2398 }, [t]);
2399 const disposeComposerProfileState = useCallback((tabId: string) => {
2400 appliedComposerProfileByTabRef.current.delete(tabId);
2401 composerProfileInFlightByTabRef.current.delete(tabId);
2402 composerProfileQueueByTabRef.current.delete(tabId);
2403 composerProfileLifecycleByTabRef.current.set(
2404 tabId,
2405 (composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) + 1,
2406 );
2407 }, []);
2408 const liveStore = useMemo<ControllerLiveStore>(() => ({
2409 subscribe(tabId, listener) {
2410 if (!tabId) return () => {};
2411 let listeners = liveListenersByTabRef.current.get(tabId);
2412 if (!listeners) {
2413 listeners = new Set();
2414 liveListenersByTabRef.current.set(tabId, listeners);
2415 }
2416 listeners.add(listener);
2417 return () => {
2418 listeners?.delete(listener);
2419 if (listeners?.size === 0) liveListenersByTabRef.current.delete(tabId);
2420 };
2421 },
2422 getSnapshot(tabId) {
2423 return tabId ? statesRef.current.get(tabId)?.live : undefined;
2424 },
2425 getModelActiveAt(tabId) {
2426 return tabId ? statesRef.current.get(tabId)?.turnModelActiveAt : undefined;
2427 },
2428 }), []);
2429 const beginActiveNavigation = useCallback(() => {
2430 activeNavigationSeqRef.current += 1;
2431 const seq = activeNavigationSeqRef.current;
2432 noteNavigationRequested(seq);
2433 const sourceTabId = activeTabIdRef.current;
2434 const source: NavigationSourceSnapshot = {
2435 tabId: sourceTabId,
2436 state: sourceTabId ? statesRef.current.get(sourceTabId) : undefined,
2437 tab: sourceTabId ? listedSessionIdentityByTabRef.current.get(sourceTabId) : undefined,
2438 };
2439 navigationSourcesRef.current.clear();
2440 navigationSourcesRef.current.set(seq, source);
2441 registerNavigationIntent(seq);
2442 return seq;
2443 }, [registerNavigationIntent]);
2444 const snapshotNavigationSourceTab = useCallback((navigationSeq: number) => {
2445 const source = navigationSourcesRef.current.get(navigationSeq);
2446 if (!source?.tabId || source.tab || source.tabPromise) return;
2447 source.tabPromise = app.ListTabs()
2448 .then((tabs) => asArray(tabs).find((tab) => tab.id === source.tabId))
2449 .catch(() => undefined);
2450 void source.tabPromise.then((tab) => { source.tab = tab; });
2451 }, []);
2452 const isNavigationIntentCurrent = useCallback((seq: number): boolean => {
2453 return activeNavigationSeqRef.current === seq;
2454 }, []);
2455 const currentNavigationIntent = useCallback((): number => activeNavigationSeqRef.current, []);
2456 const requireRegisteredNavigationIntent = useCallback(async (seq: number): Promise<void> => {
2457 const token = await registeredNavigationIntent(seq);
2458 if (!token) throw new Error("navigation intent registration failed");
2459 if (!isNavigationIntentCurrent(seq)) throw new Error("navigation intent was superseded");
2460 }, [isNavigationIntentCurrent, registeredNavigationIntent]);
2461 const navigationCompletionCurrent = useCallback((seq: number, kind: string, tabId: string): boolean => {
2462 if (activeNavigationSeqRef.current === seq) return true;
2463 addBreadcrumb(kind, `stale ${tabId} seq=${seq} current=${activeNavigationSeqRef.current}`);
2464 return false;
2465 }, []);
2466
2467 // The active tab's current state, with a stable identity for cancel().
2468 const activeState = activeTabId ? getOrCreateState(statesRef.current, activeTabId) : initialState;
2469 const runtimeState = useRuntimeSession(activeTabId, activeState.meta);
2470 const stateRef = useRef(activeState);
2471 const backendActiveTabIdRef = useRef<string | undefined>(undefined);
2472 const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
2473 // The latest ticketed topic activation (StartTopicActivation). Registered
2474 // before the backend call returns so synchronously-emitted lifecycle events
2475 // always match; a terminal event arriving before the ticket is stashed and
2476 // replayed once the ticket lands.
2477 const pendingTopicActivationRef = useRef<PendingTopicActivation | undefined>(undefined);
2478 const topicActivationSeqRef = useRef(0);
2479 const readyMetaReconcileSeq = useRef(0);
2480 const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
2481 activeTabIdRef.current = activeTabId;
2482 stateRef.current = activeState;
2483
2484 // Dispatch to a specific tab's state. If the tab doesn't have state yet, it's
2485 // created. Bumps the version so React re-renders when it becomes active.
2486 const dispatchTo = useCallback((tabId: string, action: Action) => {
2487 const states = statesRef.current;
2488 const prev = getOrCreateState(states, tabId);
2489 // Activity timestamps belong to one submitted turn. A new optimistic turn
2490 // must age from its own turnStartAt if turn_started is lost, not inherit an
2491 // old turn's already-stale wire timestamp and probe immediately.
2492 if (action.type === "user") lastTurnActivityAtByTab.current.delete(tabId);
2493 const next = reducer(prev, action);
2494 if (prev !== next) {
2495 getTranscriptStore().setState(tabId, next);
2496 // A tab with a live or in-flight turn is pinned out of transcript-store
2497 // eviction; its cached rows must survive until the turn settles.
2498 getTranscriptStore().setPinned(tabId, Boolean(next.running || next.turnActive || next.live));
2499 uiPerfTracker.onStateCommit();
2500 notifyLiveListeners(tabId);
2501 const streamDeltaOnly =
2502 (action.type === "stream_batch" ||
2503 (action.type === "event" && (action.e.kind === "text" || action.e.kind === "reasoning"))) &&
2504 prev.items === next.items &&
2505 prev.currentAssistant === next.currentAssistant &&
2506 prev.pendingUser === next.pendingUser &&
2507 prev.retry === next.retry;
2508 // Text/reasoning-only deltas only update the live stream — which the
2509 // frontend reads through its own subscription — so they must not bump the
2510 // full controller tree (the run-strip TPS estimate subscribes to the live
2511 // stream directly and updates itself).
2512 if (!streamDeltaOnly) bump();
2513 }
2514 }, [bump, notifyLiveListeners]);
2515 useEffect(() => {
2516 if (!activeTabId || !runtimeState.known || !runtimeState.state) return;
2517 dispatchTo(activeTabId, { type: "runtime_snapshot", snapshot: runtimeState.state });
2518 }, [activeTabId, dispatchTo, runtimeState.known, runtimeState.state]);
2519 const clearBalanceForTab = useCallback((tabId: string): void => {
2520 invalidateSharedQuery("BalanceForTab", [tabId]);
2521 invalidateSharedQuery("MetaForTab", [tabId]);
2522 const seq = (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1;
2523 balanceRefreshSeqByTab.current.set(tabId, seq);
2524 dispatchTo(tabId, { type: "balance", balance: { available: false, display: "" } });
2525 }, [dispatchTo]);
2526
2527 const invalidateProviderStateForTab = useCallback((tabId: string): void => {
2528 balanceRefreshSeqByTab.current.set(
2529 tabId,
2530 (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1,
2531 );
2532 modelSwitchSeqByTab.current.set(
2533 tabId,
2534 (modelSwitchSeqByTab.current.get(tabId) ?? 0) + 1,
2535 );
2536 }, []);
2537
2538 const refreshBalanceForTab = useCallback(async (
2539 tabId: string,
2540 options: { apply?: () => boolean } = {},
2541 ): Promise<void> => {
2542 const seq = (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1;
2543 balanceRefreshSeqByTab.current.set(tabId, seq);
2544 try {
2545 const balance = await app.BalanceForTab(tabId);
2546 if (balanceRefreshSeqByTab.current.get(tabId) !== seq) return;
2547 if (options.apply && !options.apply()) return;
2548 if (balance.err?.trim()) return;
2549 dispatchTo(tabId, { type: "balance", balance });
2550 } catch {
2551 // Balance is optional. Keep the last explicit cleared/unavailable state
2552 // instead of surfacing a provider-specific wallet failure in chat.
2553 }
2554 }, [dispatchTo]);
2555
2556 const confirmBackendActiveTab = useCallback((tabId: string) => {
2557 backendActiveTabIdRef.current = tabId;
2558 dispatchTo(tabId, { type: "backend_activation_done" });
2559 }, [dispatchTo]);
2560
2561 const reassertVisibleTabAfterStaleNavigation = useCallback(async (kind: string, staleTabId: string): Promise<void> => {
2562 // Backend navigation calls activate their result before returning. If a
2563 // newer tab click won in the frontend while that call was in flight, put
2564 // the backend back on the visible tab. Re-check after every await because
2565 // another click can supersede the target while SetActiveTab is running.
2566 for (;;) {
2567 const currentTabId = activeTabIdRef.current;
2568 if (!currentTabId) return;
2569 if (currentTabId === staleTabId) {
2570 confirmBackendActiveTab(currentTabId);
2571 return;
2572 }
2573 try {
2574 await app.SetActiveTab(currentTabId);
2575 } catch (err) {
2576 addBreadcrumb(kind, `stale reassert failed ${currentTabId}: ${errorMessage(err)}`);
2577 return;
2578 }
2579 if (activeTabIdRef.current === currentTabId) {
2580 confirmBackendActiveTab(currentTabId);
2581 addBreadcrumb(kind, `stale reasserted ${currentTabId}`);
2582 return;
2583 }
2584 }
2585 }, [confirmBackendActiveTab]);
2586
2587 const trackBackendActivation = useCallback((tabId: string, promise: Promise<boolean>) => {
2588 backendActivationPromises.current.set(tabId, promise);
2589 void promise.finally(() => {
2590 if (backendActivationPromises.current.get(tabId) === promise) {
2591 backendActivationPromises.current.delete(tabId);
2592 }
2593 });
2594 }, []);
2595
2596 const waitForBackendActiveTab = useCallback(async (tabId: string): Promise<boolean> => {
2597 const pending = backendActivationPromises.current.get(tabId);
2598 if (pending) {
2599 const activated = await pending.catch(() => false);
2600 if (!activated) return false;
2601 }
2602 return backendActiveTabIdRef.current === tabId && activeTabIdRef.current === tabId;
2603 }, []);
2604
2605 const { invalidateCheckpoints, settleCheckpoints, refreshCheckpoints, refreshTurnBoundaries } = useMemo(() => createTurnBoundaryReads(dispatchTo), [dispatchTo]);
2606 const metaRefreshSeq = useRef(new Map<string, number>());
2607 const sessionLoadSeq = useRef(new Map<string, number>());
2608 const historyWindowSeq = useRef(new Map<string, number>());
2609 const cancelHydrateSeq = useRef(new Map<string, number>());
2610 const sessionLoadInFlight = useRef(new Map<string, { identityKey: string; revision?: number; digest?: string; promise: Promise<void> }>());
2611 const transcriptSubscriptions = useRef(new Map<string, () => void>());
2612 const bumpMetaRefreshSeq = useCallback((tabId: string): number => {
2613 const seq = (metaRefreshSeq.current.get(tabId) ?? 0) + 1;
2614 metaRefreshSeq.current.set(tabId, seq);
2615 return seq;
2616 }, []);
2617 const metaRefreshCurrent = useCallback((tabId: string, seq: number): boolean => {
2618 return metaRefreshSeq.current.get(tabId) === seq;
2619 }, []);
2620 const bumpSessionLoadSeq = useCallback((tabId: string): number => {
2621 bumpMetaRefreshSeq(tabId);
2622 invalidateSharedQuery("MetaForTab", [tabId]);
2623 historyWindowSeq.current.set(tabId, (historyWindowSeq.current.get(tabId) ?? 0) + 1);
2624 const seq = (sessionLoadSeq.current.get(tabId) ?? 0) + 1;
2625 sessionLoadSeq.current.set(tabId, seq);
2626 return seq;
2627 }, [bumpMetaRefreshSeq]);
2628 // Ref-resolved content updates flow from the transcript store into the tab's
2629 // state as id-keyed patches. Subscribed once per tab; released when the tab
2630 // state is dropped (close / single-surface prune).
2631 const ensureTranscriptSubscription = useCallback((tabId: string, binding?: { path: string; key: string }) => {
2632 if (binding?.key && getTranscriptStore().noteSessionBinding(tabId, binding.path, binding.key)) {
2633 followers.current.get(tabId)?.stop();
2634 followers.current.delete(tabId);
2635 }
2636 if (transcriptSubscriptions.current.has(tabId)) return;
2637 const unsubscribe = getTranscriptStore().subscribe(tabId, (change) => {
2638 if (!statesRef.current.has(tabId)) return;
2639 dispatchTo(tabId, { type: "history_items_patch", patches: change.patches, expected: change.expected });
2640 const patchCount = Object.keys(change.patches).length;
2641 if (patchCount > 0) {
2642 recordFrontendDiagnostic("history", "history.items-patch", {
2643 patchCount,
2644 contentRevision: statesRef.current.get(tabId)?.historyLayoutRevision,
2645 });
2646 }
2647 });
2648 transcriptSubscriptions.current.set(tabId, unsubscribe);
2649 }, [dispatchTo]);
2650 const startTranscriptFollow = useCallback(async (tabId: string, path: string) => {
2651 ensureTranscriptSubscription(tabId, { path, key: sessionIdentityStableKey(statesRef.current.get(tabId)?.meta) });
2652 followers.current.get(tabId)?.stop();
2653 const follower = new TranscriptSessionFollower(tabId, path, false, action => {
2654 if (followers.current.get(tabId) === follower) dispatchTo(tabId, action);
2655 });
2656 followers.current.set(tabId, follower);
2657 await follower.start();
2658 return follower.metrics;
2659 }, [dispatchTo, ensureTranscriptSubscription]);
2660 const detachTranscriptState = useCallback((tabId: string) => {
2661 followers.current.get(tabId)?.stop();
2662 followers.current.delete(tabId);
2663 // A detached tab can still have an older-page request awaiting the bridge. Keep
2664 // a tombstone generation so a later tab reusing the same id cannot make
2665 // that completion current again.
2666 historyWindowSeq.current.set(tabId, (historyWindowSeq.current.get(tabId) ?? 0) + 1);
2667 transcriptSubscriptions.current.get(tabId)?.();
2668 transcriptSubscriptions.current.delete(tabId);
2669 }, []);
2670 const releaseTranscriptState = useCallback((tabId: string) => {
2671 detachTranscriptState(tabId);
2672 getTranscriptStore().evictTab(tabId);
2673 }, [detachTranscriptState]);
2674 const sessionLoadCurrent = useCallback((tabId: string, seq: number): boolean => {
2675 return sessionLoadSeq.current.get(tabId) === seq;
2676 }, []);
2677 const bumpCancelHydrateSeq = useCallback((tabId: string): number => {
2678 const seq = (cancelHydrateSeq.current.get(tabId) ?? 0) + 1;
2679 cancelHydrateSeq.current.set(tabId, seq);
2680 return seq;
2681 }, []);
2682 const cancelHydrateCurrent = useCallback((tabId: string, seq: number): boolean => {
2683 return cancelHydrateSeq.current.get(tabId) === seq;
2684 }, []);
2685 const loadMetaForTab = useCallback(async (tabId: string): Promise<Meta | undefined> => {
2686 const seq = bumpMetaRefreshSeq(tabId);
2687 const meta = await app.MetaForTab(tabId).catch(() => undefined);
2688 if (!metaRefreshCurrent(tabId, seq)) return undefined;
2689 if (meta?.runtime?.epoch) runtimeEpochByTabRef.current.set(tabId, meta.runtime.epoch);
2690 return meta;
2691 }, [bumpMetaRefreshSeq, metaRefreshCurrent]);
2692 const refreshMetaOnlyForTab = useCallback(async (tabId: string): Promise<Meta | undefined> => {
2693 const meta = await loadMetaForTab(tabId);
2694 if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
2695 return meta;
2696 }, [dispatchTo, loadMetaForTab]);
2697 const refreshMetaForTab = useCallback(async (tabId: string): Promise<void> => {
2698 const sessionSeq = sessionLoadSeq.current.get(tabId) ?? 0;
2699 const meta = await loadMetaForTab(tabId);
2700 if (meta === undefined || (sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
2701 dispatchTo(tabId, { type: "meta", meta });
2702 const [context, effort] = await Promise.all([
2703 app.ContextUsageForTab(tabId).catch(() => undefined),
2704 app.EffortForTab(tabId).catch(() => undefined),
2705 ]);
2706 if ((sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
2707 if (context !== undefined) dispatchTo(tabId, { type: "context", context });
2708 if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
2709 }, [dispatchTo, loadMetaForTab]);
2710
2711 const loadSessionDataForTab = useCallback(async (
2712 tabId: string,
2713 reset = false,
2714 reason: HydrateReason = "startup",
2715 options: SessionHydrationOptions<Item, HydrateSurfacePolicy> = {},
2716 ) => {
2717 const surfacePolicy = options.surfacePolicy ?? "preserve-current"; const resetSurface = reset || surfacePolicy === "replace-surface";
2718 const stateMeta = statesRef.current.get(tabId)?.meta;
2719 const resolvedIdentity = sessionIdentityStableKey(options) ? options : stateMeta ?? options;
2720 const sessionPath = (resolvedIdentity.sessionPath ?? "").trim();
2721 const sessionRevision = "sessionRevision" in options ? options.sessionRevision : stateMeta?.sessionRevision;
2722 const sessionDigest = "sessionDigest" in options ? options.sessionDigest : stateMeta?.sessionDigest;
2723 const targetIdentity = { ...resolvedIdentity, sessionPath };
2724 const targetIdentityKey = sessionIdentityStableKey(targetIdentity);
2725 const canJoinInFlight = !resetSurface && !options.skipHistory && !options.recoveryCurrent && !options.freshSnapshot;
2726 const shouldTrackInFlight = !options.skipHistory;
2727 if (canJoinInFlight) {
2728 const existing = sessionLoadInFlight.current.get(tabId);
2729 if (targetIdentityKey && existing?.identityKey === targetIdentityKey && existing.revision === sessionRevision && existing.digest === sessionDigest) return existing.promise;
2730 } else {
2731 sessionLoadInFlight.current.delete(tabId);
2732 }
2733 if (resetSurface) invalidateCheckpoints(tabId);
2734 const promise = (async () => {
2735 const cancelHydrateGeneration = options.cancelHydrateGeneration;
2736 if (cancelHydrateGeneration !== undefined && !cancelHydrateCurrent(tabId, cancelHydrateGeneration)) return;
2737 const seq = bumpSessionLoadSeq(tabId);
2738 const hydrateStartedAt = Date.now();
2739 const skipHistory = Boolean(
2740 (options.skipHistory ||
2741 (options.preserveCachedHistory && !resetSurface && (
2742 statesRef.current.get(tabId)?.transcriptProtocol === 2 && sameSessionHydrateIdentity(targetIdentity, statesRef.current.get(tabId)?.meta)
2743 || hasReusableCachedTranscript(statesRef.current.get(tabId), targetIdentity, sessionRevision, sessionDigest)))) &&
2744 followers.current.has(tabId) && statesRef.current.get(tabId)?.transcriptProtocol === 2,
2745 );
2746 const deferResetUntilHistory = Boolean(surfacePolicy === "preserve-current" && (options.deferResetUntilHistory ?? true) && resetSurface && !skipHistory);
2747 // Request seq alone cannot stop clear→mode-switch races: a load started
2748 // after clear with stale meta.sessionPath must also be rejected.
2749 const stillCurrent = () => {
2750 if (options.recoveryCurrent && !options.recoveryCurrent()) return false;
2751 if (!sessionLoadCurrent(tabId, seq)) return false;
2752 if (cancelHydrateGeneration !== undefined && !cancelHydrateCurrent(tabId, cancelHydrateGeneration)) return false;
2753 const meta = statesRef.current.get(tabId)?.meta;
2754 return hydrateIdentityCurrent(targetIdentity, meta);
2755 };
2756 if (!stillCurrent()) return;
2757 addBreadcrumb("tab.hydrate", `start ${reason} ${tabId}`);
2758 ensureTranscriptSubscription(tabId);
2759 dispatchTo(tabId, { type: "hydrate_start", reason, placeholderItems: resolveHydratePlaceholders(options.placeholderItems) });
2760 if (resetSurface && !deferResetUntilHistory && stillCurrent()) dispatchTo(tabId, { type: "reset" });
2761 const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
2762 const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
2763 const noteFailure = (label: string, err: unknown) => {
2764 addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
2765 };
2766
2767 const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
2768 const startedAt = Date.now();
2769 addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
2770 try {
2771 const value = await load();
2772 addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
2773 return value;
2774 } catch (err) {
2775 noteFailure(label, err);
2776 return undefined;
2777 }
2778 };
2779
2780 const modern = !skipHistory;
2781 const snapshotLoaded = modern ? await loadTimed("transcript follow", async () => {
2782 await startTranscriptFollow(tabId, sessionPath);
2783 return true;
2784 }) : false;
2785 if (!stillCurrent()) return;
2786 if (!skipHistory && snapshotLoaded !== true) {
2787 const error = t("history.failedLoadHistory");
2788 dispatchTo(tabId, { type: "hydrate_error", reason, error });
2789 // SessionRecoveryBanner owns recovery; chat notices survive successful snapshots.
2790 return;
2791 }
2792 dispatchTo(tabId, { type: "hydrate_done" });
2793 addBreadcrumb("tab.hydrate", `done ${reason} ${tabId} ms=${Date.now() - hydrateStartedAt}`);
2794
2795 // Phase 2: local ancillary data. It stays inside the same in-flight
2796 // promise so duplicate ready/startup hydrations coalesce, but it runs
2797 // after hydrate_done so slow bridge calls don't keep the visible transcript
2798 // in a loading state.
2799 await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
2800 if (!stillCurrent()) return;
2801 if (!stillVisible()) {
2802 addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
2803 return;
2804 }
2805 let meta = await loadTimed("meta", () => loadMetaForTab(tabId));
2806 if (!stillCurrent()) return;
2807 if (!stillVisible()) {
2808 addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
2809 return;
2810 }
2811 if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
2812 const ancillaryStartedAt = Date.now();
2813 const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
2814 return loadTimed(`ancillary ${label}`, load);
2815 };
2816 const [effort, jobs, context] = await Promise.all([
2817 loadAncillary("effort", () => app.EffortForTab(tabId)),
2818 loadAncillary("jobs", () => app.JobsForTab(tabId)),
2819 loadAncillary("context", () => app.ContextUsageForTab(tabId)),
2820 ]);
2821 if (!stillCurrent()) return;
2822 if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
2823 if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
2824 if (context !== undefined) dispatchTo(tabId, { type: "context", context });
2825 // Signal ContextPanel to re-fetch now that ancillary data (context,
2826 // effort, jobs) has landed. Without this, the right-side panel keeps
2827 // stale RequestCount / ElapsedMs / SessionCost from before a session
2828 // rebind because its refreshKey (dockRefreshKey) only bumps on turn_done.
2829 dispatchTo(tabId, { type: "context_panel_refresh" });
2830 await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
2831 if (!stillCurrent()) return;
2832 if (!stillVisible()) {
2833 addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
2834 return;
2835 }
2836 const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
2837 if (!stillCurrent()) return;
2838 if (!stillVisible()) {
2839 addBreadcrumb("tab.hydrate", `checkpoints ignored inactive ${reason} ${tabId}`);
2840 return;
2841 }
2842 void settleCheckpoints(tabId, checkpoints);
2843 addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
2844 void refreshBalanceForTab(tabId, {
2845 apply: () => sessionLoadCurrent(tabId, seq) && stillVisible(),
2846 });
2847 })();
2848 if (shouldTrackInFlight) {
2849 sessionLoadInFlight.current.set(tabId, { identityKey: targetIdentityKey, revision: sessionRevision, digest: sessionDigest, promise });
2850 }
2851 try {
2852 await promise;
2853 } finally {
2854 if (sessionLoadInFlight.current.get(tabId)?.promise === promise) {
2855 sessionLoadInFlight.current.delete(tabId);
2856 }
2857 }
2858 }, [bumpSessionLoadSeq, cancelHydrateCurrent, dispatchTo, invalidateCheckpoints, loadMetaForTab, refreshBalanceForTab, refreshTurnBoundaries, sessionLoadCurrent, startTranscriptFollow]);
2859
2860 /**
2861 * Publish the bounded durable history window before a local controller has
2862 * finished booting. The canonical history service can resolve a tab's
2863 * SessionID without a bound controller, so navigation must not wait for MCP,
2864 * provider, lease, or runtime setup merely to make the conversation readable.
2865 *
2866 * This is deliberately a one-shot readable baseline, not a second live
2867 * transcript owner. Once the runtime is ready, TranscriptSessionFollower
2868 * installs the authoritative protocol-v2 cut and owns subsequent changes.
2869 */
2870 const primeReadableHistoryForTab = useCallback(async (
2871 tabId: string,
2872 target: TabMeta,
2873 reason: HydrateReason,
2874 navigationIntent: number,
2875 current: () => boolean,
2876 ): Promise<"cached" | "loaded" | "miss"> => {
2877 const sessionPath = (target.sessionPath ?? "").trim();
2878 const identity = sessionIdentityFields(target);
2879 const seq = bumpSessionLoadSeq(tabId);
2880 const stillCurrent = () => current()
2881 && sessionLoadCurrent(tabId, seq)
2882 && hydrateIdentityCurrent(identity, statesRef.current.get(tabId)?.meta);
2883 if (!stillCurrent()) return "miss";
2884 ensureTranscriptSubscription(tabId, { path: sessionPath, key: sessionIdentityStableKey(target) });
2885 const store = getTranscriptStore();
2886 const startedAt = Date.now();
2887 const resident = store.peek(tabId, sessionPath, {
2888 revision: target.sessionRevision,
2889 digest: target.sessionDigest,
2890 });
2891 noteNavigationHistoryRequested(navigationIntent, Boolean(resident));
2892 recordFrontendDiagnostic("navigation", resident ? "navigation.history-cache-hit" : "navigation.history-cache-miss", {
2893 tabId,
2894 reason,
2895 });
2896 if (resident) {
2897 if (!stillCurrent()) return "miss";
2898 dispatchTo(tabId, historyReplaceAction(resident));
2899 dispatchTo(tabId, { type: "hydrate_done" });
2900 noteNavigationHistoryReadable(navigationIntent, true);
2901 recordFrontendDiagnostic("navigation", "navigation.history-readable", {
2902 tabId,
2903 reason,
2904 source: "cache",
2905 durationMs: Date.now() - startedAt,
2906 });
2907 return "cached";
2908 }
2909 try {
2910 const projection = await store.loadLatest(tabId, sessionPath, {
2911 preferResident: true,
2912 expectedRevision: target.sessionRevision,
2913 expectedDigest: target.sessionDigest,
2914 current: stillCurrent,
2915 });
2916 if (!projection || !stillCurrent()) return "miss";
2917 dispatchTo(tabId, historyReplaceAction(projection));
2918 dispatchTo(tabId, { type: "hydrate_done" });
2919 noteNavigationHistoryReadable(navigationIntent, false);
2920 recordFrontendDiagnostic("navigation", "navigation.history-readable", {
2921 tabId,
2922 reason,
2923 source: "disk",
2924 durationMs: Date.now() - startedAt,
2925 });
2926 return "loaded";
2927 } catch (error) {
2928 // Runtime activation may still succeed and its protocol-v2 follower will
2929 // retry from the controller-owned cut. Keep the target skeleton instead
2930 // of turning an early-read miss into a terminal navigation failure.
2931 addBreadcrumb("tab.hydrate", `readable baseline failed ${reason} ${tabId}: ${errorMessage(error)}`);
2932 recordFrontendDiagnostic("navigation", "navigation.history-readable-failed", {
2933 tabId,
2934 reason,
2935 durationMs: Date.now() - startedAt,
2936 });
2937 return "miss";
2938 }
2939 }, [bumpSessionLoadSeq, dispatchTo, ensureTranscriptSubscription, sessionLoadCurrent]);
2940
2941 // On-demand full content for a ref-replaced history field (entries carrying
2942 // refs[] ship a ≤4KiB preview inline). Resolves through the transcript
2943 // store, which patches the projected items by stable id on completion. The
2944 // rendering layer calls this when a truncated entry scrolls into view.
2945 const requestHistoryFullContent = useCallback(async (entryId: string, field: string): Promise<string | undefined> => {
2946 const tabId = activeTabIdRef.current;
2947 if (!tabId) return undefined;
2948 ensureTranscriptSubscription(tabId);
2949 return getTranscriptStore().requestFullContent(tabId, entryId, field);
2950 }, [ensureTranscriptSubscription]);
2951
2952 const loadOlderHistory = useCallback(async (tabId?: string, targetTurn?: number, trigger: HistoryLoadType = "retry"): Promise<HistoryLoadOutcome> => {
2953 const targetTabId = tabId || activeTabIdRef.current;
2954 if (!targetTabId) return "empty";
2955 const state = statesRef.current.get(targetTabId);
2956 if (!state?.historyHasOlder || state.historyOlderLoading) return "empty";
2957 const requestSeq = (historyWindowSeq.current.get(targetTabId) ?? 0) + 1;
2958 historyWindowSeq.current.set(targetTabId, requestSeq);
2959 recordFrontendDiagnostic("history", "history.older-request", {
2960 trigger, intent: activeNavigationSeqRef.current, targeted: targetTurn !== undefined,
2961 });
2962 ensureTranscriptSubscription(targetTabId);
2963 return loadHistoryWindow({
2964 tabId: targetTabId, direction: "older", targetTurn, trigger, state, requestSeq,
2965 isCurrent: (seq) => historyWindowSeq.current.get(targetTabId) === seq,
2966 currentState: () => statesRef.current.get(targetTabId),
2967 dispatch: (action) => dispatchTo(targetTabId, action),
2968 });
2969 }, [dispatchTo, ensureTranscriptSubscription, startTranscriptFollow]);
2970
2971 const loadNewerHistory = useCallback(async (tabId?: string, latest = false): Promise<HistoryLoadOutcome> => {
2972 const targetTabId = tabId || activeTabIdRef.current;
2973 if (!targetTabId) return "empty";
2974 const state = statesRef.current.get(targetTabId);
2975 if (!state || (!latest && (!state.historyHasNewer || state.historyNewerLoading))) return "empty";
2976 const requestSeq = (historyWindowSeq.current.get(targetTabId) ?? 0) + 1;
2977 historyWindowSeq.current.set(targetTabId, requestSeq);
2978 ensureTranscriptSubscription(targetTabId);
2979 return loadHistoryWindow({
2980 tabId: targetTabId, direction: latest ? "latest" : "newer", trigger: latest ? "return-latest" : "viewport-user",
2981 state, requestSeq,
2982 isCurrent: (seq) => historyWindowSeq.current.get(targetTabId) === seq,
2983 currentState: () => statesRef.current.get(targetTabId),
2984 dispatch: (action) => dispatchTo(targetTabId, action),
2985 });
2986 }, [dispatchTo, ensureTranscriptSubscription]);
2987
2988 const activeTabFromBackend = useCallback(async (): Promise<TabMeta | undefined> => {
2989 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
2990 for (const tab of tabs) listedSessionIdentityByTabRef.current.set(tab.id, tab);
2991 return tabs.find((tab) => tab.active) ?? tabs[0];
2992 }, []);
2993
2994 // snapshotAt is the promptEventClock() reading taken after the backend call
2995 // produced `tab`. The reducer uses it to ignore snapshots that predate a
2996 // live approval/ask event (#6429).
2997 const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot, snapshotAt?: number) => {
2998 const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
2999 const runtimeEpoch = tab.runtime?.epoch;
3000 if (statesRef.current.get(tabId)?.transcriptProtocol === 1) {
3001 dispatchTo(tabId, { type: "backend_status", running: foregroundRunning, backgroundJobs: tab.backgroundJobs, runtimeEpoch });
3002 return Boolean(statesRef.current.get(tabId)?.running || statesRef.current.get(tabId)?.pendingPrompt);
3003 }
3004 // Will the reducer reject this as a snapshot that predates the live prompt?
3005 // Computed on pre-dispatch state so we can schedule an authoritative
3006 // refetch when a stale idle snapshot is ignored.
3007 const rejectedStaleIdle = !tab.pendingPrompt && runtimeSnapshotPredatesPrompt(statesRef.current.get(tabId), snapshotAt);
3008 dispatchTo(tabId, {
3009 type: "backend_status",
3010 running: foregroundRunning,
3011 turnStartedAt: tab.turnStartedAt,
3012 pendingPrompt: Boolean(tab.pendingPrompt),
3013 backgroundJobs: tab.backgroundJobs ?? 0,
3014 cancelRequested: Boolean(tab.cancelRequested),
3015 cancellable: foregroundRunning,
3016 turnId: tab.turnId,
3017 turnStatus: tab.turnStatus,
3018 runtimeEpoch,
3019 turnEventSeq: tab.turnEventSeq,
3020 snapshotAt,
3021 });
3022 // backend_status reconciliation can clear a live prompt from frontend state.
3023 // If the backend is still blocked, ask it to replay the approval/ask event.
3024 if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
3025 // A stale idle snapshot the reducer ignored cannot be trusted to have kept a
3026 // GENUINE prompt: navigation can drop the prompt anchor, so a delayed replay
3027 // of an already-answered prompt looks like a fresh prompt and re-anchors,
3028 // making this authoritative idle look stale. Refetch backend truth once so a
3029 // resolved prompt is cleared instead of surviving as a zombie (#6432).
3030 if (rejectedStaleIdle) scheduleStalePromptReconcileRef.current(tabId);
3031 // A prompt that survived reconciliation (fresh pendingPrompt=true meta, or
3032 // a stale snapshot the reducer ignored) keeps the tab blocked on the user.
3033 // Report it as foreground-running so callers do not treat the snapshot as
3034 // a missed turn_done and reset the session out from under the prompt.
3035 const local = statesRef.current.get(tabId);
3036 if (local?.approval || local?.ask) return true;
3037 return foregroundRunning;
3038 }, [dispatchTo]);
3039
3040 const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
3041 for (let attempt = 0; attempt < 60; attempt += 1) {
3042 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
3043 const tab = tabs.find((candidate) => candidate.id === tabId);
3044 if (!tab || tab.ready || tab.startupErr) return;
3045 await new Promise((resolve) => window.setTimeout(resolve, 100));
3046 }
3047 }, []);
3048
3049 const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
3050 const snapshotAt = promptEventClock();
3051 // The navigation generation fences same-tab session rebinds as well as tab-id changes.
3052 const expectedNavigationSeq = options.navigationIntentSeq ?? activeNavigationSeqRef.current;
3053 const active = await activeTabFromBackend();
3054 if (!active) return undefined;
3055 if (!isNavigationIntentCurrent(expectedNavigationSeq)) return active.id;
3056 // When guard is true, skip if the frontend already settled on a
3057 // different tab while we were fetching — this prevents fire-and-forget
3058 // calls from mount/onReady from overwriting a user-initiated tab switch
3059 // (e.g. handleNewTab → ensureBlankSurface / switchTab).
3060 if (guard && activeTabIdRef.current && activeTabIdRef.current !== active.id) return active.id;
3061 if (activeTabIdRef.current !== active.id && options.navigationIntentSeq === undefined) beginActiveNavigation();
3062 const previousState = statesRef.current.get(active.id);
3063 const hydration = activeTabHydrationPlan(active, previousState?.meta, reset, options.surfacePolicy, options.preserveCachedHistory);
3064 setActiveTabId(active.id);
3065 activeTabIdRef.current = active.id;
3066 confirmBackendActiveTab(active.id);
3067 if (active.runtime?.epoch) runtimeEpochByTabRef.current.set(active.id, active.runtime.epoch);
3068 dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, previousState?.meta) });
3069 if (!reset && hydration.surfacePolicy === "preserve-current") dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
3070 const load = loadSessionDataForTab(active.id, reset, "startup", hydration.loadOptions);
3071 if (reset || hydration.surfacePolicy === "replace-surface") dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
3072 if (options.deferHydration) void load;
3073 else await load;
3074 return active.id;
3075 }, [activeTabFromBackend, beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, isNavigationIntentCurrent, loadSessionDataForTab]);
3076
3077 const reconcileTabRuntime = useCallback(async (
3078 tabId: string,
3079 options: { hydrateSessionData?: boolean; refreshAncillary?: boolean } = {},
3080 ): Promise<TabMeta[] | undefined> => {
3081 const hydrateSessionData = options.hydrateSessionData ?? true;
3082 const refreshAncillary = options.refreshAncillary ?? true;
3083 const snapshotAt = promptEventClock();
3084 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
3085 const tab = tabs.find((candidate) => candidate.id === tabId);
3086 if (!tab) return undefined;
3087 if (tab.runtime?.epoch) runtimeEpochByTabRef.current.set(tabId, tab.runtime.epoch);
3088 const local = statesRef.current.get(tabId);
3089 const needsInitialLoad = !local?.meta;
3090 const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab, snapshotAt);
3091 const missedTurnDone = Boolean(local?.running && !foregroundRunning);
3092 if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
3093 await loadSessionDataForTab(tabId, missedTurnDone, "startup", {
3094 ...sessionIdentityFields(tab),
3095 sessionRevision: tab.sessionRevision,
3096 sessionDigest: tab.sessionDigest,
3097 });
3098 return tabs;
3099 }
3100 if (!refreshAncillary) return tabs;
3101 const [jobs, effort] = await Promise.all([
3102 app.JobsForTab(tabId).catch(() => undefined),
3103 app.EffortForTab(tabId).catch(() => undefined),
3104 ]);
3105 if (jobs) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
3106 if (effort) dispatchTo(tabId, { type: "effort", effort });
3107 await refreshBalanceForTab(tabId);
3108 return tabs;
3109 }, [dispatchRuntimeStatusForTab, loadSessionDataForTab, refreshBalanceForTab]);
3110
3111 const reconcileRuntimeAfterRejectedMutation = useCallback(async (tabId: string): Promise<void> => {
3112 const result = await findTabAfterSubmitFailure(app, tabId, CANCEL_RECONCILE_DELAYS_MS, promptEventClock);
3113 if (!result) return;
3114 const [tab, snapshotAt] = result;
3115 if (tab?.runtime?.epoch) runtimeEpochByTabRef.current.set(tabId, tab.runtime.epoch);
3116 dispatchRuntimeStatusForTab(tabId, tab ?? { running: false }, snapshotAt);
3117 if (tab) await startTranscriptFollow(tabId, tab.sessionPath ?? "");
3118 }, [dispatchRuntimeStatusForTab, startTranscriptFollow]);
3119
3120 // Authoritative backstop for the prompt-freshness heuristic: after the reducer
3121 // rejects a stale idle snapshot, refetch backend state once. If the backend
3122 // resolved the prompt, the fresh snapshot (fetched after any in-flight replay)
3123 // is newer than the anchor and reconciles the zombie away; if the prompt is
3124 // genuinely pending, the fresh snapshot keeps it. Debounced per tab so a burst
3125 // of stale snapshots schedules at most one refetch (#6432).
3126 const scheduleStalePromptReconcile = useCallback((tabId: string) => {
3127 if (stalePromptReconcileTimers.current.has(tabId)) return;
3128 const timer = window.setTimeout(() => {
3129 stalePromptReconcileTimers.current.delete(tabId);
3130 void reconcileTabRuntime(tabId, RUNTIME_STATUS_ONLY).catch(() => {});
3131 }, STALE_PROMPT_RECONCILE_MS);
3132 stalePromptReconcileTimers.current.set(tabId, timer);
3133 }, [reconcileTabRuntime]);
3134 scheduleStalePromptReconcileRef.current = scheduleStalePromptReconcile;
3135
3136 const clearCancelReconcileTimer = useCallback((tabId: string) => {
3137 const timer = cancelReconcileTimers.current.get(tabId);
3138 if (timer === undefined) return;
3139 window.clearTimeout(timer);
3140 cancelReconcileTimers.current.delete(tabId);
3141 }, []);
3142
3143 const scheduleCancelReconcile = useCallback((tabId: string, attempt = 0) => {
3144 clearCancelReconcileTimer(tabId);
3145 const delay = CANCEL_RECONCILE_DELAYS_MS[Math.min(attempt, CANCEL_RECONCILE_DELAYS_MS.length - 1)];
3146 const timer = window.setTimeout(() => {
3147 cancelReconcileTimers.current.delete(tabId);
3148 void reconcileTabRuntime(tabId, RUNTIME_STATUS_ONLY).then((tabs) => {
3149 const tab = tabs?.find((candidate) => candidate.id === tabId);
3150 if (!tab) return;
3151 const stillReconciling = foregroundRunningFromRuntimeMeta(tab) || Boolean(tab.cancelRequested);
3152 if (stillReconciling && attempt + 1 < CANCEL_RECONCILE_DELAYS_MS.length) {
3153 scheduleCancelReconcile(tabId, attempt + 1);
3154 return;
3155 }
3156 // TurnDone(interrupted) is now the authoritative cancellation boundary.
3157 // Never replace the whole transcript here: a stale cancellation load
3158 // can finish after the user's replacement turn and erase that newer
3159 // prompt/answer. The terminal event patches only the active turn.
3160 if (!stillReconciling) {
3161 const current = statesRef.current.get(tabId);
3162 if (current?.transcriptProtocol === 2 && (current.running || current.cancelRequested)) {
3163 void startTranscriptFollow(tabId, current.meta?.sessionPath ?? "").catch(() => {});
3164 }
3165 void refreshCheckpoints(tabId);
3166 }
3167 }).catch(() => {});
3168 }, delay);
3169 cancelReconcileTimers.current.set(tabId, timer);
3170 }, [clearCancelReconcileTimer, reconcileTabRuntime, refreshCheckpoints, startTranscriptFollow]);
3171
3172 // Topic-activation lifecycle events drive the ticketed activation flow: the
3173 // visible surface already switched when StartTopicActivation returned; the
3174 // history hydrate waits for the terminal "ready" of the LATEST request.
3175 // Events for superseded requestIds (including their "cancelled") are
3176 // dropped; agent:ready/agent:event handling is untouched and still covers
3177 // every non-ticketed flow (rebind, recovery, restore, SetActiveTab).
3178 const restoreNavigationSource = useCallback(async (navigationSeq: number, targetTabId: string, error?: string): Promise<boolean> => {
3179 const source = navigationSourcesRef.current.get(navigationSeq);
3180 const sourceTabId = source?.tabId;
3181 const sourceState = source?.state;
3182 if (!sourceTabId || !sourceState || !isNavigationIntentCurrent(navigationSeq) || activeTabIdRef.current !== targetTabId) return false;
3183 // Keep the failed target masked while the backend source is rebound. If
3184 // restoration also fails, backend_activation_done lets App expose the
3185 // target's retry surface instead of leaving an infinite navigation mask.
3186 dispatchTo(targetTabId, { type: "backend_activation_start" });
3187 const sourceTab = source.tab ?? await source.tabPromise;
3188 const { restoreNavigationBackend } = await import("./controllerSwitchNotices");
3189 const restored = await restoreNavigationBackend(sourceTabId, targetTabId, sourceTab);
3190 if (!restored) {
3191 if (isNavigationIntentCurrent(navigationSeq) && activeTabIdRef.current === targetTabId) dispatchTo(targetTabId, { type: "backend_activation_done" });
3192 return false;
3193 }
3194 const { restoredTabId, restoredMeta } = restored;
3195 if (!isNavigationIntentCurrent(navigationSeq) || activeTabIdRef.current !== targetTabId) {
3196 await reassertVisibleTabAfterStaleNavigation("navigation.restore-source", restoredTabId);
3197 return false;
3198 }
3199 if (restoredMeta) {
3200 ensureTranscriptSubscription(restoredTabId);
3201 statesRef.current.set(restoredTabId, {
3202 ...sourceState,
3203 meta: metaFromTab(restoredMeta, sourceState.meta),
3204 hydrating: false,
3205 hydrateReason: undefined,
3206 hydrateError: undefined,
3207 hydrateHistoryLoaded: true,
3208 hydratePlaceholderItems: undefined,
3209 backendActivationPending: false,
3210 });
3211 notifyLiveListeners(restoredTabId);
3212 }
3213 setActiveTabId(restoredTabId);
3214 activeTabIdRef.current = restoredTabId;
3215 confirmBackendActiveTab(restoredTabId);
3216 if (error) dispatchTo(restoredTabId, { type: "local_notice", level: "warn", text: error, preserveRuntime: true });
3217 if (restoredMeta && restoredTabId !== sourceTabId) {
3218 void loadSessionDataForTab(restoredTabId, false, "open-topic", {
3219 placeholderItems: sourceState.items,
3220 preserveCachedHistory: false,
3221 ...sessionIdentityFields(restoredMeta),
3222 sessionRevision: restoredMeta.sessionRevision,
3223 sessionDigest: restoredMeta.sessionDigest,
3224 sessionGeneration: restoredMeta.sessionGeneration,
3225 surfacePolicy: "preserve-current",
3226 }).then(() => reconcileTabRuntime(restoredTabId, RUNTIME_STATUS_ONLY)).catch(() => {});
3227 }
3228 navigationSourcesRef.current.delete(navigationSeq);
3229 return true;
3230 }, [confirmBackendActiveTab, dispatchTo, ensureTranscriptSubscription, isNavigationIntentCurrent, loadSessionDataForTab, notifyLiveListeners, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
3231
3232 const monitorNavigationHydration = useCallback((
3233 navigationSeq: number,
3234 targetTabId: string,
3235 hydration: Promise<void>,
3236 onReady?: () => unknown | Promise<unknown>,
3237 ) => {
3238 void hydration.then(async () => {
3239 if (!isNavigationIntentCurrent(navigationSeq) || activeTabIdRef.current !== targetTabId) return;
3240 if (statesRef.current.get(targetTabId)?.hydrateError) {
3241 await restoreNavigationSource(navigationSeq, targetTabId, t("history.failedOpenSession"));
3242 return;
3243 }
3244 await onReady?.();
3245 }).catch(async () => {
3246 if (!isNavigationIntentCurrent(navigationSeq) || activeTabIdRef.current !== targetTabId) return;
3247 const safeError = t("history.failedOpenSession");
3248 dispatchTo(targetTabId, { type: "hydrate_error", reason: "open-topic", error: safeError });
3249 await restoreNavigationSource(navigationSeq, targetTabId, safeError);
3250 });
3251 }, [dispatchTo, isNavigationIntentCurrent, restoreNavigationSource]);
3252
3253 const handleTopicActivationEvent = useCallback((event: TopicActivationEvent) => {
3254 const pending = pendingTopicActivationRef.current;
3255 if (!pending || event.requestId !== pending.requestId) return;
3256 if (event.phase === "starting") {
3257 noteActivationStarted(event.requestId, event.tabId);
3258 return;
3259 }
3260 if (!pending.tabId) {
3261 // The ticket has not resolved yet; replay once activateTopic applies it.
3262 pending.terminal = event;
3263 return;
3264 }
3265 if (event.phase === "cancelled") {
3266 noteActivationSettled(event.requestId, "cancelled");
3267 if (pendingTopicActivationRef.current === pending) pendingTopicActivationRef.current = undefined;
3268 if (pending.tabId && isNavigationIntentCurrent(pending.navigationSeq) && activeTabIdRef.current === pending.tabId) {
3269 void restoreNavigationSource(pending.navigationSeq, pending.tabId);
3270 }
3271 return;
3272 }
3273 pendingTopicActivationRef.current = undefined;
3274 if (!isNavigationIntentCurrent(pending.navigationSeq)) return;
3275 const tabId = pending.tabId;
3276 if (activeTabIdRef.current !== tabId) return;
3277 if (event.phase === "failed") {
3278 noteActivationSettled(event.requestId, "failed", event.error);
3279 const safeError = t("history.failedOpenSession");
3280 const current = statesRef.current.get(tabId);
3281 // Runtime activation and readable history are independent. If the
3282 // controller/lease/MCP phase fails after the canonical transcript was
3283 // already published, keep that transcript selected and make only the
3284 // write side unavailable. Treating this as a history failure used to
3285 // restore the source surface and throw away a perfectly readable target.
3286 if (current?.hydrating) dispatchTo(tabId, { type: "hydrate_error", reason: "open-topic", error: safeError });
3287 if (current?.meta) {
3288 dispatchTo(tabId, {
3289 type: "meta",
3290 meta: {
3291 ...current.meta,
3292 ready: false,
3293 startupErr: safeError,
3294 runtime: current.meta.runtime
3295 ? { ...current.meta.runtime, phase: "failed" }
3296 : current.meta.runtime,
3297 },
3298 });
3299 }
3300 dispatchTo(tabId, { type: "local_notice", level: "warn", text: safeError, preserveRuntime: true });
3301 return;
3302 }
3303 noteActivationSettled(event.requestId, "ready");
3304 noteNavigationRuntimeReady(pending.navigationSeq, pending.runtimeInitiallyReady);
3305 ensureTranscriptSubscription(tabId);
3306 // The ticket already prepared the target. Preserve any Ask that raced
3307 // ready while reset=true supersedes the earlier agent-ready history read.
3308 void loadSessionDataForTab(tabId, true, "open-topic", { placeholderItems: pending.placeholderItems })
3309 .then(() => {
3310 if (!isNavigationIntentCurrent(pending.navigationSeq) || activeTabIdRef.current !== tabId) return;
3311 const hydrated = statesRef.current.get(tabId);
3312 if (hydrated?.hydrateError) {
3313 void restoreNavigationSource(pending.navigationSeq, tabId, t("history.failedOpenSession"));
3314 return;
3315 }
3316 return reconcileTabRuntime(tabId, RUNTIME_STATUS_ONLY);
3317 })
3318 .catch(() => {
3319 if (isNavigationIntentCurrent(pending.navigationSeq) && activeTabIdRef.current === tabId) {
3320 void restoreNavigationSource(pending.navigationSeq, tabId, t("history.failedOpenSession"));
3321 }
3322 });
3323 }, [dispatchTo, ensureTranscriptSubscription, isNavigationIntentCurrent, loadSessionDataForTab, reconcileTabRuntime, restoreNavigationSource]);
3324
3325 useEffect(() => {
3326 const textBatch = createRafBatch<StreamDeltaEntry>((batch) => {
3327 uiPerfTracker.onStreamDispatch();
3328 for (const b of coalesceStreamDeltas(batch)) dispatchTo(b.tabId, { type: "stream_batch", segments: b.segments });
3329 });
3330 const receiveWireEvent = (e: WireEvent) => {
3331 // Untagged compatibility events belong to the tab that the backend has
3332 // actually activated, not the frontend's optimistic selection. During a
3333 // slow SetActiveTab these can differ, and routing to the optimistic tab
3334 // leaks the previous session's approval/ask gate into the new composer.
3335 const targetTabId = e.tabId || backendActiveTabIdRef.current || activeTabIdRef.current;
3336 if (!targetTabId) return;
3337 const acceptedEpoch = runtimeEpochByTabRef.current.get(targetTabId);
3338 if (e.runtimeEpoch) {
3339 if (!acceptsRuntimeEventEpoch(acceptedEpoch, e.runtimeEpoch)) return;
3340 if (!acceptedEpoch) runtimeEpochByTabRef.current.set(targetTabId, e.runtimeEpoch);
3341 }
3342 const currentMeta = statesRef.current.get(targetTabId)?.meta;
3343 if (e.sessionGeneration !== undefined && (!currentMeta || currentMeta.sessionGeneration === undefined || e.sessionGeneration !== currentMeta.sessionGeneration)) return;
3344 handleWireEvent({ ...e, tabId: targetTabId });
3345 };
3346 const handleWireEvent = (e: WireEvent) => {
3347 const targetTabId = e.tabId;
3348 if (!targetTabId) throw new Error("ordered event has no target tab");
3349 if (e.kind === "turn_done" || e.kind === "context_maintenance") {
3350 void app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
3351 }
3352 if (e.kind === "turn_done") {
3353 invalidateSharedQuery("BalanceForTab", [targetTabId]);
3354 void refreshBalanceForTab(targetTabId);
3355 app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
3356 void refreshTurnBoundaries(targetTabId);
3357 invalidateSharedQuery("MetaForTab", [targetTabId]);
3358 void refreshMetaForTab(targetTabId);
3359 }
3360 if (e.kind === "turn_done" || e.kind === "notice") {
3361 app.JobsForTab(targetTabId).then((jobs) => dispatchTo(targetTabId, { type: "jobs", jobs: asArray(jobs) })).catch(() => {});
3362 }
3363 if (e.kind === "session_changed" && e.sessionReset) {
3364 // The controller replaced the transcript under the same path (a head
3365 // switch from /switch, /branch, or /rewind); reload rather than patch.
3366 void loadSessionDataForTab(targetTabId, true, "session-changed");
3367 }
3368 };
3369 const off = onEvent(receiveWireEvent);
3370
3371 const offReady = onReady((readyTabId) => {
3372 const activeId = activeTabIdRef.current;
3373 if (readyTabId && activeId && readyTabId !== activeId) {
3374 addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
3375 return;
3376 }
3377 // Refresh metadata without turning passive readiness into navigation.
3378 void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true, navigationIntentSeq: activeNavigationSeqRef.current });
3379 });
3380
3381 // A rebuilt controller reissues approval/ask ids from "1" (see sound.ts).
3382 // Drop this tab's id-anchored prompt bookkeeping so a genuinely new
3383 // prompt from the new controller is never misread as a stale replay of
3384 // one the old controller already resolved (#6432 round 3). A tab-less
3385 // rebuild (settings-wide) affects every known tab.
3386 const offRebuilt = onRuntimeRebuilt((rebuiltTabId, runtimeEpoch) => {
3387 if (rebuiltTabId) {
3388 followers.current.get(rebuiltTabId)?.stop();
3389 followers.current.delete(rebuiltTabId);
3390 invalidateSharedQuery("MetaForTab", [rebuiltTabId]);
3391 if (runtimeEpoch) runtimeEpochByTabRef.current.set(rebuiltTabId, runtimeEpoch);
3392 dispatchTo(rebuiltTabId, { type: "controller_rebuilt" });
3393 if (!statesRef.current.get(rebuiltTabId)?.hydrating && !statesRef.current.get(rebuiltTabId)?.backendActivationPending) void startTranscriptFollow(rebuiltTabId, statesRef.current.get(rebuiltTabId)?.meta?.sessionPath ?? "").catch(error => dispatchTo(rebuiltTabId, { type: "transcript_connection", status: "disconnected", error: String(error) }));
3394 } else {
3395 if (runtimeEpoch) {
3396 for (const id of Array.from(statesRef.current.keys())) runtimeEpochByTabRef.current.set(id, runtimeEpoch);
3397 }
3398 for (const id of Array.from(statesRef.current.keys())) {
3399 followers.current.get(id)?.stop();
3400 followers.current.delete(id);
3401 invalidateSharedQuery("MetaForTab", [id]);
3402 dispatchTo(id, { type: "controller_rebuilt" });
3403 if (!statesRef.current.get(id)?.hydrating && !statesRef.current.get(id)?.backendActivationPending) void startTranscriptFollow(id, statesRef.current.get(id)?.meta?.sessionPath ?? "").catch(error => dispatchTo(id, { type: "transcript_connection", status: "disconnected", error: String(error) }));
3404 }
3405 }
3406 });
3407 const offTopicActivation = onTopicActivation(handleTopicActivationEvent);
3408 // tab:meta carries a full refreshed Meta after the backend's background
3409 // refresh of the expensive fields (git branch, image-input capability) —
3410 // those arrive empty in the first MetaForTab response now. Merge it like a
3411 // MetaForTab result, fenced to the session the tab is currently bound to.
3412 const offTabMeta = onTabMeta(({ tabId, meta }) => {
3413 if (!tabId || !meta) return;
3414 const current = statesRef.current.get(tabId);
3415 if (!current?.meta) return;
3416 if (sessionIdentityStableKey(meta) && !sameSessionHydrateIdentity(meta, current.meta)) return;
3417 dispatchTo(tabId, { type: "meta", meta });
3418 });
3419
3420 const offRecovery = startControllerEventRecovery({
3421 navigation: () => activeNavigationSeqRef.current,
3422 bindings: () => new Map(Array.from(statesRef.current, ([id, state]) => [id, JSON.stringify([sessionIdentityStableKey(state.meta), sessionLoadSeq.current.get(id)])])),
3423 meta: id => statesRef.current.get(id)?.meta,
3424 now: promptEventClock,
3425 flush: () => textBatch.drain(),
3426 prepare: tab => {
3427 if (tab.runtime?.epoch) runtimeEpochByTabRef.current.set(tab.id, tab.runtime.epoch);
3428 invalidateSharedQuery("MetaForTab", [tab.id]);
3429 dispatchTo(tab.id, { type: "optimistic_meta", meta: metaFromTab(tab, statesRef.current.get(tab.id)?.meta) });
3430 },
3431 runtime: (tab, snapshotAt) => {
3432 dispatchRuntimeStatusForTab(tab.id, tab, snapshotAt);
3433 },
3434 resynchronize: async tab => { await startTranscriptFollow(tab.id, tab.sessionPath ?? ""); },
3435 reset: id => { followers.current.get(id)?.stop(); followers.current.delete(id); },
3436 hydrate: (tab, recoveryCurrent) => loadSessionDataForTab(tab.id, true, "startup", {
3437 ...sessionIdentityFields(tab), sessionRevision: tab.sessionRevision,
3438 sessionDigest: tab.sessionDigest, sessionGeneration: tab.sessionGeneration, recoveryCurrent,
3439 }),
3440 });
3441
3442 // Passive hydration must not invalidate the concurrent draft-restore probe.
3443 void syncActiveTabFromBackend(false, true, { navigationIntentSeq: activeNavigationSeqRef.current });
3444 // The event subscription is live now, so ask the backend to re-emit any
3445 // approval/ask prompt that was already blocking a tab before this load —
3446 // otherwise a session left mid-confirmation shows "waiting" with no modal
3447 // and no way to stop (#3844).
3448 void app.ReplayPendingPrompts().catch(() => {});
3449 return () => {
3450 textBatch.drain();
3451 for (const follower of followers.current.values()) follower.stop();
3452 followers.current.clear();
3453 for (const timer of cancelReconcileTimers.current.values()) {
3454 window.clearTimeout(timer);
3455 }
3456 cancelReconcileTimers.current.clear();
3457 for (const timer of stalePromptReconcileTimers.current.values()) {
3458 window.clearTimeout(timer);
3459 }
3460 stalePromptReconcileTimers.current.clear();
3461 off();
3462 offReady();
3463 offRebuilt();
3464 offTopicActivation();
3465 offTabMeta();
3466 offRecovery();
3467 };
3468 }, [dispatchRuntimeStatusForTab, dispatchTo, handleTopicActivationEvent, loadSessionDataForTab, refreshBalanceForTab, refreshCheckpoints, refreshMetaForTab, syncActiveTabFromBackend, startTranscriptFollow]);
3469
3470 // Track the visible tab in the transcript store: the active tab is pinned
3471 // out of LRU eviction. (In-flight loads of background tabs still complete
3472 // into their own per-tab state; store generations move on session switch,
3473 // evict, and unload — not on visible-tab changes.)
3474 const previousStoreActiveTabRef = useRef<string | undefined>(undefined);
3475 useEffect(() => {
3476 getTranscriptStore().noteActiveTab(activeTabId, previousStoreActiveTabRef.current);
3477 previousStoreActiveTabRef.current = activeTabId;
3478 }, [activeTabId, startTranscriptFollow]);
3479
3480 // History reads route by binding identity: a remote tab's session lives on
3481 // its serve host, so answering it locally would mix two sessions.
3482 useEffect(() => {
3483 setTranscriptBindingIdentity((tabId) => (statesRef.current.get(tabId)?.meta?.remote ? "remote" : "local"));
3484 return () => setTranscriptBindingIdentity(() => "local");
3485 }, []);
3486
3487 // Keep shared all-source telemetry live between turn boundaries. Delivery
3488 // mode can complete dozens of provider requests inside one UI turn, while
3489 // the status bar reads state.context and would otherwise stay pinned to the
3490 // previous turn_done snapshot. A usage event is emitted after the backend
3491 // has recorded that request, so refresh the authoritative tab aggregate here.
3492 // The usage sequence and active-tab checks make this latest-request-wins:
3493 // slower snapshots cannot overwrite a newer usage event or a tab switch.
3494 useEffect(() => {
3495 const tabId = activeTabId;
3496 const usageSeq = activeState.usageSeq;
3497 if (!tabId || usageSeq <= 0 || !activeState.turnActive) return;
3498
3499 let cancelled = false;
3500 void app.ContextUsageForTab(tabId).then((context) => {
3501 if (cancelled || activeTabIdRef.current !== tabId) return;
3502 if (statesRef.current.get(tabId)?.usageSeq !== usageSeq) return;
3503 dispatchTo(tabId, { type: "context", context });
3504 }).catch(() => {});
3505
3506 return () => {
3507 cancelled = true;
3508 };
3509 }, [activeTabId, activeState.turnActive, activeState.usageSeq, dispatchTo]);
3510
3511 // If the startup ready event is missed, keep the composer lock in sync with
3512 // the active tab's backend metadata without kicking off tab activation work.
3513 // Remote tabs are exempt: their readiness flows through remote-tab state
3514 // events, and MetaForTab reports ready:false for them forever — reconciling
3515 // would just burn every attempt on a surface that never uses it.
3516 useEffect(() => {
3517 const tabId = activeTabId;
3518 const meta = activeState.meta;
3519 if (!tabId || !meta || meta.remote || meta.ready || meta.startupErr || activeState.backendActivationPending) {
3520 readyMetaReconcileSeq.current += 1;
3521 readyMetaReconcileActive.current = undefined;
3522 return;
3523 }
3524
3525 let cancelled = false;
3526 let timer: number | undefined;
3527 const seq = readyMetaReconcileSeq.current + 1;
3528 readyMetaReconcileSeq.current = seq;
3529 readyMetaReconcileActive.current = { tabId, seq };
3530
3531 const stillCurrent = () => {
3532 const active = readyMetaReconcileActive.current;
3533 return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
3534 };
3535
3536 const schedule = (attempt: number) => {
3537 timer = window.setTimeout(() => {
3538 void tick(attempt);
3539 }, STARTUP_READY_META_RECONCILE_MS);
3540 };
3541
3542 const tick = async (attempt: number) => {
3543 if (!stillCurrent()) return;
3544 const current = statesRef.current.get(tabId);
3545 if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
3546 const nextMeta = await refreshMetaOnlyForTab(tabId);
3547 if (!stillCurrent()) return;
3548 if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
3549 schedule(attempt + 1);
3550 };
3551
3552 schedule(0);
3553 return () => {
3554 cancelled = true;
3555 if (timer !== undefined) window.clearTimeout(timer);
3556 };
3557 }, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, refreshMetaOnlyForTab]);
3558
3559
3560 const rejectTurnSubmission = useCallback((tabId: string, submissionId: string, error: unknown) => {
3561 if (!statesRef.current.get(tabId)?.localSubmissions[submissionId]) return;
3562 if (isUnknownSubmissionError(error)) {
3563 dispatchTo(tabId, { type: "turn_submit_unknown", submissionId, error: `${t("chat.submissionUnknown")}: ${errorMessage(error)}` });
3564 void reconcileRuntimeAfterRejectedMutation(tabId);
3565 return;
3566 }
3567 dispatchTo(tabId, { type: "turn_submit_rejected", submissionId, error: `Send failed: ${errorMessage(error)}` });
3568 void reconcileRuntimeAfterRejectedMutation(tabId);
3569 }, [dispatchTo, reconcileRuntimeAfterRejectedMutation]);
3570
3571 // Replay any pending approval/ask prompts when switching tabs, so a
3572 // plan-mode session left awaiting confirmation rebuilds its modal (#4275).
3573 useEffect(() => {
3574 replayPendingPromptsForActiveTab(activeTabId);
3575 }, [activeTabId]);
3576
3577 const sendToTab = useCallback(async (
3578 tabId: string,
3579 displayText: string,
3580 submitText = displayText,
3581 originalText?: string,
3582 structured?: import("./invocationDisplay").StructuredInvocationSubmit,
3583 initialGoal?: {
3584 goal: string;
3585 collaborationMode: CollaborationMode;
3586 toolApprovalMode: ToolApprovalMode;
3587 },
3588 ) => {
3589 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3590 let currentState = getOrCreateState(statesRef.current, tabId);
3591 if (currentState.transcriptProtocol !== 2 && !followers.current.has(tabId)) {
3592 await startTranscriptFollow(tabId, currentState.meta?.sessionPath ?? "");
3593 currentState = getOrCreateState(statesRef.current, tabId);
3594 }
3595 if (currentState.transcriptProtocol !== 2 || currentState.transcriptConnection !== "connected") {
3596 throw new Error("Transcript v2 is not synchronized. Upgrade Desktop and Serve together, or reconnect.");
3597 }
3598 const runtime = currentState.meta?.runtime;
3599 if (currentState.meta && !runtimeReadyForSubmit(currentState.meta)) {
3600 throw new Error(runtime?.issue?.message || currentState.meta.startupErr || t("composer.workspaceStarting"));
3601 }
3602 const seq = currentState.seq;
3603 const submissionId = structured?.attachmentSubmissionId ?? createTurnSubmissionId(tabId, currentState.sessionGen, seq, runtimeEpochByTabRef.current.get(tabId) ?? runtime?.epoch);
3604 const submissionCurrent = () => submissionBindingCurrent(statesRef.current.get(tabId), currentState);
3605 const promptEpoch = currentState.promptEpoch;
3606 const { display, submit } = normalizeTurnSubmit(displayText, submitText);
3607 const original = originalText?.trim() ?? "";
3608 bumpCancelHydrateSeq(tabId);
3609 if (currentState.hydrateReason === "rewind") dispatchTo(tabId, { type: "hydrate_done" });
3610 dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq, submissionId });
3611 invalidateCache();
3612 try {
3613 const [outcome, detail] = await import("./turnSubmit").then(module => module.submitTurn(app, tabId, submissionId, display, submit, original, structured, initialGoal));
3614 if (!submissionCurrent()) return;
3615 if (outcome === 1) {
3616 dispatchTo(tabId, { type: "send_confirmed", submissionId });
3617 const ids = detail as string[];
3618 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch: promptEpoch });
3619 return;
3620 }
3621 if (outcome === 2) {
3622 dispatchTo(tabId, { type: "management_confirmed", submissionId });
3623 return;
3624 }
3625 if (outcome === 3) dispatchTo(tabId, { type: "turn_admitted", turnId: detail as string, submissionId });
3626 dispatchTo(tabId, { type: "send_confirmed", submissionId });
3627 } catch (error) {
3628 if (submissionCurrent()) rejectTurnSubmission(tabId, submissionId, error);
3629 throw error;
3630 }
3631 }, [bumpCancelHydrateSeq, dispatchTo, rejectTurnSubmission, startTranscriptFollow]);
3632
3633 const recoverDeliveryToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText) => {
3634 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3635 const currentState = getOrCreateState(statesRef.current, tabId);
3636 const runtime = currentState.meta?.runtime;
3637 if (currentState.meta && !runtimeReadyForSubmit(currentState.meta)) {
3638 throw new Error(runtime?.issue?.message || currentState.meta.startupErr || t("composer.workspaceStarting"));
3639 }
3640 const seq = currentState.seq;
3641 const submissionId = createTurnSubmissionId(tabId, currentState.sessionGen, seq, runtimeEpochByTabRef.current.get(tabId) ?? runtime?.epoch);
3642 const current = () => submissionBindingCurrent(statesRef.current.get(tabId), currentState);
3643 const display = displayText.trim();
3644 const submit = submitText.trim();
3645 dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq, submissionId, deliveryRecovery: true });
3646 invalidateCache();
3647 try {
3648 void app.SubmitDeliveryRecoveryToTabWithID(tabId, display, submit, submissionId).then(
3649 () => { if (current()) dispatchTo(tabId, { type: "send_confirmed", submissionId }); },
3650 (error) => { if (current()) rejectTurnSubmission(tabId, submissionId, error); },
3651 );
3652 } catch (error) {
3653 if (current()) rejectTurnSubmission(tabId, submissionId, error);
3654 throw error;
3655 }
3656 }, [dispatchTo, rejectTurnSubmission]);
3657
3658 const send = useCallback((displayText: string, submitText = displayText) => {
3659 const tabId = activeTabIdRef.current ?? activeTabId;
3660 if (tabId) {
3661 return sendToTab(tabId, displayText, submitText);
3662 }
3663 const snapshotAt = promptEventClock();
3664 return activeTabFromBackend().then(async (active) => {
3665 if (!active?.id) throw new Error(t("composer.workspaceStarting"));
3666 setActiveTabId(active.id);
3667 activeTabIdRef.current = active.id;
3668 confirmBackendActiveTab(active.id);
3669 dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
3670 await startTranscriptFollow(active.id, "");
3671 return sendToTab(active.id, displayText, submitText);
3672 });
3673 }, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab, startTranscriptFollow]);
3674
3675 const runShellForTab = useCallback(async (tabId: string, command: string) => {
3676 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3677 const currentState = getOrCreateState(statesRef.current, tabId);
3678 const current = () => submissionBindingCurrent(statesRef.current.get(tabId), currentState);
3679 const submissionId = createTurnSubmissionId(tabId, currentState.sessionGen, currentState.seq, runtimeEpochByTabRef.current.get(tabId) ?? currentState.meta?.runtime?.epoch);
3680 dispatchTo(tabId, { type: "user", text: `!${command}`, seq: currentState.seq, submissionId });
3681 try {
3682 await app.RunShellForTab(tabId, command);
3683 if (current()) dispatchTo(tabId, { type: "send_confirmed", submissionId });
3684 } catch (error) {
3685 if (current()) dispatchTo(tabId, { type: isUnknownSubmissionError(error) ? "turn_submit_unknown" : "send_failed", submissionId, error: `Command failed: ${error instanceof Error ? error.message : String(error)}` });
3686 throw error;
3687 }
3688 }, [dispatchTo]);
3689
3690 const runShell = useCallback(async (command: string) => {
3691 if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
3692 await runShellForTab(activeTabId, command);
3693 }, [activeTabId, runShellForTab]);
3694
3695 const steerForTab = useCallback(async (tabId: string, text: string) => {
3696 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3697 const turnId = typeof app.EnqueueInboxSteerForTurn === "function" ? await resolveActiveTurnId(app, tabId, statesRef.current.get(tabId)?.activeTurnId) : undefined;
3698 // Durable steer first: body is on disk before admission. Rejected steers
3699 // become follow-ups automatically (disposition queued_followup).
3700 const receipt = typeof app.EnqueueInboxSteerForTurn === "function"
3701 ? turnId
3702 ? await app.EnqueueInboxSteerForTurn(tabId, turnId, text, text, "")
3703 : await Promise.reject(new Error("active turn id is unavailable; refresh and try again"))
3704 : await app.EnqueueInboxSteer(tabId, text, text, "");
3705 if (receipt?.error) throw new Error(receipt.error);
3706 // queued_followup is success: the instruction is durable and will run at
3707 // the next idle/tool-boundary kick. Do not surface it as a send failure.
3708 }, []);
3709
3710 const steer = useCallback(async (text: string) => {
3711 if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
3712 await steerForTab(activeTabId, text);
3713 }, [activeTabId, steerForTab]);
3714
3715 const notice = useCallback((text: string, level: "info" | "warn" = "info") => {
3716 if (!activeTabId) return;
3717 dispatchTo(activeTabId, { type: "local_notice", level, text });
3718 }, [activeTabId, dispatchTo]);
3719
3720 // Extension form dismissed/submitted locally: hide the surface. The backend
3721 // round-trip (SubmitExtensionForm) lives in App.tsx, which owns the toast
3722 // context used for error reporting.
3723 const dismissExtensionForm = useCallback((tabId = activeTabId, identity?: Pick<ExtensionFormState, "pluginId" | "surfaceId" | "formInstanceId">) => {
3724 if (!tabId) return;
3725 dispatchTo(tabId, { type: "clearExtensionForm", identity });
3726 }, [activeTabId, dispatchTo]);
3727
3728 // The App drained the queued extension notifications into the toast system.
3729 const drainExtensionNotifications = useCallback(() => {
3730 if (!activeTabId) return;
3731 dispatchTo(activeTabId, { type: "extension_notifications_drained" });
3732 }, [activeTabId, dispatchTo]);
3733
3734 const cancelTab = useCallback(async (tabId: string, inboxItemIDs: string[] = []): Promise<Omit<CancelOutcome, "restoredText">> => {
3735 bumpCancelHydrateSeq(tabId);
3736 try {
3737 let turnId = statesRef.current.get(tabId)?.activeTurnId;
3738 const exactAPIAvailable = inboxItemIDs.length > 0
3739 ? typeof app.InterruptTurnWithInboxItemsForTab === "function"
3740 : typeof app.InterruptTurnForTab === "function";
3741 if (!turnId && exactAPIAvailable) {
3742 turnId = await resolveActiveTurnId(app, tabId);
3743 }
3744 const result = await requestSessionCancel(app, tabId, inboxItemIDs, turnId);
3745 if (result.warning) dispatchTo(tabId, { type: "local_notice", level: "warn", text: result.warning });
3746 return result;
3747 } catch (error) {
3748 const message = formatInboxCancelError(error, getLocale());
3749 dispatchTo(tabId, { type: "local_notice", level: "warn", text: message });
3750 return { discardedItemIds: [], error: message };
3751 } finally {
3752 scheduleCancelReconcile(tabId, 0);
3753 }
3754 }, [bumpCancelHydrateSeq, dispatchTo, scheduleCancelReconcile]);
3755
3756 const cancelForTab = useCallback(async (tabId: string, inboxItemIDs: string[] = []): Promise<CancelOutcome> => {
3757 const cur = statesRef.current.get(tabId);
3758 let restoredText: string | undefined;
3759 if (cur?.running && cur.pendingUser !== undefined) {
3760 restoredText = cur.pendingUser;
3761 dispatchTo(tabId, { type: "unsend" });
3762 } else {
3763 dispatchTo(tabId, { type: "cancel_requested" });
3764 }
3765 const result = await cancelTab(tabId, inboxItemIDs);
3766 return { restoredText, ...result };
3767 }, [cancelTab, dispatchTo]);
3768
3769 const cancel = useCallback(async (inboxItemIDs: string[] = []): Promise<CancelOutcome> => {
3770 const tabId = activeTabId;
3771 if (!tabId) return { discardedItemIds: [] };
3772 return cancelForTab(tabId, inboxItemIDs);
3773 }, [activeTabId, cancelForTab]);
3774
3775 const isPromptCurrentForTab = useCallback((target: InteractionTarget) => {
3776 const state = statesRef.current.get(target.tabId);
3777 return Boolean(state && stateOwnsInteraction(state, target));
3778 }, []);
3779 const approveForTab = useCallback((target: InteractionTarget, allow: boolean, session: boolean, persist: boolean) => {
3780 if (!target.tabId) return;
3781 const promptState = statesRef.current.get(target.tabId);
3782 const epoch = promptState?.promptEpoch ?? 0;
3783 dispatchTo(target.tabId, { type: "clearApproval", target });
3784 return resolvePromptForSession(target, {
3785 allow,
3786 session,
3787 persist,
3788 generation: target.requestGeneration,
3789 permissionRevision: target.permissionRevision,
3790 }).catch((error) => {
3791 handlePromptFailure(dispatchTo, target, epoch, error);
3792 throw error;
3793 });
3794 }, [dispatchTo]);
3795
3796 const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => {
3797 if (activeTabId) return approveForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "approval", id), allow, session, persist);
3798 }, [activeTabId, approveForTab]);
3799
3800 const resolvePlanDecisionForTab = useCallback((target: InteractionTarget, action: "start_execution" | "revise_plan" | "exit_plan") => {
3801 if (!target.tabId) return;
3802 const epoch = statesRef.current.get(target.tabId)?.promptEpoch ?? 0;
3803 dispatchTo(target.tabId, { type: "clearApproval", target });
3804 return resolvePromptForSession(target, { action }).catch((error) => {
3805 handlePromptFailure(dispatchTo, target, epoch, error);
3806 throw error;
3807 });
3808 }, [dispatchTo]);
3809
3810 const resolvePlanDecision = useCallback((id: string, action: "start_execution" | "revise_plan" | "exit_plan") => {
3811 if (activeTabId) return resolvePlanDecisionForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "plan", id), action);
3812 }, [activeTabId, resolvePlanDecisionForTab]);
3813
3814 const resolveRecoveryForTab = useCallback((target: InteractionTarget, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => {
3815 if (!target.tabId) return;
3816 const epoch = statesRef.current.get(target.tabId)?.promptEpoch ?? 0;
3817 dispatchTo(target.tabId, { type: "clearApproval", target });
3818 return resolvePromptForSession(target, { action, feedback }).catch((error) => {
3819 handlePromptFailure(dispatchTo, target, epoch, error);
3820 throw error;
3821 });
3822 }, [dispatchTo]);
3823
3824 const resolveRecovery = useCallback((id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => {
3825 if (activeTabId) return resolveRecoveryForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "recovery", id), action, feedback);
3826 }, [activeTabId, resolveRecoveryForTab]);
3827
3828 const answerQuestionForTab = useCallback((target: InteractionTarget, answers: QuestionAnswer[]): Promise<void> => {
3829 if (!target.tabId) return Promise.reject(new Error("source tab is unavailable"));
3830 const state = statesRef.current.get(target.tabId);
3831 const epoch = state?.promptEpoch ?? 0;
3832 return resolvePromptForSession(target, { questions: answers }).then(
3833 () => dispatchTo(target.tabId, { type: "ask_submit_succeeded", target, epoch }),
3834 (error) => {
3835 if (isStalePromptError(error)) dispatchTo(target.tabId, { type: "expire_prompt", target, epoch });
3836 else dispatchTo(target.tabId, { type: "local_notice", level: "warn", text: t("notice.askSubmitFailed", { error: errorMessage(error) }), preserveRuntime: true });
3837 void reconcileRuntimeAfterRejectedMutation(target.tabId);
3838 throw error;
3839 },
3840 );
3841 }, [dispatchTo, reconcileRuntimeAfterRejectedMutation]);
3842
3843 const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]): Promise<void> => {
3844 if (!activeTabId) return Promise.reject(new Error("active tab is unavailable"));
3845 return answerQuestionForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "ask", id), answers);
3846 }, [activeTabId, answerQuestionForTab]);
3847
3848 const answerMCPInteractionForTab = useCallback(
3849 (target: InteractionTarget, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => {
3850 if (!target.tabId) return;
3851 const promptState = statesRef.current.get(target.tabId);
3852 const epoch = promptState?.promptEpoch ?? 0;
3853 dispatchTo(target.tabId, { type: "expire_prompt", target, epoch });
3854 resolvePromptForSession(target, { action, content: content ?? null }).catch((error) => handlePromptFailure(dispatchTo, target, epoch, error));
3855 },
3856 [dispatchTo],
3857 );
3858
3859 const answerMCPInteraction = useCallback(
3860 (id: string, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => {
3861 if (activeTabId) answerMCPInteractionForTab(interactionTargetFromState(activeTabId, statesRef.current.get(activeTabId), "mcp", id), action, content);
3862 },
3863 [activeTabId, answerMCPInteractionForTab],
3864 );
3865
3866 const setControllerModeForTab = useCallback((tabId: string, mode: Mode): Promise<void> => {
3867 if (!tabId) return Promise.resolve();
3868 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3869 return app.SetModeForTab(tabId, mode).then((drained) => {
3870 // Only dismiss the approvals the backend reports it actually
3871 // auto-allowed. Fresh prompts (plan/memory/sandbox escape) survive a
3872 // yolo switch backend-side and must stay visible (#6432 round 4).
3873 const ids = Array.isArray(drained) ? drained : [];
3874 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch });
3875 }).catch(() => {});
3876 }, [dispatchTo]);
3877
3878 const setControllerMode = useCallback((mode: Mode): Promise<void> => {
3879 if (!activeTabId) return Promise.resolve();
3880 return setControllerModeForTab(activeTabId, mode);
3881 }, [activeTabId, setControllerModeForTab]);
3882
3883 const setCollaborationModeForTab = useCallback(async (tabId: string, mode: CollaborationMode): Promise<void> => {
3884 if (!tabId) return;
3885 await app.SetCollaborationModeForTab(tabId, mode).catch(() => {});
3886 await refreshMetaForTab(tabId);
3887 }, [refreshMetaForTab]);
3888
3889 const setCollaborationMode = useCallback(async (mode: CollaborationMode): Promise<void> => {
3890 if (!activeTabId) return;
3891 await setCollaborationModeForTab(activeTabId, mode);
3892 }, [activeTabId, setCollaborationModeForTab]);
3893
3894 const setToolApprovalModeForTab = useCallback(async (tabId: string, mode: ToolApprovalMode): Promise<void> => {
3895 if (!tabId) return;
3896 const current = await app.PermissionSnapshotForTab(tabId);
3897 await app.SetPermissionPresetForTab(tabId, normalizeToolApprovalMode(mode), current.revision);
3898 await refreshMetaForTab(tabId);
3899 }, [refreshMetaForTab]);
3900
3901 const setToolApprovalMode = useCallback(async (mode: ToolApprovalMode): Promise<void> => {
3902 if (!activeTabId) return;
3903 await setToolApprovalModeForTab(activeTabId, mode);
3904 }, [activeTabId, setToolApprovalModeForTab]);
3905
3906 const setQualityFloor = useCallback(async (floor: QualityFloor): Promise<void> => {
3907 if (!activeTabId) return;
3908 await app.SetQualityFloorForTab(activeTabId, floor).catch(() => undefined);
3909 await refreshMetaForTab(activeTabId);
3910 }, [activeTabId, refreshMetaForTab]);
3911
3912 const setComposerProfileForTab = useCallback(async (
3913 tabId: string,
3914 collaborationMode: CollaborationMode,
3915 toolApprovalMode: ToolApprovalMode,
3916 goal: string,
3917 options?: { propagateError?: boolean },
3918 ): Promise<boolean> => {
3919 if (!tabId) return false;
3920 const state = statesRef.current.get(tabId);
3921 const promptEpoch = state?.promptEpoch ?? 0;
3922 const key = composerProfileApplicationKey(
3923 runtimeEpochByTabRef.current.get(tabId) ?? state?.meta?.runtime?.epoch,
3924 collaborationMode,
3925 toolApprovalMode,
3926 goal,
3927 );
3928 if (appliedComposerProfileByTabRef.current.get(tabId) === key) return true;
3929 const existing = composerProfileInFlightByTabRef.current.get(tabId);
3930 if (existing?.key === key) return existing.promise;
3931
3932 const lifecycle = composerProfileLifecycleByTabRef.current.get(tabId) ?? 0;
3933 const previous = composerProfileQueueByTabRef.current.get(tabId) ?? Promise.resolve();
3934 const promise = previous.then(async () => {
3935 if ((composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) !== lifecycle) return false;
3936 if (appliedComposerProfileByTabRef.current.get(tabId) === key) return true;
3937 let drained: string[] | void;
3938 try {
3939 drained = await app.SetComposerProfileForTab(
3940 tabId,
3941 collaborationMode,
3942 toolApprovalMode,
3943 goal,
3944 );
3945 } catch (error) {
3946 if ((composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) === lifecycle) {
3947 await refreshMetaForTab(tabId);
3948 }
3949 if (options?.propagateError) throw error;
3950 return false;
3951 }
3952 if ((composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) !== lifecycle) return false;
3953 appliedComposerProfileByTabRef.current.set(tabId, key);
3954 const ids = Array.isArray(drained) ? drained : [];
3955 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch: promptEpoch });
3956 await refreshMetaForTab(tabId);
3957 return true;
3958 });
3959 const tail = promise.then(() => {}, () => {});
3960 composerProfileQueueByTabRef.current.set(tabId, tail);
3961 composerProfileInFlightByTabRef.current.set(tabId, { key, promise });
3962 try {
3963 return await promise;
3964 } finally {
3965 const current = composerProfileInFlightByTabRef.current.get(tabId);
3966 if (current?.promise === promise) composerProfileInFlightByTabRef.current.delete(tabId);
3967 if (composerProfileQueueByTabRef.current.get(tabId) === tail) {
3968 composerProfileQueueByTabRef.current.delete(tabId);
3969 }
3970 }
3971 }, [dispatchTo, refreshMetaForTab]);
3972
3973 const {
3974 setGoalForTab, setGoal, editGoalForTab, clearGoalForTab, clearGoal,
3975 resumeGoalForTab, resumeGoal, pauseGoalForTab, pauseGoal,
3976 } = useGoalControllerActions(activeTabId, refreshMetaForTab);
3977
3978 const newSession = useCallback(async () => {
3979 const tabId = activeTabId;
3980 if (tabId) await waitForTabReady(tabId);
3981 if (tabId) {
3982 addBreadcrumb("session.new", `click ${tabId}`);
3983 invalidateCheckpoints(tabId);
3984 bumpSessionLoadSeq(tabId);
3985 dispatchTo(tabId, { type: "reset" });
3986 dispatchTo(tabId, { type: "hydrate_start", reason: "new-session" });
3987 addBreadcrumb("session.new", `visible-reset ${tabId}`);
3988 }
3989 try {
3990 if (tabId) await app.NewSessionForTab(tabId);
3991 else await app.NewSession();
3992 addBreadcrumb("session.new", `backend-done ${tabId ?? ""}`);
3993 } catch (err) {
3994 if (tabId) {
3995 dispatchTo(tabId, { type: "hydrate_error", reason: "new-session", error: errorMessage(err) });
3996 void loadSessionDataForTab(tabId, true, "new-session").then(() => {
3997 dispatchTo(tabId, { type: "local_notice", level: "warn", text: `New session failed: ${errorMessage(err)}` });
3998 });
3999 }
4000 return; // backend refused (workspace starting / failed) — keep the transcript
4001 }
4002 invalidateCache();
4003 if (tabId) {
4004 await startTranscriptFollow(tabId, (await app.MetaForTab(tabId)).sessionPath ?? "");
4005 dispatchTo(tabId, { type: "hydrate_done" });
4006 void refreshMetaForTab(tabId);
4007 app.ContextUsageForTab(tabId).then((context) => dispatchTo(tabId, { type: "context", context })).catch(() => {});
4008 void refreshTurnBoundaries(tabId);
4009 }
4010 }, [activeTabId, invalidateCheckpoints, bumpSessionLoadSeq, dispatchTo, ensureTranscriptSubscription, loadSessionDataForTab, refreshTurnBoundaries, refreshMetaForTab, startTranscriptFollow, waitForTabReady]);
4011
4012 const clearSession = useCallback(async () => {
4013 const tabId = activeTabId;
4014 if (tabId) await waitForTabReady(tabId);
4015 if (tabId) {
4016 invalidateCheckpoints(tabId);
4017 bumpSessionLoadSeq(tabId);
4018 sessionLoadInFlight.current.delete(tabId);
4019 dispatchTo(tabId, { type: "hydrate_start", reason: "new-session" });
4020 }
4021 let cleared: SessionClearResult;
4022 try {
4023 cleared = tabId ? await app.ClearSessionForTab(tabId) : await app.ClearSession();
4024 } catch {
4025 if (tabId) void loadSessionDataForTab(tabId, false, "startup", { preserveCachedHistory: true });
4026 return;
4027 }
4028 if (tabId) bumpSessionLoadSeq(tabId);
4029 invalidateCache();
4030 if (tabId) {
4031 // Retire every resident projection for this tab so a mode switch cannot
4032 // preferResident-serve the destroyed transcript.
4033 getTranscriptStore().evictTab(tabId);
4034 const existing = statesRef.current.get(tabId)?.meta;
4035 const nextMeta = {
4036 ...(existing ?? { label: "", ready: true, eventChannel: "agent:event", cwd: "" }),
4037 sessionPath: cleared.sessionPath || "",
4038 session: cleared.session ?? null,
4039 sessionRevision: cleared.sessionRevision,
4040 sessionDigest: cleared.sessionDigest,
4041 sessionGeneration: cleared.sessionGeneration,
4042 };
4043 // Meta first so reset preserves the replacement identity.
4044 dispatchTo(tabId, { type: "optimistic_meta", meta: nextMeta });
4045 dispatchTo(tabId, { type: "reset" });
4046 await startTranscriptFollow(tabId, (await app.MetaForTab(tabId)).sessionPath ?? "");
4047 dispatchTo(tabId, { type: "hydrate_done" });
4048 }
4049 }, [activeTabId, invalidateCheckpoints, bumpSessionLoadSeq, dispatchTo, ensureTranscriptSubscription, loadSessionDataForTab, startTranscriptFollow, waitForTabReady]);
4050
4051 const listSessions = useCallback(async (): Promise<SessionMeta[]> => {
4052 const page = await app.ListHistorySessions({ scope: "all", workspaceRoot: "", status: "all", timeFilter: "all", query: "", cursor: "", limit: 200 });
4053 if (!page) throw new Error(t("history.failedLoadHistory"));
4054 return asArray<SessionMeta>(page.items);
4055 }, []);
4056 const listTrashedSessions = useCallback(async (): Promise<SessionMeta[]> => asArray<SessionMeta>(await app.ListTrashedSessions()), []);
4057 const retrySessionHistory = useCallback(async (tabId?: string) => {
4058 const id = tabId || activeTabIdRef.current; if (!id) return;
4059 const m = statesRef.current.get(id)?.meta;
4060 await loadSessionDataForTab(id, false, "startup", {
4061 ...sessionIdentityFields(m),
4062 freshSnapshot: true,
4063 sessionRevision: m?.sessionRevision, sessionDigest: m?.sessionDigest, preserveCachedHistory: false,
4064 });
4065 }, [loadSessionDataForTab]);
4066 const reconcileSessionNavigationForTab = useCallback(async (
4067 tabId: string,
4068 navigationSeq: number,
4069 sessionSeq: number,
4070 ): Promise<boolean> => {
4071 // Resample and replay prompts cleared by post-Resume/Open hydration.
4072 await refreshMetaOnlyForTab(tabId);
4073 if (!isNavigationIntentCurrent(navigationSeq) || !sessionLoadCurrent(tabId, sessionSeq)) return false;
4074 await reconcileTabRuntime(tabId, { hydrateSessionData: false, refreshAncillary: false });
4075 if (!isNavigationIntentCurrent(navigationSeq) || !sessionLoadCurrent(tabId, sessionSeq)) return false;
4076 replayPendingPromptsForActiveTab(tabId);
4077 return true;
4078 }, [isNavigationIntentCurrent, reconcileTabRuntime, refreshMetaOnlyForTab, sessionLoadCurrent]);
4079 const failSessionNavigation = useCallback(async (navigationSeq: number, tabId: string): Promise<SurfaceDataCommit> => {
4080 if (!isNavigationIntentCurrent(navigationSeq)) return { intent: navigationSeq, outcome: "superseded", tabId };
4081 const error = t("history.failedOpenSession");
4082 dispatchTo(tabId, { type: "hydrate_error", reason: "resume-session", error });
4083 await restoreNavigationSource(navigationSeq, tabId, error);
4084 return { intent: navigationSeq, outcome: "failed", tabId, error };
4085 }, [dispatchTo, isNavigationIntentCurrent, restoreNavigationSource]);
4086 const resumeSession = useCallback((path: string, tabId?: string, navigationIntentSeq?: number): NavigationResult<void> | undefined => {
4087 const targetTabId = tabId || activeTabId;
4088 if (!targetTabId) return;
4089 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4090 snapshotNavigationSourceTab(navigationSeq);
4091 const terminal = (outcome: SurfaceDataOutcome, error?: string): SurfaceDataCommit => ({ intent: navigationSeq, outcome, tabId: targetTabId, error });
4092 const existingState = statesRef.current.get(targetTabId);
4093 const sameSession = sameSessionHydrateIdentity({ sessionPath: path }, existingState?.meta); const placeholderItems = sameSessionPlaceholderItems({ sessionPath: path }, existingState);
4094 if (!sameSession) invalidateCheckpoints(targetTabId);
4095 const seq = bumpSessionLoadSeq(targetTabId);
4096 beginResumeHistory();
4097 // Withholding readiness is what keeps a switch from submitting into the runtime it is leaving: the composer reopens once the reconcile confirms the new session.
4098 if (existingState?.meta) dispatchTo(targetTabId, { type: "optimistic_meta", meta: { ...existingState.meta, sessionPath: path, ready: sameSession ? existingState.meta.ready : false } });
4099 dispatchTo(targetTabId, { type: "hydrate_start", reason: "resume-session", placeholderItems });
4100 if (!sameSession) dispatchTo(targetTabId, { type: "reset" });
4101 const surfaceReady = (async (): Promise<SurfaceDataCommit> => {
4102 await requireRegisteredNavigationIntent(navigationSeq);
4103 if (tabId) await waitForTabReady(tabId);
4104 else if (!(await waitForBackendActiveTab(targetTabId))) {
4105 return failSessionNavigation(navigationSeq, targetTabId);
4106 }
4107 if (!navigationCompletionCurrent(navigationSeq, "session.resume", targetTabId) || !sessionLoadCurrent(targetTabId, seq)) return terminal("superseded");
4108 dispatchTo(targetTabId, { type: "hydrate_start", reason: "resume-session", placeholderItems });
4109 const switchStarted = performance.now();
4110 let phases: import("./sessionDiagnostics").HistorySwitchPhases | void;
4111 try {
4112 if (!app.ResumeTranscriptSessionForTab) throw new Error("Transcript v2 requires an updated Desktop");
4113 phases = await app.ResumeTranscriptSessionForTab(targetTabId, path);
4114 } catch {
4115 if (!isNavigationIntentCurrent(navigationSeq) || !sessionLoadCurrent(targetTabId, seq)) return terminal("superseded");
4116 return failSessionNavigation(navigationSeq, targetTabId);
4117 }
4118 if (!navigationCompletionCurrent(navigationSeq, "session.resume", targetTabId) || !sessionLoadCurrent(targetTabId, seq)) return terminal("superseded");
4119 const snapshotStarted = performance.now();
4120 const metrics = await startTranscriptFollow(targetTabId, path);
4121 if (!navigationCompletionCurrent(navigationSeq, "session.resume", targetTabId) || !sessionLoadCurrent(targetTabId, seq)) return terminal("superseded");
4122 noteTranscriptFollowSwitch(phases, metrics, performance.now() - switchStarted, performance.now() - snapshotStarted);
4123 dispatchTo(targetTabId, { type: "hydrate_done" });
4124 if (!(await reconcileSessionNavigationForTab(targetTabId, navigationSeq, seq))) return terminal("superseded");
4125 app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
4126 void refreshTurnBoundaries(targetTabId);
4127 return terminal("ready");
4128 })().catch(() => failSessionNavigation(navigationSeq, targetTabId));
4129 return { value: undefined, surfaceReady };
4130 }, [activeTabId, beginActiveNavigation, bumpSessionLoadSeq, dispatchTo, ensureTranscriptSubscription, failSessionNavigation, invalidateCheckpoints, navigationCompletionCurrent, reconcileSessionNavigationForTab, refreshTurnBoundaries, requireRegisteredNavigationIntent, sessionLoadCurrent, startTranscriptFollow, snapshotNavigationSourceTab, waitForBackendActiveTab, waitForTabReady]);
4131
4132 const openChannelSession = useCallback((path: string, tabId: string, navigationIntentSeq?: number): NavigationResult<void> | undefined => {
4133 if (!tabId) return;
4134 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4135 snapshotNavigationSourceTab(navigationSeq);
4136 const existingState = statesRef.current.get(tabId); const sameSession = sameSessionHydrateIdentity({ sessionPath: path }, existingState?.meta);
4137 if (!sameSession) invalidateCheckpoints(tabId);
4138 const seq = bumpSessionLoadSeq(tabId);
4139 beginResumeHistory();
4140 // Same withholding as resumeSession: a channel switch must not submit into the runtime it is leaving.
4141 if (existingState?.meta) dispatchTo(tabId, { type: "optimistic_meta", meta: { ...existingState.meta, sessionPath: path, ready: sameSession ? existingState.meta.ready : false } });
4142 dispatchTo(tabId, { type: "hydrate_start", reason: "resume-session", placeholderItems: sameSessionPlaceholderItems({ sessionPath: path }, existingState) });
4143 if (!sameSession) dispatchTo(tabId, { type: "reset" });
4144 const terminal = (outcome: SurfaceDataOutcome, error?: string): SurfaceDataCommit => ({ intent: navigationSeq, outcome, tabId, error });
4145 const surfaceReady = (async (): Promise<SurfaceDataCommit> => {
4146 await requireRegisteredNavigationIntent(navigationSeq);
4147 await waitForTabReady(tabId);
4148 if (!navigationCompletionCurrent(navigationSeq, "session.channel", tabId) || !sessionLoadCurrent(tabId, seq)) return terminal("superseded");
4149 const switchStarted = performance.now();
4150 let phases: import("./sessionDiagnostics").HistorySwitchPhases | void;
4151 try {
4152 if (!app.OpenChannelTranscriptSessionForTab) throw new Error("Transcript v2 requires an updated Desktop");
4153 phases = await app.OpenChannelTranscriptSessionForTab(tabId, path);
4154 } catch {
4155 if (!isNavigationIntentCurrent(navigationSeq) || !sessionLoadCurrent(tabId, seq)) return terminal("superseded");
4156 return failSessionNavigation(navigationSeq, tabId);
4157 }
4158 if (!navigationCompletionCurrent(navigationSeq, "session.channel", tabId) || !sessionLoadCurrent(tabId, seq)) return terminal("superseded");
4159 const snapshotStarted = performance.now();
4160 const metrics = await startTranscriptFollow(tabId, path);
4161 if (!navigationCompletionCurrent(navigationSeq, "session.channel", tabId) || !sessionLoadCurrent(tabId, seq)) return terminal("superseded");
4162 noteTranscriptFollowSwitch(phases, metrics, performance.now() - switchStarted, performance.now() - snapshotStarted);
4163 dispatchTo(tabId, { type: "hydrate_done" });
4164 if (!(await reconcileSessionNavigationForTab(tabId, navigationSeq, seq))) return terminal("superseded");
4165 app.ContextUsageForTab(tabId).then((context) => dispatchTo(tabId, { type: "context", context })).catch(() => {});
4166 void refreshTurnBoundaries(tabId);
4167 return terminal("ready");
4168 })().catch(() => failSessionNavigation(navigationSeq, tabId));
4169 return { value: undefined, surfaceReady };
4170 }, [beginActiveNavigation, bumpSessionLoadSeq, dispatchTo, ensureTranscriptSubscription, failSessionNavigation, invalidateCheckpoints, isNavigationIntentCurrent, navigationCompletionCurrent, reconcileSessionNavigationForTab, refreshTurnBoundaries, requireRegisteredNavigationIntent, sessionLoadCurrent, startTranscriptFollow, snapshotNavigationSourceTab, waitForTabReady]);
4171
4172 const { openCanonicalSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession } =
4173 useSessionCatalogActions(requireRegisteredNavigationIntent, isNavigationIntentCurrent, syncActiveTabFromBackend, invalidateCache);
4174 const refreshMeta = useCallback(async () => {
4175 if (!activeTabId) return;
4176 invalidateSharedQuery("MetaForTab", [activeTabId]);
4177 await refreshMetaForTab(activeTabId);
4178 }, [activeTabId, refreshMetaForTab]);
4179
4180 const refreshWorkspaceState = useCallback(async (path: string, navigationSeq: number): Promise<string> => {
4181 if (!path) return path;
4182 if (!isNavigationIntentCurrent(navigationSeq)) await reassertVisibleTabAfterStaleNavigation("workspace.switch", "");
4183 else {
4184 const activatedTabId = await syncActiveTabFromBackend(true, false, { navigationIntentSeq: navigationSeq, surfacePolicy: "replace-surface", deferHydration: true });
4185 if (!isNavigationIntentCurrent(navigationSeq)) await reassertVisibleTabAfterStaleNavigation("workspace.switch", activatedTabId ?? "");
4186 }
4187 return path;
4188 }, [isNavigationIntentCurrent, reassertVisibleTabAfterStaleNavigation, syncActiveTabFromBackend]);
4189
4190 const pickWorkspace = useCallback(async (navigationIntentSeq?: number): Promise<string> => {
4191 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4192 await requireRegisteredNavigationIntent(navigationSeq);
4193 const path = await app.PickWorkspace();
4194 return refreshWorkspaceState(path, navigationSeq);
4195 }, [beginActiveNavigation, refreshWorkspaceState, requireRegisteredNavigationIntent]);
4196 const switchWorkspace = useCallback(async (path: string, navigationIntentSeq?: number): Promise<string> => {
4197 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4198 await requireRegisteredNavigationIntent(navigationSeq);
4199 const next = await app.SwitchWorkspace(path);
4200 return refreshWorkspaceState(next, navigationSeq);
4201 }, [beginActiveNavigation, refreshWorkspaceState, requireRegisteredNavigationIntent]);
4202
4203 const compact = useCallback(() => {
4204 const tabId = activeTabIdRef.current;
4205 if (!tabId) return;
4206 void waitForTabReady(tabId).then(() => app.CompactForTab(tabId).catch(() => {}));
4207 }, [waitForTabReady]);
4208
4209 const enqueueModelSwitch = useCallback((tabId: string, name: string, fallbackBalance?: BalanceInfo) => {
4210 let queue = modelSwitchQueueByTab.current.get(tabId);
4211 if (!queue) {
4212 queue = { running: false, fallbackBalance };
4213 modelSwitchQueueByTab.current.set(tabId, queue);
4214 }
4215 const queueState = queue;
4216
4217 return new Promise<ModelSwitchQueueResult>((resolve, reject) => {
4218 const request: ModelSwitchQueueRequest = { name, resolve, reject };
4219 const run = (next: ModelSwitchQueueRequest) => {
4220 queueState.running = true;
4221 void Promise.resolve()
4222 .then(() => app.SetModelForTab(tabId, next.name))
4223 .then(
4224 () => next.resolve("applied"),
4225 (err) => next.reject(err),
4226 )
4227 .finally(() => {
4228 if (modelSwitchQueueByTab.current.get(tabId) !== queueState) return;
4229 const pending = queueState.pending;
4230 queueState.pending = undefined;
4231 if (pending) {
4232 run(pending);
4233 return;
4234 }
4235 queueState.running = false;
4236 modelSwitchQueueByTab.current.delete(tabId);
4237 });
4238 };
4239
4240 if (queueState.running) {
4241 queueState.pending?.resolve("superseded");
4242 queueState.pending = request;
4243 return;
4244 }
4245 run(request);
4246 });
4247 }, []);
4248
4249 const { setModelForTab, setEffortForTab } = useMemo(() => createControllerModelCommands({
4250 statesRef, modelSwitchSeqByTab, modelSwitchSuccessVersionByTab, modelSwitchQueueByTab,
4251 enqueueModelSwitch, clearBalanceForTab, dispatchTo, refreshBalanceForTab, refreshMetaForTab,
4252 }), [enqueueModelSwitch, clearBalanceForTab, dispatchTo, refreshBalanceForTab, refreshMetaForTab]);
4253 const setModel = useCallback((name: string) => activeTabId ? setModelForTab(activeTabId, name) : Promise.resolve(false), [activeTabId, setModelForTab]);
4254 const setEffort = useCallback((level: string) => activeTabId ? setEffortForTab(activeTabId, level) : Promise.resolve(), [activeTabId, setEffortForTab]);
4255
4256 const cancelJob = useCallback(async (jobID: string): Promise<boolean> => {
4257 const tabId = activeTabId;
4258 if (!tabId || !jobID.trim()) return false;
4259 try {
4260 const cancelled = await app.CancelJobForTab(tabId, jobID);
4261 const jobs = asArray(await app.JobsForTab(tabId));
4262 dispatchTo(tabId, { type: "jobs", jobs });
4263 await refreshMetaForTab(tabId);
4264 return cancelled;
4265 } catch {
4266 dispatchTo(tabId, { type: "local_notice", level: "warn", text: t("status.jobStopFailed") });
4267 return false;
4268 }
4269 }, [activeTabId, dispatchTo, refreshMetaForTab]);
4270
4271 const fetchMemory = useCallback((): Promise<MemoryView> =>
4272 app.Memory().catch(() => ({
4273 docs: [], facts: [], archives: [], scopes: [], instructionDiagnostics: [], conflicts: [],
4274 lastRecall: { query: "", hits: [], omitted: 0, charBudget: 0, usedChars: 0 },
4275 storeDir: "", available: false,
4276 })), []);
4277 const remember = useCallback(async (scope: string, note: string) => { await app.Remember(scope, note).catch(() => {}); }, []);
4278 const forget = useCallback(async (name: string) => { await app.Forget(name).catch(() => {}); }, []);
4279 const saveDoc = useCallback(async (path: string, body: string) => { await app.SaveDoc(path, body).catch(() => {}); }, []);
4280
4281 const adoptReturnedTab = async (tab: TabMeta, sourceTabId: string, navigationSeq: number, reason: string): Promise<string | undefined> => {
4282 const snapshotAt = promptEventClock();
4283 const navigationUnchanged = activeNavigationSeqRef.current === navigationSeq;
4284 const activateFork = tab.active && navigationUnchanged && activeTabIdRef.current === sourceTabId;
4285 if (!activateFork) {
4286 dispatchTo(tab.id, { type: "optimistic_meta", meta: metaFromTab(tab, statesRef.current.get(tab.id)?.meta) });
4287 dispatchRuntimeStatusForTab(tab.id, tab, snapshotAt);
4288 const currentTabId = activeTabIdRef.current;
4289 if (tab.active) {
4290 await reassertVisibleTabAfterStaleNavigation(reason, tab.id);
4291 } else if (!tab.active && navigationUnchanged && currentTabId === sourceTabId) {
4292 await syncActiveTabFromBackend(false, true);
4293 }
4294 addBreadcrumb(reason, `stale completion ${tab.id} current=${currentTabId ?? ""}`);
4295 await waitForTabReady(tab.id);
4296 return tab.id;
4297 }
4298 beginActiveNavigation();
4299 setActiveTabId(tab.id);
4300 activeTabIdRef.current = tab.id;
4301 confirmBackendActiveTab(tab.id);
4302 dispatchRuntimeStatusForTab(tab.id, tab, snapshotAt);
4303 await waitForTabReady(tab.id);
4304 await loadSessionDataForTab(tab.id, true);
4305 await reconcileTabRuntime(tab.id, RUNTIME_STATUS_ONLY);
4306 return tab.id;
4307 };
4308 const rewindForTabDetailed = useCallback(async (sourceTabId: string, turn: number, scope: string): Promise<RewindResultView & { ok: boolean }> => {
4309 if (!sourceTabId) return { ok: false };
4310 const forkNavigationSeq = activeNavigationSeqRef.current;
4311 await waitForTabReady(sourceTabId);
4312 const actionScope = (["fork", "fork-worktree", "summ-from", "summ-upto", "conversation", "code", "both"].includes(scope) ? scope : "both") as MessageActionScope;
4313 const { messageActionBusyText, settleForkConversationForTab } = await import("./controllerSwitchNotices");
4314 dispatchTo(sourceTabId, { type: "message_action_start", action: { turn, scope: actionScope } });
4315 dispatchTo(sourceTabId, { type: "local_notice", level: "info", text: messageActionBusyText(actionScope) });
4316 try {
4317 if (actionScope === "fork" || actionScope === "fork-worktree") {
4318 return settleForkConversationForTab(app, sourceTabId, turn, actionScope === "fork-worktree",
4319 (tabId, level, text) => dispatchTo(tabId, { type: "local_notice", level, text }),
4320 tab => adoptReturnedTab(tab, sourceTabId, forkNavigationSeq, "tab.fork"),
4321 () => syncActiveTabFromBackend(true));
4322 }
4323
4324 let outcome: RewindResultView & { ok: boolean } = { ok: true };
4325 let partialNotice = "";
4326 if (actionScope === "summ-from") await app.SummarizeFromForTab(sourceTabId, turn);
4327 else if (actionScope === "summ-upto") await app.SummarizeUpToForTab(sourceTabId, turn);
4328 else {
4329 const { commitRewindWithPreview, partialRewindNotice, rewindFailureDetail, rewindOutcome, settleRewindTarget } = await import("./rewindCommit");
4330 const result = await commitRewindWithPreview(sourceTabId, turn, actionScope);
4331 if (!result?.ok) {
4332 dispatchTo(sourceTabId, { type: "local_notice", level: "warn", text: rewindFailureDetail(result) });
4333 return { ok: false, written: result?.written, deleted: result?.deleted };
4334 }
4335 outcome = rewindOutcome(result);
4336 outcome.tabId = await settleRewindTarget(result, tab => adoptReturnedTab(tab, sourceTabId, forkNavigationSeq, "tab.rewind"), waitForTabReady);
4337 partialNotice = partialRewindNotice(result);
4338 }
4339
4340 await loadSessionDataForTab(sourceTabId, true, "rewind");
4341 if (partialNotice) await import("./rewindCommit").then(({ dispatchPartialRewindNotice }) =>
4342 dispatchPartialRewindNotice(partialNotice, sourceTabId, outcome.tabId, (tabId, text) => dispatchTo(tabId, { type: "local_notice", level: "warn", text })));
4343 return outcome;
4344 } catch {
4345 if (actionScope === "fork" || actionScope === "fork-worktree") {
4346 dispatchTo(sourceTabId, { type: "local_notice", level: "warn", text: t("rewind.forkFailed") });
4347 }
4348 return { ok: false };
4349 } finally {
4350 dispatchTo(sourceTabId, { type: "message_action_done" });
4351 }
4352 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, syncActiveTabFromBackend, waitForTabReady]);
4353
4354 const rewindForTab = useCallback(async (sourceTabId: string, turn: number, scope: string): Promise<boolean> => {
4355 return (await rewindForTabDetailed(sourceTabId, turn, scope)).ok;
4356 }, [rewindForTabDetailed]);
4357 const forkTurnForTab = useCallback((sourceTabId: string, target: import("./forkTargets").ForkTargetView): Promise<boolean> =>
4358 settleForkTurnForTab(app, sourceTabId, target, {
4359 dispatch: (action) => dispatchTo(sourceTabId, action),
4360 adopt: (tab) => adoptReturnedTab(tab, sourceTabId, activeNavigationSeqRef.current, "tab.fork-target"),
4361 sync: () => syncActiveTabFromBackend(true), waitForTabReady,
4362 }), [adoptReturnedTab, dispatchTo, syncActiveTabFromBackend, waitForTabReady]);
4363
4364 const rewind = useCallback(async (turn: number, scope: string): Promise<boolean> => {
4365 if (!activeTabId) return false;
4366 return rewindForTab(activeTabId, turn, scope);
4367 }, [activeTabId, rewindForTab]);
4368
4369 const undoRewindForTab = useCallback(async (sourceTabId: string, transactionId: string): Promise<boolean> => {
4370 if (!sourceTabId || !transactionId) return false;
4371 try {
4372 const { undoCommittedRewind } = await import("./rewindCommit");
4373 const result = await undoCommittedRewind(sourceTabId, transactionId);
4374 if (!result?.ok) {
4375 const detail = result?.error || "undo rewind failed";
4376 dispatchTo(sourceTabId, { type: "local_notice", level: "warn", text: detail });
4377 return false;
4378 }
4379 await loadSessionDataForTab(sourceTabId, true, "rewind");
4380 return true;
4381 } catch (err) {
4382 dispatchTo(sourceTabId, {
4383 type: "local_notice",
4384 level: "warn",
4385 text: err instanceof Error ? err.message : String(err),
4386 });
4387 return false;
4388 }
4389 }, [dispatchTo, loadSessionDataForTab]);
4390
4391 // Tab management: switch preserves per-tab state; open creates it.
4392 const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta, navigationIntentSeq?: number): Promise<TabMeta[] | undefined> => {
4393 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4394 await requireRegisteredNavigationIntent(navigationSeq);
4395 if (!navigationCompletionCurrent(navigationSeq, "tab.switch", tabId)) return undefined;
4396 snapshotNavigationSourceTab(navigationSeq);
4397 const startedAt = Date.now();
4398 topicActivationSeqRef.current += 1;
4399 const switchRequestId = `fe-switch-${Date.now()}-${topicActivationSeqRef.current}`;
4400 noteActivationRequested(switchRequestId);
4401 const previousTabId = activeTabIdRef.current;
4402 const targetState = statesRef.current.get(tabId);
4403 const currentTargetIdentity = targetState?.meta ?? listedSessionIdentityByTabRef.current.get(tabId);
4404 const targetIdentity = optimisticTab ? sessionIdentityFields(optimisticTab) : undefined;
4405 const targetSessionRevision = optimisticTab?.sessionRevision;
4406 const targetSessionDigest = optimisticTab?.sessionDigest;
4407 const targetSessionGeneration = optimisticTab?.sessionGeneration;
4408 const sameSession = sameSessionHydrateIdentity(targetIdentity, currentTargetIdentity);
4409 const optimisticStatus = optimisticTab ? backendStatusFromRuntimeMeta(optimisticTab) : undefined;
4410 const adoptUnboundLiveSurface = canAdoptUnboundLiveSurface(targetIdentity, currentTargetIdentity, targetState, Boolean(optimisticStatus?.running), optimisticTab?.runtime?.epoch, runtimeEpochByTabRef.current.get(tabId));
4411 const preserveTargetSurface = sameSession || adoptUnboundLiveSurface;
4412 const placeholderItems = sameSession ? targetState?.items : undefined;
4413 const preserveCachedHistory = sameSession && hasReusableCachedTranscript(targetState, targetIdentity ?? {}, targetSessionRevision, targetSessionDigest);
4414 addBreadcrumb("tab.switch", `click ${tabId}`);
4415 setActiveTabId(tabId);
4416 activeTabIdRef.current = tabId;
4417 noteNavigationIdentityPublished(navigationSeq, tabId);
4418 dispatchTo(tabId, { type: "backend_activation_start", backendPendingPrompt: Boolean(optimisticTab?.pendingPrompt) });
4419 noteActivationStarted(switchRequestId, tabId);
4420 if (optimisticTab) {
4421 dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
4422 }
4423 // Remote tabs have no local controller/history slice. Their transcript is
4424 // hydrated by useRemoteSession via RemoteTabSnapshot after ready. Running
4425 // HistorySliceForTab here fails with "session path unavailable" and the
4426 // hydrateError path would bounce the user back to the previous local tab.
4427 if (optimisticTab?.remote) {
4428 if (!preserveTargetSurface) dispatchTo(tabId, { type: "reset" });
4429 dispatchTo(tabId, { type: "hydrate_done" });
4430 const backendActivation = app.SetActiveTab(tabId)
4431 .then(async () => {
4432 if (!isNavigationIntentCurrent(navigationSeq) || activeTabIdRef.current !== tabId) {
4433 noteActivationSettled(switchRequestId, "cancelled");
4434 await reassertVisibleTabAfterStaleNavigation("tab.switch", tabId);
4435 return false;
4436 }
4437 confirmBackendActiveTab(tabId);
4438 noteActivationSettled(switchRequestId, "ready");
4439 return true;
4440 })
4441 .catch((err) => {
4442 noteActivationSettled(switchRequestId, "failed", errorMessage(err));
4443 if (!isNavigationIntentCurrent(navigationSeq)) return false;
4444 dispatchTo(tabId, { type: "backend_activation_done" });
4445 if (previousTabId && activeTabIdRef.current === tabId) {
4446 setActiveTabId(previousTabId);
4447 activeTabIdRef.current = previousTabId;
4448 }
4449 return false;
4450 });
4451 trackBackendActivation(tabId, backendActivation);
4452 return backendActivation.then(async (activated) => {
4453 if (!activated || !isNavigationIntentCurrent(navigationSeq)) return undefined;
4454 return reconcileTabRuntime(tabId, RUNTIME_STATUS_ONLY);
4455 });
4456 }
4457 if (!preserveTargetSurface) dispatchTo(tabId, { type: "reset" });
4458 if (optimisticStatus?.running) dispatchTo(tabId, optimisticStatus);
4459 dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab", placeholderItems });
4460 const readableTarget = optimisticTab ?? listedSessionIdentityByTabRef.current.get(tabId);
4461 // A resident/live target is already the freshest readable surface. A
4462 // durable baseline read must not replace its optimistic user message or
4463 // active assistant tail while backend activation is pending.
4464 if (readableTarget && !preserveCachedHistory && !hasCachedLiveTurn(targetState)) {
4465 void primeReadableHistoryForTab(tabId, readableTarget, "switch-tab", navigationSeq, () =>
4466 isNavigationIntentCurrent(navigationSeq) && activeTabIdRef.current === tabId,
4467 );
4468 }
4469 addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
4470 const backendActivation = app.SetActiveTab(tabId)
4471 .then(async () => {
4472 const navigationCurrent = isNavigationIntentCurrent(navigationSeq);
4473 if (!navigationCurrent || activeTabIdRef.current !== tabId) {
4474 const currentTabId = activeTabIdRef.current;
4475 noteActivationSettled(switchRequestId, "cancelled");
4476 await reassertVisibleTabAfterStaleNavigation("tab.switch", tabId);
4477 addBreadcrumb("tab.switch", `set-active-stale ${tabId} seq=${navigationSeq} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
4478 return false;
4479 }
4480 confirmBackendActiveTab(tabId);
4481 // Re-run the scoped replay after backend activation. This closes the
4482 // window where the runtime is reattached while the optimistic switch
4483 // is in flight and the first replay still sees no controller on tabId.
4484 replayPendingPromptsForActiveTab(tabId);
4485 addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
4486 return true;
4487 })
4488 .catch((err) => {
4489 noteActivationSettled(switchRequestId, "failed", errorMessage(err));
4490 if (!isNavigationIntentCurrent(navigationSeq)) return false;
4491 dispatchTo(tabId, { type: "backend_activation_done" });
4492 dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
4493 if (previousTabId && activeTabIdRef.current === tabId) {
4494 setActiveTabId(previousTabId);
4495 activeTabIdRef.current = previousTabId;
4496 addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
4497 }
4498 return false;
4499 });
4500 trackBackendActivation(tabId, backendActivation);
4501 const backendSwitch = backendActivation
4502 .then(async (activated) => {
4503 if (!activated || !isNavigationIntentCurrent(navigationSeq)) {
4504 if (!activated) noteActivationSettled(switchRequestId, "failed", "backend activation did not complete");
4505 return undefined;
4506 }
4507 const tabs = await reconcileTabRuntime(tabId, { hydrateSessionData: false, refreshAncillary: false });
4508 if (!isNavigationIntentCurrent(navigationSeq)) return tabs;
4509 const runtimeMeta = statesRef.current.get(tabId)?.meta;
4510 if (runtimeReadyForSubmit(runtimeMeta)) {
4511 noteNavigationRuntimeReady(
4512 navigationSeq,
4513 Boolean(optimisticTab?.ready && (!optimisticTab.runtime || optimisticTab.runtime.phase === "ready")),
4514 );
4515 }
4516 const hydration = loadSessionDataForTab(tabId, false, "switch-tab", {
4517 skipHistory: sameSession && hasCachedLiveTurn(statesRef.current.get(tabId)),
4518 placeholderItems,
4519 surfacePolicy: preserveTargetSurface ? "preserve-current" : "replace-surface",
4520 preserveCachedHistory,
4521 ...sessionIdentityFields(optimisticTab),
4522 sessionRevision: targetSessionRevision,
4523 sessionDigest: targetSessionDigest,
4524 sessionGeneration: targetSessionGeneration,
4525 });
4526 // Release the click queue as soon as activation has yielded its target.
4527 // Hydration continues independently; the App-level surface transaction
4528 // retains the source until this target commits data and paint.
4529 void hydration.then(async () => {
4530 if (!isNavigationIntentCurrent(navigationSeq)) return;
4531 const hydratedTargetState = statesRef.current.get(tabId);
4532 if (hydratedTargetState?.hydrateError) {
4533 noteActivationSettled(switchRequestId, "failed", hydratedTargetState.hydrateError);
4534 await restoreNavigationSource(navigationSeq, tabId, t("history.failedOpenSession"));
4535 return;
4536 }
4537 noteActivationSettled(switchRequestId, "ready");
4538 }).catch((err) => {
4539 noteActivationSettled(switchRequestId, "failed", errorMessage(err));
4540 if (isNavigationIntentCurrent(navigationSeq)) {
4541 dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: t("history.failedOpenSession") });
4542 void restoreNavigationSource(navigationSeq, tabId, t("history.failedOpenSession"));
4543 }
4544 });
4545 return tabs;
4546 })
4547 .catch((err) => {
4548 noteActivationSettled(switchRequestId, "failed", errorMessage(err));
4549 if (isNavigationIntentCurrent(navigationSeq)) {
4550 dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: t("history.failedOpenSession") });
4551 void restoreNavigationSource(navigationSeq, tabId, t("history.failedOpenSession"));
4552 }
4553 return undefined;
4554 });
4555 return backendSwitch;
4556 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchTo, isNavigationIntentCurrent, loadSessionDataForTab, navigationCompletionCurrent, primeReadableHistoryForTab, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, restoreNavigationSource, snapshotNavigationSourceTab, trackBackendActivation]);
4557
4558 const switchRemoteTab = useRemoteTabSwitch({
4559 activeTabIdRef, setActiveTabId, beginNavigation: beginActiveNavigation,
4560 requireRegisteredNavigation: requireRegisteredNavigationIntent,
4561 navigationCanComplete: navigationCompletionCurrent,
4562 navigationIsCurrent: isNavigationIntentCurrent,
4563 confirmBackendActiveTab,
4564 reassertVisibleTab: reassertVisibleTabAfterStaleNavigation,
4565 });
4566
4567 const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4568 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4569 await requireRegisteredNavigationIntent(navigationSeq);
4570 snapshotNavigationSourceTab(navigationSeq);
4571 const snapshotAt = promptEventClock();
4572 const meta = await app.OpenProjectTab(workspaceRoot, topicId);
4573 if (!navigationCompletionCurrent(navigationSeq, "tab.open-project", meta.id)) {
4574 await reassertVisibleTabAfterStaleNavigation("tab.open-project", meta.id);
4575 return meta;
4576 }
4577 const prevState = statesRef.current.get(meta.id);
4578 const isNewTab = !prevState;
4579 const sameSession = sameSessionHydrateIdentity(meta, prevState?.meta);
4580 const preserveCachedHistory = sameSession && hasReusableCachedTranscript(prevState, meta, meta.sessionRevision, meta.sessionDigest);
4581 setActiveTabId(meta.id);
4582 activeTabIdRef.current = meta.id;
4583 confirmBackendActiveTab(meta.id);
4584 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4585 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4586 const load = loadSessionDataForTab(meta.id, !sameSession, "open-topic", {
4587 placeholderItems: sameSessionPlaceholderItems(meta, prevState), surfacePolicy: sameSession ? "preserve-current" : "replace-surface", preserveCachedHistory,
4588 ...sessionIdentityFields(meta), sessionRevision: meta.sessionRevision, sessionDigest: meta.sessionDigest,
4589 });
4590 monitorNavigationHydration(navigationSeq, meta.id, load, isNewTab ? () => reconcileTabRuntime(meta.id, RUNTIME_STATUS_ONLY) : undefined);
4591 return meta;
4592 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, monitorNavigationHydration, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4593
4594 const openGlobalTab = useCallback(async (topicId: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4595 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4596 await requireRegisteredNavigationIntent(navigationSeq);
4597 snapshotNavigationSourceTab(navigationSeq);
4598 const snapshotAt = promptEventClock();
4599 const meta = await app.OpenGlobalTab(topicId);
4600 if (!navigationCompletionCurrent(navigationSeq, "tab.open-global", meta.id)) {
4601 await reassertVisibleTabAfterStaleNavigation("tab.open-global", meta.id);
4602 return meta;
4603 }
4604 const prevState = statesRef.current.get(meta.id);
4605 const isNewTab = !prevState;
4606 const sameSession = sameSessionHydrateIdentity(meta, prevState?.meta);
4607 const preserveCachedHistory = sameSession && hasReusableCachedTranscript(prevState, meta, meta.sessionRevision, meta.sessionDigest);
4608 setActiveTabId(meta.id);
4609 activeTabIdRef.current = meta.id;
4610 confirmBackendActiveTab(meta.id);
4611 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4612 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4613 const load = loadSessionDataForTab(meta.id, !sameSession, "open-topic", {
4614 placeholderItems: sameSessionPlaceholderItems(meta, prevState), surfacePolicy: sameSession ? "preserve-current" : "replace-surface", preserveCachedHistory,
4615 ...sessionIdentityFields(meta), sessionRevision: meta.sessionRevision, sessionDigest: meta.sessionDigest,
4616 });
4617 monitorNavigationHydration(navigationSeq, meta.id, load, isNewTab ? () => reconcileTabRuntime(meta.id, RUNTIME_STATUS_ONLY) : undefined);
4618 return meta;
4619 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, monitorNavigationHydration, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4620
4621 const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4622 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4623 await requireRegisteredNavigationIntent(navigationSeq);
4624 snapshotNavigationSourceTab(navigationSeq);
4625 const snapshotAt = promptEventClock();
4626 const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
4627 if (!navigationCompletionCurrent(navigationSeq, "tab.open-session", meta.id)) {
4628 await reassertVisibleTabAfterStaleNavigation("tab.open-session", meta.id);
4629 return meta;
4630 }
4631 const prevState = statesRef.current.get(meta.id);
4632 const isNewTab = !prevState;
4633 const sameSession = sameSessionHydrateIdentity(meta, prevState?.meta);
4634 const preserveCachedHistory = sameSession && hasReusableCachedTranscript(prevState, meta, meta.sessionRevision, meta.sessionDigest);
4635 setActiveTabId(meta.id);
4636 activeTabIdRef.current = meta.id;
4637 confirmBackendActiveTab(meta.id);
4638 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4639 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4640 const load = loadSessionDataForTab(meta.id, !sameSession, "open-topic", {
4641 placeholderItems: sameSessionPlaceholderItems(meta, prevState), surfacePolicy: sameSession ? "preserve-current" : "replace-surface", preserveCachedHistory,
4642 ...sessionIdentityFields(meta), sessionRevision: meta.sessionRevision, sessionDigest: meta.sessionDigest,
4643 });
4644 monitorNavigationHydration(navigationSeq, meta.id, load, isNewTab ? () => reconcileTabRuntime(meta.id, RUNTIME_STATUS_ONLY) : undefined);
4645 return meta;
4646 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, monitorNavigationHydration, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4647
4648 const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = "", navigationIntentSeq?: number): Promise<TabMeta> => {
4649 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4650 await requireRegisteredNavigationIntent(navigationSeq);
4651 snapshotNavigationSourceTab(navigationSeq);
4652 const snapshotAt = promptEventClock();
4653 // Ticketed two-phase activation: the backend switches the visible surface
4654 // before returning the ticket; the controller build and tab prune finish
4655 // in the background and report through "topic:activation". Register the
4656 // pending ticket before the call so synchronously-emitted events match.
4657 topicActivationSeqRef.current += 1;
4658 const pending: PendingTopicActivation = { requestId: `fe-act-${Date.now()}-${topicActivationSeqRef.current}`, navigationSeq };
4659 pendingTopicActivationRef.current = pending;
4660 noteActivationRequested(pending.requestId);
4661 const ticket = await app.StartTopicActivation({
4662 selector: sessionPath.startsWith("session-source:") ? { source: JSON.parse(decodeURIComponent(sessionPath.slice("session-source:".length))) }
4663 : sessionPath.startsWith("session-id:") ? { ref: { hostId: "local", sessionId: sessionPath.slice("session-id:".length) } }
4664 : sessionPath ? { sessionPath } : undefined,
4665 scope,
4666 workspaceRoot,
4667 topicId,
4668 sessionPath,
4669 requestId: pending.requestId,
4670 });
4671 const meta = ticket.meta;
4672 pending.tabId = ticket.tabId;
4673 pending.runtimeInitiallyReady = Boolean(meta.ready && (!meta.runtime || meta.runtime.phase === "ready"));
4674 if (pendingTopicActivationRef.current === pending && ticket.requestId) {
4675 if (ticket.requestId !== pending.requestId) aliasActivationRequest(pending.requestId, ticket.requestId);
4676 pending.requestId = ticket.requestId;
4677 }
4678 if (!navigationCompletionCurrent(navigationSeq, "topic.activate", meta.id)) {
4679 // A newer navigation started while the backend processed this
4680 // activation. Applying the stale result would flip the visible tab
4681 // away from the user's last click and — worse — the single-surface
4682 // prune below deletes every other tab's cached state, blanking the
4683 // surface the user is actually looking at. Last click wins: hand the
4684 // meta back for bookkeeping and leave the visible state to the newer
4685 // navigation. The backend supersedes this ticket (its terminal event
4686 // is ignored above: the pending slot belongs to the newer request).
4687 await reassertVisibleTabAfterStaleNavigation("topic.activate", meta.id);
4688 return meta;
4689 }
4690 const previousSurface = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current) : undefined;
4691 const sameSession = sameSessionHydrateIdentity(meta, previousSurface?.meta);
4692 const prevItems = sameSessionPlaceholderItems(meta, previousSurface);
4693 pending.placeholderItems = prevItems;
4694 setActiveTabId(meta.id);
4695 activeTabIdRef.current = meta.id;
4696 noteNavigationIdentityPublished(navigationSeq, meta.id);
4697 confirmBackendActiveTab(meta.id);
4698 noteActivationStarted(pending.requestId, meta.id);
4699 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4700 if (!sameSession) dispatchTo(meta.id, { type: "reset" });
4701 // A new-surface reset clears volatile runtime flags. Publish the ticket's
4702 // authoritative running state afterwards so a reattached live session
4703 // cannot briefly become idle depending on React's reducer scheduling.
4704 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4705 // Ready hydrates; only same-session items are a safe placeholder.
4706 dispatchTo(meta.id, { type: "hydrate_start", reason: "open-topic", placeholderItems: prevItems });
4707 // History is independently readable from the canonical session service as
4708 // soon as StartTopicActivation has published the tab identity. Do not wait
4709 // for the controller build/lease/MCP path before showing it.
4710 if (sameSession && hasCachedLiveTurn(previousSurface)) {
4711 dispatchTo(meta.id, { type: "hydrate_done" });
4712 } else {
4713 void primeReadableHistoryForTab(meta.id, meta, "open-topic", navigationSeq, () =>
4714 navigationCompletionCurrent(navigationSeq, "topic.activate.history", meta.id)
4715 && activeTabIdRef.current === meta.id,
4716 );
4717 }
4718 if (pending.terminal && pendingTopicActivationRef.current === pending) {
4719 // The terminal event beat the ticket resolution; process it now.
4720 handleTopicActivationEvent(pending.terminal);
4721 }
4722 return meta;
4723 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, handleTopicActivationEvent, navigationCompletionCurrent, primeReadableHistoryForTab, reassertVisibleTabAfterStaleNavigation, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4724
4725 // Ensure a blank tab exists for the given scope — reuses an existing one
4726 // or creates a new tab, then loads its session data.
4727 const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4728 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4729 await requireRegisteredNavigationIntent(navigationSeq);
4730 snapshotNavigationSourceTab(navigationSeq);
4731 const snapshotAt = promptEventClock();
4732 const meta = await app.EnsureBlankTab(scope, workspaceRoot);
4733 if (!navigationCompletionCurrent(navigationSeq, "tab.ensure-blank", meta.id)) {
4734 await reassertVisibleTabAfterStaleNavigation("tab.ensure-blank", meta.id);
4735 return meta;
4736 }
4737 // EnsureBlankTab may return a tab id already present in local state.
4738 // Invalidate its old hydration and force a fresh history read, otherwise a
4739 // late request can restore orphaned tool cards from the prior session.
4740 invalidateCheckpoints(meta.id);
4741 const isNewTab = !statesRef.current.has(meta.id);
4742 setActiveTabId(meta.id);
4743 activeTabIdRef.current = meta.id;
4744 confirmBackendActiveTab(meta.id);
4745 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4746 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4747 const load = loadSessionDataForTab(meta.id, true, "new-session", {
4748 surfacePolicy: "replace-surface", ...sessionIdentityFields(meta),
4749 });
4750 monitorNavigationHydration(navigationSeq, meta.id, load, isNewTab ? () => reconcileTabRuntime(meta.id, RUNTIME_STATUS_ONLY) : undefined);
4751 return meta;
4752 }, [beginActiveNavigation, invalidateCheckpoints, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, monitorNavigationHydration, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4753
4754 const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4755 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4756 await requireRegisteredNavigationIntent(navigationSeq);
4757 snapshotNavigationSourceTab(navigationSeq);
4758 const snapshotAt = promptEventClock();
4759 const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
4760 if (!navigationCompletionCurrent(navigationSeq, "surface.ensure-blank", meta.id)) {
4761 await reassertVisibleTabAfterStaleNavigation("surface.ensure-blank", meta.id);
4762 return meta;
4763 }
4764 setActiveTabId(meta.id);
4765 activeTabIdRef.current = meta.id;
4766 confirmBackendActiveTab(meta.id);
4767 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4768 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4769 const load = loadSessionDataForTab(meta.id, true, "new-session", {
4770 surfacePolicy: "replace-surface", ...sessionIdentityFields(meta),
4771 });
4772 monitorNavigationHydration(navigationSeq, meta.id, load, () => reconcileTabRuntime(meta.id, RUNTIME_STATUS_ONLY));
4773 return meta;
4774 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, monitorNavigationHydration, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4775
4776 const createIsolatedWorktree = useCallback(async (workspaceRoot: string, navigationIntentSeq?: number): Promise<DeliveryWorktreeOpenResult> => {
4777 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4778 await requireRegisteredNavigationIntent(navigationSeq);
4779 snapshotNavigationSourceTab(navigationSeq);
4780 const snapshotAt = promptEventClock();
4781 const result = await app.CreateIsolatedWorktree(workspaceRoot);
4782 const meta = result.tab;
4783 if (!navigationCompletionCurrent(navigationSeq, "tab.isolated-worktree", meta.id)) {
4784 await reassertVisibleTabAfterStaleNavigation("tab.isolated-worktree", meta.id);
4785 return result;
4786 }
4787 const prevState = statesRef.current.get(meta.id);
4788 const isNewTab = !prevState;
4789 const sameSession = sameSessionHydrateIdentity(meta, prevState?.meta);
4790 setActiveTabId(meta.id);
4791 activeTabIdRef.current = meta.id;
4792 confirmBackendActiveTab(meta.id);
4793 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4794 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4795 const load = loadSessionDataForTab(meta.id, !sameSession, "open-topic", {
4796 placeholderItems: sameSessionPlaceholderItems(meta, prevState), surfacePolicy: sameSession ? "preserve-current" : "replace-surface",
4797 ...sessionIdentityFields(meta), sessionRevision: meta.sessionRevision, sessionDigest: meta.sessionDigest,
4798 });
4799 monitorNavigationHydration(navigationSeq, meta.id, load, isNewTab ? () => reconcileTabRuntime(meta.id, RUNTIME_STATUS_ONLY) : undefined);
4800 return result;
4801 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, monitorNavigationHydration, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, requireRegisteredNavigationIntent, snapshotNavigationSourceTab]);
4802
4803 const commitSingleSurfaceNavigation = useCallback((tabId: string) => {
4804 if (!tabId || activeTabIdRef.current !== tabId) return false;
4805 for (const id of Array.from(statesRef.current.keys())) {
4806 if (id === tabId) continue;
4807 invalidateProviderStateForTab(id);
4808 disposeComposerProfileState(id);
4809 statesRef.current.delete(id);
4810 // Single-surface navigation only releases live ownership. Keep the
4811 // durable projection in the store's existing bounded LRU so reopening a
4812 // local session can paint immediately while its runtime reattaches.
4813 detachTranscriptState(id);
4814 // Without a follower, backend completion cannot clear a renderer pin.
4815 getTranscriptStore().setPinned(id, false);
4816 notifyLiveListeners(id);
4817 }
4818 return true;
4819 }, [detachTranscriptState, disposeComposerProfileState, invalidateProviderStateForTab, notifyLiveListeners]);
4820
4821 const closeTab = useCallback(async (
4822 tabId: string,
4823 policy: "keep_running" | "stop_and_close" = "keep_running",
4824 ): Promise<boolean> => {
4825 const navigationSeq = tabId === activeTabIdRef.current ? beginActiveNavigation() : undefined;
4826 try {
4827 if (navigationSeq !== undefined) await requireRegisteredNavigationIntent(navigationSeq);
4828 await app.CloseTabWithPolicy(tabId, policy);
4829 invalidateProviderStateForTab(tabId);
4830 disposeComposerProfileState(tabId);
4831 statesRef.current.delete(tabId);
4832 releaseTranscriptState(tabId);
4833 notifyLiveListeners(tabId);
4834 bump();
4835 if (tabId === activeTabId) await syncActiveTabFromBackend(false);
4836 return true;
4837 } catch {
4838 return false;
4839 }
4840 }, [activeTabId, beginActiveNavigation, bump, disposeComposerProfileState, invalidateProviderStateForTab, notifyLiveListeners, releaseTranscriptState, requireRegisteredNavigationIntent, syncActiveTabFromBackend]);
4841
4842 const reorderTabs = useCallback(async (tabIds: string[]) => {
4843 try {
4844 await app.ReorderTabs(tabIds);
4845 } catch { /* ignore */ }
4846 }, []);
4847
4848 const projectedState = useMemo(() => {
4849 if (!runtimeState.known) return activeState;
4850 return {
4851 ...activeState,
4852 running: activeState.transcriptProtocol === 2 ? activeState.running : runtimeState.running ?? activeState.running,
4853 };
4854 }, [activeState, runtimeState.known, runtimeState.running]);
4855 return {
4856 state: projectedState,
4857 liveStore,
4858 activeTabId,
4859 send, sendToTab, recoverDeliveryToTab, runShell, runShellForTab, steer, steerForTab, notice,
4860 cancel, cancelForTab, approve, approveForTab, isPromptCurrentForTab, resolvePlanDecision, resolvePlanDecisionForTab,
4861 resolveRecovery, resolveRecoveryForTab, answerQuestion, answerQuestionForTab,
4862 answerMCPInteraction, answerMCPInteractionForTab, setControllerMode, setControllerModeForTab,
4863 dismissExtensionForm, drainExtensionNotifications,
4864 setCollaborationMode, setCollaborationModeForTab, setToolApprovalMode, setToolApprovalModeForTab, setQualityFloor, setComposerProfileForTab, setGoal, setGoalForTab, editGoalForTab, clearGoal, clearGoalForTab, resumeGoal, resumeGoalForTab, pauseGoal, pauseGoalForTab,
4865 newSession, clearSession, listSessions, listTrashedSessions, retrySessionHistory, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
4866 loadOlderHistory, loadNewerHistory,
4867 requestHistoryFullContent,
4868 refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, rewindForTabDetailed, undoRewindForTab, forkTurnForTab, setModel, setModelForTab, setEffort, setEffortForTab, cancelJob,
4869 fetchMemory, remember, forget, saveDoc,
4870 switchTab, switchRemoteTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, createIsolatedWorktree, commitSingleSurfaceNavigation, closeTab, reorderTabs,
4871 // The App queue advances this at enqueue time, before an older activation
4872 // can finish and prune the surface selected by the newer click.
4873 noteNavigationIntent: beginActiveNavigation,
4874 currentNavigationIntent,
4875 registeredNavigationIntent,
4876 isNavigationIntentCurrent,
4877 reassertVisibleTabAfterStaleNavigation,
4878 syncActiveTab: syncActiveTabFromBackend,
4879 openCanonicalSession,
4880 };
4881 }
4882
4882 lines TYPESCRIPT