返回 DeepSeek-Reasonix
useController.ts
根目录 / desktop / frontend / src / lib / useController.ts
1 // useController is the frontend's state machine over the agent's event stream. It
2 // maintains per-tab state so background tabs preserve their streaming output, tool
3 // states, and approvals when the user switches away and back. The active tab's state
4 // is what components render.
5
6 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7 import { asArray } from "./array";
8 import { addBreadcrumb } from "./breadcrumbs";
9 import { app, onEvent, onReady, onRuntimeRebuilt } from "./bridge";
10 import { invalidateCache } from "./composerHistory";
11 import { formatGuardianAssessmentNotice } from "./guardianEvents";
12 import { createRafBatch } from "./rafBatch";
13 import { t, type DictKey } from "./i18n";
14 import { sameTodoList } from "./todoVisibility";
15 import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
16 import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
17 import type {
18 BalanceInfo,
19 CheckpointMeta,
20 CollaborationMode,
21 ContextInfo,
22 DeliveryWorktreeOpenResult,
23 EffortInfo,
24 HistoryMessage,
25 HistoryPage,
26 JobView,
27 MemoryCitation,
28 MemoryView,
29 Meta,
30 Mode,
31 QuestionAnswer,
32 SessionMeta,
33 TabMeta,
34 TokenMode,
35 ToolApprovalMode,
36 WireApproval,
37 WireAsk,
38 WireDecisionReceipt,
39 WireEvent,
40 WireExtensionCard,
41 WireExtensionForm,
42 WireExtensionStatus,
43 WireExtensionSurface,
44 WireFinalReadiness,
45 WireTool,
46 WireUsage,
47 WireShellExecution,
48 } from "./types";
49
50 export type ToolStatus = "running" | "done" | "error" | "stopped";
51
52 // Reserved ToolProgress channel names for sub-agent progress previews (the Go
53 // tracker emits these; ordinary tool progress must never use them).
54 export const SUBAGENT_PROGRESS_STATUS = "reasonix.subagent.status";
55 export const SUBAGENT_PROGRESS_REASONING = "reasonix.subagent.reasoning";
56 export const SUBAGENT_PROGRESS_TEXT = "reasonix.subagent.text";
57 export const SUBAGENT_PROGRESS_NOTICE = "reasonix.subagent.notice";
58
59 // Reserved names are matched by prefix so a future channel never falls back
60 // to ordinary tool output on older frontends.
61 const SUBAGENT_PROGRESS_PREFIX = "reasonix.subagent.";
62
63 const SUBAGENT_PROGRESS_PHASES = new Set([
64 "queued", "running", "reasoning", "responding", "tool", "retrying", "completed", "failed", "cancelled",
65 ]);
66
67 // Tool names that initialize a sub-agent progress card. parallel_tasks/fleet
68 // are group cards: they settle when their whole child progress tree is
69 // terminal, since they never receive a terminal status of their own.
70 const SUBAGENT_PROGRESS_TOOLS = new Set(["task", "read_only_task", "parallel_tasks", "fleet"]);
71
72 // Per-channel preview retention. The backend already bounds what it sends
73 // (8 KiB pending per child); these caps keep one hot card from dominating the
74 // live conversation memory.
75 const SUBAGENT_PREVIEW_REASONING_LIMIT = 8 << 10;
76 const SUBAGENT_PREVIEW_TEXT_LIMIT = 8 << 10;
77 const SUBAGENT_PREVIEW_NOTICE_LIMIT = 2 << 10;
78
79 export type SubagentPhase =
80 | "queued" | "running" | "reasoning" | "responding" | "tool" | "retrying"
81 | "completed" | "failed" | "cancelled";
82
83 // In-memory-only sub-agent progress preview. Never persisted: history
84 // hydration rebuilds tool items from the transcript without these fields, and
85 // the full sub-agent transcript stays the source of truth after a restart.
86 export type SubagentProgress = {
87 phase: SubagentPhase;
88 reasoning: string;
89 text: string;
90 notice: string;
91 lastActivityAt: number;
92 truncated: boolean;
93 durationMs?: number;
94 startedAt: number;
95 };
96
97 export function isSubagentProgressName(name: string | undefined): boolean {
98 return !!name && name.startsWith(SUBAGENT_PROGRESS_PREFIX);
99 }
100
101 export function isTerminalSubagentPhase(phase: string | undefined): boolean {
102 return phase === "completed" || phase === "failed" || phase === "cancelled";
103 }
104
105 function isGroupSubagentTool(name: string): boolean {
106 return name === "parallel_tasks" || name === "fleet";
107 }
108
109 function terminalStatusOf(phase: string): ToolStatus {
110 switch (phase) {
111 case "completed": return "done";
112 case "failed": return "error";
113 case "cancelled": return "stopped";
114 }
115 return "running";
116 }
117
118 function freshSubagentProgress(): SubagentProgress {
119 const now = Date.now();
120 return { phase: "running", reasoning: "", text: "", notice: "", lastActivityAt: now, truncated: false, startedAt: now };
121 }
122
123 /** Keeps the most recent `limit` code points; surrogate pairs stay intact. */
124 function tailPreview(text: string, limit: number): string {
125 if (text.length <= limit) return text;
126 const pts = Array.from(text);
127 return pts.slice(pts.length - limit).join("");
128 }
129
130 // --- Sub-agent progress reducer helpers --------------------------------------
131
132 // Applies one reserved ToolProgress event to the target card's in-memory
133 // preview. The card must exist (its dispatch always precedes progress events)
134 // and have been initialized by the dispatch. Never writes tool.output, never
135 // touches the parent LiveStream, and never produces history data.
136 function applySubagentProgress(s: State, t: WireTool): State {
137 if (!t.id) return s;
138 const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === t.id);
139 if (idx < 0) return s;
140 const next = [...s.items];
141 const it = next[idx];
142 if (it.kind !== "tool" || !it.subagentProgress) return s;
143 const sp: SubagentProgress = { ...it.subagentProgress, lastActivityAt: Date.now() };
144 switch (t.name) {
145 case SUBAGENT_PROGRESS_STATUS: {
146 const phase = t.output ?? "";
147 if (!SUBAGENT_PROGRESS_PHASES.has(phase)) return s; // unknown phase: ignore
148 sp.phase = phase as SubagentPhase;
149 if (isTerminalSubagentPhase(phase) && typeof t.durationMs === "number") sp.durationMs = t.durationMs;
150 break;
151 }
152 case SUBAGENT_PROGRESS_REASONING:
153 sp.reasoning = tailPreview(sp.reasoning + (t.output ?? ""), SUBAGENT_PREVIEW_REASONING_LIMIT);
154 sp.truncated = sp.truncated || !!t.truncated;
155 break;
156 case SUBAGENT_PROGRESS_TEXT:
157 sp.text = tailPreview(sp.text + (t.output ?? ""), SUBAGENT_PREVIEW_TEXT_LIMIT);
158 sp.truncated = sp.truncated || !!t.truncated;
159 break;
160 case SUBAGENT_PROGRESS_NOTICE:
161 sp.notice = tailPreview(sp.notice + (t.output ?? ""), SUBAGENT_PREVIEW_NOTICE_LIMIT);
162 sp.truncated = sp.truncated || !!t.truncated;
163 break;
164 default:
165 return s;
166 }
167 const status = isTerminalSubagentPhase(sp.phase) ? terminalStatusOf(sp.phase) : it.status;
168 next[idx] = { ...it, subagentProgress: sp, status };
169 return { ...s, items: next };
170 }
171
172 // Nested real tool activity refreshes its sub-agent parent's recent activity
173 // and switches the phase to "tool". Terminal parents are left untouched.
174 function touchSubagentParent(next: Item[], parentId: string): void {
175 const idx = next.findIndex((it) => it.kind === "tool" && it.id === parentId && it.subagentProgress);
176 if (idx < 0) return;
177 const it = next[idx];
178 if (it.kind !== "tool" || !it.subagentProgress || isTerminalSubagentPhase(it.subagentProgress.phase)) return;
179 next[idx] = { ...it, subagentProgress: { ...it.subagentProgress, phase: "tool", lastActivityAt: Date.now() } };
180 }
181
182 export type LiveStream = {
183 id: string;
184 text: string;
185 reasoning: string;
186 reasoningComplete: boolean;
187 reasoningStartedAt?: number;
188 reasoningCompletedAt?: number;
189 };
190
191 /** Speculative journal for one sampling attempt — rolled back on discard. */
192 type StreamAttemptJournal = {
193 id: string;
194 baselineLive?: LiveStream;
195 baselineTurnArgChars: number;
196 /** Tool cards created by this attempt (running, no result yet). */
197 createdToolIds: string[];
198 /** Prior state of tools that existed before this attempt and were patched. */
199 priorTools: Record<string, Extract<Item, { kind: "tool" }>>;
200 };
201 export type ControllerLiveStore = {
202 subscribe: (tabId: string | undefined, listener: () => void) => () => void;
203 getSnapshot: (tabId: string | undefined) => LiveStream | undefined;
204 };
205 export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
206 export type MessageActionState = { turn: number; scope: MessageActionScope };
207 export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup" | "rewind";
208 type SyncActiveTabOptions = {
209 preserveCachedHistory?: boolean;
210 };
211
212 type ModelSwitchQueueResult = "applied" | "superseded";
213
214 type ModelSwitchQueueRequest = {
215 name: string;
216 resolve: (result: ModelSwitchQueueResult) => void;
217 reject: (err: unknown) => void;
218 };
219
220 type ModelSwitchQueueState = {
221 running: boolean;
222 pending?: ModelSwitchQueueRequest;
223 fallbackBalance?: BalanceInfo;
224 };
225
226 const HISTORY_PAGE_TURNS = 60;
227
228 export type Item =
229 | { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
230 | { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; memoryCitations?: MemoryCitation[] }
231 | { kind: "phase"; id: string; text: string }
232 | { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string; title?: string; variant?: "delivery"; action?: "continue_delivery"; decisionReceipt?: WireDecisionReceipt }
233 | {
234 kind: "compaction";
235 id: string;
236 pending: boolean;
237 trigger: string;
238 messages: number;
239 summary: string;
240 archive: string;
241 }
242 | {
243 kind: "tool";
244 id: string;
245 name: string;
246 args: string;
247 readOnly: boolean;
248 resolvedName?: string;
249 capabilityId?: string;
250 status: ToolStatus;
251 output?: string;
252 error?: string;
253 truncated?: boolean;
254 dataArchived?: boolean; // args/output trimmed for memory; full data available via backend
255 durationMs?: number;
256 subject?: string; // stable collapsed subject from archived history payloads
257 summary?: string; // stable collapsed readout kept even after args/output archive
258 fileDiff?: ToolFileDiff; // previewed whole-file diff from writer dispatch
259 isShell?: boolean; // bash tool or !command — structured shell card presentation
260 execution?: WireShellExecution; // local shell metadata
261 parentId?: string; // a sub-agent call nests under the `task` call with this id
262 profile?: { model?: string; effort?: string }; // subagent model/effort from tool event
263 argChars?: number; // args still streaming from the model: cumulative chars received
264 subagentProgress?: SubagentProgress; // in-memory-only preview, never hydrated from history
265 }
266 | {
267 kind: "extension";
268 id: string;
269 // surfaceKey is "<pluginId>:<surfaceId>"; a re-published card replaces the
270 // previous one in place instead of appending a duplicate transcript entry.
271 surfaceKey: string;
272 pluginId: string;
273 surfaceId: string;
274 generation?: number;
275 card: WireExtensionCard;
276 };
277
278 type ToolItem = Extract<Item, { kind: "tool" }>;
279 export type ExtensionItem = Extract<Item, { kind: "extension" }>;
280
281 // Extension UI surfaces (stage 8b2) — per-tab state fed by extension_surface /
282 // extension_status wire events. Statuses and generations key on
283 // "<pluginId>:<surfaceId>"; the form is the single pending form surface (a new
284 // form replaces the old, matching the backend's one-blocking-prompt model);
285 // notifications queue until the App drains them into the toast system.
286 export interface ExtensionStatusEntry {
287 pluginId: string;
288 surfaceId: string;
289 label: string;
290 detail?: string;
291 severity?: string;
292 progress?: number;
293 generation?: number;
294 }
295
296 export interface ExtensionFormState {
297 pluginId: string;
298 surfaceId: string;
299 generation?: number;
300 form: WireExtensionForm;
301 }
302
303 export interface ExtensionNotificationEntry {
304 id: string;
305 pluginId: string;
306 title: string;
307 body?: string;
308 severity?: string;
309 }
310
311 // extensionSurfaceKey is the identity a sidecar re-publishes under to replace
312 // one of its surfaces.
313 export function extensionSurfaceKey(surface: Pick<WireExtensionSurface, "pluginId" | "surfaceId">): string {
314 return `${surface.pluginId}:${surface.surfaceId}`;
315 }
316
317 // acceptsExtensionGeneration drops a re-ordered surface publication: within
318 // one runtime, a sidecar's generation is monotonic, so anything older than the
319 // last accepted generation for the same surface is stale. Events without a
320 // generation always pass (they carry no ordering claim).
321 export function acceptsExtensionGeneration(stored: number | undefined, incoming: number | undefined): boolean {
322 return incoming === undefined || stored === undefined || incoming >= stored;
323 }
324
325 // Mid-turn steer messages are recorded as info notices carrying this prefix —
326 // both live (the "steer" event below) and in replayed history (desktop/app.go
327 // prefixes persisted steers the same way). The prefix is the only durable
328 // marker, so display code identifies steers by it.
329 export const STEER_NOTICE_PREFIX = "↪ ";
330
331 export function isSteerNoticeText(text: string): boolean {
332 return text.startsWith(STEER_NOTICE_PREFIX);
333 }
334
335 interface State {
336 items: Item[];
337 running: boolean;
338 turnActive: boolean;
339 pendingPrompt: boolean;
340 backgroundJobs: number;
341 cancelRequested: boolean;
342 cancellable: boolean;
343 approval?: WireApproval;
344 ask?: WireAsk;
345 usage?: WireUsage;
346 context: ContextInfo;
347 meta?: Meta;
348 balance?: BalanceInfo;
349 effort?: EffortInfo;
350 jobs: JobView[];
351 checkpoints: CheckpointMeta[];
352 hydrating: boolean;
353 hydrateReason?: HydrateReason;
354 hydrateError?: string;
355 hydrateHistoryLoaded?: boolean;
356 hydratePlaceholderItems?: Item[];
357 historyStartTurn: number;
358 historyTotalTurns: number;
359 historyHasOlder: boolean;
360 historyOlderLoading: boolean;
361 backendActivationPending: boolean;
362 messageAction?: MessageActionState;
363 currentAssistant?: string;
364 live?: LiveStream;
365 pendingUser?: string;
366 deliveryRecoveryActive: boolean;
367 discardTurn?: boolean;
368 turnStartAt: number;
369 // Time spent waiting on the user (approval/ask) within the current turn.
370 // Closed intervals accumulate here; an open interval uses promptWaitStartedAt
371 // so background tabs keep counting while not rendered by Composer.
372 turnWaitAccumMs: number;
373 promptWaitStartedAt?: number;
374 // promptEventClock() reading taken when the CURRENT pending prompt first
375 // arrived. Orders the prompt against reconciliation snapshots so a snapshot
376 // fetched before the event cannot clear the prompt it never knew about
377 // (#6429). Anchored to the prompt's first arrival and NOT advanced by a
378 // same-id replay, so an authoritative idle snapshot taken after the user
379 // answered is never mistaken for stale (#6432 reverse race).
380 promptArrivedAt?: number;
381 // Id of the prompt promptArrivedAt is anchored to. A replay re-emitting the
382 // same id keeps the original arrival time; only a genuinely new prompt id
383 // (backend ids are monotonic within a controller) re-anchors it.
384 promptArrivedId?: string;
385 // Id of the most recently user-resolved approval/ask (explicit answer,
386 // cancel-through-mode-switch, etc). A replay carrying this same id is a
387 // stale re-delivery of an already-answered prompt, not a new one — arming
388 // it would resurrect a zombie no downstream snapshot may ever get a chance
389 // to reject (#6432 round 2: idle-applied-before-replay, and
390 // running=true/pendingPrompt=false snapshots that never clear approval/ask).
391 resolvedPromptId?: string;
392 // Monotonic per-tab prompt-id namespace generation. Approval/ask ids restart
393 // from "1" whenever the backend controller is rebuilt, so any id captured
394 // before the bump (an in-flight prompt answer or mode-switch RPC) must not
395 // touch bookkeeping written after it. Late callbacks from the old controller
396 // otherwise act on a different prompt that reused the same numeric id.
397 promptEpoch: number;
398 turnTokens: number;
399 turnTotalTokens: number;
400 turnCost: number;
401 // Cumulative argument characters of the tool call currently streaming its
402 // args (partial dispatch progress). Folded into the composer pill as an
403 // estimated-token tail; cleared when the round's usage arrives (which then
404 // includes those tokens for real) and on turn start.
405 turnArgChars: number;
406 sessionTokens: number;
407 sessionCost: number;
408 sessionCurrency: string;
409 retry?: { attempt: number; max: number; observedAt: number };
410 seq: number;
411 sessionGen: number;
412 // Per-session counter bumped after hydration ancillary data (context, effort,
413 // jobs) arrives. ContextPanel reads this (merged into refreshKey) so the
414 // right-side panel re-fetches after a session rebind instead of showing stale
415 // RequestCount / ElapsedMs / SessionCost from before the swap.
416 contextPanelSeq: number;
417 // Monotonic count of usage events from ANY source (executor, subagent,
418 // title…). Drives right-panel snapshot refreshes so sub-agent activity keeps
419 // the session metrics live; state.usage stays executor-gated for the gauge.
420 usageSeq: number;
421 // Extension UI surfaces (stage 8b2). See the ExtensionStatusEntry block
422 // above for the keying/lifecycle rules.
423 extensionStatuses: Record<string, ExtensionStatusEntry>;
424 extensionForm?: ExtensionFormState;
425 extensionNotifications: ExtensionNotificationEntry[];
426 // Last accepted generation per extension surface key; guards against
427 // re-ordered publications (acceptsExtensionGeneration).
428 extensionGenerations: Record<string, number>;
429 // Speculative sampling-attempt journal for Codex-style stream replay.
430 // Host-local only; never hydrated from history.
431 streamAttemptJournal?: StreamAttemptJournal;
432 }
433
434 export const initialState: State = {
435 items: [],
436 running: false,
437 turnActive: false,
438 pendingPrompt: false,
439 backgroundJobs: 0,
440 cancelRequested: false,
441 cancellable: false,
442 context: { used: 0, window: 0, sessionTokens: 0 },
443 jobs: [],
444 checkpoints: [],
445 hydrating: false,
446 historyStartTurn: 0,
447 historyTotalTurns: 0,
448 historyHasOlder: false,
449 historyOlderLoading: false,
450 backendActivationPending: false,
451 deliveryRecoveryActive: false,
452 promptEpoch: 0,
453 turnStartAt: 0,
454 turnWaitAccumMs: 0,
455 turnTokens: 0,
456 turnTotalTokens: 0,
457 turnCost: 0,
458 turnArgChars: 0,
459 sessionTokens: 0,
460 sessionCost: 0,
461 sessionCurrency: "¥",
462 seq: 0,
463 sessionGen: 0,
464 contextPanelSeq: 0,
465 usageSeq: 0,
466 extensionStatuses: {},
467 extensionNotifications: [],
468 extensionGenerations: {},
469 };
470
471 function usageTotalTokens(usage?: WireUsage): number {
472 if (!usage) return 0;
473 if (usage.totalTokens > 0) return usage.totalTokens;
474 const promptTokens = usage.promptTokens || usage.cacheHitTokens + usage.cacheMissTokens;
475 return Math.max(0, promptTokens + usage.completionTokens);
476 }
477
478 type RuntimeMetaSnapshot = {
479 running: boolean;
480 pendingPrompt?: boolean;
481 backgroundJobs?: number;
482 cancelRequested?: boolean;
483 cancellable?: boolean;
484 };
485
486 export function foregroundRunningFromRuntimeMeta(meta: RuntimeMetaSnapshot): boolean {
487 if (typeof meta.cancellable === "boolean") return meta.cancellable;
488 if ((meta.backgroundJobs ?? 0) > 0 && !meta.pendingPrompt) return false;
489 return Boolean(meta.running);
490 }
491
492 // Clock used to order live prompt events against runtime snapshot fetches.
493 // Monotonic (immune to wall-clock jumps) with sub-millisecond resolution, so
494 // an event and a snapshot initiated in the same millisecond still order
495 // correctly. Only ever compared against itself.
496 export function promptEventClock(): number {
497 return typeof performance !== "undefined" ? performance.now() : Date.now();
498 }
499
500 // True when a runtime snapshot was fetched before the tab's live approval/ask
501 // event arrived. Such a snapshot reports the tab idle only because it predates
502 // the prompt (pre-attach ListTabs, activation-time metas); applying it would
503 // clear the only UI able to answer the prompt — and, since it also carries
504 // pendingPrompt=false, skip the compensating replay (#6429, #5561, #5481).
505 // Ties count as stale: keeping a prompt one extra round is recoverable, while
506 // clearing a live prompt is the bug this guards against.
507 export function runtimeSnapshotPredatesPrompt(
508 state: { approval?: unknown; ask?: unknown; promptArrivedAt?: number } | undefined,
509 snapshotAt: number | undefined,
510 ): boolean {
511 if (!state || (!state.approval && !state.ask)) return false;
512 if (snapshotAt === undefined || state.promptArrivedAt === undefined) return false;
513 return snapshotAt <= state.promptArrivedAt;
514 }
515
516 function runtimeSnapshotPredatesRetry(
517 state: Pick<State, "retry"> | undefined,
518 snapshotAt: number | undefined,
519 ): boolean {
520 if (snapshotAt === undefined || state?.retry?.observedAt === undefined) return false;
521 return snapshotAt <= state.retry.observedAt;
522 }
523
524 function updatesContextGauge(usage?: WireUsage): boolean {
525 const source = usage?.source?.trim();
526 return !source || source === "executor";
527 }
528
529 export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
530 const cwd = tab.cwd || tab.workspaceRoot || existing?.cwd || "";
531 const toolApprovalMode = normalizeToolApprovalMode(
532 tab.toolApprovalMode,
533 normalizeMode(tab.mode),
534 modeHasAutoApproveTools(tab.mode),
535 (tab.toolApprovalMode ?? "").trim() === "" ? existing?.toolApprovalMode : undefined,
536 );
537 const autoApproveTools = toolApprovalMode === "yolo";
538 return {
539 label: tab.label || existing?.label || "",
540 ready: tab.ready,
541 runtime: tab.runtime,
542 startupErr: tab.startupErr,
543 eventChannel: existing?.eventChannel ?? "agent:event",
544 cwd,
545 workspaceRoot: tab.workspaceRoot || existing?.workspaceRoot || cwd,
546 workspaceName: tab.workspaceName || existing?.workspaceName,
547 workspacePath: tab.workspacePath || tab.workspaceRoot || existing?.workspacePath,
548 sessionPath: tab.sessionPath !== undefined ? tab.sessionPath : existing?.sessionPath,
549 gitBranch: tab.gitBranch || existing?.gitBranch,
550 autoApproveTools,
551 bypass: autoApproveTools,
552 collaborationMode: tab.collaborationMode ?? existing?.collaborationMode ?? "normal",
553 toolApprovalMode,
554
555 tokenMode: tab.tokenMode ?? existing?.tokenMode ?? "full",
556 goal: tab.goal ?? existing?.goal,
557 goalStatus: tab.goalStatus ?? existing?.goalStatus,
558 canonicalTodos: existing?.canonicalTodos,
559 };
560 }
561
562 function countsTowardCurrentTurn(state: State): boolean {
563 return state.turnActive || state.running;
564 }
565
566 export function sameMeta(a?: Meta, b?: Meta): boolean {
567 if (a === b) return true;
568 if (!a || !b) return false;
569 return (
570 a.label === b.label &&
571 a.ready === b.ready &&
572 a.runtime?.phase === b.runtime?.phase &&
573 a.runtime?.epoch === b.runtime?.epoch &&
574 a.runtime?.issue?.code === b.runtime?.issue?.code &&
575 a.runtime?.issue?.message === b.runtime?.issue?.message &&
576 a.runtime?.issue?.retryable === b.runtime?.issue?.retryable &&
577 a.runtime?.issue?.holderPid === b.runtime?.issue?.holderPid &&
578 a.runtime?.issue?.holderHost === b.runtime?.issue?.holderHost &&
579 a.runtime?.issue?.acquiredAt === b.runtime?.issue?.acquiredAt &&
580 a.startupErr === b.startupErr &&
581 a.eventChannel === b.eventChannel &&
582 a.cwd === b.cwd &&
583 a.workspaceRoot === b.workspaceRoot &&
584 a.workspaceName === b.workspaceName &&
585 a.workspacePath === b.workspacePath &&
586 a.sessionPath === b.sessionPath &&
587 a.gitBranch === b.gitBranch &&
588 a.imageInputEnabled === b.imageInputEnabled &&
589 a.autoApproveTools === b.autoApproveTools &&
590 a.bypass === b.bypass &&
591 a.collaborationMode === b.collaborationMode &&
592 a.toolApprovalMode === b.toolApprovalMode &&
593
594 a.tokenMode === b.tokenMode &&
595 a.goal === b.goal &&
596 a.goalStatus === b.goalStatus &&
597 sameTodoList(a.canonicalTodos, b.canonicalTodos)
598 );
599 }
600
601 export function runtimeReadyForSubmit(meta?: Meta): boolean {
602 if (!meta || meta.ready !== true || meta.startupErr) return false;
603 return !meta.runtime || meta.runtime.phase === "ready";
604 }
605
606 // normalizeTurnSubmit is the final frontend boundary before optimistic
607 // transcript state is created. Display text may intentionally be shorter than
608 // the provider input, but a visible-only message must never start an empty model
609 // turn (#6869).
610 export function normalizeTurnSubmit(displayText: string, submitText: string): {
611 display: string;
612 submit: string;
613 } {
614 const display = displayText.trim();
615 const submit = submitText.trim();
616 if (!submit) throw new Error("Message cannot be empty.");
617 return { display, submit };
618 }
619
620 export function acceptsRuntimeEventEpoch(acceptedEpoch: string | undefined, eventEpoch: string | undefined): boolean {
621 return !eventEpoch || !acceptedEpoch || acceptedEpoch === eventEpoch;
622 }
623
624 export function composerProfileApplicationKey(
625 runtimeEpoch: string | undefined,
626 collaborationMode: CollaborationMode,
627 toolApprovalMode: ToolApprovalMode,
628 goal: string,
629 ): string {
630 return JSON.stringify([runtimeEpoch ?? "", collaborationMode, toolApprovalMode, goal]);
631 }
632
633 function metaWithoutCanonicalTodos(meta?: Meta): Meta | undefined {
634 if (!meta || meta.canonicalTodos === undefined) return meta;
635 return { ...meta, canonicalTodos: undefined };
636 }
637
638 const STALE_TURN_RECONCILE_MS = 30_000;
639 const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
640 // After a stale runtime snapshot is rejected (its fetch predates the live
641 // prompt), refetch authoritative backend state once. Short enough to be barely
642 // perceptible, long enough to let any other in-flight replay events land first
643 // so the refetch reflects settled backend truth (#6432).
644 const STALE_PROMPT_RECONCILE_MS = 150;
645 const STARTUP_READY_META_RECONCILE_MS = 250;
646 const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
647
648 export function shouldReconcileStaleTurn(
649 state: Pick<State, "running" | "turnActive"> | undefined,
650 lastTurnActivityAt: number,
651 now = Date.now(),
652 timeoutMs = STALE_TURN_RECONCILE_MS,
653 ): boolean {
654 if (!state?.running || !state.turnActive || lastTurnActivityAt <= 0) return false;
655 return Math.max(0, now - lastTurnActivityAt) >= timeoutMs;
656 }
657
658 function hasCachedLiveTurn(state: State | undefined): boolean {
659 if (!state?.running && !state?.turnActive) return false;
660 if (state.live || state.currentAssistant || state.pendingUser !== undefined) return true;
661 return state.items.some((item) =>
662 (item.kind === "assistant" && item.streaming) ||
663 (item.kind === "tool" && item.status === "running")
664 );
665 }
666
667 function hasReusableCachedTranscript(state: State | undefined, sessionPath?: string): boolean {
668 if (!state || state.items.length === 0) return false;
669 const expectedSessionPath = (sessionPath ?? "").trim();
670 if (!expectedSessionPath) return true;
671 return (state.meta?.sessionPath ?? "").trim() === expectedSessionPath;
672 }
673
674 /** Mirrors Go backend's ReadOnly() hints. */
675 export function isReadOnlyTool(name: string): boolean {
676 switch (name) {
677 case "read_file":
678 case "ls":
679 case "grep":
680 case "glob":
681 case "web_fetch":
682 case "code_index":
683 case "bash_output":
684 case "waitJob":
685 case "todo_write":
686 case "read_skill":
687 return true;
688 default:
689 return false;
690 }
691 }
692
693 const ARCHIVED_TOOL_ARG_LIMIT = 200;
694
695 function archivedToolArgs(_name: string, args: string): string {
696 return args && args.length > ARCHIVED_TOOL_ARG_LIMIT ? args.slice(0, ARCHIVED_TOOL_ARG_LIMIT) + "…" : args;
697 }
698
699 function isCanonicalTodoTool(tool: ToolItem): boolean {
700 return tool.name === "todo_write" && !tool.parentId && tool.status === "done" && !tool.error;
701 }
702
703 function latestCanonicalTodoToolIndex(items: Item[]): number {
704 for (let i = items.length - 1; i >= 0; i -= 1) {
705 const item = items[i];
706 if (item.kind === "tool" && isCanonicalTodoTool(item)) return i;
707 }
708 return -1;
709 }
710
711 function compactArchivedToolItems(items: Item[]): Item[] {
712 const canonicalTodoIndex = latestCanonicalTodoToolIndex(items);
713 return items.map((item, index) => {
714 if (item.kind !== "tool" || item.status === "running") return item;
715 const preserveArgs = index === canonicalTodoIndex;
716 const nextArgs = preserveArgs ? item.args : archivedToolArgs(item.name, item.args);
717 if (nextArgs === item.args && item.output === undefined && item.dataArchived === true) return item;
718 return {
719 ...item,
720 args: nextArgs,
721 output: undefined,
722 dataArchived: true,
723 };
724 });
725 }
726
727 type Action =
728 | { type: "event"; e: WireEvent }
729 | { type: "user"; text: string; submitText?: string; seq: number; deliveryRecovery?: boolean }
730 | { type: "unsend" }
731 | { type: "send_failed"; error: string }
732 | { type: "backend_status"; running: boolean; pendingPrompt?: boolean; backgroundJobs?: number; cancelRequested?: boolean; cancellable?: boolean; snapshotAt?: number }
733 | { type: "cancel_requested" }
734 | { type: "meta"; meta: Meta }
735 | { type: "optimistic_meta"; meta: Meta }
736 | { type: "context"; context: ContextInfo }
737 | { type: "balance"; balance: BalanceInfo }
738 | { type: "effort"; effort: EffortInfo }
739 | { type: "jobs"; jobs: JobView[] }
740 | { type: "checkpoints"; checkpoints: CheckpointMeta[] }
741 | { type: "hydrate_start"; reason: HydrateReason; placeholderItems?: Item[] }
742 | { type: "hydrate_done" }
743 | { type: "hydrate_error"; reason: HydrateReason; error: string }
744 | { type: "backend_activation_start" }
745 | { type: "backend_activation_done" }
746 | { type: "message_action_start"; action: MessageActionState }
747 | { type: "message_action_done" }
748 | { type: "history"; messages: HistoryMessage[] }
749 | { type: "history_page"; page: HistoryPage; mode: "replace" | "prepend" }
750 | { type: "history_older_start" }
751 | { type: "history_older_error" }
752 | { type: "history_checkpoint_turns"; turns: number[] }
753 | { type: "local_notice"; level: "info" | "warn"; text: string }
754 | { type: "clearApproval" }
755 | { type: "clearAsk" }
756 | { type: "clearExtensionForm" }
757 | { type: "extension_notifications_drained" }
758 | { type: "approval_drained"; ids: string[]; epoch: number }
759 | { type: "submit_prompt_failed"; id: string; epoch: number }
760 | { type: "controller_rebuilt" }
761 | { type: "reset" }
762 | { type: "context_panel_refresh" };
763
764 function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
765 const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
766 return {
767 type: "backend_status",
768 running: foregroundRunning,
769 pendingPrompt: Boolean(meta.pendingPrompt),
770 backgroundJobs: meta.backgroundJobs ?? 0,
771 cancelRequested: Boolean(meta.cancelRequested),
772 cancellable: foregroundRunning,
773 };
774 }
775
776 // ---- reducer helpers (unchanged logic) ----
777
778 export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
779 const resultByID = new Map<string, HistoryMessage>();
780 for (const m of messages) {
781 if (m.role === "tool" && m.toolCallId && !resultByID.has(m.toolCallId)) {
782 resultByID.set(m.toolCallId, m);
783 }
784 }
785 const positionalResults = positionalToolResults(messages);
786 const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
787
788 let items: Item[] = [];
789 let seq = startSeq;
790 const consumedToolIDs = new Set<string>();
791 for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
792 const m = messages[messageIndex];
793 if (m.role === "system") continue;
794 if (m.role === "phase") {
795 if (m.content.trim() !== "") {
796 items.push({ kind: "phase", id: `${idPrefix}${seq}`, text: m.content });
797 seq++;
798 }
799 continue;
800 }
801 if (m.role === "notice") {
802 if (m.content.trim() !== "" || m.decisionReceipt) {
803 const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
804 items = next.items;
805 seq = next.seq;
806 }
807 continue;
808 }
809 if (m.role === "compaction") {
810 items.push({
811 kind: "compaction",
812 id: `${idPrefix}${seq}`,
813 pending: Boolean(m.pending),
814 trigger: m.trigger ?? "",
815 messages: m.messages ?? 0,
816 summary: m.summary ?? "",
817 archive: m.archive ?? "",
818 });
819 seq++;
820 continue;
821 }
822 if (m.role === "user") {
823 if (m.content.trim() === "") continue;
824 items.push({ kind: "user", id: `${idPrefix}${seq}`, text: m.content, submitText: m.submitText, createdAt: m.createdAt, checkpointTurn: m.checkpointTurn });
825 seq++;
826 continue;
827 }
828 if (m.role === "assistant") {
829 const hasText = m.content.trim() !== "" || (m.reasoning ?? "").trim() !== "";
830 if (hasText) {
831 const memoryCitations = asArray<MemoryCitation>(m.memoryCitations);
832 items.push({
833 kind: "assistant",
834 id: `${idPrefix}${seq}`,
835 text: m.content,
836 reasoning: m.reasoning ?? "",
837 streaming: false,
838 workDurationMs: m.workDurationMs,
839 memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
840 });
841 seq++;
842 }
843 const toolCalls = m.toolCalls ?? [];
844 for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) {
845 const tc = toolCalls[callIndex];
846 const positionalResult = tc.id ? undefined : positionalResults.get(positionalToolResultKey(messageIndex, callIndex));
847 const result = tc.id ? resultByID.get(tc.id) : positionalResult?.message;
848 if (tc.id) consumedToolIDs.add(tc.id);
849 const archived = Boolean(tc.argumentsArchived || result?.toolResultArchived);
850 const output = result?.toolResultArchived ? undefined : result?.content ?? "";
851 const error = result?.toolResultError || (output ? historyToolError(output) : undefined);
852 const fileDiff = fileDiffFromWire(tc);
853 items.push({
854 kind: "tool",
855 id: tc.id || `${idPrefix}tool${seq}`,
856 name: tc.name,
857 args: tc.arguments ?? "",
858 readOnly: typeof tc.resolvedReadOnly === "boolean" ? tc.resolvedReadOnly : isReadOnlyTool(tc.name),
859 resolvedName: tc.resolvedName,
860 capabilityId: tc.capabilityId,
861 status: result ? (error ? "error" : "done") : "stopped",
862 output,
863 error,
864 dataArchived: archived || undefined,
865 subject: tc.subject,
866 summary: summarizeFileDiff(fileDiff) || tc.summary,
867 fileDiff,
868 isShell: tc.name === "bash" || (tc.id || "").startsWith("shell-"),
869 execution: result?.execution,
870 });
871 seq++;
872 }
873 continue;
874 }
875 if (m.role === "tool") {
876 if ((m.toolCallId && consumedToolIDs.has(m.toolCallId)) || consumedPositionalToolIndexes.has(messageIndex)) continue;
877 const output = m.toolResultArchived ? undefined : m.content;
878 const error = m.toolResultError || (output ? historyToolError(output) : undefined);
879 items.push({
880 kind: "tool",
881 id: m.toolCallId || `${idPrefix}tool${seq}`,
882 name: m.toolName || "tool",
883 args: "",
884 readOnly: isReadOnlyTool(m.toolName || "tool"),
885 status: error ? "error" : "done",
886 output,
887 error,
888 dataArchived: m.toolResultArchived || undefined,
889 isShell: (m.toolName || "") === "bash" || (m.toolCallId || "").startsWith("shell-"),
890 execution: m.execution,
891 });
892 seq++;
893 continue;
894 }
895 }
896 return { items, seq };
897 }
898
899 function mergeHistoryCheckpointTurns(items: Item[], turns: number[], startTurn = 0): Item[] {
900 if (!turns.some((turn) => turn >= 0)) return items;
901 const offset = Math.max(0, Math.floor(startTurn));
902 let userIndex = 0;
903 let changed = false;
904 const next = items.map((item) => {
905 if (item.kind !== "user") return item;
906 const turn = turns[offset + userIndex];
907 userIndex += 1;
908 if (turn == null || turn < 0 || item.checkpointTurn === turn) return item;
909 changed = true;
910 return { ...item, checkpointTurn: turn };
911 });
912 return changed ? next : items;
913 }
914
915 function historyPageItems(page: HistoryPage): { items: Item[]; seq: number } {
916 return historyMessagesToItems(asArray(page.messages), `h${page.startTurn}-`, 0);
917 }
918
919 function positionalToolResults(messages: HistoryMessage[]): Map<string, { message: HistoryMessage; index: number }> {
920 const out = new Map<string, { message: HistoryMessage; index: number }>();
921 const consumed = new Set<number>();
922 for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
923 const message = messages[messageIndex];
924 const toolCalls = message.role === "assistant" ? message.toolCalls ?? [] : [];
925 if (toolCalls.length === 0) continue;
926 let resultIndex = messageIndex + 1;
927 for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) {
928 if (toolCalls[callIndex].id) continue;
929 let matched = false;
930 while (resultIndex < messages.length) {
931 const candidate = messages[resultIndex];
932 if (candidate.role !== "tool") break;
933 const candidateIndex = resultIndex;
934 resultIndex += 1;
935 if (candidate.toolCallId || consumed.has(candidateIndex)) continue;
936 consumed.add(candidateIndex);
937 out.set(positionalToolResultKey(messageIndex, callIndex), { message: candidate, index: candidateIndex });
938 matched = true;
939 break;
940 }
941 if (!matched) break;
942 }
943 }
944 return out;
945 }
946
947 function positionalToolResultKey(messageIndex: number, callIndex: number): string {
948 return `${messageIndex}:${callIndex}`;
949 }
950
951 function historyToolError(output: string): string | undefined {
952 const trimmed = output.trimStart();
953 if (
954 trimmed.startsWith("[error") ||
955 trimmed.startsWith("Error:") ||
956 trimmed.startsWith("error:") ||
957 trimmed.startsWith("blocked:")
958 ) {
959 return output;
960 }
961 return undefined;
962 }
963
964 function ensureAssistant(s: State): { items: Item[]; id: string; seq: number } {
965 if (s.currentAssistant) {
966 const exists = s.items.some((it) => it.id === s.currentAssistant && it.kind === "assistant");
967 if (exists) return { items: s.items, id: s.currentAssistant, seq: s.seq };
968 }
969 const id = `a${s.seq}`;
970 const item: Item = { kind: "assistant", id, text: "", reasoning: "", streaming: true };
971 return { items: [...s.items, item], id, seq: s.seq + 1 };
972 }
973
974 function liveReasoningDurationMs(live?: LiveStream): number | undefined {
975 if (!live?.reasoningStartedAt || !live.reasoning) return undefined;
976 const completedAt = live.reasoningCompletedAt;
977 if (!completedAt || completedAt < live.reasoningStartedAt) return undefined;
978 return completedAt - live.reasoningStartedAt;
979 }
980
981 function completeLiveReasoning(live: LiveStream, now = Date.now()): LiveStream {
982 if (!live.reasoning || live.reasoningCompletedAt) {
983 return { ...live, reasoningComplete: live.reasoning !== "" || live.reasoningComplete };
984 }
985 return {
986 ...live,
987 reasoningComplete: true,
988 reasoningCompletedAt: now,
989 };
990 }
991
992 /** Closed + open user-wait ms for the active turn (approval/ask). */
993 export function currentTurnWaitMs(
994 s: Pick<State, "turnWaitAccumMs" | "promptWaitStartedAt">,
995 now = Date.now(),
996 ): number {
997 const closed = Math.max(0, s.turnWaitAccumMs || 0);
998 const open = s.promptWaitStartedAt && s.promptWaitStartedAt > 0
999 ? Math.max(0, now - s.promptWaitStartedAt)
1000 : 0;
1001 return closed + open;
1002 }
1003
1004 function currentTurnDurationMs(
1005 s: Pick<State, "turnStartAt" | "turnWaitAccumMs" | "promptWaitStartedAt">,
1006 now = Date.now(),
1007 ): number | undefined {
1008 if (!Number.isFinite(s.turnStartAt) || s.turnStartAt <= 0 || now < s.turnStartAt) return undefined;
1009 return Math.max(1, now - s.turnStartAt - currentTurnWaitMs(s, now));
1010 }
1011
1012 function beginPromptWait(s: State, now = Date.now()): State {
1013 if (s.promptWaitStartedAt && s.promptWaitStartedAt > 0) return s;
1014 return { ...s, promptWaitStartedAt: now };
1015 }
1016
1017 function endPromptWait(s: State, now = Date.now()): State {
1018 if (!s.promptWaitStartedAt || s.promptWaitStartedAt <= 0) {
1019 return s.promptWaitStartedAt === undefined ? s : { ...s, promptWaitStartedAt: undefined };
1020 }
1021 const delta = Math.max(0, now - s.promptWaitStartedAt);
1022 return {
1023 ...s,
1024 turnWaitAccumMs: Math.max(0, s.turnWaitAccumMs || 0) + delta,
1025 promptWaitStartedAt: undefined,
1026 };
1027 }
1028
1029 function endPromptWaitIfIdle(s: State, now = Date.now()): State {
1030 if (s.approval || s.ask) return s;
1031 return endPromptWait(s, now);
1032 }
1033
1034 function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnCost" | "turnArgChars"> {
1035 return {
1036 turnStartAt: now,
1037 turnWaitAccumMs: 0,
1038 promptWaitStartedAt: undefined,
1039 turnTokens: 0,
1040 turnTotalTokens: 0,
1041 turnCost: 0,
1042 turnArgChars: 0,
1043 };
1044 }
1045
1046 function flushPendingUser(s: State): State {
1047 if (s.pendingUser === undefined) return s;
1048 const lastItem = s.items[s.items.length - 1];
1049 if (lastItem?.kind === "user" && lastItem.text === s.pendingUser) {
1050 return { ...s, pendingUser: undefined };
1051 }
1052 return {
1053 ...s,
1054 seq: s.seq + 1,
1055 items: [...s.items, { kind: "user", id: `u${s.seq}`, text: s.pendingUser, createdAt: Date.now() }],
1056 pendingUser: undefined,
1057 };
1058 }
1059
1060 // applyExtensionSurfaceEvent reduces one extension_surface / extension_status
1061 // wire event. Every publication passes the per-surface generation fence first
1062 // (withAcceptedExtensionGeneration); the per-tab runtime-epoch fence in the
1063 // onEvent handler has already dropped anything from an older runtime
1064 // generation.
1065 function applyExtensionSurfaceEvent(s: State, surface: WireExtensionSurface | undefined): State {
1066 if (!surface) return s;
1067 const gated = withAcceptedExtensionGeneration(s, surface);
1068 if (gated === null) return s;
1069 s = gated;
1070 const kind = surface.kind || (surface.status ? "status" : "");
1071 switch (kind) {
1072 case "status":
1073 return applyExtensionStatus(s, surface);
1074 case "card":
1075 return applyExtensionCard(s, surface);
1076 case "form":
1077 return applyExtensionForm(s, surface);
1078 case "notification":
1079 return applyExtensionNotification(s, surface);
1080 default:
1081 return s;
1082 }
1083 }
1084
1085 // withAcceptedExtensionGeneration applies the per-surface generation fence.
1086 // Returns null when the event is a stale re-ordering and must be dropped;
1087 // otherwise returns state with the accepted generation recorded.
1088 function withAcceptedExtensionGeneration(s: State, surface: WireExtensionSurface): State | null {
1089 const key = extensionSurfaceKey(surface);
1090 if (!acceptsExtensionGeneration(s.extensionGenerations[key], surface.generation)) return null;
1091 if (surface.generation === undefined || s.extensionGenerations[key] === surface.generation) return s;
1092 return { ...s, extensionGenerations: { ...s.extensionGenerations, [key]: surface.generation } };
1093 }
1094
1095 function applyExtensionStatus(s: State, surface: WireExtensionSurface): State {
1096 const status: WireExtensionStatus | undefined = surface.status;
1097 if (!status) return s;
1098 const entry: ExtensionStatusEntry = {
1099 pluginId: surface.pluginId,
1100 surfaceId: surface.surfaceId,
1101 label: status.label,
1102 detail: status.detail,
1103 severity: status.severity,
1104 progress: status.progress,
1105 generation: surface.generation,
1106 };
1107 return { ...s, extensionStatuses: { ...s.extensionStatuses, [extensionSurfaceKey(surface)]: entry } };
1108 }
1109
1110 function applyExtensionCard(s: State, surface: WireExtensionSurface): State {
1111 const card: WireExtensionCard | undefined = surface.card;
1112 if (!card) return s;
1113 const key = extensionSurfaceKey(surface);
1114 const idx = s.items.findIndex((it) => it.kind === "extension" && it.surfaceKey === key);
1115 if (idx >= 0) {
1116 const next = [...s.items];
1117 const prev = next[idx];
1118 if (prev.kind === "extension") next[idx] = { ...prev, generation: surface.generation, card };
1119 return { ...s, items: next };
1120 }
1121 return {
1122 ...s,
1123 seq: s.seq + 1,
1124 items: [
1125 ...s.items,
1126 { kind: "extension", id: `x${s.seq}`, surfaceKey: key, pluginId: surface.pluginId, surfaceId: surface.surfaceId, generation: surface.generation, card },
1127 ],
1128 };
1129 }
1130
1131 function applyExtensionForm(s: State, surface: WireExtensionSurface): State {
1132 const form = surface.form;
1133 if (!form) return s;
1134 return {
1135 ...s,
1136 extensionForm: { pluginId: surface.pluginId, surfaceId: surface.surfaceId, generation: surface.generation, form },
1137 };
1138 }
1139
1140 function applyStreamAttempt(s: State, e: WireEvent): State {
1141 const sa = e.streamAttempt;
1142 if (!sa?.id || !sa.action) return s;
1143 switch (sa.action) {
1144 case "begin": {
1145 // Snapshot only what this attempt may mutate: live text/reasoning and
1146 // turnArgChars. Concurrent non-sampling events remain outside the journal.
1147 const baselineLive = s.live
1148 ? {
1149 id: s.live.id,
1150 text: s.live.text,
1151 reasoning: s.live.reasoning,
1152 reasoningComplete: s.live.reasoningComplete,
1153 reasoningStartedAt: s.live.reasoningStartedAt,
1154 reasoningCompletedAt: s.live.reasoningCompletedAt,
1155 }
1156 : undefined;
1157 return {
1158 ...s,
1159 running: true,
1160 turnActive: true,
1161 cancellable: true,
1162 turnStartAt: s.turnStartAt || Date.now(),
1163 streamAttemptJournal: {
1164 id: sa.id,
1165 baselineLive,
1166 baselineTurnArgChars: s.turnArgChars,
1167 createdToolIds: [],
1168 priorTools: {},
1169 },
1170 };
1171 }
1172 case "discard": {
1173 const journal = s.streamAttemptJournal;
1174 if (!journal || journal.id !== sa.id) {
1175 // Stale/out-of-order discard for an older attempt — leave the current
1176 // journal (and live speculative UI) untouched.
1177 return s;
1178 }
1179 const remove = new Set(journal.createdToolIds);
1180 const items = s.items
1181 .filter((it) => !(it.kind === "tool" && remove.has(it.id)))
1182 .map((it) => {
1183 if (it.kind !== "tool") return it;
1184 const prior = journal.priorTools[it.id];
1185 return prior ? { ...prior } : it;
1186 });
1187 // Restore live to the pre-attempt snapshot so partial text/reasoning is
1188 // replaced, not concatenated with the next attempt.
1189 const live = journal.baselineLive
1190 ? { ...journal.baselineLive }
1191 : s.live
1192 ? { ...s.live, text: "", reasoning: "", reasoningComplete: false, reasoningStartedAt: undefined, reasoningCompletedAt: undefined }
1193 : undefined;
1194 return {
1195 ...s,
1196 items,
1197 live,
1198 turnArgChars: journal.baselineTurnArgChars,
1199 streamAttemptJournal: undefined,
1200 running: true,
1201 turnActive: true,
1202 cancellable: true,
1203 };
1204 }
1205 case "commit": {
1206 // Commit clears bookkeeping only; subsequent tool_dispatch/result are real.
1207 if (s.streamAttemptJournal && s.streamAttemptJournal.id !== sa.id) {
1208 return s;
1209 }
1210 return { ...s, streamAttemptJournal: undefined };
1211 }
1212 default:
1213 return s;
1214 }
1215 }
1216
1217 /** Record a tool card mutation against the active sampling-attempt journal.
1218 * Only parent-sampling partials with a matching attemptId are journaled —
1219 * background sub-agent tools (parentId) and committed full dispatches are not.
1220 */
1221 function noteToolInJournal(
1222 s: State,
1223 toolId: string,
1224 existedBefore: boolean,
1225 prior: Extract<Item, { kind: "tool" }> | undefined,
1226 meta?: { attemptId?: string; parentId?: string; partial?: boolean },
1227 ): State {
1228 const journal = s.streamAttemptJournal;
1229 if (!journal || !toolId) return s;
1230 // Require explicit attempt membership — do not journal by arrival time alone.
1231 if (!meta?.attemptId || meta.attemptId !== journal.id) return s;
1232 if (meta.parentId) return s;
1233 if (meta.partial === false) return s;
1234 if (!existedBefore) {
1235 if (journal.createdToolIds.includes(toolId)) return s;
1236 return {
1237 ...s,
1238 streamAttemptJournal: {
1239 ...journal,
1240 createdToolIds: [...journal.createdToolIds, toolId],
1241 },
1242 };
1243 }
1244 if (prior && !journal.priorTools[toolId] && !journal.createdToolIds.includes(toolId)) {
1245 return {
1246 ...s,
1247 streamAttemptJournal: {
1248 ...journal,
1249 priorTools: { ...journal.priorTools, [toolId]: { ...prior } },
1250 },
1251 };
1252 }
1253 return s;
1254 }
1255
1256 function applyExtensionNotification(s: State, surface: WireExtensionSurface): State {
1257 const notification = surface.notification;
1258 if (!notification) return s;
1259 const entry: ExtensionNotificationEntry = {
1260 id: `xn${s.seq}`,
1261 pluginId: surface.pluginId,
1262 title: notification.title,
1263 body: notification.body,
1264 severity: notification.severity,
1265 };
1266 return { ...s, seq: s.seq + 1, extensionNotifications: [...s.extensionNotifications, entry] };
1267 }
1268
1269 function applyEvent(s: State, e: WireEvent): State {
1270 if (s.discardTurn) {
1271 if (e.kind === "turn_done") return { ...s, discardTurn: false, running: false, turnActive: false, pendingPrompt: false, cancelRequested: false, cancellable: false, currentAssistant: undefined, live: undefined };
1272 return s;
1273 }
1274 if (e.kind === "mcp_surface_ready") {
1275 // Background-only events must not confirm an optimistic user bubble.
1276 return s;
1277 }
1278 if (e.kind === "extension_surface" || e.kind === "extension_status") {
1279 // Sidecar surface publications are background-only too: they must neither
1280 // confirm an optimistic user bubble nor clear the retry indicator.
1281 return applyExtensionSurfaceEvent(s, e.extension);
1282 }
1283 if (s.pendingUser !== undefined && e.kind !== "turn_done") {
1284 s = flushPendingUser(s);
1285 }
1286 if (e.kind === "retrying") {
1287 // Retrying is emitted synchronously from inside the foreground provider
1288 // request, immediately before its cancellation-aware backoff. Treat it as
1289 // authoritative proof that the turn is still active. An idle ListTabs
1290 // snapshot fetched before this event can otherwise clear `running`,
1291 // regardless of which one reaches the reducer first, and leave the
1292 // composer showing "retrying (n/m)" without its Stop button (or Escape
1293 // cancellation) until all retries are exhausted.
1294 return {
1295 ...s,
1296 retry: {
1297 attempt: e.retryAttempt ?? 0,
1298 max: e.retryMax ?? 0,
1299 observedAt: promptEventClock(),
1300 },
1301 running: true,
1302 turnActive: true,
1303 cancellable: true,
1304 turnStartAt: s.turnStartAt || Date.now(),
1305 };
1306 }
1307 if (e.kind === "stream_attempt") {
1308 return applyStreamAttempt(s, e);
1309 }
1310 if (s.retry) s = { ...s, retry: undefined };
1311 switch (e.kind) {
1312 case "turn_started": {
1313 // Flush the user message and pre-create an empty assistant bubble
1314 // immediately so the user sees their message + a blinking cursor the
1315 // instant the backend acknowledges the turn — no dead gap waiting for
1316 // the first text/reasoning token.
1317 let cur: State = s;
1318 if (cur.pendingUser !== undefined) cur = flushPendingUser(cur);
1319 const { items, id, seq } = ensureAssistant(cur);
1320 return {
1321 ...cur,
1322 items,
1323 currentAssistant: id,
1324 seq,
1325 live: { id, text: "", reasoning: "", reasoningComplete: false },
1326 running: true,
1327 turnActive: true,
1328 pendingPrompt: false,
1329 cancelRequested: false,
1330 cancellable: true,
1331 ...resetTurnTiming(),
1332 };
1333 }
1334 case "text":
1335 case "reasoning": {
1336 const { items, id, seq } = ensureAssistant(s);
1337 const delta = e.text ?? e.reasoning ?? "";
1338 const base = s.live?.id === id ? s.live : { id, text: "", reasoning: "", reasoningComplete: false };
1339 const now = Date.now();
1340 const live =
1341 e.kind === "text"
1342 ? { ...completeLiveReasoning(base, now), text: base.text + delta }
1343 : {
1344 ...base,
1345 reasoning: base.reasoning + delta,
1346 reasoningComplete: false,
1347 reasoningStartedAt: base.reasoningStartedAt ?? (delta ? now : undefined),
1348 reasoningCompletedAt: undefined,
1349 };
1350 return { ...s, items, live, currentAssistant: id, seq };
1351 }
1352 case "message": {
1353 const existingAssistant =
1354 s.currentAssistant === undefined
1355 ? undefined
1356 : s.items.find((it): it is Extract<Item, { kind: "assistant" }> => it.kind === "assistant" && it.id === s.currentAssistant);
1357 const text = e.text ?? s.live?.text ?? existingAssistant?.text ?? "";
1358 const reasoning = e.reasoning ?? s.live?.reasoning ?? existingAssistant?.reasoning ?? "";
1359 if (text.trim() === "" && reasoning.trim() === "") {
1360 const items =
1361 existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !existingAssistant.memoryCitations?.length
1362 ? s.items.filter((it) => !(it.kind === "assistant" && it.id === existingAssistant.id))
1363 : s.items;
1364 return { ...s, items, live: undefined, currentAssistant: undefined };
1365 }
1366 const { items, id, seq } = ensureAssistant(s);
1367 const now = Date.now();
1368 const completedLive = s.live?.id === id ? completeLiveReasoning({ ...s.live, text, reasoning }, now) : undefined;
1369 const reasoningDurationMs = liveReasoningDurationMs(completedLive);
1370 const workDurationMs = currentTurnDurationMs(s, now);
1371 const next = items.map((it) =>
1372 it.kind === "assistant" && it.id === id
1373 ? (() => {
1374 const memoryCitations = asArray<MemoryCitation>(e.memoryCitations ?? it.memoryCitations);
1375 return {
1376 ...it,
1377 text,
1378 reasoning,
1379 streaming: false,
1380 reasoningComplete: reasoning !== "" || it.reasoningComplete,
1381 reasoningDurationMs: reasoningDurationMs ?? it.reasoningDurationMs,
1382 workDurationMs: Math.max(it.workDurationMs ?? 0, workDurationMs ?? 0) || undefined,
1383 memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
1384 };
1385 })()
1386 : it,
1387 );
1388 return { ...s, items: next, live: undefined, currentAssistant: undefined, seq };
1389 }
1390 case "tool_dispatch": {
1391 const t = e.tool;
1392 if (!t) return s;
1393 // A partial dispatch (args still streaming from the model) upserts a
1394 // lightweight "receiving" card immediately. Dropping it entirely — the
1395 // old behavior — left a 30KB write_file body streaming for a minute with
1396 // zero visible activity, indistinguishable from a hang. The full
1397 // dispatch that follows merges by ID and fills in args/summary.
1398 if (t.partial) {
1399 const turnArgChars = t.argChars && t.argChars > 0 ? t.argChars : s.turnArgChars;
1400 // Some OpenAI-compatible streams surface the call name before its ID.
1401 // Without a stable ID the card could never be merged with the full
1402 // dispatch (a synthetic `tool${seq}` id would orphan it as a forever-
1403 // running duplicate), so count the progress but wait for the ID before
1404 // creating the card.
1405 if (!t.id) return { ...s, turnArgChars };
1406 const id = t.id;
1407 const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === id);
1408 if (idx >= 0) {
1409 const next = [...s.items];
1410 const it = next[idx];
1411 if (it.kind === "tool" && it.status === "running" && !it.args) {
1412 const prior = it;
1413 next[idx] = { ...it, argChars: t.argChars || it.argChars };
1414 return noteToolInJournal({ ...s, items: next, turnArgChars }, id, true, prior, {
1415 attemptId: t.attemptId, parentId: t.parentId, partial: true,
1416 });
1417 }
1418 return { ...s, turnArgChars };
1419 }
1420 return noteToolInJournal({
1421 ...s,
1422 turnArgChars,
1423 seq: s.seq + 1,
1424 items: [...s.items, { kind: "tool", id, name: t.name, args: "", readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", argChars: t.argChars || undefined, parentId: t.parentId, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined }],
1425 }, id, false, undefined, { attemptId: t.attemptId, parentId: t.parentId, partial: true });
1426 }
1427 const id = t.id || `tool${s.seq}`;
1428 const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === id);
1429 if (idx >= 0) {
1430 const next = [...s.items];
1431 const it = next[idx];
1432 if (it.kind === "tool") {
1433 const args = t.args ? t.args : it.args;
1434 const fileDiff = fileDiffFromWire(t);
1435 const summary = summarizeFileDiff(fileDiff) || summarize(t.name, args) || (t.name === it.name && args === it.args ? it.summary : undefined);
1436 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, subagentProgress: it.subagentProgress ?? (SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined) };
1437 }
1438 if (t.parentId) touchSubagentParent(next, t.parentId);
1439 // Full dispatches are committed work — never journal them as speculative.
1440 return { ...s, items: next };
1441 }
1442 const args = t.args ?? "";
1443 const fileDiff = fileDiffFromWire(t);
1444 const created: ToolItem = { kind: "tool", id, name: t.name, args, readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", summary: summarizeFileDiff(fileDiff) || summarize(t.name, args), fileDiff, isShell: t.name === "bash" || id.startsWith("shell-"), execution: t.execution, parentId: t.parentId, profile: t.profile, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined };
1445 const items = [...s.items, created];
1446 // A sub-agent call nested under a task card refreshes that card's
1447 // recent activity and switches its phase to "tool".
1448 if (t.parentId) touchSubagentParent(items, t.parentId);
1449 return { ...s, seq: s.seq + 1, items };
1450 }
1451 case "tool_result": {
1452 const t = e.tool;
1453 if (!t) return s;
1454 const next = [...s.items];
1455 let idx = t.id ? next.findIndex((it) => it.kind === "tool" && it.id === t.id) : -1;
1456 if (idx < 0) {
1457 for (let i = next.length - 1; i >= 0; i--) {
1458 const it = next[i];
1459 if (it.kind === "tool" && it.status === "running") { idx = i; break; }
1460 }
1461 }
1462 if (idx >= 0) {
1463 const it = next[idx];
1464 if (it.kind === "tool") {
1465 // Archive immediately: collapsed cards only show tool name + command
1466 // subject (from args). Drop output entirely; full data is loaded on
1467 // demand via app.ToolResultForTab when the card is expanded.
1468 const existing = it;
1469 const summary = t.err ? undefined : existing.summary || summarize(existing.name, existing.args, t.output);
1470 let status: ToolStatus = t.err ? "error" : "done";
1471 if (existing.subagentProgress) {
1472 // Sub-agent progress owns the card's final visual: a background
1473 // call that returned a job id stays running while the child
1474 // works; a cancelled child keeps its stopped semantics even when
1475 // the aggregate result carries an error. Group cards
1476 // (parallel_tasks/fleet) settle only from their own lifecycle
1477 // terminal event — the backend emits running at start and exactly
1478 // one terminal at the end (including validation failures and
1479 // zero-child cancellation) — never from inferring the children
1480 // observed so far, since a background group's children dispatch
1481 // asynchronously and a fast child can finish before later ones
1482 // even appear.
1483 if (isGroupSubagentTool(existing.name)) {
1484 status = isTerminalSubagentPhase(existing.subagentProgress.phase)
1485 ? terminalStatusOf(existing.subagentProgress.phase)
1486 : "running";
1487 } else if (!isTerminalSubagentPhase(existing.subagentProgress.phase)) {
1488 status = "running";
1489 } else {
1490 status = terminalStatusOf(existing.subagentProgress.phase);
1491 }
1492 }
1493 next[idx] = {
1494 ...existing,
1495 readOnly: t.readOnly,
1496 resolvedName: t.resolvedName ?? existing.resolvedName,
1497 capabilityId: t.capabilityId ?? existing.capabilityId,
1498 status,
1499 output: t.output,
1500 error: t.err,
1501 truncated: t.truncated,
1502 durationMs: t.durationMs,
1503 summary,
1504 isShell: existing.isShell || existing.name === "bash" || t.name === "bash",
1505 execution: t.execution ?? existing.execution,
1506 };
1507 }
1508 }
1509 // A nested result refreshes its sub-agent parent's recent activity.
1510 if (t.parentId) touchSubagentParent(next, t.parentId);
1511 return { ...s, items: compactArchivedToolItems(next) };
1512 }
1513 case "tool_progress": {
1514 const t = e.tool;
1515 if (!t?.id) return s;
1516 // Reserved sub-agent progress channels update the card's in-memory
1517 // preview; they never touch tool.output or the parent's live stream.
1518 if (isSubagentProgressName(t.name)) {
1519 return applySubagentProgress(s, t);
1520 }
1521 const idx = s.items.findIndex((it) => it.kind === "tool" && it.id === t.id);
1522 if (idx < 0) return s;
1523 const next = [...s.items];
1524 const it = next[idx];
1525 if (it.kind === "tool") next[idx] = { ...it, output: (it.output ?? "") + (t.output ?? "") };
1526 // Streaming output of a sub-agent's real tool refreshes its card.
1527 if (t.parentId) touchSubagentParent(next, t.parentId);
1528 return { ...s, items: next };
1529 }
1530 case "usage": {
1531 if (!countsTowardCurrentTurn(s)) return s;
1532 const updateContextGauge = updatesContextGauge(e.usage);
1533 // Prefer Context* (latest attempt) over billable aggregates when multi-
1534 // attempt sampling recovery folds several provider calls into one Usage.
1535 // Matches Controller.ContextSnapshot: latest prompt + completion.
1536 let used = s.context.used;
1537 if (e.usage && s.context.window && updateContextGauge) {
1538 const hasContext =
1539 (e.usage.contextPromptTokens ?? 0) > 0 || (e.usage.contextCompletionTokens ?? 0) > 0;
1540 used = hasContext
1541 ? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
1542 : (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
1543 }
1544 const turnTokens = s.turnTokens + (e.usage?.completionTokens ?? 0);
1545 const usageTokens = usageTotalTokens(e.usage);
1546 const turnTotalTokens = s.turnTotalTokens + usageTokens;
1547 const sessionTokens = s.sessionTokens + usageTokens;
1548 const usageCost = e.usage?.cost ?? e.usage?.costUsd ?? 0;
1549 const turnCost = s.turnCost + usageCost;
1550 const sessionCost = s.sessionCost + usageCost;
1551 const sessionCurrency = e.usage?.currency || s.sessionCurrency || "¥";
1552 const usage = updateContextGauge ? e.usage : s.usage;
1553 // The completed round's usage now accounts for the streamed tool-call
1554 // arguments, so drop the live estimate rather than double-count it.
1555 return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, turnArgChars: 0, sessionTokens, sessionCost, sessionCurrency, usageSeq: s.usageSeq + 1 };
1556 }
1557 case "notice":
1558 return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt);
1559 case "phase":
1560 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
1561 case "compaction_started":
1562 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "compaction", id: `c${s.seq}`, pending: true, trigger: e.compaction?.trigger ?? "", messages: 0, summary: "", archive: "" }] };
1563 case "compaction_done": {
1564 const c = e.compaction;
1565 const idx = [...s.items].reverse().findIndex((it) => it.kind === "compaction" && it.pending);
1566 const at = idx < 0 ? -1 : s.items.length - 1 - idx;
1567 if (!c?.summary) {
1568 const items = at < 0 ? s.items : s.items.filter((_, i) => i !== at);
1569 return { ...s, running: s.turnActive ? s.running : false, items };
1570 }
1571 const filled: Item = { kind: "compaction", id: at < 0 ? `c${s.seq}` : (s.items[at] as Extract<Item, { kind: "compaction" }>).id, pending: false, trigger: c.trigger ?? "", messages: c.messages ?? 0, summary: c.summary, archive: c.archive ?? "" };
1572 const items = at < 0 ? [...s.items, filled] : s.items.map((it, i) => (i === at ? filled : it));
1573 return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items };
1574 }
1575 case "steer":
1576 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `s${s.seq}`, level: "info", text: `${STEER_NOTICE_PREFIX}${e.text ?? ""}` }] };
1577 case "approval_request": {
1578 if (s.cancelRequested) return s;
1579 // A delayed re-delivery of a prompt the user already answered locally
1580 // (clearApproval) must not resurrect it — no downstream snapshot is
1581 // guaranteed to ever reject it again (#6432 round 2).
1582 if (e.approval?.id !== undefined && e.approval.id === s.resolvedPromptId) return s;
1583 return beginPromptWait({
1584 ...s,
1585 approval: e.approval,
1586 // A replay of the SAME prompt (post-answer delayed delivery, or the
1587 // #6429 re-arm after activation) keeps the original arrival time; only
1588 // a genuinely new prompt id re-anchors it (#6432 reverse race).
1589 promptArrivedAt: e.approval?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
1590 promptArrivedId: e.approval?.id,
1591 pendingPrompt: true,
1592 running: true,
1593 turnActive: true,
1594 cancellable: true,
1595 });
1596 }
1597 case "ask_request": {
1598 if (s.cancelRequested) return s;
1599 if (e.ask?.id !== undefined && e.ask.id === s.resolvedPromptId) return s;
1600 return beginPromptWait({
1601 ...s,
1602 ask: e.ask,
1603 promptArrivedAt: e.ask?.id === s.promptArrivedId ? s.promptArrivedAt : promptEventClock(),
1604 promptArrivedId: e.ask?.id,
1605 pendingPrompt: true,
1606 running: true,
1607 turnActive: true,
1608 cancellable: true,
1609 });
1610 }
1611 case "guardian_assessment": {
1612 if (!e.guardian) return s;
1613 const level = e.guardian.outcome === "deny" ? "warn" : "info";
1614 return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `g${s.seq}`, level, text: formatGuardianAssessmentNotice(e.guardian) }] };
1615 }
1616 case "turn_done": {
1617 if (s.pendingUser !== undefined) s = flushPendingUser(s);
1618 const now = Date.now();
1619 const workDurationMs = currentTurnDurationMs(s, now);
1620 let lastUserIndex = -1;
1621 let lastAssistantIndex = -1;
1622 for (let i = 0; i < s.items.length; i++) {
1623 if (s.items[i].kind === "user") {
1624 lastUserIndex = i;
1625 lastAssistantIndex = -1;
1626 } else if (i > lastUserIndex && s.items[i].kind === "assistant") {
1627 lastAssistantIndex = i;
1628 }
1629 }
1630 const finalized = s.items.map((it, index) => {
1631 if (it.kind === "assistant" && s.live && it.id === s.live.id) {
1632 const completedLive = completeLiveReasoning(s.live, now);
1633 return {
1634 ...it,
1635 text: completedLive.text,
1636 reasoning: completedLive.reasoning,
1637 streaming: false,
1638 reasoningComplete: completedLive.reasoning !== "" || completedLive.reasoningComplete,
1639 reasoningDurationMs: liveReasoningDurationMs(completedLive) ?? it.reasoningDurationMs,
1640 workDurationMs: index === lastAssistantIndex
1641 ? Math.max(it.workDurationMs ?? 0, workDurationMs ?? 0) || undefined
1642 : it.workDurationMs,
1643 };
1644 }
1645 if (it.kind === "assistant") {
1646 return {
1647 ...it,
1648 streaming: false,
1649 workDurationMs: index === lastAssistantIndex
1650 ? Math.max(it.workDurationMs ?? 0, workDurationMs ?? 0) || undefined
1651 : it.workDurationMs,
1652 };
1653 }
1654 if (it.kind === "tool" && it.status === "running") return { ...it, status: "stopped" as const };
1655 return it;
1656 });
1657 let items: Item[] = s.deliveryRecoveryActive && !e.err
1658 ? finalized.filter((item) => item.kind !== "notice" || item.variant !== "delivery")
1659 : finalized;
1660 if (e.outcome === "final_readiness") {
1661 const previous = items.map((item) => item.kind === "notice" && item.variant === "delivery"
1662 ? { ...item, action: undefined }
1663 : item);
1664 items = [...previous, {
1665 kind: "notice",
1666 id: `e${s.seq}`,
1667 level: "info",
1668 variant: "delivery",
1669 title: t("notice.deliveryIncompleteTitle"),
1670 text: t("notice.deliveryIncompleteBody"),
1671 detail: deliveryReadinessDetail(e.readiness, e.err),
1672 action: "continue_delivery",
1673 }];
1674 } else if (e.outcome === "recovery_paused") {
1675 // Informational pause — not a send failure. Composer is immediately free.
1676 items = [...finalized, {
1677 kind: "notice",
1678 id: `e${s.seq}`,
1679 level: "info",
1680 title: t("notice.recoveryPausedTitle"),
1681 text: t("notice.recoveryPausedBody"),
1682 }];
1683 } else if (e.err) {
1684 items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err }];
1685 }
1686 // Plan approval can arrive before turn_done on some Wails event paths.
1687 // Keep that gate visible instead of clearing the only UI that can answer it.
1688 const keepPlanApproval = s.approval?.tool === "exit_plan_mode";
1689 let next: State = {
1690 ...s,
1691 items,
1692 live: undefined,
1693 streamAttemptJournal: undefined,
1694 running: keepPlanApproval,
1695 turnActive: keepPlanApproval,
1696 pendingPrompt: keepPlanApproval,
1697 cancelRequested: false,
1698 cancellable: keepPlanApproval,
1699 currentAssistant: undefined,
1700 approval: keepPlanApproval ? s.approval : undefined,
1701 ask: undefined,
1702 deliveryRecoveryActive: false,
1703 seq: s.seq + 1,
1704 };
1705 // Close user-wait unless the plan approval gate remains open.
1706 if (!keepPlanApproval) next = endPromptWait(next, now);
1707 return next;
1708 }
1709 default: return s;
1710 }
1711 }
1712
1713 export function reducer(s: State, a: Action): State {
1714 switch (a.type) {
1715 case "user": {
1716 const seq = a.seq !== undefined ? a.seq : s.seq;
1717 return {
1718 ...s,
1719 seq: seq + 1,
1720 items: [...s.items, { kind: "user", id: `u${seq}`, text: a.text, submitText: a.submitText, createdAt: Date.now() }],
1721 running: true,
1722 pendingPrompt: false,
1723 cancelRequested: false,
1724 cancellable: true,
1725 ...resetTurnTiming(),
1726 // New turn epoch: forget the previous prompt anchor so a genuinely new
1727 // prompt re-anchors freshly instead of inheriting a stale id/time.
1728 promptArrivedAt: undefined,
1729 promptArrivedId: undefined,
1730 pendingUser: a.text,
1731 deliveryRecoveryActive: Boolean(a.deliveryRecovery),
1732 discardTurn: false,
1733 };
1734 }
1735 case "unsend": {
1736 const cleared = endPromptWait({
1737 ...s,
1738 pendingUser: undefined,
1739 discardTurn: true,
1740 running: false,
1741 pendingPrompt: false,
1742 cancelRequested: true,
1743 cancellable: false,
1744 approval: undefined,
1745 ask: undefined,
1746 promptArrivedAt: undefined,
1747 promptArrivedId: undefined,
1748 live: undefined,
1749 });
1750 return cleared;
1751 }
1752 case "cancel_requested": {
1753 return endPromptWait({
1754 ...s,
1755 pendingPrompt: false,
1756 cancelRequested: true,
1757 approval: undefined,
1758 ask: undefined,
1759 promptArrivedAt: undefined,
1760 promptArrivedId: undefined,
1761 cancellable: s.running || s.turnActive,
1762 });
1763 }
1764 case "send_failed": {
1765 if (s.pendingUser === undefined) return s;
1766 let idx = -1;
1767 for (let i = s.items.length - 1; i >= 0; i--) {
1768 const it = s.items[i];
1769 if (it.kind === "user" && it.text === s.pendingUser) { idx = i; break; }
1770 }
1771 const items = idx >= 0 ? s.items.map((it, i) => (i === idx ? { ...it, failed: true } : it)) : s.items;
1772 const notice: Item = { kind: "notice", id: `n${s.seq}`, level: "warn", text: a.error };
1773 return { ...s, pendingUser: undefined, deliveryRecoveryActive: false, running: false, turnActive: false, pendingPrompt: false, cancelRequested: false, cancellable: false, live: undefined, seq: s.seq + 1, items: [...items, notice] };
1774 }
1775 case "backend_status": {
1776 // A snapshot fetched before the live approval/ask event arrived cannot
1777 // know about the prompt; everything it reports about the turn lifecycle
1778 // is equally stale. Ignore it and let an explicit answer/cancel or a
1779 // fresher snapshot settle the state (#6429).
1780 if (runtimeSnapshotPredatesPrompt(s, a.snapshotAt)) return s;
1781 const pendingPrompt = Boolean(a.pendingPrompt);
1782 const backgroundJobs = Math.max(0, a.backgroundJobs ?? s.backgroundJobs ?? 0);
1783 const cancelRequested = Boolean(a.cancelRequested);
1784 const foregroundRunning = foregroundRunningFromRuntimeMeta({ running: a.running, pendingPrompt, backgroundJobs, cancellable: a.cancellable });
1785 // A retry event is newer evidence of foreground activity than an idle
1786 // snapshot whose fetch started earlier. Keep the turn cancellable until
1787 // a snapshot started after the retry confirms that it is actually idle.
1788 if (!foregroundRunning && runtimeSnapshotPredatesRetry(s, a.snapshotAt)) return s;
1789 const cancellable = foregroundRunning;
1790 const clearsRetry = !foregroundRunning && s.retry !== undefined;
1791 if (
1792 foregroundRunning === s.running &&
1793 pendingPrompt === s.pendingPrompt &&
1794 backgroundJobs === s.backgroundJobs &&
1795 cancelRequested === s.cancelRequested &&
1796 cancellable === s.cancellable &&
1797 !clearsRetry
1798 ) return s;
1799 if (foregroundRunning) {
1800 return {
1801 ...s,
1802 running: true,
1803 turnActive: true,
1804 pendingPrompt,
1805 backgroundJobs,
1806 cancelRequested,
1807 cancellable,
1808 turnStartAt: s.turnStartAt || Date.now(),
1809 };
1810 }
1811 const finalized = s.items.map((it) => {
1812 if (it.kind === "assistant" && s.live && it.id === s.live.id) return { ...it, text: s.live.text, reasoning: s.live.reasoning, streaming: false };
1813 if (it.kind === "assistant" && it.streaming) return { ...it, streaming: false };
1814 if (it.kind === "tool" && it.status === "running") return { ...it, status: "stopped" as const };
1815 return it;
1816 });
1817 return endPromptWait({
1818 ...s,
1819 items: finalized,
1820 running: false,
1821 turnActive: false,
1822 pendingPrompt,
1823 backgroundJobs,
1824 cancelRequested,
1825 cancellable,
1826 live: undefined,
1827 currentAssistant: undefined,
1828 approval: undefined,
1829 ask: undefined,
1830 retry: undefined,
1831 });
1832 }
1833 case "meta": {
1834 const meta = a.meta.sessionPath === undefined && s.meta?.sessionPath !== undefined ? { ...a.meta, sessionPath: s.meta.sessionPath } : a.meta;
1835 return sameMeta(s.meta, meta) ? s : { ...s, meta };
1836 }
1837 case "optimistic_meta": return sameMeta(s.meta, a.meta) ? s : { ...s, meta: a.meta, hydrateError: undefined };
1838 case "context": {
1839 const sessionTokens = typeof a.context.sessionTokens === "number"
1840 ? Math.max(0, a.context.sessionTokens)
1841 : s.sessionTokens;
1842 const sessionCost = typeof a.context.sessionCost === "number" && a.context.sessionCost > 0
1843 ? a.context.sessionCost
1844 : s.sessionCost;
1845 const sessionCurrency = a.context.sessionCurrency || s.sessionCurrency;
1846 // Mid-turn snapshot refreshes can race a rebuilt executor whose
1847 // LastUsage is still nil: the backend then reports used=0 for a session
1848 // that visibly holds tokens, and the gauge collapses to "0/1M" until the
1849 // next executor usage arrives. Keep the last known fill while a turn is
1850 // live; genuine resets flow through the "reset" action or land when the
1851 // session is idle.
1852 const context =
1853 a.context.used === 0 && s.context.used > 0 && (s.running || s.turnActive) && a.context.window === s.context.window
1854 ? { ...a.context, used: s.context.used }
1855 : a.context;
1856 return { ...s, context, sessionTokens, sessionCost, sessionCurrency };
1857 }
1858 case "balance": return { ...s, balance: a.balance };
1859 case "effort": return { ...s, effort: a.effort };
1860 case "jobs": return { ...s, jobs: a.jobs };
1861 case "checkpoints": return { ...s, checkpoints: a.checkpoints };
1862 case "hydrate_start": return {
1863 ...s,
1864 hydrating: true,
1865 hydrateReason: a.reason,
1866 hydrateError: undefined,
1867 hydrateHistoryLoaded: false,
1868 hydratePlaceholderItems: a.placeholderItems?.length ? a.placeholderItems : undefined,
1869 };
1870 case "hydrate_done": return s.hydrating || s.hydrateReason || s.hydrateError || s.hydrateHistoryLoaded || s.hydratePlaceholderItems
1871 ? { ...s, hydrating: false, hydrateReason: undefined, hydrateError: undefined, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined }
1872 : s;
1873 case "hydrate_error": return { ...s, hydrating: false, hydrateReason: a.reason, hydrateError: a.error, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined };
1874 case "backend_activation_start": return {
1875 ...s,
1876 // The target tab may contain a prompt event that was routed there while
1877 // frontend selection was ahead of backend activation. Reset that
1878 // uncertain lifecycle first; optimistic backend metadata is applied
1879 // immediately afterwards and restores a genuinely running target.
1880 backendActivationPending: true,
1881 pendingPrompt: false,
1882 approval: undefined,
1883 ask: undefined,
1884 // New tab epoch: drop the prompt anchor so the post-activation replay
1885 // re-anchors against this activation, keeping the #6429 stale-snapshot
1886 // guard armed for the freshly restored prompt.
1887 promptArrivedAt: undefined,
1888 promptArrivedId: undefined,
1889 running: false,
1890 turnActive: false,
1891 cancellable: false,
1892 };
1893 case "backend_activation_done": return s.backendActivationPending ? { ...s, backendActivationPending: false } : s;
1894 case "message_action_start": return { ...s, messageAction: a.action };
1895 case "message_action_done": return { ...s, messageAction: undefined };
1896 case "history": {
1897 const { items, seq } = historyMessagesToItems(a.messages, "h", s.seq);
1898 return { ...s, items: compactArchivedToolItems(items), seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyOlderLoading: false };
1899 }
1900 case "history_page": {
1901 const { items, seq } = historyPageItems(a.page);
1902 const nextItems = a.mode === "prepend" ? [...items, ...s.items] : items;
1903 return {
1904 ...s,
1905 items: compactArchivedToolItems(nextItems),
1906 seq: Math.max(s.seq, seq),
1907 hydrateHistoryLoaded: true,
1908 hydratePlaceholderItems: undefined,
1909 historyStartTurn: a.page.startTurn,
1910 historyTotalTurns: a.page.totalTurns,
1911 historyHasOlder: a.page.hasOlder,
1912 historyOlderLoading: false,
1913 };
1914 }
1915 case "history_older_start": return s.historyOlderLoading ? s : { ...s, historyOlderLoading: true };
1916 case "history_older_error": return s.historyOlderLoading ? { ...s, historyOlderLoading: false } : s;
1917 case "history_checkpoint_turns":
1918 return { ...s, items: mergeHistoryCheckpointTurns(s.items, a.turns, s.historyStartTurn) };
1919 case "local_notice": return { ...s, running: false, turnActive: false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: a.level, text: a.text }] };
1920 case "clearApproval": {
1921 const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask), resolvedPromptId: s.approval?.id ?? s.resolvedPromptId };
1922 return endPromptWaitIfIdle(next);
1923 }
1924 case "clearAsk": {
1925 const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval), resolvedPromptId: s.ask?.id ?? s.resolvedPromptId };
1926 return endPromptWaitIfIdle(next);
1927 }
1928 case "clearExtensionForm": return s.extensionForm ? { ...s, extensionForm: undefined } : s;
1929 case "extension_notifications_drained": return s.extensionNotifications.length > 0 ? { ...s, extensionNotifications: [] } : s;
1930 // A tool-approval posture switch auto-allowed exactly these prompt ids on
1931 // the backend. Hide + tombstone the visible approval only when it is one
1932 // of them; anything else (plan/memory/sandbox-escape, ask-rule approvals
1933 // under auto) is still genuinely pending there and must stay visible —
1934 // tombstoning it would filter every future replay and strand the turn. The
1935 // drain result must also belong to this controller's prompt-id epoch.
1936 case "approval_drained": {
1937 if (s.promptEpoch !== a.epoch || !s.approval || !a.ids.includes(s.approval.id)) return s;
1938 const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask), resolvedPromptId: s.approval.id };
1939 return endPromptWaitIfIdle(next);
1940 }
1941 // The optimistic clearApproval/clearAsk tombstone was wrong: the backend
1942 // call that was supposed to actually resolve this id failed, so the
1943 // prompt is still genuinely pending there. Undo the tombstone so the next
1944 // replay (proactively requested by the caller) can re-arm it instead of
1945 // being silently swallowed forever. Only for the epoch the RPC was issued
1946 // in: after a controller rebuild the same numeric id names a DIFFERENT
1947 // prompt, and a late failure from the old controller must not erase the
1948 // new controller's tombstone.
1949 case "submit_prompt_failed":
1950 return s.resolvedPromptId === a.id && s.promptEpoch === a.epoch ? { ...s, resolvedPromptId: undefined } : s;
1951 // A controller rebuild (model/effort/token-mode switch) replaces the
1952 // backend controller in place and its approval/ask ids restart from "1"
1953 // (per-controller counters, see sound.ts). Any id-anchored bookkeeping
1954 // from the OLD controller is meaningless for the new one and must be
1955 // dropped, or a genuinely new prompt reusing an old id would be misread
1956 // as a stale replay of an already-answered prompt and silently ignored.
1957 case "controller_rebuilt":
1958 // A rebuild restarts the runtime's extension sidecars too, so extension
1959 // surface state (and the per-surface generation fence) from the old
1960 // runtime is meaningless for the new one and is dropped with the rest of
1961 // the id-anchored bookkeeping.
1962 return {
1963 ...s,
1964 promptEpoch: s.promptEpoch + 1,
1965 resolvedPromptId: undefined,
1966 promptArrivedId: undefined,
1967 promptArrivedAt: undefined,
1968 extensionStatuses: {},
1969 extensionForm: undefined,
1970 extensionNotifications: [],
1971 extensionGenerations: {},
1972 };
1973 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 };
1974 case "context_panel_refresh": return { ...s, contextPanelSeq: s.contextPanelSeq + 1 };
1975 case "event": return applyEvent(s, a.e);
1976 default: return s;
1977 }
1978 }
1979
1980 // ---- per-tab state map ----
1981
1982 type TabStates = Map<string, State>;
1983
1984 function getOrCreateState(states: TabStates, tabId: string): State {
1985 if (!states.has(tabId)) states.set(tabId, { ...initialState });
1986 return states.get(tabId)!;
1987 }
1988
1989 function messageActionBusyText(scope: MessageActionScope): string {
1990 switch (scope) {
1991 case "fork":
1992 return t("rewind.busyFork");
1993 case "summ-from":
1994 return t("rewind.busySummFrom");
1995 case "summ-upto":
1996 return t("rewind.busySummUpto");
1997 case "conversation":
1998 return t("rewind.busyConversation");
1999 case "code":
2000 return t("rewind.busyCode");
2001 default:
2002 return t("rewind.busyBoth");
2003 }
2004 }
2005
2006 function errorMessage(err: unknown): string {
2007 if (err instanceof Error) return err.message;
2008 if (typeof err === "string") return err;
2009 return String(err || "");
2010 }
2011
2012 export function effortSwitchNoticeText(err: unknown): string {
2013 return settingSwitchNoticeText(err, "effort", {
2014 busy: "status.effortSwitchBusy",
2015 busyRunning: "status.effortSwitchBusyRunning",
2016 busyPrompt: "status.effortSwitchBusyPrompt",
2017 busyJobs: "status.effortSwitchBusyJobs",
2018 leaseHeld: "status.effortSwitchLeaseHeld",
2019 starting: "status.effortSwitchStarting",
2020 startupFailed: "status.effortSwitchStartupFailed",
2021 retry: "status.effortSwitchRetry",
2022 failed: "status.effortSwitchFailed",
2023 });
2024 }
2025
2026 export function modelSwitchNoticeText(err: unknown): string {
2027 const msg = errorMessage(err).trim() || "unknown error";
2028 const unknownModel = /^unknown model (.+)$/i.exec(msg);
2029 if (unknownModel) {
2030 return t("status.modelSwitchUnknown", { model: unknownModel[1] });
2031 }
2032 const unavailable = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
2033 if (unavailable) {
2034 return t("status.modelSwitchProviderUnavailable", { model: unavailable[1], provider: unavailable[2] });
2035 }
2036 return settingSwitchNoticeText(msg, "model", {
2037 busy: "status.modelSwitchBusy",
2038 busyRunning: "status.modelSwitchBusyRunning",
2039 busyPrompt: "status.modelSwitchBusyPrompt",
2040 busyJobs: "status.modelSwitchBusyJobs",
2041 leaseHeld: "status.modelSwitchLeaseHeld",
2042 starting: "status.modelSwitchStarting",
2043 startupFailed: "status.modelSwitchStartupFailed",
2044 retry: "status.modelSwitchRetry",
2045 failed: "status.modelSwitchFailed",
2046 });
2047 }
2048
2049 export function tokenModeSwitchNoticeText(err: unknown): string {
2050 return settingSwitchNoticeText(err, "token mode", {
2051 busy: "status.tokenModeSwitchBusy",
2052 busyRunning: "status.tokenModeSwitchBusyRunning",
2053 busyPrompt: "status.tokenModeSwitchBusyPrompt",
2054 busyJobs: "status.tokenModeSwitchBusyJobs",
2055 leaseHeld: "status.tokenModeSwitchLeaseHeld",
2056 starting: "status.tokenModeSwitchStarting",
2057 startupFailed: "status.tokenModeSwitchStartupFailed",
2058 retry: "status.tokenModeSwitchRetry",
2059 failed: "status.tokenModeSwitchFailed",
2060 });
2061 }
2062
2063 // noticeCodeKeys maps the backend's stable notice codes (event.NoticeCode*) to
2064 // dictionary keys. Codes survive backend copy edits, unlike the exact-text
2065 // matching in backendNoticeKey, which stays only as the fallback for events
2066 // and replayed histories that carry no code.
2067 const noticeCodeKeys: Record<string, DictKey> = {
2068 final_readiness: "notice.finalReadiness",
2069 empty_final: "notice.emptyFinal",
2070 executor_handoff: "notice.executorHandoff",
2071 tool_budget: "notice.toolBudget",
2072 loop_guard: "notice.loopGuard",
2073 workspace_lease: "notice.workspaceLease",
2074 cancelled_turn_display: "notice.cancelledTurnDisplay",
2075 session_recovery_forked: "recovery.noticeSavedCopy",
2076 session_recovery_adopted: "recovery.noticeAdopted",
2077 session_recovery_adopted_covered: "recovery.noticeAdoptedCovered",
2078 session_recovery_depth_cap: "recovery.noticeKeptCurrent",
2079 session_shutdown_recovery_forked: "recovery.noticeSavedCopy",
2080 decision_receipt: "notice.decisionReceiptTitle",
2081 };
2082
2083 // localizedNoticeText localizes a notice's main copy by its stable code first,
2084 // then falls back to English-text matching for codeless payloads.
2085 export function localizedNoticeText(text: string, code?: string): string {
2086 if (code === "unapplied_steer") {
2087 const separator = text.indexOf("\n");
2088 const guidance = separator >= 0 ? text.slice(separator + 1) : text;
2089 return t("notice.unappliedSteer", { guidance });
2090 }
2091 const key = code ? noticeCodeKeys[code] : undefined;
2092 if (key) return t(key);
2093 return localizedBackendNoticeText(text);
2094 }
2095
2096 const deliveryRequirementKeys: Record<string, DictKey> = {
2097 project_check: "notice.deliveryRequirementProjectCheck",
2098 todo: "notice.deliveryRequirementTodo",
2099 criteria: "notice.deliveryRequirementCriteria",
2100 verification: "notice.deliveryRequirementVerification",
2101 review: "notice.deliveryRequirementReview",
2102 signoff: "notice.deliveryRequirementSignoff",
2103 action: "notice.deliveryRequirementAction",
2104 mutation: "notice.deliveryRequirementMutation",
2105 capability: "notice.deliveryRequirementCapability",
2106 };
2107
2108 export function deliveryReadinessDetail(readiness: WireFinalReadiness | undefined, fallback = ""): string {
2109 const labels = asArray(readiness?.missing)
2110 .map((id) => deliveryRequirementKeys[id])
2111 .filter((key): key is DictKey => Boolean(key))
2112 .map((key) => t(key));
2113 if (labels.length === 0) return fallback;
2114 return t("notice.deliveryIncompleteMissing", { items: labels.join(t("notice.deliveryRequirementSeparator")) });
2115 }
2116
2117 export function localizedBackendNoticeText(text: string): string {
2118 const msg = text.trim();
2119 const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
2120 if (autosave) {
2121 return t("status.sessionAutosaveFailed", { err: autosave[1] });
2122 }
2123 const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
2124 if (saveBefore) {
2125 return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
2126 }
2127 const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
2128 if (modelFallback) {
2129 return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
2130 }
2131 const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
2132 if (backgroundJob) {
2133 return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
2134 }
2135 const canonicalNoticeKey = backendNoticeKey(msg);
2136 if (canonicalNoticeKey) {
2137 return t(canonicalNoticeKey);
2138 }
2139 if (
2140 /^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
2141 /^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
2142 ) {
2143 return t("recovery.noticeSavedCopy");
2144 }
2145 if (
2146 /^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
2147 /^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
2148 ) {
2149 return t("recovery.noticeKeptCurrent");
2150 }
2151 if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
2152 return t("recovery.noticeAdoptedCovered");
2153 }
2154 if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
2155 return t("recovery.noticeAdopted");
2156 }
2157 return msg;
2158 }
2159
2160 function backendNoticeKey(msg: string): DictKey | "" {
2161 switch (msg) {
2162 case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.":
2163 return "notice.finalReadiness";
2164 case "No visible answer was produced; asking the assistant to respond again.":
2165 return "notice.emptyFinal";
2166 case "The assistant answered before taking action; asking it to use the required tools.":
2167 return "notice.executorHandoff";
2168 case "Tool round limit reached; asking the assistant to summarize progress.":
2169 return "notice.toolBudget";
2170 case "The assistant is stuck retrying a blocked action; asking it to change approach.":
2171 return "notice.loopGuard";
2172 case "Context is getting large; preserving cache until cleanup is needed.":
2173 return "notice.contextLarge";
2174 case "Context cleanup skipped for now.":
2175 return "notice.contextCleanupSkipped";
2176 case "Automatic context cleanup paused because the context window is too small.":
2177 return "notice.contextCleanupPaused";
2178 case "Context was compacted without a generated summary.":
2179 return "notice.compactionNoSummary";
2180 case "Goal is not ready to complete yet; continuing the remaining work.":
2181 return "notice.goalNotReady";
2182 case "Goal still has unfinished task state; continuing the remaining work.":
2183 return "notice.goalUnfinished";
2184 case "AutoResearch status update failed.":
2185 return "notice.autoresearchStatusFailed";
2186 case "AutoResearch task marked blocked.":
2187 return "notice.autoresearchBlocked";
2188 case "Job artifact migration failed.":
2189 return "notice.jobArtifactMigrationFailed";
2190 case "Background job teardown timed out.":
2191 return "notice.jobTeardownTimeout";
2192 case "Some plan-mode tool settings were ignored.":
2193 return "notice.planModeToolSettingsIgnored";
2194 case "Some plan-mode command settings were ignored.":
2195 return "notice.planModeCommandSettingsIgnored";
2196 case "Config migration did not complete.":
2197 return "notice.configMigrationIncomplete";
2198 case "Selected model is missing its API key.":
2199 return "notice.modelMissingApiKey";
2200 case "An MCP server failed to start.":
2201 return "notice.mcpServerFailed";
2202 case "Some MCP servers failed to start; run /mcp for details.":
2203 return "notice.mcpServersFailed";
2204 case "Guardian was disabled because its model was not found.":
2205 return "notice.guardianModelMissing";
2206 case "Guardian was disabled because it could not start.":
2207 return "notice.guardianStartFailed";
2208 default:
2209 return "";
2210 }
2211 }
2212
2213 function recoveryNoticeDedupeKey(text: string, code?: string): string {
2214 switch (code) {
2215 case "session_recovery_forked":
2216 case "session_shutdown_recovery_forked":
2217 return "recovery:saved-copy";
2218 case "session_recovery_depth_cap":
2219 return "recovery:kept-current";
2220 case "session_recovery_adopted_covered":
2221 return "recovery:adopted-covered";
2222 case "session_recovery_adopted":
2223 return "recovery:adopted";
2224 }
2225 const msg = text.trim();
2226 if (
2227 /^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
2228 /^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
2229 msg === t("recovery.noticeSavedCopy")
2230 ) {
2231 return "recovery:saved-copy";
2232 }
2233 if (
2234 /^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
2235 /^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
2236 msg === t("recovery.noticeKeptCurrent")
2237 ) {
2238 return "recovery:kept-current";
2239 }
2240 if (
2241 /^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
2242 msg === t("recovery.noticeAdoptedCovered")
2243 ) {
2244 return "recovery:adopted-covered";
2245 }
2246 if (
2247 /^session changed on disk; adopted the newer transcript$/i.test(msg) ||
2248 msg === t("recovery.noticeAdopted")
2249 ) {
2250 return "recovery:adopted";
2251 }
2252 return "";
2253 }
2254
2255 function quietTranscriptNoticeKey(text: string, code?: string): string {
2256 const recovery = recoveryNoticeDedupeKey(text, code);
2257 if (recovery) return recovery;
2258
2259 const msg = text.trim();
2260 if (/^guardian enabled · model=.+$/i.test(msg)) {
2261 return "startup:guardian-enabled";
2262 }
2263 if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
2264 return "startup:mcp-failures";
2265 }
2266 const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
2267 if (directMCPFailure) {
2268 const name = directMCPFailure[1].toLowerCase();
2269 if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
2270 return "startup:mcp-failure";
2271 }
2272 }
2273 if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
2274 return "startup:plugin-demote";
2275 }
2276 if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
2277 return "settings:deferred-refresh-applied";
2278 }
2279 return "";
2280 }
2281
2282 function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
2283 if (quietTranscriptNoticeKey(rawText, code)) {
2284 return { items, seq };
2285 }
2286 const text = localizedNoticeText(rawText, code);
2287 if (quietTranscriptNoticeKey(text, code)) {
2288 return { items, seq };
2289 }
2290 const trimmedDetail = detail?.trim();
2291 return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
2292 }
2293
2294 function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): State {
2295 const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail, code, decisionReceipt);
2296 return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
2297 }
2298
2299 function localizedSessionAction(action: string): string {
2300 switch (action.trim()) {
2301 case "changing model":
2302 return t("status.actionChangingModel");
2303 case "changing effort":
2304 return t("status.actionChangingEffort");
2305 case "changing token mode":
2306 return t("status.actionChangingTokenMode");
2307 case "rebuilding settings":
2308 return t("status.actionRebuildingSettings");
2309 case "switching sessions":
2310 return t("status.actionSwitchingSessions");
2311 case "switching tabs":
2312 return t("status.actionSwitchingTabs");
2313 case "autosave":
2314 return t("status.actionAutosave");
2315 default:
2316 return action.trim() || t("status.actionCurrentSession");
2317 }
2318 }
2319
2320 function settingSwitchNoticeText(
2321 err: unknown,
2322 setting: "effort" | "model" | "token mode",
2323 keys: {
2324 busy: DictKey;
2325 busyRunning: DictKey;
2326 busyPrompt: DictKey;
2327 busyJobs: DictKey;
2328 leaseHeld: DictKey;
2329 starting: DictKey;
2330 startupFailed: DictKey;
2331 retry: DictKey;
2332 failed: DictKey;
2333 },
2334 ): string {
2335 const msg = errorMessage(err).trim() || "unknown error";
2336 const lower = msg.toLowerCase();
2337 if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
2338 const detail = /running=(true|false);\s*pending_prompt=(true|false);\s*background_jobs=(\d+)/i.exec(msg);
2339 if (detail?.[2] === "true") return t(keys.busyPrompt);
2340 if (detail?.[1] === "true") return t(keys.busyRunning);
2341 const jobs = Number(detail?.[3] ?? 0);
2342 if (jobs > 0) return t(keys.busyJobs, { n: jobs });
2343 return t(keys.busy);
2344 }
2345 if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
2346 return t(keys.leaseHeld);
2347 }
2348 if (lower.includes("workspace is still starting")) {
2349 return t(keys.starting);
2350 }
2351 if (lower.startsWith("workspace failed to start")) {
2352 return t(keys.startupFailed, { err: msg });
2353 }
2354 if (lower.includes(`changed while switching ${setting}`) || (lower.includes("tab ") && lower.includes("not found"))) {
2355 return t(keys.retry);
2356 }
2357 return t(keys.failed, { err: msg });
2358 }
2359
2360 export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
2361 if (!activeTabId) return;
2362 void replay().catch(() => {});
2363 }
2364
2365 export function useController() {
2366 const statesRef = useRef<TabStates>(new Map());
2367 const liveListenersByTabRef = useRef(new Map<string, Set<() => void>>());
2368 const balanceRefreshSeqByTab = useRef(new Map<string, number>());
2369 const modelSwitchSeqByTab = useRef(new Map<string, number>());
2370 const modelSwitchSuccessVersionByTab = useRef(new Map<string, number>());
2371 const modelSwitchQueueByTab = useRef(new Map<string, ModelSwitchQueueState>());
2372 const lastTurnActivityAtByTab = useRef(new Map<string, number>());
2373 const runtimeEpochByTabRef = useRef(new Map<string, string>());
2374 const appliedComposerProfileByTabRef = useRef(new Map<string, string>());
2375 const composerProfileInFlightByTabRef = useRef(new Map<string, { key: string; promise: Promise<boolean> }>());
2376 const composerProfileQueueByTabRef = useRef(new Map<string, Promise<void>>());
2377 const composerProfileLifecycleByTabRef = useRef(new Map<string, number>());
2378 const cancelReconcileTimers = useRef(new Map<string, number>());
2379 const stalePromptReconcileTimers = useRef(new Map<string, number>());
2380 // Indirection so dispatchRuntimeStatusForTab (defined above reconcileTabRuntime)
2381 // can schedule an authoritative refetch after it rejects a stale snapshot.
2382 const scheduleStalePromptReconcileRef = useRef<(tabId: string) => void>(() => {});
2383 const [activeTabId, setActiveTabId] = useState<string | undefined>();
2384 const activeTabIdRef = useRef<string | undefined>(undefined);
2385 // Invalidates async navigation completions even for ABA switches where the
2386 // visible tab ID eventually returns to the original value.
2387 const activeNavigationSeqRef = useRef(0);
2388 // A render-triggering counter so that mutations to a non-active tab's state still
2389 // cause a re-render when that tab becomes active.
2390 const [, setVersion] = useState(0);
2391 const bump = useCallback(() => setVersion((v) => v + 1), []);
2392 const notifyLiveListeners = useCallback((tabId: string) => {
2393 for (const listener of liveListenersByTabRef.current.get(tabId) ?? []) listener();
2394 }, [t]);
2395 const disposeComposerProfileState = useCallback((tabId: string) => {
2396 appliedComposerProfileByTabRef.current.delete(tabId);
2397 composerProfileInFlightByTabRef.current.delete(tabId);
2398 composerProfileQueueByTabRef.current.delete(tabId);
2399 composerProfileLifecycleByTabRef.current.set(
2400 tabId,
2401 (composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) + 1,
2402 );
2403 }, []);
2404 const liveStore = useMemo<ControllerLiveStore>(() => ({
2405 subscribe(tabId, listener) {
2406 if (!tabId) return () => {};
2407 let listeners = liveListenersByTabRef.current.get(tabId);
2408 if (!listeners) {
2409 listeners = new Set();
2410 liveListenersByTabRef.current.set(tabId, listeners);
2411 }
2412 listeners.add(listener);
2413 return () => {
2414 listeners?.delete(listener);
2415 if (listeners?.size === 0) liveListenersByTabRef.current.delete(tabId);
2416 };
2417 },
2418 getSnapshot(tabId) {
2419 return tabId ? statesRef.current.get(tabId)?.live : undefined;
2420 },
2421 }), []);
2422 const beginActiveNavigation = useCallback(() => {
2423 activeNavigationSeqRef.current += 1;
2424 return activeNavigationSeqRef.current;
2425 }, []);
2426 const isNavigationIntentCurrent = useCallback((seq: number): boolean => {
2427 return activeNavigationSeqRef.current === seq;
2428 }, []);
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 stateRef = useRef(activeState);
2438 const backendActiveTabIdRef = useRef<string | undefined>(undefined);
2439 const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
2440 const readyMetaReconcileSeq = useRef(0);
2441 const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
2442 activeTabIdRef.current = activeTabId;
2443 stateRef.current = activeState;
2444
2445 // Dispatch to a specific tab's state. If the tab doesn't have state yet, it's
2446 // created. Bumps the version so React re-renders when it becomes active.
2447 const dispatchTo = useCallback((tabId: string, action: Action) => {
2448 const states = statesRef.current;
2449 const prev = getOrCreateState(states, tabId);
2450 const next = reducer(prev, action);
2451 if (prev !== next) {
2452 states.set(tabId, next);
2453 notifyLiveListeners(tabId);
2454 const streamDeltaOnly =
2455 action.type === "event" &&
2456 (action.e.kind === "text" || action.e.kind === "reasoning") &&
2457 prev.items === next.items &&
2458 prev.currentAssistant === next.currentAssistant &&
2459 prev.pendingUser === next.pendingUser &&
2460 prev.retry === next.retry;
2461 if (!streamDeltaOnly) bump();
2462 }
2463 }, [bump, notifyLiveListeners]);
2464
2465 const clearBalanceForTab = useCallback((tabId: string): void => {
2466 const seq = (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1;
2467 balanceRefreshSeqByTab.current.set(tabId, seq);
2468 dispatchTo(tabId, { type: "balance", balance: { available: false, display: "" } });
2469 }, [dispatchTo]);
2470
2471 const invalidateProviderStateForTab = useCallback((tabId: string): void => {
2472 balanceRefreshSeqByTab.current.set(
2473 tabId,
2474 (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1,
2475 );
2476 modelSwitchSeqByTab.current.set(
2477 tabId,
2478 (modelSwitchSeqByTab.current.get(tabId) ?? 0) + 1,
2479 );
2480 }, []);
2481
2482 const refreshBalanceForTab = useCallback(async (
2483 tabId: string,
2484 options: { apply?: () => boolean } = {},
2485 ): Promise<void> => {
2486 const seq = (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1;
2487 balanceRefreshSeqByTab.current.set(tabId, seq);
2488 try {
2489 const balance = await app.BalanceForTab(tabId);
2490 if (balanceRefreshSeqByTab.current.get(tabId) !== seq) return;
2491 if (options.apply && !options.apply()) return;
2492 if (balance.err?.trim()) return;
2493 dispatchTo(tabId, { type: "balance", balance });
2494 } catch {
2495 // Balance is optional. Keep the last explicit cleared/unavailable state
2496 // instead of surfacing a provider-specific wallet failure in chat.
2497 }
2498 }, [dispatchTo]);
2499
2500 const confirmBackendActiveTab = useCallback((tabId: string) => {
2501 backendActiveTabIdRef.current = tabId;
2502 dispatchTo(tabId, { type: "backend_activation_done" });
2503 }, [dispatchTo]);
2504
2505 const reassertVisibleTabAfterStaleNavigation = useCallback(async (kind: string, staleTabId: string): Promise<void> => {
2506 // Backend navigation calls activate their result before returning. If a
2507 // newer tab click won in the frontend while that call was in flight, put
2508 // the backend back on the visible tab. Re-check after every await because
2509 // another click can supersede the target while SetActiveTab is running.
2510 for (;;) {
2511 const currentTabId = activeTabIdRef.current;
2512 if (!currentTabId) return;
2513 if (currentTabId === staleTabId) {
2514 confirmBackendActiveTab(currentTabId);
2515 return;
2516 }
2517 try {
2518 await app.SetActiveTab(currentTabId);
2519 } catch (err) {
2520 addBreadcrumb(kind, `stale reassert failed ${currentTabId}: ${errorMessage(err)}`);
2521 return;
2522 }
2523 if (activeTabIdRef.current === currentTabId) {
2524 confirmBackendActiveTab(currentTabId);
2525 addBreadcrumb(kind, `stale reasserted ${currentTabId}`);
2526 return;
2527 }
2528 }
2529 }, [confirmBackendActiveTab]);
2530
2531 const trackBackendActivation = useCallback((tabId: string, promise: Promise<boolean>) => {
2532 backendActivationPromises.current.set(tabId, promise);
2533 void promise.finally(() => {
2534 if (backendActivationPromises.current.get(tabId) === promise) {
2535 backendActivationPromises.current.delete(tabId);
2536 }
2537 });
2538 }, []);
2539
2540 const waitForBackendActiveTab = useCallback(async (tabId: string): Promise<boolean> => {
2541 const pending = backendActivationPromises.current.get(tabId);
2542 if (pending) {
2543 const activated = await pending.catch(() => false);
2544 if (!activated) return false;
2545 }
2546 return backendActiveTabIdRef.current === tabId && activeTabIdRef.current === tabId;
2547 }, []);
2548
2549 const checkpointRefreshSeq = useRef(new Map<string, number>());
2550 const metaRefreshSeq = useRef(new Map<string, number>());
2551 const sessionLoadSeq = useRef(new Map<string, number>());
2552 const sessionLoadInFlight = useRef(new Map<string, { sessionPath: string; promise: Promise<void> }>());
2553 const bumpMetaRefreshSeq = useCallback((tabId: string): number => {
2554 const seq = (metaRefreshSeq.current.get(tabId) ?? 0) + 1;
2555 metaRefreshSeq.current.set(tabId, seq);
2556 return seq;
2557 }, []);
2558 const metaRefreshCurrent = useCallback((tabId: string, seq: number): boolean => {
2559 return metaRefreshSeq.current.get(tabId) === seq;
2560 }, []);
2561 const bumpSessionLoadSeq = useCallback((tabId: string): number => {
2562 // A session transition changes the meaning of every tab-scoped Meta field.
2563 // Invalidate requests that started against the previous session before
2564 // resetting or hydrating the visible state.
2565 bumpMetaRefreshSeq(tabId);
2566 const seq = (sessionLoadSeq.current.get(tabId) ?? 0) + 1;
2567 sessionLoadSeq.current.set(tabId, seq);
2568 return seq;
2569 }, [bumpMetaRefreshSeq]);
2570 const sessionLoadCurrent = useCallback((tabId: string, seq: number): boolean => {
2571 return sessionLoadSeq.current.get(tabId) === seq;
2572 }, []);
2573 const loadMetaForTab = useCallback(async (tabId: string): Promise<Meta | undefined> => {
2574 const seq = bumpMetaRefreshSeq(tabId);
2575 const meta = await app.MetaForTab(tabId).catch(() => undefined);
2576 if (!metaRefreshCurrent(tabId, seq)) return undefined;
2577 if (meta?.runtime?.epoch) runtimeEpochByTabRef.current.set(tabId, meta.runtime.epoch);
2578 return meta;
2579 }, [bumpMetaRefreshSeq, metaRefreshCurrent]);
2580 const refreshMetaOnlyForTab = useCallback(async (tabId: string): Promise<Meta | undefined> => {
2581 const meta = await loadMetaForTab(tabId);
2582 if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
2583 return meta;
2584 }, [dispatchTo, loadMetaForTab]);
2585 const refreshMetaForTab = useCallback(async (tabId: string): Promise<void> => {
2586 const sessionSeq = sessionLoadSeq.current.get(tabId) ?? 0;
2587 const meta = await refreshMetaOnlyForTab(tabId);
2588 if (meta === undefined || (sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
2589 const [context, effort] = await Promise.all([
2590 app.ContextUsageForTab(tabId).catch(() => undefined),
2591 app.EffortForTab(tabId).catch(() => undefined),
2592 ]);
2593 if ((sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
2594 if (context !== undefined) dispatchTo(tabId, { type: "context", context });
2595 if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
2596 }, [dispatchTo, refreshMetaOnlyForTab]);
2597 const bumpCheckpointRefreshSeq = useCallback((tabId: string): number => {
2598 const seq = (checkpointRefreshSeq.current.get(tabId) ?? 0) + 1;
2599 checkpointRefreshSeq.current.set(tabId, seq);
2600 return seq;
2601 }, []);
2602 const refreshCheckpoints = useCallback(async (tabId: string) => {
2603 const seq = bumpCheckpointRefreshSeq(tabId);
2604 const checkpoints = await app.CheckpointsForTab(tabId).catch(() => undefined);
2605 if (checkpointRefreshSeq.current.get(tabId) !== seq || checkpoints === undefined) return;
2606 dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
2607 }, [bumpCheckpointRefreshSeq, dispatchTo]);
2608
2609 const loadSessionDataForTab = useCallback(async (
2610 tabId: string,
2611 reset = false,
2612 reason: HydrateReason = "startup",
2613 options: { skipHistory?: boolean; placeholderItems?: Item[]; preserveCachedHistory?: boolean; sessionPath?: string } = {},
2614 ) => {
2615 const sessionPath = (options.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath ?? "").trim();
2616 const canJoinInFlight = !reset && !options.skipHistory;
2617 const shouldTrackInFlight = !options.skipHistory;
2618 if (canJoinInFlight) {
2619 const existing = sessionLoadInFlight.current.get(tabId);
2620 if (existing?.sessionPath === sessionPath) return existing.promise;
2621 } else {
2622 sessionLoadInFlight.current.delete(tabId);
2623 }
2624
2625 const promise = (async () => {
2626 const seq = bumpSessionLoadSeq(tabId);
2627 const hydrateStartedAt = Date.now();
2628 const skipHistory = Boolean(
2629 options.skipHistory ||
2630 (options.preserveCachedHistory && !reset && hasReusableCachedTranscript(statesRef.current.get(tabId), options.sessionPath)),
2631 );
2632 addBreadcrumb("tab.hydrate", `start ${reason} ${tabId}`);
2633 dispatchTo(tabId, { type: "hydrate_start", reason, placeholderItems: options.placeholderItems });
2634 if (reset && sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "reset" });
2635
2636 const stillCurrent = () => sessionLoadCurrent(tabId, seq);
2637 const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
2638 const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
2639 const noteFailure = (label: string, err: unknown) => {
2640 addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
2641 };
2642
2643 const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
2644 const startedAt = Date.now();
2645 addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
2646 try {
2647 const value = await load();
2648 addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
2649 return value;
2650 } catch (err) {
2651 noteFailure(label, err);
2652 return undefined;
2653 }
2654 };
2655
2656 const historyStartedAt = Date.now();
2657 const historyPage = skipHistory
2658 ? undefined
2659 : await loadTimed("history", () => app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS));
2660
2661 if (!stillCurrent()) return;
2662 if (!skipHistory && historyPage !== undefined) {
2663 const messages = asArray(historyPage.messages);
2664 dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" });
2665 addBreadcrumb(
2666 "tab.hydrate",
2667 `history page ${tabId} messages=${messages.length} turns=${historyPage.startTurn}-${historyPage.endTurn}/${historyPage.totalTurns} ms=${Date.now() - historyStartedAt}`,
2668 );
2669 if (reason === "switch-tab") {
2670 addBreadcrumb(
2671 "tab.switch",
2672 `history-done ${tabId} messages=${messages.length} turns=${historyPage.startTurn}-${historyPage.endTurn}/${historyPage.totalTurns} ms=${Date.now() - historyStartedAt}`,
2673 );
2674 }
2675 } else if (skipHistory) {
2676 const skipReason = options.skipHistory ? "cached-live-turn" : "cached-transcript";
2677 addBreadcrumb("tab.hydrate", `history skipped ${tabId} reason=${skipReason}`);
2678 if (reason === "switch-tab") {
2679 addBreadcrumb("tab.switch", `history-done ${tabId} skipped ms=${Date.now() - historyStartedAt}`);
2680 }
2681 }
2682
2683 dispatchTo(tabId, { type: "hydrate_done" });
2684 addBreadcrumb("tab.hydrate", `done ${reason} ${tabId} ms=${Date.now() - hydrateStartedAt}`);
2685
2686 // Phase 2: local ancillary data. It stays inside the same in-flight
2687 // promise so duplicate ready/startup hydrations coalesce, but it runs
2688 // after hydrate_done so slow Wails calls don't keep the visible transcript
2689 // in a loading state.
2690 await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
2691 if (!stillCurrent()) return;
2692 if (!stillVisible()) {
2693 addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
2694 return;
2695 }
2696 const meta = await loadTimed("meta", () => loadMetaForTab(tabId));
2697 if (!stillCurrent()) return;
2698 if (!stillVisible()) {
2699 addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
2700 return;
2701 }
2702 if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
2703 const ancillaryStartedAt = Date.now();
2704 const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
2705 return loadTimed(`ancillary ${label}`, load);
2706 };
2707 const [effort, jobs, context] = await Promise.all([
2708 loadAncillary("effort", () => app.EffortForTab(tabId)),
2709 loadAncillary("jobs", () => app.JobsForTab(tabId)),
2710 loadAncillary("context", () => app.ContextUsageForTab(tabId)),
2711 ]);
2712 if (!stillCurrent()) return;
2713 if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
2714 if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
2715 if (context !== undefined) dispatchTo(tabId, { type: "context", context });
2716 // Signal ContextPanel to re-fetch now that ancillary data (context,
2717 // effort, jobs) has landed. Without this, the right-side panel keeps
2718 // stale RequestCount / ElapsedMs / SessionCost from before a session
2719 // rebind because its refreshKey (dockRefreshKey) only bumps on turn_done.
2720 dispatchTo(tabId, { type: "context_panel_refresh" });
2721 await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
2722 if (!stillCurrent()) return;
2723 if (!stillVisible()) {
2724 addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
2725 return;
2726 }
2727 const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
2728 if (!stillCurrent()) return;
2729 if (!stillVisible()) {
2730 addBreadcrumb("tab.hydrate", `checkpoints ignored inactive ${reason} ${tabId}`);
2731 return;
2732 }
2733 if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
2734 addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
2735 void refreshBalanceForTab(tabId, {
2736 apply: () => sessionLoadCurrent(tabId, seq) && stillVisible(),
2737 });
2738 })();
2739 if (shouldTrackInFlight) {
2740 sessionLoadInFlight.current.set(tabId, { sessionPath, promise });
2741 }
2742 try {
2743 await promise;
2744 } finally {
2745 if (sessionLoadInFlight.current.get(tabId)?.promise === promise) {
2746 sessionLoadInFlight.current.delete(tabId);
2747 }
2748 }
2749 }, [bumpSessionLoadSeq, dispatchTo, loadMetaForTab, refreshBalanceForTab, sessionLoadCurrent]);
2750
2751 const loadOlderHistory = useCallback(async (tabId?: string): Promise<void> => {
2752 const targetTabId = tabId || activeTabIdRef.current;
2753 if (!targetTabId) return;
2754 const state = statesRef.current.get(targetTabId);
2755 if (!state?.historyHasOlder || state.historyOlderLoading || state.running) return;
2756 const beforeTurn = state.historyStartTurn;
2757 const sessionPath = state.meta?.sessionPath ?? "";
2758 dispatchTo(targetTabId, { type: "history_older_start" });
2759 const startedAt = Date.now();
2760 try {
2761 const page = await app.HistoryPageForTab(targetTabId, beforeTurn, HISTORY_PAGE_TURNS);
2762 const current = statesRef.current.get(targetTabId);
2763 if (!current || current.historyStartTurn !== beforeTurn || (current.meta?.sessionPath ?? "") !== sessionPath) {
2764 dispatchTo(targetTabId, { type: "history_older_error" });
2765 return;
2766 }
2767 dispatchTo(targetTabId, { type: "history_page", page, mode: "prepend" });
2768 addBreadcrumb(
2769 "tab.hydrate",
2770 `history older ${targetTabId} messages=${asArray(page.messages).length} turns=${page.startTurn}-${page.endTurn}/${page.totalTurns} ms=${Date.now() - startedAt}`,
2771 );
2772 } catch (err) {
2773 dispatchTo(targetTabId, { type: "history_older_error" });
2774 addBreadcrumb("tab.hydrate", `history older failed ${targetTabId}: ${errorMessage(err)}`);
2775 }
2776 }, [dispatchTo]);
2777
2778 const activeTabFromBackend = useCallback(async (): Promise<TabMeta | undefined> => {
2779 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
2780 return tabs.find((tab) => tab.active) ?? tabs[0];
2781 }, []);
2782
2783 // snapshotAt is the promptEventClock() reading taken immediately before
2784 // initiating the backend call that produced `tab`. The reducer uses it to
2785 // ignore snapshots that predate a live approval/ask event (#6429).
2786 const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot, snapshotAt?: number) => {
2787 const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
2788 // Will the reducer reject this as a snapshot that predates the live prompt?
2789 // Computed on pre-dispatch state so we can schedule an authoritative
2790 // refetch when a stale idle snapshot is ignored.
2791 const rejectedStaleIdle = !tab.pendingPrompt && runtimeSnapshotPredatesPrompt(statesRef.current.get(tabId), snapshotAt);
2792 dispatchTo(tabId, {
2793 type: "backend_status",
2794 running: foregroundRunning,
2795 pendingPrompt: Boolean(tab.pendingPrompt),
2796 backgroundJobs: tab.backgroundJobs ?? 0,
2797 cancelRequested: Boolean(tab.cancelRequested),
2798 cancellable: foregroundRunning,
2799 snapshotAt,
2800 });
2801 // backend_status reconciliation can clear a live prompt from frontend state.
2802 // If the backend is still blocked, ask it to replay the approval/ask event.
2803 if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
2804 // A stale idle snapshot the reducer ignored cannot be trusted to have kept a
2805 // GENUINE prompt: navigation can drop the prompt anchor, so a delayed replay
2806 // of an already-answered prompt looks like a fresh prompt and re-anchors,
2807 // making this authoritative idle look stale. Refetch backend truth once so a
2808 // resolved prompt is cleared instead of surviving as a zombie (#6432).
2809 if (rejectedStaleIdle) scheduleStalePromptReconcileRef.current(tabId);
2810 // A prompt that survived reconciliation (fresh pendingPrompt=true meta, or
2811 // a stale snapshot the reducer ignored) keeps the tab blocked on the user.
2812 // Report it as foreground-running so callers do not treat the snapshot as
2813 // a missed turn_done and reset the session out from under the prompt.
2814 const local = statesRef.current.get(tabId);
2815 if (local?.approval || local?.ask) return true;
2816 return foregroundRunning;
2817 }, [dispatchTo]);
2818
2819 const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
2820 for (let attempt = 0; attempt < 60; attempt += 1) {
2821 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
2822 const tab = tabs.find((candidate) => candidate.id === tabId);
2823 if (!tab || tab.ready || tab.startupErr) return;
2824 await new Promise((resolve) => window.setTimeout(resolve, 100));
2825 }
2826 }, []);
2827
2828 const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
2829 const snapshotAt = promptEventClock();
2830 const active = await activeTabFromBackend();
2831 if (!active) return undefined;
2832 // When guard is true, skip if the frontend already settled on a
2833 // different tab while we were fetching — this prevents fire-and-forget
2834 // calls from mount/onReady from overwriting a user-initiated tab switch
2835 // (e.g. handleNewTab → ensureBlankSurface / switchTab).
2836 if (guard && activeTabIdRef.current && activeTabIdRef.current !== active.id) {
2837 return active.id;
2838 }
2839 if (activeTabIdRef.current !== active.id) beginActiveNavigation();
2840 setActiveTabId(active.id);
2841 activeTabIdRef.current = active.id;
2842 confirmBackendActiveTab(active.id);
2843 if (active.runtime?.epoch) runtimeEpochByTabRef.current.set(active.id, active.runtime.epoch);
2844 dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
2845 const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
2846 if (!reset) dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
2847 await loadSessionDataForTab(active.id, reset, "startup", {
2848 preserveCachedHistory,
2849 sessionPath: active.sessionPath,
2850 });
2851 if (reset) dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
2852 return active.id;
2853 }, [activeTabFromBackend, beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
2854
2855 const reconcileTabRuntime = useCallback(async (
2856 tabId: string,
2857 options: { hydrateSessionData?: boolean } = {},
2858 ): Promise<TabMeta[] | undefined> => {
2859 const hydrateSessionData = options.hydrateSessionData ?? true;
2860 const snapshotAt = promptEventClock();
2861 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
2862 const tab = tabs.find((candidate) => candidate.id === tabId);
2863 if (!tab) return undefined;
2864 if (tab.runtime?.epoch) runtimeEpochByTabRef.current.set(tabId, tab.runtime.epoch);
2865 const local = statesRef.current.get(tabId);
2866 const needsInitialLoad = !local?.meta;
2867 const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab, snapshotAt);
2868 const missedTurnDone = Boolean(local?.running && !foregroundRunning);
2869 if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
2870 await loadSessionDataForTab(tabId, missedTurnDone, "startup");
2871 return tabs;
2872 }
2873 const [jobs, effort] = await Promise.all([
2874 app.JobsForTab(tabId).catch(() => undefined),
2875 app.EffortForTab(tabId).catch(() => undefined),
2876 ]);
2877 if (jobs) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
2878 if (effort) dispatchTo(tabId, { type: "effort", effort });
2879 await refreshBalanceForTab(tabId);
2880 return tabs;
2881 }, [dispatchRuntimeStatusForTab, loadSessionDataForTab, refreshBalanceForTab]);
2882
2883 // Authoritative backstop for the prompt-freshness heuristic: after the reducer
2884 // rejects a stale idle snapshot, refetch backend state once. If the backend
2885 // resolved the prompt, the fresh snapshot (fetched after any in-flight replay)
2886 // is newer than the anchor and reconciles the zombie away; if the prompt is
2887 // genuinely pending, the fresh snapshot keeps it. Debounced per tab so a burst
2888 // of stale snapshots schedules at most one refetch (#6432).
2889 const scheduleStalePromptReconcile = useCallback((tabId: string) => {
2890 if (stalePromptReconcileTimers.current.has(tabId)) return;
2891 const timer = window.setTimeout(() => {
2892 stalePromptReconcileTimers.current.delete(tabId);
2893 void reconcileTabRuntime(tabId, { hydrateSessionData: false }).catch(() => {});
2894 }, STALE_PROMPT_RECONCILE_MS);
2895 stalePromptReconcileTimers.current.set(tabId, timer);
2896 }, [reconcileTabRuntime]);
2897 scheduleStalePromptReconcileRef.current = scheduleStalePromptReconcile;
2898
2899 const clearCancelReconcileTimer = useCallback((tabId: string) => {
2900 const timer = cancelReconcileTimers.current.get(tabId);
2901 if (timer === undefined) return;
2902 window.clearTimeout(timer);
2903 cancelReconcileTimers.current.delete(tabId);
2904 }, []);
2905
2906 const scheduleCancelReconcile = useCallback((tabId: string, attempt = 0) => {
2907 clearCancelReconcileTimer(tabId);
2908 const delay = CANCEL_RECONCILE_DELAYS_MS[Math.min(attempt, CANCEL_RECONCILE_DELAYS_MS.length - 1)];
2909 const timer = window.setTimeout(() => {
2910 cancelReconcileTimers.current.delete(tabId);
2911 void reconcileTabRuntime(tabId, { hydrateSessionData: false }).then((tabs) => {
2912 const tab = tabs?.find((candidate) => candidate.id === tabId);
2913 if (!tab) return;
2914 const stillReconciling = foregroundRunningFromRuntimeMeta(tab) || Boolean(tab.cancelRequested);
2915 if (stillReconciling && attempt + 1 < CANCEL_RECONCILE_DELAYS_MS.length) {
2916 scheduleCancelReconcile(tabId, attempt + 1);
2917 }
2918 }).catch(() => {});
2919 }, delay);
2920 cancelReconcileTimers.current.set(tabId, timer);
2921 }, [clearCancelReconcileTimer, reconcileTabRuntime]);
2922
2923 useEffect(() => {
2924 const textBatch = createRafBatch<{ tabId: string; e: WireEvent }>((batch) => {
2925 for (const { tabId, e } of batch) dispatchTo(tabId, { type: "event", e });
2926 });
2927 const off = onEvent((e) => {
2928 // Untagged compatibility events belong to the tab that the backend has
2929 // actually activated, not the frontend's optimistic selection. During a
2930 // slow SetActiveTab these can differ, and routing to the optimistic tab
2931 // leaks the previous session's approval/ask gate into the new composer.
2932 const targetTabId = e.tabId || backendActiveTabIdRef.current || activeTabIdRef.current;
2933 if (!targetTabId) return;
2934 const acceptedEpoch = runtimeEpochByTabRef.current.get(targetTabId);
2935 if (e.runtimeEpoch) {
2936 if (!acceptsRuntimeEventEpoch(acceptedEpoch, e.runtimeEpoch)) return;
2937 if (!acceptedEpoch) runtimeEpochByTabRef.current.set(targetTabId, e.runtimeEpoch);
2938 }
2939 if (
2940 e.kind === "turn_started" ||
2941 e.kind === "text" ||
2942 e.kind === "reasoning" ||
2943 e.kind === "message" ||
2944 e.kind === "tool_dispatch" ||
2945 e.kind === "tool_progress" ||
2946 e.kind === "tool_result"
2947 ) {
2948 lastTurnActivityAtByTab.current.set(targetTabId, Date.now());
2949 }
2950 if (e.kind === "text" || e.kind === "reasoning") {
2951 textBatch.push({ tabId: targetTabId, e });
2952 } else {
2953 textBatch.drain();
2954 dispatchTo(targetTabId, { type: "event", e });
2955 }
2956 if (e.kind === "turn_done") {
2957 if (!e.err) {
2958 app.HistoryCheckpointTurnsForTab(targetTabId)
2959 .then((turns) => dispatchTo(targetTabId, { type: "history_checkpoint_turns", turns: asArray(turns) }))
2960 .catch(() => {});
2961 }
2962 app
2963 .ContextUsageForTab(targetTabId)
2964 .then((context) => dispatchTo(targetTabId, { type: "context", context }))
2965 .catch(() => {});
2966 void refreshBalanceForTab(targetTabId);
2967 app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
2968 void refreshCheckpoints(targetTabId);
2969 void refreshMetaForTab(targetTabId);
2970 }
2971 if (e.kind === "turn_done" || e.kind === "notice") {
2972 app.JobsForTab(targetTabId).then((jobs) => dispatchTo(targetTabId, { type: "jobs", jobs: asArray(jobs) })).catch(() => {});
2973 }
2974 });
2975
2976 const offReady = onReady((readyTabId) => {
2977 const activeId = activeTabIdRef.current;
2978 if (readyTabId && activeId && readyTabId !== activeId) {
2979 addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
2980 return;
2981 }
2982 // A ready event can race the initial hydrate. Refresh the tab metadata
2983 // first so a stale ready=false snapshot does not keep the composer locked.
2984 void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
2985 });
2986
2987 // A rebuilt controller reissues approval/ask ids from "1" (see sound.ts).
2988 // Drop this tab's id-anchored prompt bookkeeping so a genuinely new
2989 // prompt from the new controller is never misread as a stale replay of
2990 // one the old controller already resolved (#6432 round 3). A tab-less
2991 // rebuild (settings-wide) affects every known tab.
2992 const offRebuilt = onRuntimeRebuilt((rebuiltTabId, runtimeEpoch) => {
2993 if (rebuiltTabId) {
2994 if (runtimeEpoch) runtimeEpochByTabRef.current.set(rebuiltTabId, runtimeEpoch);
2995 dispatchTo(rebuiltTabId, { type: "controller_rebuilt" });
2996 } else {
2997 if (runtimeEpoch) {
2998 for (const id of Array.from(statesRef.current.keys())) runtimeEpochByTabRef.current.set(id, runtimeEpoch);
2999 }
3000 for (const id of Array.from(statesRef.current.keys())) dispatchTo(id, { type: "controller_rebuilt" });
3001 }
3002 });
3003
3004 void syncActiveTabFromBackend(false, true);
3005 // The event subscription is live now, so ask the backend to re-emit any
3006 // approval/ask prompt that was already blocking a tab before this load —
3007 // otherwise a session left mid-confirmation shows "waiting" with no modal
3008 // and no way to stop (#3844).
3009 void app.ReplayPendingPrompts().catch(() => {});
3010
3011 return () => {
3012 textBatch.drain();
3013 for (const timer of cancelReconcileTimers.current.values()) {
3014 window.clearTimeout(timer);
3015 }
3016 cancelReconcileTimers.current.clear();
3017 for (const timer of stalePromptReconcileTimers.current.values()) {
3018 window.clearTimeout(timer);
3019 }
3020 stalePromptReconcileTimers.current.clear();
3021 off();
3022 offReady();
3023 offRebuilt();
3024 };
3025 }, [dispatchTo, loadSessionDataForTab, refreshBalanceForTab, refreshCheckpoints, refreshMetaForTab, syncActiveTabFromBackend]);
3026
3027 // Keep shared all-source telemetry live between turn boundaries. Delivery
3028 // mode can complete dozens of provider requests inside one UI turn, while
3029 // the status bar reads state.context and would otherwise stay pinned to the
3030 // previous turn_done snapshot. A usage event is emitted after the backend
3031 // has recorded that request, so refresh the authoritative tab aggregate here.
3032 // The usage sequence and active-tab checks make this latest-request-wins:
3033 // slower snapshots cannot overwrite a newer usage event or a tab switch.
3034 useEffect(() => {
3035 const tabId = activeTabId;
3036 const usageSeq = activeState.usageSeq;
3037 if (!tabId || usageSeq <= 0 || !activeState.turnActive) return;
3038
3039 let cancelled = false;
3040 void app.ContextUsageForTab(tabId).then((context) => {
3041 if (cancelled || activeTabIdRef.current !== tabId) return;
3042 if (statesRef.current.get(tabId)?.usageSeq !== usageSeq) return;
3043 dispatchTo(tabId, { type: "context", context });
3044 }).catch(() => {});
3045
3046 return () => {
3047 cancelled = true;
3048 };
3049 }, [activeTabId, activeState.turnActive, activeState.usageSeq, dispatchTo]);
3050
3051 // If the startup ready event is missed, keep the composer lock in sync with
3052 // the active tab's backend metadata without kicking off tab activation work.
3053 useEffect(() => {
3054 const tabId = activeTabId;
3055 const meta = activeState.meta;
3056 if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
3057 readyMetaReconcileSeq.current += 1;
3058 readyMetaReconcileActive.current = undefined;
3059 return;
3060 }
3061
3062 let cancelled = false;
3063 let timer: number | undefined;
3064 const seq = readyMetaReconcileSeq.current + 1;
3065 readyMetaReconcileSeq.current = seq;
3066 readyMetaReconcileActive.current = { tabId, seq };
3067
3068 const stillCurrent = () => {
3069 const active = readyMetaReconcileActive.current;
3070 return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
3071 };
3072
3073 const schedule = (attempt: number) => {
3074 timer = window.setTimeout(() => {
3075 void tick(attempt);
3076 }, STARTUP_READY_META_RECONCILE_MS);
3077 };
3078
3079 const tick = async (attempt: number) => {
3080 if (!stillCurrent()) return;
3081 const current = statesRef.current.get(tabId);
3082 if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
3083 const nextMeta = await refreshMetaOnlyForTab(tabId);
3084 if (!stillCurrent()) return;
3085 if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
3086 schedule(attempt + 1);
3087 };
3088
3089 schedule(0);
3090 return () => {
3091 cancelled = true;
3092 if (timer !== undefined) window.clearTimeout(timer);
3093 };
3094 }, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, refreshMetaOnlyForTab]);
3095
3096 // Stale-turn watchdog: if the frontend thinks the agent is running but the
3097 // turn stream has gone quiet, reconcile with the backend. This catches cases
3098 // where the Wails event channel silently drops turn_done after the final
3099 // message or synthetic todo update has already closed the live stream.
3100 useEffect(() => {
3101 if (!activeTabId) return;
3102 const s = statesRef.current.get(activeTabId);
3103 const now = Date.now();
3104 const lastTurnActivityAt = lastTurnActivityAtByTab.current.get(activeTabId) ?? 0;
3105 if (!s?.running || !s.turnActive || lastTurnActivityAt <= 0) return;
3106 const since = Math.max(0, now - lastTurnActivityAt);
3107 if (shouldReconcileStaleTurn(s, lastTurnActivityAt, now)) {
3108 void reconcileTabRuntime(activeTabId);
3109 return;
3110 }
3111 const timer = window.setTimeout(() => {
3112 const cur = statesRef.current.get(activeTabId);
3113 const lastActivity = lastTurnActivityAtByTab.current.get(activeTabId) ?? 0;
3114 if (shouldReconcileStaleTurn(cur, lastActivity)) {
3115 void reconcileTabRuntime(activeTabId);
3116 }
3117 }, STALE_TURN_RECONCILE_MS - since);
3118 return () => window.clearTimeout(timer);
3119 }, [activeTabId, reconcileTabRuntime, activeState.running, activeState.turnActive]);
3120
3121 // Replay any pending approval/ask prompts when switching tabs, so a
3122 // plan-mode session left awaiting confirmation rebuilds its modal (#4275).
3123 useEffect(() => {
3124 replayPendingPromptsForActiveTab(activeTabId);
3125 }, [activeTabId]);
3126
3127 const sendToTab = useCallback(async (
3128 tabId: string,
3129 displayText: string,
3130 submitText = displayText,
3131 originalText?: string,
3132 structured?: import("./invocationDisplay").StructuredInvocationSubmit,
3133 initialGoal?: {
3134 goal: string;
3135 collaborationMode: CollaborationMode;
3136 toolApprovalMode: ToolApprovalMode;
3137 },
3138 ) => {
3139 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3140 const currentState = getOrCreateState(statesRef.current, tabId);
3141 const runtime = currentState.meta?.runtime;
3142 if (currentState.meta && !runtimeReadyForSubmit(currentState.meta)) {
3143 throw new Error(runtime?.issue?.message || currentState.meta.startupErr || t("composer.workspaceStarting"));
3144 }
3145 const seq = currentState.seq;
3146 const promptEpoch = currentState.promptEpoch;
3147 const { display, submit } = normalizeTurnSubmit(displayText, submitText);
3148 const original = originalText?.trim() ?? "";
3149 dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq });
3150 invalidateCache();
3151 try {
3152 const submitPromise = initialGoal
3153 ? app.SubmitInitialGoalToTab(
3154 tabId,
3155 initialGoal.goal,
3156 structured?.display.trim() || display,
3157 structured?.input.trim() || submit,
3158 structured?.invocations ?? [],
3159 initialGoal.collaborationMode,
3160 initialGoal.toolApprovalMode,
3161 )
3162 : structured
3163 ? app.SubmitInvocationsToTab(tabId, structured.display.trim(), structured.input.trim(), structured.invocations)
3164 : original
3165 ? app.SubmitEditedDisplayToTab(tabId, display, submit, original)
3166 : display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit);
3167 if (initialGoal) {
3168 const drained = await submitPromise;
3169 const ids = Array.isArray(drained) ? drained : [];
3170 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch: promptEpoch });
3171 return;
3172 }
3173 void submitPromise.catch((error) => {
3174 dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
3175 });
3176 } catch (error) {
3177 dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
3178 throw error;
3179 }
3180 }, [dispatchTo]);
3181
3182 const recoverDeliveryToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText) => {
3183 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3184 const currentState = getOrCreateState(statesRef.current, tabId);
3185 const runtime = currentState.meta?.runtime;
3186 if (currentState.meta && !runtimeReadyForSubmit(currentState.meta)) {
3187 throw new Error(runtime?.issue?.message || currentState.meta.startupErr || t("composer.workspaceStarting"));
3188 }
3189 const seq = currentState.seq;
3190 const display = displayText.trim();
3191 const submit = submitText.trim();
3192 dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq, deliveryRecovery: true });
3193 invalidateCache();
3194 try {
3195 void app.SubmitDeliveryRecoveryToTab(tabId, display, submit).catch((error) => {
3196 dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
3197 });
3198 } catch (error) {
3199 dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
3200 throw error;
3201 }
3202 }, [dispatchTo]);
3203
3204 const send = useCallback((displayText: string, submitText = displayText) => {
3205 const tabId = activeTabIdRef.current ?? activeTabId;
3206 if (tabId) {
3207 return sendToTab(tabId, displayText, submitText);
3208 }
3209 const snapshotAt = promptEventClock();
3210 return activeTabFromBackend().then((active) => {
3211 if (!active?.id) throw new Error(t("composer.workspaceStarting"));
3212 setActiveTabId(active.id);
3213 activeTabIdRef.current = active.id;
3214 confirmBackendActiveTab(active.id);
3215 dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
3216 return sendToTab(active.id, displayText, submitText);
3217 });
3218 }, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
3219
3220 const runShellForTab = useCallback(async (tabId: string, command: string) => {
3221 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3222 dispatchTo(tabId, { type: "user", text: `!${command}`, seq: getOrCreateState(statesRef.current, tabId).seq });
3223 try {
3224 await app.RunShellForTab(tabId, command);
3225 } catch (error) {
3226 dispatchTo(tabId, { type: "send_failed", error: `Command failed: ${error instanceof Error ? error.message : String(error)}` });
3227 throw error;
3228 }
3229 }, [dispatchTo]);
3230
3231 const runShell = useCallback(async (command: string) => {
3232 if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
3233 await runShellForTab(activeTabId, command);
3234 }, [activeTabId, runShellForTab]);
3235
3236 const steerForTab = useCallback(async (tabId: string, text: string) => {
3237 if (!tabId) throw new Error(t("composer.workspaceStarting"));
3238 // No optimistic user bubble: rewind/fork map turns by counting user items,
3239 // and a steer is not a backend turn — the Steer event's ↪ notice is the
3240 // visible confirmation (#3660). Keep backend rejection as a rejected
3241 // promise: Composer retains the guidance item until TurnDone, then sends it
3242 // as a normal follow-up instead of clearing running state prematurely.
3243 await app.SteerForTab(tabId, text);
3244 }, []);
3245
3246 const steer = useCallback(async (text: string) => {
3247 if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
3248 await steerForTab(activeTabId, text);
3249 }, [activeTabId, steerForTab]);
3250
3251 const notice = useCallback((text: string, level: "info" | "warn" = "info") => {
3252 if (!activeTabId) return;
3253 dispatchTo(activeTabId, { type: "local_notice", level, text });
3254 }, [activeTabId, dispatchTo]);
3255
3256 // Extension form dismissed/submitted locally: hide the surface. The backend
3257 // round-trip (SubmitExtensionForm) lives in App.tsx, which owns the toast
3258 // context used for error reporting.
3259 const dismissExtensionForm = useCallback(() => {
3260 if (!activeTabId) return;
3261 dispatchTo(activeTabId, { type: "clearExtensionForm" });
3262 }, [activeTabId, dispatchTo]);
3263
3264 // The App drained the queued extension notifications into the toast system.
3265 const drainExtensionNotifications = useCallback(() => {
3266 if (!activeTabId) return;
3267 dispatchTo(activeTabId, { type: "extension_notifications_drained" });
3268 }, [activeTabId, dispatchTo]);
3269
3270 const cancelTab = useCallback((tabId: string) => {
3271 app.CancelTab(tabId)
3272 .then(() => scheduleCancelReconcile(tabId))
3273 .catch((error) => {
3274 dispatchTo(tabId, { type: "local_notice", level: "warn", text: `Cancel failed: ${errorMessage(error)}` });
3275 });
3276 }, [dispatchTo, scheduleCancelReconcile]);
3277
3278 const cancel = useCallback((): string | undefined => {
3279 const cur = stateRef.current;
3280 const tabId = activeTabId;
3281 if (cur.running && cur.pendingUser !== undefined) {
3282 const text = cur.pendingUser;
3283 if (tabId) {
3284 dispatchTo(tabId, { type: "unsend" });
3285 cancelTab(tabId);
3286 }
3287 return text;
3288 }
3289 if (tabId) {
3290 dispatchTo(tabId, { type: "cancel_requested" });
3291 cancelTab(tabId);
3292 }
3293 return undefined;
3294 }, [activeTabId, cancelTab, dispatchTo]);
3295
3296 const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => {
3297 if (!activeTabId) return;
3298 const tabId = activeTabId;
3299 // Pin the failure callback to the prompt-id epoch the RPC was issued in:
3300 // if a controller rebuild lands while the call is in flight, a late
3301 // failure must not undo bookkeeping the NEW controller wrote for the same
3302 // numeric id (#6432 round 4).
3303 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3304 dispatchTo(tabId, { type: "clearApproval" });
3305 app.ApproveTab(tabId, id, allow, session, persist).catch(() => {
3306 // The backend never actually resolved this prompt — undo the optimistic
3307 // tombstone and ask it to replay, so the approval card can come back
3308 // instead of being silently lost forever (#6432 round 3).
3309 dispatchTo(tabId, { type: "submit_prompt_failed", id, epoch });
3310 replayPendingPromptsForActiveTab(tabId);
3311 });
3312 }, [activeTabId, dispatchTo]);
3313
3314 const resolvePlanDecision = useCallback((id: string, action: "start_execution" | "revise_plan" | "exit_plan") => {
3315 if (!activeTabId) return;
3316 const tabId = activeTabId;
3317 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3318 dispatchTo(tabId, { type: "clearApproval" });
3319 const request = typeof app.ResolvePlanDecisionTab === "function"
3320 ? app.ResolvePlanDecisionTab(tabId, id, action)
3321 : app.ApproveTab(tabId, id, action === "start_execution", false, false);
3322 request.catch(() => {
3323 dispatchTo(tabId, { type: "submit_prompt_failed", id, epoch });
3324 replayPendingPromptsForActiveTab(tabId);
3325 });
3326 }, [activeTabId, dispatchTo]);
3327
3328 const resolveRecovery = useCallback((id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => {
3329 if (!activeTabId) return;
3330 const tabId = activeTabId;
3331 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3332 dispatchTo(tabId, { type: "clearApproval" });
3333 app.ResolveRecoveryTab(tabId, id, action, feedback).catch(() => {
3334 dispatchTo(tabId, { type: "submit_prompt_failed", id, epoch });
3335 replayPendingPromptsForActiveTab(tabId);
3336 });
3337 }, [activeTabId, dispatchTo]);
3338
3339 const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]) => {
3340 if (!activeTabId) return;
3341 const tabId = activeTabId;
3342 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3343 dispatchTo(tabId, { type: "clearAsk" });
3344 app.AnswerQuestionForTab(tabId, id, answers).catch(() => {
3345 dispatchTo(tabId, { type: "submit_prompt_failed", id, epoch });
3346 replayPendingPromptsForActiveTab(tabId);
3347 });
3348 }, [activeTabId, dispatchTo]);
3349
3350 const setControllerMode = useCallback((mode: Mode): Promise<void> => {
3351 if (!activeTabId) return Promise.resolve();
3352 const tabId = activeTabId;
3353 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3354 return app.SetModeForTab(tabId, mode).then((drained) => {
3355 // Only dismiss the approvals the backend reports it actually
3356 // auto-allowed. Fresh prompts (plan/memory/sandbox escape) survive a
3357 // yolo switch backend-side and must stay visible (#6432 round 4).
3358 const ids = Array.isArray(drained) ? drained : [];
3359 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch });
3360 }).catch(() => {});
3361 }, [activeTabId, dispatchTo]);
3362
3363 const setCollaborationModeForTab = useCallback(async (tabId: string, mode: CollaborationMode): Promise<void> => {
3364 if (!tabId) return;
3365 await app.SetCollaborationModeForTab(tabId, mode).catch(() => {});
3366 await refreshMetaForTab(tabId);
3367 }, [refreshMetaForTab]);
3368
3369 const setCollaborationMode = useCallback(async (mode: CollaborationMode): Promise<void> => {
3370 if (!activeTabId) return;
3371 await setCollaborationModeForTab(activeTabId, mode);
3372 }, [activeTabId, setCollaborationModeForTab]);
3373
3374 const setToolApprovalModeForTab = useCallback(async (tabId: string, mode: ToolApprovalMode): Promise<void> => {
3375 if (!tabId) return;
3376 const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0;
3377 // Same contract as setControllerMode: the backend reports which pending
3378 // approvals the new posture auto-allowed; anything else is still pending
3379 // there (fresh prompts; ask-rule approvals under auto) and stays visible.
3380 const drained = await app.SetToolApprovalModeForTab(tabId, mode).catch(() => undefined);
3381 const ids = Array.isArray(drained) ? drained : [];
3382 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch });
3383 await refreshMetaForTab(tabId);
3384 }, [dispatchTo, refreshMetaForTab]);
3385
3386 const setToolApprovalMode = useCallback(async (mode: ToolApprovalMode): Promise<void> => {
3387 if (!activeTabId) return;
3388 await setToolApprovalModeForTab(activeTabId, mode);
3389 }, [activeTabId, setToolApprovalModeForTab]);
3390
3391 const setComposerProfileForTab = useCallback(async (
3392 tabId: string,
3393 collaborationMode: CollaborationMode,
3394 toolApprovalMode: ToolApprovalMode,
3395 goal: string,
3396 options?: { propagateError?: boolean },
3397 ): Promise<boolean> => {
3398 if (!tabId) return false;
3399 const state = statesRef.current.get(tabId);
3400 const promptEpoch = state?.promptEpoch ?? 0;
3401 const key = composerProfileApplicationKey(
3402 runtimeEpochByTabRef.current.get(tabId) ?? state?.meta?.runtime?.epoch,
3403 collaborationMode,
3404 toolApprovalMode,
3405 goal,
3406 );
3407 if (appliedComposerProfileByTabRef.current.get(tabId) === key) return true;
3408 const existing = composerProfileInFlightByTabRef.current.get(tabId);
3409 if (existing?.key === key) return existing.promise;
3410
3411 const lifecycle = composerProfileLifecycleByTabRef.current.get(tabId) ?? 0;
3412 const previous = composerProfileQueueByTabRef.current.get(tabId) ?? Promise.resolve();
3413 const promise = previous.then(async () => {
3414 if ((composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) !== lifecycle) return false;
3415 if (appliedComposerProfileByTabRef.current.get(tabId) === key) return true;
3416 let drained: string[] | void;
3417 try {
3418 drained = await app.SetComposerProfileForTab(
3419 tabId,
3420 collaborationMode,
3421 toolApprovalMode,
3422 goal,
3423 );
3424 } catch (error) {
3425 if ((composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) === lifecycle) {
3426 await refreshMetaForTab(tabId);
3427 }
3428 if (options?.propagateError) throw error;
3429 return false;
3430 }
3431 if ((composerProfileLifecycleByTabRef.current.get(tabId) ?? 0) !== lifecycle) return false;
3432 appliedComposerProfileByTabRef.current.set(tabId, key);
3433 const ids = Array.isArray(drained) ? drained : [];
3434 if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch: promptEpoch });
3435 await refreshMetaForTab(tabId);
3436 return true;
3437 });
3438 const tail = promise.then(() => {}, () => {});
3439 composerProfileQueueByTabRef.current.set(tabId, tail);
3440 composerProfileInFlightByTabRef.current.set(tabId, { key, promise });
3441 try {
3442 return await promise;
3443 } finally {
3444 const current = composerProfileInFlightByTabRef.current.get(tabId);
3445 if (current?.promise === promise) composerProfileInFlightByTabRef.current.delete(tabId);
3446 if (composerProfileQueueByTabRef.current.get(tabId) === tail) {
3447 composerProfileQueueByTabRef.current.delete(tabId);
3448 }
3449 }
3450 }, [dispatchTo, refreshMetaForTab]);
3451
3452 const setGoalForTab = useCallback(async (tabId: string, goal: string): Promise<void> => {
3453 if (!tabId) return;
3454 // Propagate activation failures so the first Goal turn (especially structured
3455 // Skill submit) can abort instead of executing without an active Goal.
3456 try {
3457 await app.SetGoalForTab(tabId, goal);
3458 } finally {
3459 await refreshMetaForTab(tabId);
3460 }
3461 }, [refreshMetaForTab]);
3462
3463 const setGoal = useCallback(async (goal: string): Promise<void> => {
3464 if (!activeTabId) return;
3465 await setGoalForTab(activeTabId, goal);
3466 }, [activeTabId, setGoalForTab]);
3467
3468 const clearGoalForTab = useCallback(async (tabId: string): Promise<void> => {
3469 if (!tabId) return;
3470 try {
3471 await app.ClearGoalForTab(tabId);
3472 } finally {
3473 await refreshMetaForTab(tabId);
3474 }
3475 }, [refreshMetaForTab]);
3476
3477 const clearGoal = useCallback(async (): Promise<void> => {
3478 if (!activeTabId) return;
3479 await clearGoalForTab(activeTabId);
3480 }, [activeTabId, clearGoalForTab]);
3481
3482 const resumeGoalForTab = useCallback(async (tabId: string): Promise<boolean> => {
3483 if (!tabId) return false;
3484 try {
3485 const resumed = await app.ResumeGoalForTab(tabId);
3486 await refreshMetaForTab(tabId);
3487 return resumed;
3488 } catch {
3489 return false;
3490 }
3491 }, [refreshMetaForTab]);
3492
3493 const resumeGoal = useCallback(async (): Promise<boolean> => {
3494 if (!activeTabId) return false;
3495 return resumeGoalForTab(activeTabId);
3496 }, [activeTabId, resumeGoalForTab]);
3497
3498 const pauseGoalForTab = useCallback(async (tabId: string): Promise<boolean> => {
3499 if (!tabId) return false;
3500 try {
3501 const paused = await app.PauseGoalForTab(tabId);
3502 await refreshMetaForTab(tabId);
3503 return paused;
3504 } catch {
3505 return false;
3506 }
3507 }, [refreshMetaForTab]);
3508
3509 const pauseGoal = useCallback(async (): Promise<boolean> => {
3510 if (!activeTabId) return false;
3511 return pauseGoalForTab(activeTabId);
3512 }, [activeTabId, pauseGoalForTab]);
3513
3514 const newSession = useCallback(async () => {
3515 const tabId = activeTabId;
3516 if (tabId) await waitForTabReady(tabId);
3517 if (tabId) {
3518 addBreadcrumb("session.new", `click ${tabId}`);
3519 bumpCheckpointRefreshSeq(tabId);
3520 bumpSessionLoadSeq(tabId);
3521 dispatchTo(tabId, { type: "reset" });
3522 dispatchTo(tabId, { type: "hydrate_start", reason: "new-session" });
3523 addBreadcrumb("session.new", `visible-reset ${tabId}`);
3524 }
3525 try {
3526 if (tabId) await app.NewSessionForTab(tabId);
3527 else await app.NewSession();
3528 addBreadcrumb("session.new", `backend-done ${tabId ?? ""}`);
3529 } catch (err) {
3530 if (tabId) {
3531 dispatchTo(tabId, { type: "hydrate_error", reason: "new-session", error: errorMessage(err) });
3532 void loadSessionDataForTab(tabId, true, "new-session").then(() => {
3533 dispatchTo(tabId, { type: "local_notice", level: "warn", text: `New session failed: ${errorMessage(err)}` });
3534 });
3535 }
3536 return; // backend refused (workspace starting / failed) — keep the transcript
3537 }
3538 invalidateCache();
3539 if (tabId) {
3540 dispatchTo(tabId, { type: "history", messages: [] });
3541 dispatchTo(tabId, { type: "hydrate_done" });
3542 void refreshMetaForTab(tabId);
3543 app.ContextUsageForTab(tabId).then((context) => dispatchTo(tabId, { type: "context", context })).catch(() => {});
3544 void refreshCheckpoints(tabId);
3545 }
3546 }, [activeTabId, bumpCheckpointRefreshSeq, bumpSessionLoadSeq, dispatchTo, loadSessionDataForTab, refreshCheckpoints, refreshMetaForTab, waitForTabReady]);
3547
3548 const clearSession = useCallback(async () => {
3549 const tabId = activeTabId;
3550 if (tabId) await waitForTabReady(tabId);
3551 if (tabId) {
3552 bumpCheckpointRefreshSeq(tabId);
3553 bumpSessionLoadSeq(tabId);
3554 }
3555 try {
3556 if (tabId) await app.ClearSessionForTab(tabId);
3557 else await app.ClearSession();
3558 } catch {
3559 if (tabId) void loadSessionDataForTab(tabId);
3560 return;
3561 }
3562 if (tabId) bumpSessionLoadSeq(tabId);
3563 invalidateCache();
3564 if (tabId) {
3565 dispatchTo(tabId, { type: "reset" });
3566 // Clear placeholder items since no history action follows.
3567 dispatchTo(tabId, { type: "history", messages: [] });
3568 }
3569 }, [activeTabId, bumpCheckpointRefreshSeq, bumpSessionLoadSeq, dispatchTo, loadSessionDataForTab, waitForTabReady]);
3570
3571 const listSessions = useCallback(async (): Promise<SessionMeta[]> => asArray<SessionMeta>(await app.ListSessions().catch(() => [])), []);
3572 const listTrashedSessions = useCallback(async (): Promise<SessionMeta[]> => asArray<SessionMeta>(await app.ListTrashedSessions().catch(() => [])), []);
3573 const resumeSession = useCallback(async (path: string, tabId?: string, navigationIntentSeq?: number) => {
3574 const targetTabId = tabId || activeTabId;
3575 if (!targetTabId) return;
3576 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
3577 if (tabId) await waitForTabReady(tabId);
3578 else if (!(await waitForBackendActiveTab(targetTabId))) return;
3579 if (!navigationCompletionCurrent(navigationSeq, "session.resume", targetTabId)) return;
3580 const seq = bumpSessionLoadSeq(targetTabId);
3581 dispatchTo(targetTabId, { type: "hydrate_start", reason: "resume-session" });
3582 let page: HistoryPage;
3583 try {
3584 page = tabId
3585 ? await app.ResumeSessionPageForTab(tabId, path, HISTORY_PAGE_TURNS)
3586 : await app.ResumeSessionPage(path, HISTORY_PAGE_TURNS);
3587 } catch (err) {
3588 if (isNavigationIntentCurrent(navigationSeq) && sessionLoadCurrent(targetTabId, seq)) {
3589 dispatchTo(targetTabId, { type: "hydrate_error", reason: "resume-session", error: errorMessage(err) });
3590 dispatchTo(targetTabId, { type: "local_notice", level: "warn", text: `${t("history.failedOpenSession")}: ${errorMessage(err)}` });
3591 }
3592 return;
3593 }
3594 if (!navigationCompletionCurrent(navigationSeq, "session.resume", targetTabId) || !sessionLoadCurrent(targetTabId, seq)) return;
3595 dispatchTo(targetTabId, { type: "reset" });
3596 dispatchTo(targetTabId, { type: "history_page", page, mode: "replace" });
3597 dispatchTo(targetTabId, { type: "hydrate_done" });
3598 await refreshMetaOnlyForTab(targetTabId);
3599 if (!isNavigationIntentCurrent(navigationSeq) || !sessionLoadCurrent(targetTabId, seq)) return;
3600 app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
3601 void refreshCheckpoints(targetTabId);
3602 }, [activeTabId, beginActiveNavigation, bumpSessionLoadSeq, dispatchTo, isNavigationIntentCurrent, navigationCompletionCurrent, refreshCheckpoints, refreshMetaOnlyForTab, sessionLoadCurrent, waitForBackendActiveTab, waitForTabReady]);
3603
3604 const openChannelSession = useCallback(async (path: string, tabId: string, navigationIntentSeq?: number) => {
3605 if (!tabId) return;
3606 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
3607 await waitForTabReady(tabId);
3608 if (!navigationCompletionCurrent(navigationSeq, "session.channel", tabId)) return;
3609 const seq = bumpSessionLoadSeq(tabId);
3610 dispatchTo(tabId, { type: "hydrate_start", reason: "resume-session" });
3611 let page: HistoryPage;
3612 try {
3613 page = await app.OpenChannelSessionPageForTab(tabId, path, HISTORY_PAGE_TURNS);
3614 } catch (err) {
3615 if (isNavigationIntentCurrent(navigationSeq) && sessionLoadCurrent(tabId, seq)) {
3616 dispatchTo(tabId, { type: "hydrate_error", reason: "resume-session", error: errorMessage(err) });
3617 dispatchTo(tabId, { type: "local_notice", level: "warn", text: `${t("history.failedOpenSession")}: ${errorMessage(err)}` });
3618 }
3619 return;
3620 }
3621 if (!navigationCompletionCurrent(navigationSeq, "session.channel", tabId) || !sessionLoadCurrent(tabId, seq)) return;
3622 dispatchTo(tabId, { type: "reset" });
3623 dispatchTo(tabId, { type: "history_page", page, mode: "replace" });
3624 dispatchTo(tabId, { type: "hydrate_done" });
3625 await refreshMetaOnlyForTab(tabId);
3626 if (!isNavigationIntentCurrent(navigationSeq) || !sessionLoadCurrent(tabId, seq)) return;
3627 app.ContextUsageForTab(tabId).then((context) => dispatchTo(tabId, { type: "context", context })).catch(() => {});
3628 void refreshCheckpoints(tabId);
3629 }, [beginActiveNavigation, bumpSessionLoadSeq, dispatchTo, isNavigationIntentCurrent, navigationCompletionCurrent, refreshCheckpoints, refreshMetaOnlyForTab, sessionLoadCurrent, waitForTabReady]);
3630
3631 const previewSession = useCallback(async (path: string): Promise<HistoryMessage[]> => asArray<HistoryMessage>(await app.PreviewSession(path).catch(() => [])), []);
3632 const deleteSession = useCallback((path: string) => app.DeleteSession(path).finally(() => invalidateCache()), []);
3633 const restoreSession = useCallback((path: string) => app.RestoreSession(path).catch(() => {}).finally(() => invalidateCache()), []);
3634 const purgeTrashedSession = useCallback((path: string) => app.PurgeTrashedSession(path).catch(() => {}).finally(() => invalidateCache()), []);
3635 const renameSession = useCallback((path: string, title: string) => app.RenameSession(path, title).catch(() => {}).finally(() => invalidateCache()), []);
3636
3637 const refreshMeta = useCallback(async () => {
3638 if (!activeTabId) return;
3639 await refreshMetaForTab(activeTabId);
3640 }, [activeTabId, refreshMetaForTab]);
3641
3642 const refreshWorkspaceState = useCallback(async (path: string): Promise<string> => {
3643 if (path) await syncActiveTabFromBackend(true);
3644 return path;
3645 }, [syncActiveTabFromBackend]);
3646
3647 const pickWorkspace = useCallback(async (): Promise<string> => {
3648 beginActiveNavigation();
3649 const path = await app.PickWorkspace().catch(() => "");
3650 return refreshWorkspaceState(path);
3651 }, [beginActiveNavigation, refreshWorkspaceState]);
3652 const switchWorkspace = useCallback(async (path: string): Promise<string> => {
3653 beginActiveNavigation();
3654 const next = await app.SwitchWorkspace(path).catch(() => "");
3655 return refreshWorkspaceState(next);
3656 }, [beginActiveNavigation, refreshWorkspaceState]);
3657
3658 const compact = useCallback(() => {
3659 const tabId = activeTabIdRef.current;
3660 if (!tabId) return;
3661 void waitForTabReady(tabId).then(() => app.CompactForTab(tabId).catch(() => {}));
3662 }, [waitForTabReady]);
3663
3664 const enqueueModelSwitch = useCallback((tabId: string, name: string, fallbackBalance?: BalanceInfo) => {
3665 let queue = modelSwitchQueueByTab.current.get(tabId);
3666 if (!queue) {
3667 queue = { running: false, fallbackBalance };
3668 modelSwitchQueueByTab.current.set(tabId, queue);
3669 }
3670 const queueState = queue;
3671
3672 return new Promise<ModelSwitchQueueResult>((resolve, reject) => {
3673 const request: ModelSwitchQueueRequest = { name, resolve, reject };
3674 const run = (next: ModelSwitchQueueRequest) => {
3675 queueState.running = true;
3676 void Promise.resolve()
3677 .then(() => app.SetModelForTab(tabId, next.name))
3678 .then(
3679 () => next.resolve("applied"),
3680 (err) => next.reject(err),
3681 )
3682 .finally(() => {
3683 if (modelSwitchQueueByTab.current.get(tabId) !== queueState) return;
3684 const pending = queueState.pending;
3685 queueState.pending = undefined;
3686 if (pending) {
3687 run(pending);
3688 return;
3689 }
3690 queueState.running = false;
3691 modelSwitchQueueByTab.current.delete(tabId);
3692 });
3693 };
3694
3695 if (queueState.running) {
3696 queueState.pending?.resolve("superseded");
3697 queueState.pending = request;
3698 return;
3699 }
3700 run(request);
3701 });
3702 }, []);
3703
3704 const setModel = useCallback(async (name: string) => {
3705 if (!activeTabId) return false;
3706 const tabId = activeTabId;
3707 const switchSeq = (modelSwitchSeqByTab.current.get(tabId) ?? 0) + 1;
3708 const successVersion = modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0;
3709 const existingQueue = modelSwitchQueueByTab.current.get(tabId);
3710 // Every attempt in one queued burst shares the balance that was visible
3711 // before the first switch cleared it. Otherwise a later queued failure
3712 // captures the placeholder and cannot restore the outgoing provider.
3713 const fallbackBalance = existingQueue
3714 ? existingQueue.fallbackBalance
3715 : statesRef.current.get(tabId)?.balance;
3716 modelSwitchSeqByTab.current.set(tabId, switchSeq);
3717 // Hide the outgoing provider's wallet as soon as the user starts a hot
3718 // switch. If the rebuild fails, the catch path re-queries the still-active
3719 // provider and restores its balance.
3720 clearBalanceForTab(tabId);
3721 try {
3722 const result = await enqueueModelSwitch(tabId, name, fallbackBalance);
3723 if (result === "superseded") return false;
3724 modelSwitchSuccessVersionByTab.current.set(
3725 tabId,
3726 (modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0) + 1,
3727 );
3728 } catch (err) {
3729 if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false;
3730 dispatchTo(tabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) });
3731 const olderSwitchSucceeded =
3732 (modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0) !== successVersion;
3733 // Restore the known balance only when no older overlapping switch
3734 // completed after this attempt began. Otherwise the backend now owns a
3735 // different provider and the refresh below must establish its balance.
3736 if (fallbackBalance && !olderSwitchSucceeded) {
3737 dispatchTo(tabId, { type: "balance", balance: fallbackBalance });
3738 }
3739 void refreshBalanceForTab(tabId);
3740 // A superseded success deliberately skips its own UI reconciliation.
3741 // If this latest queued switch then fails, reconcile the model metadata
3742 // to the provider that actually became active in the backend.
3743 if (olderSwitchSucceeded) await refreshMetaForTab(tabId);
3744 return false;
3745 }
3746 if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false;
3747 void refreshBalanceForTab(tabId);
3748 await refreshMetaForTab(tabId);
3749 return modelSwitchSeqByTab.current.get(tabId) === switchSeq;
3750 }, [activeTabId, clearBalanceForTab, dispatchTo, enqueueModelSwitch, refreshBalanceForTab, refreshMetaForTab]);
3751
3752 const setEffort = useCallback(async (level: string) => {
3753 if (!activeTabId) return;
3754 try {
3755 await app.SetEffortForTab(activeTabId, level);
3756 } catch (err) {
3757 dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) });
3758 return;
3759 }
3760 await refreshMetaForTab(activeTabId);
3761 }, [activeTabId, dispatchTo, refreshMetaForTab]);
3762
3763 const setTokenMode = useCallback(async (mode: TokenMode): Promise<boolean> => {
3764 if (!activeTabId) return false;
3765 try {
3766 await app.SetTokenModeForTab(activeTabId, mode);
3767 } catch (err) {
3768 dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
3769 return false;
3770 }
3771 await refreshMetaForTab(activeTabId);
3772 return true;
3773 }, [activeTabId, dispatchTo, refreshMetaForTab]);
3774
3775 const cancelJob = useCallback(async (jobID: string): Promise<boolean> => {
3776 const tabId = activeTabId;
3777 if (!tabId || !jobID.trim()) return false;
3778 try {
3779 const cancelled = await app.CancelJobForTab(tabId, jobID);
3780 const jobs = asArray(await app.JobsForTab(tabId));
3781 dispatchTo(tabId, { type: "jobs", jobs });
3782 await refreshMetaForTab(tabId);
3783 return cancelled;
3784 } catch {
3785 dispatchTo(tabId, { type: "local_notice", level: "warn", text: t("status.jobStopFailed") });
3786 return false;
3787 }
3788 }, [activeTabId, dispatchTo, refreshMetaForTab]);
3789
3790 const fetchMemory = useCallback((): Promise<MemoryView> =>
3791 app.Memory().catch(() => ({
3792 docs: [], facts: [], archives: [], scopes: [], instructionDiagnostics: [], conflicts: [],
3793 lastRecall: { query: "", hits: [], omitted: 0, charBudget: 0, usedChars: 0 },
3794 storeDir: "", available: false,
3795 })), []);
3796 const remember = useCallback(async (scope: string, note: string) => { await app.Remember(scope, note).catch(() => {}); }, []);
3797 const forget = useCallback(async (name: string) => { await app.Forget(name).catch(() => {}); }, []);
3798 const saveDoc = useCallback(async (path: string, body: string) => { await app.SaveDoc(path, body).catch(() => {}); }, []);
3799
3800 type RewindOutcome = {
3801 ok: boolean;
3802 transactionId?: string;
3803 undoAvailable?: boolean;
3804 written?: string[];
3805 deleted?: string[];
3806 };
3807
3808 const rewindForTabDetailed = useCallback(async (sourceTabId: string, turn: number, scope: string): Promise<RewindOutcome> => {
3809 if (!sourceTabId) return { ok: false };
3810 const forkNavigationSeq = activeNavigationSeqRef.current;
3811 await waitForTabReady(sourceTabId);
3812 const actionScope = (["fork", "summ-from", "summ-upto", "conversation", "code", "both"].includes(scope) ? scope : "both") as MessageActionScope;
3813 dispatchTo(sourceTabId, { type: "message_action_start", action: { turn, scope: actionScope } });
3814 dispatchTo(sourceTabId, { type: "local_notice", level: "info", text: messageActionBusyText(actionScope) });
3815 try {
3816 if (actionScope === "fork") {
3817 const snapshotAt = promptEventClock();
3818 const tab = await app.ForkForTab(sourceTabId, turn);
3819 if (tab?.id) {
3820 const navigationUnchanged = activeNavigationSeqRef.current === forkNavigationSeq;
3821 const activateFork = tab.active && navigationUnchanged && activeTabIdRef.current === sourceTabId;
3822 if (!activateFork) {
3823 dispatchTo(tab.id, { type: "optimistic_meta", meta: metaFromTab(tab, statesRef.current.get(tab.id)?.meta) });
3824 dispatchRuntimeStatusForTab(tab.id, tab, snapshotAt);
3825 const currentTabId = activeTabIdRef.current;
3826 if (tab.active) {
3827 await reassertVisibleTabAfterStaleNavigation("tab.fork", tab.id);
3828 } else if (!tab.active && navigationUnchanged && currentTabId === sourceTabId) {
3829 await syncActiveTabFromBackend(false, true);
3830 }
3831 addBreadcrumb("tab.fork", `stale completion ${tab.id} current=${currentTabId ?? ""}`);
3832 return { ok: true };
3833 }
3834 beginActiveNavigation();
3835 setActiveTabId(tab.id);
3836 activeTabIdRef.current = tab.id;
3837 confirmBackendActiveTab(tab.id);
3838 dispatchRuntimeStatusForTab(tab.id, tab, snapshotAt);
3839 await waitForTabReady(tab.id);
3840 await loadSessionDataForTab(tab.id, true);
3841 await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
3842 } else {
3843 await syncActiveTabFromBackend(true);
3844 }
3845 return { ok: true };
3846 }
3847
3848 let outcome: RewindOutcome = { ok: true };
3849 if (actionScope === "summ-from") await app.SummarizeFromForTab(sourceTabId, turn);
3850 else if (actionScope === "summ-upto") await app.SummarizeUpToForTab(sourceTabId, turn);
3851 else {
3852 const { commitRewindWithPreview } = await import("./rewindCommit");
3853 const result = await commitRewindWithPreview(sourceTabId, turn, actionScope);
3854 if (!result?.ok) {
3855 const detail = result?.error
3856 || (result?.conflicts?.length ? result.conflicts.join("; ") : "")
3857 || "rewind failed";
3858 dispatchTo(sourceTabId, { type: "local_notice", level: "warn", text: detail });
3859 return { ok: false, written: result?.written, deleted: result?.deleted };
3860 }
3861 outcome = result as RewindOutcome;
3862 }
3863
3864 await loadSessionDataForTab(sourceTabId, true, "rewind");
3865 return outcome;
3866 } catch {
3867 return { ok: false };
3868 } finally {
3869 dispatchTo(sourceTabId, { type: "message_action_done" });
3870 }
3871 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, syncActiveTabFromBackend, waitForTabReady]);
3872
3873 const rewindForTab = useCallback(async (sourceTabId: string, turn: number, scope: string): Promise<boolean> => {
3874 return (await rewindForTabDetailed(sourceTabId, turn, scope)).ok;
3875 }, [rewindForTabDetailed]);
3876
3877 const rewind = useCallback(async (turn: number, scope: string): Promise<boolean> => {
3878 if (!activeTabId) return false;
3879 return rewindForTab(activeTabId, turn, scope);
3880 }, [activeTabId, rewindForTab]);
3881
3882 const undoRewindForTab = useCallback(async (sourceTabId: string, transactionId: string): Promise<boolean> => {
3883 if (!sourceTabId || !transactionId) return false;
3884 try {
3885 const { undoCommittedRewind } = await import("./rewindCommit");
3886 const result = await undoCommittedRewind(sourceTabId, transactionId);
3887 if (!result?.ok) {
3888 const detail = result?.error || "undo rewind failed";
3889 dispatchTo(sourceTabId, { type: "local_notice", level: "warn", text: detail });
3890 return false;
3891 }
3892 await loadSessionDataForTab(sourceTabId, true, "rewind");
3893 return true;
3894 } catch (err) {
3895 dispatchTo(sourceTabId, {
3896 type: "local_notice",
3897 level: "warn",
3898 text: err instanceof Error ? err.message : String(err),
3899 });
3900 return false;
3901 }
3902 }, [dispatchTo, loadSessionDataForTab]);
3903
3904 // Tab management: switch preserves per-tab state; open creates it.
3905 const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta, navigationIntentSeq?: number): Promise<TabMeta[] | undefined> => {
3906 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
3907 if (!navigationCompletionCurrent(navigationSeq, "tab.switch", tabId)) return undefined;
3908 const startedAt = Date.now();
3909 const previousTabId = activeTabIdRef.current;
3910 const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
3911 const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
3912 addBreadcrumb("tab.switch", `click ${tabId}`);
3913 setActiveTabId(tabId);
3914 activeTabIdRef.current = tabId;
3915 dispatchTo(tabId, { type: "backend_activation_start" });
3916 if (optimisticTab) {
3917 dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
3918 const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
3919 if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
3920 }
3921 dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
3922 addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
3923 const backendActivation = app.SetActiveTab(tabId)
3924 .then(async () => {
3925 const navigationCurrent = isNavigationIntentCurrent(navigationSeq);
3926 if (!navigationCurrent || activeTabIdRef.current !== tabId) {
3927 const currentTabId = activeTabIdRef.current;
3928 await reassertVisibleTabAfterStaleNavigation("tab.switch", tabId);
3929 addBreadcrumb("tab.switch", `set-active-stale ${tabId} seq=${navigationSeq} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
3930 return false;
3931 }
3932 confirmBackendActiveTab(tabId);
3933 addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
3934 return true;
3935 })
3936 .catch((err) => {
3937 if (!isNavigationIntentCurrent(navigationSeq)) return false;
3938 dispatchTo(tabId, { type: "backend_activation_done" });
3939 dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
3940 if (previousTabId && activeTabIdRef.current === tabId) {
3941 setActiveTabId(previousTabId);
3942 activeTabIdRef.current = previousTabId;
3943 addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
3944 }
3945 return false;
3946 });
3947 trackBackendActivation(tabId, backendActivation);
3948 const backendSwitch = backendActivation
3949 .then(async (activated) => {
3950 if (!activated || !isNavigationIntentCurrent(navigationSeq)) return undefined;
3951 const tabs = await reconcileTabRuntime(tabId, { hydrateSessionData: false });
3952 if (!isNavigationIntentCurrent(navigationSeq)) return tabs;
3953 void loadSessionDataForTab(tabId, false, "switch-tab", {
3954 skipHistory: hasCachedLiveTurn(statesRef.current.get(tabId)),
3955 preserveCachedHistory,
3956 sessionPath: targetSessionPath,
3957 });
3958 return tabs;
3959 })
3960 .catch((err) => {
3961 if (isNavigationIntentCurrent(navigationSeq)) {
3962 dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
3963 }
3964 return undefined;
3965 });
3966 return backendSwitch;
3967 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchTo, isNavigationIntentCurrent, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, trackBackendActivation]);
3968
3969 const openProjectTab = useCallback(async (workspaceRoot: string, topicId: string, navigationIntentSeq?: number): Promise<TabMeta> => {
3970 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
3971 const snapshotAt = promptEventClock();
3972 const meta = await app.OpenProjectTab(workspaceRoot, topicId);
3973 if (!navigationCompletionCurrent(navigationSeq, "tab.open-project", meta.id)) {
3974 await reassertVisibleTabAfterStaleNavigation("tab.open-project", meta.id);
3975 return meta;
3976 }
3977 const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
3978 const prevState = statesRef.current.get(meta.id);
3979 const isNewTab = !prevState;
3980 const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath);
3981 setActiveTabId(meta.id);
3982 activeTabIdRef.current = meta.id;
3983 confirmBackendActiveTab(meta.id);
3984 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
3985 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
3986 const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
3987 placeholderItems: isNewTab ? prevItems : undefined,
3988 preserveCachedHistory,
3989 sessionPath: meta.sessionPath,
3990 });
3991 if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
3992 else void load;
3993 return meta;
3994 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
3995
3996 const openGlobalTab = useCallback(async (topicId: string, navigationIntentSeq?: number): Promise<TabMeta> => {
3997 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
3998 const snapshotAt = promptEventClock();
3999 const meta = await app.OpenGlobalTab(topicId);
4000 if (!navigationCompletionCurrent(navigationSeq, "tab.open-global", meta.id)) {
4001 await reassertVisibleTabAfterStaleNavigation("tab.open-global", meta.id);
4002 return meta;
4003 }
4004 const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
4005 const prevState = statesRef.current.get(meta.id);
4006 const isNewTab = !prevState;
4007 const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath);
4008 setActiveTabId(meta.id);
4009 activeTabIdRef.current = meta.id;
4010 confirmBackendActiveTab(meta.id);
4011 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4012 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4013 const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
4014 placeholderItems: isNewTab ? prevItems : undefined,
4015 preserveCachedHistory,
4016 sessionPath: meta.sessionPath,
4017 });
4018 if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
4019 else void load;
4020 return meta;
4021 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
4022
4023 const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4024 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4025 const snapshotAt = promptEventClock();
4026 const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
4027 if (!navigationCompletionCurrent(navigationSeq, "tab.open-session", meta.id)) {
4028 await reassertVisibleTabAfterStaleNavigation("tab.open-session", meta.id);
4029 return meta;
4030 }
4031 const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
4032 const prevState = statesRef.current.get(meta.id);
4033 const isNewTab = !prevState;
4034 const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath);
4035 setActiveTabId(meta.id);
4036 activeTabIdRef.current = meta.id;
4037 confirmBackendActiveTab(meta.id);
4038 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4039 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4040 const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
4041 placeholderItems: isNewTab ? prevItems : undefined,
4042 preserveCachedHistory,
4043 sessionPath: meta.sessionPath,
4044 });
4045 if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
4046 else void load;
4047 return meta;
4048 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
4049
4050 const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = "", navigationIntentSeq?: number): Promise<TabMeta> => {
4051 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4052 const snapshotAt = promptEventClock();
4053 const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
4054 if (!navigationCompletionCurrent(navigationSeq, "topic.activate", meta.id)) {
4055 // A newer navigation started while the backend processed this
4056 // activation. Applying the stale result would flip the visible tab
4057 // away from the user's last click and — worse — the single-surface
4058 // prune below deletes every other tab's cached state, blanking the
4059 // surface the user is actually looking at. Last click wins: hand the
4060 // meta back for bookkeeping and leave the visible state to the newer
4061 // navigation.
4062 await reassertVisibleTabAfterStaleNavigation("topic.activate", meta.id);
4063 return meta;
4064 }
4065 // Save previous tab's items so the new tab can use them as a placeholder
4066 // during loading, avoiding a blank/Welcome flash before history arrives.
4067 const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
4068 for (const id of Array.from(statesRef.current.keys())) {
4069 if (id !== meta.id) {
4070 invalidateProviderStateForTab(id);
4071 disposeComposerProfileState(id);
4072 statesRef.current.delete(id);
4073 }
4074 }
4075 setActiveTabId(meta.id);
4076 activeTabIdRef.current = meta.id;
4077 confirmBackendActiveTab(meta.id);
4078 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4079 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4080 void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
4081 .then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
4082 .catch(() => {});
4083 return meta;
4084 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, disposeComposerProfileState, invalidateProviderStateForTab, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
4085
4086 // Ensure a blank tab exists for the given scope — reuses an existing one
4087 // or creates a new tab, then loads its session data.
4088 const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4089 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4090 const snapshotAt = promptEventClock();
4091 const meta = await app.EnsureBlankTab(scope, workspaceRoot);
4092 if (!navigationCompletionCurrent(navigationSeq, "tab.ensure-blank", meta.id)) {
4093 await reassertVisibleTabAfterStaleNavigation("tab.ensure-blank", meta.id);
4094 return meta;
4095 }
4096 // EnsureBlankTab may return a tab id already present in local state.
4097 // Invalidate its old hydration and force a fresh history read, otherwise a
4098 // late request can restore orphaned tool cards from the prior session.
4099 bumpCheckpointRefreshSeq(meta.id);
4100 const isNewTab = !statesRef.current.has(meta.id);
4101 setActiveTabId(meta.id);
4102 activeTabIdRef.current = meta.id;
4103 confirmBackendActiveTab(meta.id);
4104 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4105 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4106 const load = loadSessionDataForTab(meta.id, true, "new-session", { sessionPath: meta.sessionPath });
4107 if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
4108 else void load;
4109 return meta;
4110 }, [beginActiveNavigation, bumpCheckpointRefreshSeq, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
4111
4112 const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string, navigationIntentSeq?: number): Promise<TabMeta> => {
4113 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4114 const snapshotAt = promptEventClock();
4115 const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
4116 if (!navigationCompletionCurrent(navigationSeq, "surface.ensure-blank", meta.id)) {
4117 await reassertVisibleTabAfterStaleNavigation("surface.ensure-blank", meta.id);
4118 return meta;
4119 }
4120 for (const id of Array.from(statesRef.current.keys())) {
4121 if (id !== meta.id) {
4122 invalidateProviderStateForTab(id);
4123 disposeComposerProfileState(id);
4124 statesRef.current.delete(id);
4125 }
4126 }
4127 setActiveTabId(meta.id);
4128 activeTabIdRef.current = meta.id;
4129 confirmBackendActiveTab(meta.id);
4130 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4131 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4132 void loadSessionDataForTab(meta.id, true, "open-topic")
4133 .then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
4134 .catch(() => {});
4135 return meta;
4136 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, disposeComposerProfileState, invalidateProviderStateForTab, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
4137
4138 const createDeliveryWorktree = useCallback(async (workspaceRoot: string, navigationIntentSeq?: number): Promise<DeliveryWorktreeOpenResult> => {
4139 const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
4140 const snapshotAt = promptEventClock();
4141 const result = await app.CreateDeliveryWorktree(workspaceRoot);
4142 const meta = result.tab;
4143 if (!navigationCompletionCurrent(navigationSeq, "tab.delivery-worktree", meta.id)) {
4144 await reassertVisibleTabAfterStaleNavigation("tab.delivery-worktree", meta.id);
4145 return result;
4146 }
4147 const isNewTab = !statesRef.current.has(meta.id);
4148 setActiveTabId(meta.id);
4149 activeTabIdRef.current = meta.id;
4150 confirmBackendActiveTab(meta.id);
4151 dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
4152 dispatchRuntimeStatusForTab(meta.id, meta, snapshotAt);
4153 const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
4154 if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
4155 else void load;
4156 return result;
4157 }, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime]);
4158
4159 const closeTab = useCallback(async (
4160 tabId: string,
4161 policy: "keep_running" | "stop_and_close" = "keep_running",
4162 ): Promise<boolean> => {
4163 if (tabId === activeTabIdRef.current) beginActiveNavigation();
4164 try {
4165 await app.CloseTabWithPolicy(tabId, policy);
4166 invalidateProviderStateForTab(tabId);
4167 disposeComposerProfileState(tabId);
4168 statesRef.current.delete(tabId);
4169 notifyLiveListeners(tabId);
4170 bump();
4171 if (tabId === activeTabId) await syncActiveTabFromBackend(false);
4172 return true;
4173 } catch {
4174 return false;
4175 }
4176 }, [activeTabId, beginActiveNavigation, bump, disposeComposerProfileState, invalidateProviderStateForTab, notifyLiveListeners, syncActiveTabFromBackend]);
4177
4178 const reorderTabs = useCallback(async (tabIds: string[]) => {
4179 try {
4180 await app.ReorderTabs(tabIds);
4181 } catch { /* ignore */ }
4182 }, []);
4183
4184 return {
4185 state: activeState,
4186 liveStore,
4187 activeTabId,
4188 send, sendToTab, recoverDeliveryToTab, runShell, runShellForTab, steer, steerForTab, notice, cancel, approve, resolvePlanDecision, resolveRecovery, answerQuestion, setControllerMode,
4189 dismissExtensionForm, drainExtensionNotifications,
4190 setCollaborationMode, setCollaborationModeForTab, setToolApprovalMode, setToolApprovalModeForTab, setComposerProfileForTab, setGoal, setGoalForTab, clearGoal, clearGoalForTab, resumeGoal, resumeGoalForTab, pauseGoal, pauseGoalForTab,
4191 newSession, clearSession, listSessions, listTrashedSessions, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
4192 loadOlderHistory,
4193 refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, rewindForTabDetailed, undoRewindForTab, setModel, setEffort, setTokenMode, cancelJob,
4194 fetchMemory, remember, forget, saveDoc,
4195 switchTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, createDeliveryWorktree, closeTab, reorderTabs,
4196 // Invalidate in-flight navigation completions (activateTopic's stale
4197 // guard) from outside the hook. The App-level navigation queue must call
4198 // this at ENQUEUE time: a queued click does not run — and so does not
4199 // advance this epoch — until the running request finishes, which would
4200 // let the running stale activation pass the guard and prune the state of
4201 // the surface the user just clicked.
4202 noteNavigationIntent: beginActiveNavigation,
4203 isNavigationIntentCurrent,
4204 syncActiveTab: syncActiveTabFromBackend,
4205 };
4206 }
4207
4207 lines TYPESCRIPT