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