ready.';
+ expect(stripStreamingBlocks(buffered)).toBe("I switched tabs and came back");
+ });
+
+ it("keeps prose that follows a COMPLETE block", () => {
+ // The open-block cut runs to end-of-string, so it must never see a block
+ // that already closed — otherwise the agent's closing question disappears.
+ const text = "Drafted.\n" + block('{"title":"T","html":"
x
"}') + "\nWant me to send it?";
+ expect(stripStreamingBlocks(text)).toBe("Drafted.\n\nWant me to send it?");
+ });
+
+ it("leaves ordinary prose and complete code blocks alone", () => {
+ const text = "Here is a snippet:\n```ts\nconst a = 1;\n```\nDone.";
+ expect(stripStreamingBlocks(text)).toBe(text);
+ });
+});
diff --git a/src/lib/scratchpad-block.ts b/src/lib/scratchpad-block.ts
index a85b7ece..7d6d6862 100644
--- a/src/lib/scratchpad-block.ts
+++ b/src/lib/scratchpad-block.ts
@@ -125,3 +125,20 @@ export function stripScratchpadBlocks(text: string): string {
SCRATCHPAD_BLOCK.lastIndex = 0;
return text.replace(SCRATCHPAD_BLOCK, "").trim();
}
+
+/** A block that has been opened but not yet closed — i.e. one still streaming. */
+const OPEN_BLOCK = /```(?:brains-)?scratchpad[\s\S]*$/i;
+
+/**
+ * What a PARTIAL turn should show. A block's payload is a document, not prose:
+ * while it streams, the raw JSON and HTML would otherwise render as the agent's
+ * message — and worse, rejoining a turn mid-block means the opening fence
+ * arrived while the user was elsewhere, so the strip above cannot match it and
+ * naked markup ("…
ready.") renders as an answer.
+ *
+ * Complete blocks are removed first, so this only ever cuts one that is
+ * genuinely unterminated — never prose that follows a finished block.
+ */
+export function stripStreamingBlocks(text: string): string {
+ return stripScratchpadBlocks(text).replace(OPEN_BLOCK, "").trim();
+}
diff --git a/src/lib/turn-restore.test.ts b/src/lib/turn-restore.test.ts
new file mode 100644
index 00000000..f935e1de
--- /dev/null
+++ b/src/lib/turn-restore.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+import {
+ isTerminalRunState,
+ mergeWorkingRuns,
+ restoredTurnActivity,
+ runStateIsSettled,
+ runStateIsWorking,
+ tabIsWorkingOnOpen,
+ TERMINAL_RUN_STATES,
+} from "./turn-restore";
+
+describe("restoredTurnActivity", () => {
+ it("shows the working indicator for a turn still in flight", () => {
+ // The regression this exists for: switching away from a working chat and
+ // back rendered the user's message with nothing under it and no spinner.
+ expect(restoredTurnActivity({ hasPendingAsk: false, runState: "running" })).toBe("working");
+ expect(restoredTurnActivity({ hasPendingAsk: false, runState: "pending" })).toBe("working");
+ expect(restoredTurnActivity({ hasPendingAsk: false, runState: "RUNNING" })).toBe("working");
+ });
+
+ it("counts a SPAWNING turn as working — the cold-start window", () => {
+ // The nastiest version of this bug: the live event handler counts anything
+ // non-terminal as working, so if the restore recognised only running/pending
+ // it would classify the entire spawn (10-20s on a cold CLI) as idle, tear
+ // down a live turn's spinner and disarm its watchdog. Any state that is not
+ // terminal must mean working, in BOTH places.
+ expect(restoredTurnActivity({ hasPendingAsk: false, runState: "spawning" })).toBe("working");
+ expect(restoredTurnActivity({ hasPendingAsk: false, runState: "some-future-state" })).toBe(
+ "working",
+ );
+ });
+
+ it("never spins when the agent is waiting on the user", () => {
+ expect(restoredTurnActivity({ hasPendingAsk: true, runState: "running" })).toBe(
+ "waiting-for-user",
+ );
+ });
+
+ it("is idle for finished states, and for no state at all", () => {
+ for (const runState of [...TERMINAL_RUN_STATES, "", " "]) {
+ expect(restoredTurnActivity({ hasPendingAsk: false, runState })).toBe("idle");
+ }
+ });
+});
+
+describe("isTerminalRunState", () => {
+ it("is the exact complement the live event handler uses to clear workingRuns", () => {
+ for (const state of TERMINAL_RUN_STATES) expect(isTerminalRunState(state)).toBe(true);
+ for (const state of ["running", "pending", "spawning", "anything-else"]) {
+ expect(isTerminalRunState(state)).toBe(false);
+ }
+ });
+
+ it("tolerates casing, padding and non-strings", () => {
+ expect(isTerminalRunState(" IDLE ")).toBe(true);
+ expect(isTerminalRunState(null)).toBe(false);
+ expect(isTerminalRunState(undefined)).toBe(false);
+ });
+});
+
+describe("runStateIsWorking", () => {
+ it("is the one predicate every consumer of a run state should share", () => {
+ expect(runStateIsWorking("running")).toBe(true);
+ expect(runStateIsWorking("spawning")).toBe(true);
+ expect(runStateIsWorking("pending")).toBe(true);
+ for (const state of TERMINAL_RUN_STATES) expect(runStateIsWorking(state)).toBe(false);
+ // No state makes no claim that work is happening.
+ expect(runStateIsWorking("")).toBe(false);
+ expect(runStateIsWorking(null)).toBe(false);
+ });
+});
+
+describe("runStateIsSettled", () => {
+ it("excludes idle, because a live process sits idle between turns", () => {
+ // This is the distinction that makes mergeWorkingRuns correct: `idle` is
+ // terminal for "is a turn running?" but NOT proof the run is over, because a
+ // warm agent process reports idle while waiting for its first message.
+ expect(runStateIsSettled("idle")).toBe(false);
+ expect(runStateIsSettled("completed")).toBe(true);
+ expect(runStateIsSettled("failed")).toBe(true);
+ expect(runStateIsSettled("stopped")).toBe(true);
+ expect(runStateIsSettled("running")).toBe(false);
+ });
+});
+
+describe("mergeWorkingRuns", () => {
+ it("keeps a just-marked warm run that the backend still reports as idle", () => {
+ // The regression this exists for: dispatch marks the run in-flight, then calls
+ // loadSessions() unawaited. On the warm path the row is still `idle` (the
+ // process is alive, waiting), so rebuilding the set from persisted status
+ // erased the mark — and a tab switch in that window read the turn as dead.
+ const merged = mergeWorkingRuns(new Set(["warm-1"]), [{ id: "warm-1", status: "idle" }]);
+ expect(merged.has("warm-1")).toBe(true);
+ });
+
+ it("still adopts runs the backend reports as running", () => {
+ const merged = mergeWorkingRuns(new Set(), [
+ { id: "a", status: "running" },
+ { id: "b", status: "idle" },
+ ]);
+ expect([...merged]).toEqual(["a"]);
+ });
+
+ it("reaps a mark once the run has genuinely settled", () => {
+ for (const status of ["completed", "failed", "stopped"]) {
+ const merged = mergeWorkingRuns(new Set(["gone"]), [{ id: "gone", status }]);
+ expect(merged.has("gone")).toBe(false);
+ }
+ });
+
+ it("keeps a mark for a run missing from the list rather than dropping a live turn", () => {
+ const merged = mergeWorkingRuns(new Set(["unlisted"]), [{ id: "other", status: "idle" }]);
+ expect(merged.has("unlisted")).toBe(true);
+ });
+});
+
+describe("tabIsWorkingOnOpen", () => {
+ it("seeds the spinner from the globally tracked working set", () => {
+ const working = new Set(["run-a"]);
+ expect(tabIsWorkingOnOpen("run-a", working)).toBe(true);
+ expect(tabIsWorkingOnOpen("run-b", working)).toBe(false);
+ });
+
+ it("is false for a tab with no session yet", () => {
+ expect(tabIsWorkingOnOpen(null, new Set(["run-a"]))).toBe(false);
+ expect(tabIsWorkingOnOpen("", new Set(["run-a"]))).toBe(false);
+ expect(tabIsWorkingOnOpen(undefined, new Set())).toBe(false);
+ });
+});
diff --git a/src/lib/turn-restore.ts b/src/lib/turn-restore.ts
new file mode 100644
index 00000000..a487ca99
--- /dev/null
+++ b/src/lib/turn-restore.ts
@@ -0,0 +1,127 @@
+/**
+ * What should the chat show for a session the user just switched (back) into?
+ *
+ * A turn outlives the tab it was started from: the agent keeps working while the
+ * user reads another conversation. Coming back must therefore restore one of
+ * three states, and getting it wrong is what made a live turn look dead — the
+ * user's message on screen, nothing under it, no spinner.
+ */
+
+/**
+ * The run states that mean a turn is OVER. Everything else — `running`,
+ * `pending`, `spawning`, and any state a future CLI adds — means it is still
+ * going.
+ *
+ * This list is the single source of truth for that question, and it is shared
+ * deliberately: the live event handler decides "is this run working?" by the
+ * complement of this set, and the restore path below must agree with it exactly.
+ * When the two drift the disagreement is invisible and brutal — a turn spends
+ * its whole `spawning` phase (10-20s on a cold CLI) counted as working by one
+ * and idle by the other, so returning to the tab tears down a live turn's
+ * spinner and disarms its watchdog.
+ */
+export const TERMINAL_RUN_STATES = ["idle", "completed", "failed", "stopped"] as const;
+
+export function isTerminalRunState(state: unknown): boolean {
+ return (TERMINAL_RUN_STATES as readonly string[]).includes(
+ String(state ?? "")
+ .trim()
+ .toLowerCase(),
+ );
+}
+
+/**
+ * Is a turn still in flight, judged from a run state alone?
+ *
+ * Every consumer of a run state should ask through this, so they cannot drift
+ * apart the way the live handler and the restore once did. An empty state makes
+ * no claim that work is happening and counts as not-working; anything else that
+ * is not terminal — including `spawning` — counts as working.
+ */
+export function runStateIsWorking(state: unknown): boolean {
+ const value = String(state ?? "")
+ .trim()
+ .toLowerCase();
+ return !!value && !isTerminalRunState(value);
+}
+
+/**
+ * Terminal states a LIVE process can never sit in.
+ *
+ * `idle` is deliberately NOT one of them. A warm agent process is idle between
+ * turns, so its run row reads `idle` while it is very much alive — and a poll
+ * that took `idle` for "finished" is precisely what erased a turn the moment
+ * after it was marked in-flight. These three, by contrast, mean the run is over
+ * for good, so they are safe to reap a stale mark with.
+ */
+const SETTLED_RUN_STATES = ["completed", "failed", "stopped"] as const;
+
+export function runStateIsSettled(state: unknown): boolean {
+ return (SETTLED_RUN_STATES as readonly string[]).includes(
+ String(state ?? "")
+ .trim()
+ .toLowerCase(),
+ );
+}
+
+/**
+ * Fold a freshly-polled run list into the set of runs known to be working.
+ *
+ * The poll is a SEED, not the authority: it may add runs this app never marked
+ * (recovered from another source), but it must not remove a mark just because a
+ * row has not caught up yet. Live `run_state` events, an explicit stop, a failed
+ * dispatch and the watchdog are what clear marks — all of them definite. A mark
+ * is only reaped here when the backend says the run has genuinely settled.
+ */
+export function mergeWorkingRuns(
+ marked: ReadonlySet,
+ persisted: ReadonlyArray<{ id: string; status?: unknown }>,
+): Set {
+ const next = new Set();
+ for (const run of persisted) {
+ if (
+ String(run.status ?? "")
+ .trim()
+ .toLowerCase() === "running"
+ )
+ next.add(run.id);
+ }
+ const statusById = new Map(persisted.map((run) => [run.id, run.status]));
+ for (const id of marked) {
+ // Absent from the list is kept too: it can be filtered or lag, and dropping a
+ // live turn is worse than holding a mark for one more poll.
+ if (!statusById.has(id) || !runStateIsSettled(statusById.get(id))) next.add(id);
+ }
+ return next;
+}
+
+export type RestoredTurnActivity =
+ /** The agent asked something and is blocked on the user — never a spinner. */
+ | "waiting-for-user"
+ /** The turn is still in flight: show the working indicator, arm the watchdog. */
+ | "working"
+ /** Nothing in flight. */
+ | "idle";
+
+export function restoredTurnActivity(input: {
+ hasPendingAsk: boolean;
+ /** Latest known run state ("running" | "spawning" | "idle" | "completed" | …). */
+ runState: string;
+}): RestoredTurnActivity {
+ if (input.hasPendingAsk) return "waiting-for-user";
+ return runStateIsWorking(input.runState) ? "working" : "idle";
+}
+
+/**
+ * Optimistic seed used the instant a tab is shown, before any IPC: the workspace
+ * already tracks which runs are working (live `run_state` events maintain the set
+ * for EVERY run, not just the open one), so the spinner can be correct on the
+ * first frame instead of after a getRun + transcript round-trip. Authoritative
+ * state from `restoredTurnActivity` corrects it moments later.
+ */
+export function tabIsWorkingOnOpen(
+ sessionId: string | null | undefined,
+ workingRuns: ReadonlySet,
+): boolean {
+ return !!sessionId && workingRuns.has(sessionId);
+}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 73fb28ae..12b7e7a2 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -56,9 +56,17 @@
import { fmtElapsedMs, formatDurationSec } from "$lib/utils/format";
import { latestRunState, mergeSessionEvents } from "$lib/session-event-replay";
import { silentTurnDisposition } from "$lib/session-watchdog";
+ import {
+ isTerminalRunState,
+ mergeWorkingRuns,
+ restoredTurnActivity,
+ runStateIsWorking,
+ tabIsWorkingOnOpen,
+ } from "$lib/turn-restore";
import {
parseScratchpadBlocks,
stripScratchpadBlocks,
+ stripStreamingBlocks,
type ScratchpadSpec,
} from "$lib/scratchpad-block";
import {
@@ -377,6 +385,41 @@
let sending = $state(false);
// run ids that are actively working (any session, not just the open one) → spinner in the list
let workingRuns = $state>(new Set());
+ /** When each run's current turn began. Keyed by run so the elapsed counter is a
+ * property of the TURN, not of how long the user has been looking at it — the
+ * timer used to restart from zero on every tab switch. Plain Map: the effect
+ * that reads it re-runs whenever `sending`/`runId` change, which is exactly
+ * when the answer can differ. */
+ const turnStartedByRun = new Map();
+ /** Mark a run as in-flight before its first `run_state` event arrives, so every
+ * other view of it (tab dot, sessions drawer, a switch-back spinner) is right
+ * from the moment the turn is sent rather than one round-trip later. Also
+ * stamps the turn's start, which is why it takes `startedAt`. */
+ function markRunWorking(id: string, startedAt = Date.now()): void {
+ if (!id) return;
+ if (!turnStartedByRun.has(id)) turnStartedByRun.set(id, startedAt);
+ if (workingRuns.has(id)) return;
+ const next = new Set(workingRuns);
+ next.add(id);
+ workingRuns = next;
+ }
+ /** Drop every trace of a run's in-flight turn.
+ *
+ * Called on the terminal `run_state`, but ALSO on the paths that end a turn
+ * without one — a failed dispatch, an explicit stop, a deleted session. Those
+ * are the paths that made this state a lie: a run left in `workingRuns` spins
+ * in the sidebar forever and re-seeds a spinner on every visit to its tab. */
+ function forgetRunActivity(id: string): void {
+ if (!id) return;
+ if (workingRuns.has(id)) {
+ const next = new Set(workingRuns);
+ next.delete(id);
+ workingRuns = next;
+ }
+ streamingByRun.delete(id);
+ hiddenRuns.delete(id);
+ turnStartedByRun.delete(id);
+ }
let chatError = $state(null);
// auth-expiry recovery: when a turn fails because the vendor CLI's OAuth expired
// and couldn't refresh, we show a friendly "Session expired → Reconnect" state
@@ -1441,6 +1484,26 @@
// that read so the older snapshot can never overwrite activity that arrived
// while the user was switching back to the tab.
const sessionHydrationEventBuffers = new Map();
+ // The run each tab last dispatched. `tab.sessionId` is the real binding, but it
+ // is persisted state that something can clear — and when it does, the tab
+ // resolves to no session, its restore is skipped, and a finished answer becomes
+ // unreachable behind a cached prompt. This is the in-memory fallback used to
+ // repair that, and it is deliberately NOT the same storage.
+ const lastRunByTab = new Map();
+ function rememberTabRun(tabId: string | null, sessionId: string): void {
+ if (!tabId || !sessionId) return;
+ lastRunByTab.set(tabId, sessionId);
+ }
+ // Partial answer text per RUN, so leaving a turn and returning to it does not
+ // resume from the middle of a sentence. Only the open session renders deltas,
+ // so a switch-back used to show whatever happened to arrive next — a fragment
+ // like "ched tabs and came back" — instead of the answer from its start.
+ // Dropped as soon as the message completes (it becomes a transcript row).
+ const streamingByRun = new Map();
+ const STREAM_BUFFER_CAP = 400_000;
+ const STREAM_TRUNCATED = "\n\n… (earlier output dropped from the background buffer) …\n\n";
+ /** Runs whose text must never surface (silent engage / summarizer turns). */
+ const hiddenRuns = new Set();
function workspaceTabId(): string {
return `tab-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`}`;
@@ -1687,8 +1750,13 @@
itemHtml = retained?.html ?? null;
webviewErr = null;
canvasFrameLoading = false;
- streaming = "";
- sending = false;
+ // Resume the in-flight answer from its start, not from the next delta.
+ streaming = tab.sessionId ? (streamingByRun.get(tab.sessionId) ?? "") : "";
+ // A turn outlives the tab it was started from. Seed the working state from
+ // the globally tracked run set so returning to a busy chat shows its spinner
+ // on the FIRST frame — the authoritative restore in openSession lands only
+ // after getRun + transcript IPC, and until it did, a live turn read as dead.
+ sending = tabIsWorkingOnOpen(tab.sessionId, workingRuns);
turnStopping = false;
chatError = null;
messages =
@@ -1714,7 +1782,29 @@
void openSession(liveTab.sessionId, false);
return;
}
- if ((workspaceTabTranscriptCache.get(liveTab.id)?.length ?? 0) > 0) return;
+ // No session on the tab, but a conversation happened here: its binding is
+ // gone. Stopping silently is what turned that into "my answer never came
+ // back" — the tab renders its cached prompt forever while the finished
+ // answer sits unreachable in the run's event log. Repair from the run this
+ // tab dispatched, and only fall back to showing the cache if even that is
+ // unknown.
+ if ((workspaceTabTranscriptCache.get(liveTab.id)?.length ?? 0) > 0) {
+ const strandedRun = lastRunByTab.get(liveTab.id);
+ if (strandedRun) {
+ clientLog(
+ "warn",
+ `tab ${liveTab.id} lost its binding to run ${strandedRun} — rebinding and restoring`,
+ );
+ bindWorkspaceTabToSession(liveTab.id, strandedRun);
+ void openSession(strandedRun, false);
+ } else {
+ clientLog(
+ "warn",
+ `tab ${liveTab.id} has a transcript but no session and no known run — showing cache`,
+ );
+ }
+ return;
+ }
newChat(true);
}, 0);
});
@@ -1994,27 +2084,76 @@
// before the runId filter so a board created while its tab is in the
// background is still catalogued and attached to that exact tab.
observeArtifactToolEvent(ev);
+ // Accumulate partial answer text for EVERY run, not just the open one, so
+ // returning to a turn shows it whole. Bounded, and dropped on completion.
+ if (ev.run_id && !ev.parent_tool_use_id && !hiddenRuns.has(String(ev.run_id))) {
+ const rid = String(ev.run_id);
+ if (ev.type === "message_delta") {
+ const soFar = streamingByRun.get(rid) ?? "";
+ const next = soFar + (ev.text ?? "");
+ // Past the cap, keep the TAIL and say so. Silently keeping the head
+ // would splice a gap into the middle of the restored answer with no
+ // marker — a lie the reader cannot see.
+ streamingByRun.set(
+ rid,
+ next.length <= STREAM_BUFFER_CAP
+ ? next
+ : STREAM_TRUNCATED + next.slice(next.length - STREAM_BUFFER_CAP),
+ );
+ } else if (ev.type === "message_complete") {
+ streamingByRun.delete(rid);
+ }
+ }
// Track working state for ALL runs (not just the open one) so every in-flight
// session shows a spinner in the sidebar, not only the active chat.
if (ev.type === "run_state" && ev.run_id) {
- const done = ["idle", "completed", "failed", "stopped"].includes(ev.state);
- const wasWorking = workingRuns.has(ev.run_id);
- if (done && workingRuns.has(ev.run_id)) {
+ // One id shape for every collection keyed by run: a write under `123` and a
+ // delete under `"123"` would leak the entry forever.
+ const rid = String(ev.run_id);
+ // Shared with the restore path (turn-restore.ts) on purpose — "still
+ // working" must mean the same thing live and on reopen, or a `spawning`
+ // turn is working here and idle there.
+ const done = isTerminalRunState(ev.state);
+ const wasWorking = workingRuns.has(rid);
+ if (done && workingRuns.has(rid)) {
const w = new Set(workingRuns);
- w.delete(ev.run_id);
+ w.delete(rid);
workingRuns = w;
- } else if (!done && !workingRuns.has(ev.run_id) && !(turnStopping && ev.run_id === runId)) {
+ } else if (!done && !workingRuns.has(rid) && !(turnStopping && rid === runId)) {
const w = new Set(workingRuns);
- w.add(ev.run_id);
+ w.add(rid);
workingRuns = w;
}
- if (done && wasWorking && ev.run_id !== runId) {
+ // Stamp the turn's start for ANY run, so a turn begun in a background tab
+ // still reports a truthful age when the user opens it.
+ if (!done && !turnStartedByRun.has(rid)) turnStartedByRun.set(rid, Date.now());
+ if (done && wasWorking && rid !== runId) {
const next = new Set(unreadSessions);
- next.add(ev.run_id);
+ next.add(rid);
unreadSessions = next;
}
+ if (done) forgetRunActivity(rid);
}
if (!runId || ev.run_id !== runId) return;
+ // Second line of defence for the same invariant the hydrate repair covers: a
+ // run streaming into the visible tab must be bound to it. This one only fires
+ // when the workspace still knows the run (`runId` is set) but the tab lost its
+ // binding — the hydrate path handles the harder case where both are gone.
+ // The tab must EXIST to be repairable: `bindWorkspaceTabToSession` no-ops on an
+ // unknown id, so treating "gone" like "unbound" would re-log and re-persist on
+ // every delta of the turn — a defect detector must not be able to bury its own
+ // signal. A successful bind clears the condition, so this fires once.
+ const activeWorkspaceTab = activeTabId
+ ? workspaceTabs.find((tab) => tab.id === activeTabId)
+ : undefined;
+ if (activeWorkspaceTab && !activeWorkspaceTab.sessionId) {
+ clientLog(
+ "warn",
+ `tab ${activeWorkspaceTab.id} was unbound while run ${ev.run_id} streamed — rebinding`,
+ );
+ bindWorkspaceTabToSession(activeWorkspaceTab.id, String(ev.run_id));
+ rememberTabRun(activeWorkspaceTab.id, String(ev.run_id));
+ }
if (sending) armWatchdog(); // any activity = still alive; push the silence deadline out
if (ev.type === "message_delta") {
if (!ev.parent_tool_use_id && !hideTurn) streaming += ev.text ?? "";
@@ -2346,9 +2485,13 @@
sending = false;
turnStopping = false;
actorLive = disposition.actorLive;
- const next = new Set(workingRuns);
- next.delete(target);
- workingRuns = next;
+ // A turn settled by the watchdog emits no terminal event of its own, so
+ // this is one of the paths that has to clean up after itself — and it must
+ // drop EVERY per-run trace, not just the working mark: a leftover buffer
+ // re-seeds a stale partial answer over the hydrated transcript, a leftover
+ // start time opens the next turn's clock inflated, and a run stuck in
+ // `hiddenRuns` never buffers its later visible turns at all.
+ forgetRunActivity(target);
clearWatchdog();
if (disposition.error) handleTurnError(disposition.error, "");
} catch (err) {
@@ -3111,17 +3254,22 @@
void refreshMissionControl(true);
}
});
- // per-second elapsed timer for the running turn (only ticks while sending; depends on
- // `sending` only, writes elapsedSec without reading it → no reactive loop)
+ // per-second elapsed timer for the running turn (ticks while sending; reads
+ // `sending`/`runId`, writes elapsedSec without reading it → no reactive loop).
+ // The clock belongs to the TURN: it counts from when the turn started, so
+ // leaving the tab and returning continues the count instead of restarting it.
let elapsedSec = $state(0);
$effect(() => {
if (!sending) {
elapsedSec = 0;
return;
}
- const start = Date.now();
- elapsedSec = 0;
- const iv = setInterval(() => (elapsedSec = Math.floor((Date.now() - start) / 1000)), 1000);
+ // Unknown start (e.g. a turn that began before this app launched) falls back
+ // to now — better an under-count than a fabricated age.
+ const start = turnStartedByRun.get(runId) ?? Date.now();
+ const tick = () => (elapsedSec = Math.max(0, Math.floor((Date.now() - start) / 1000)));
+ tick();
+ const iv = setInterval(tick, 1000);
return () => clearInterval(iv);
});
function fmtElapsed(s: number): string {
@@ -3832,11 +3980,10 @@
if (!stoppedRunId || turnStopping) return;
turnStopping = true;
clearWatchdog();
- if (workingRuns.has(stoppedRunId)) {
- const next = new Set(workingRuns);
- next.delete(stoppedRunId);
- workingRuns = next;
- }
+ // Stopping ends the turn without a terminal run_state of its own, so clear
+ // every per-run trace here or the buffer, the clock and the working mark
+ // outlive the turn they describe.
+ forgetRunActivity(stoppedRunId);
try {
// Cancel only the active turn. Killing the run removes its persistent actor,
// which makes the next message fail instead of continuing the conversation.
@@ -6879,6 +7026,12 @@
}
// hide raw code blocks from the chat — in a build session ```json (action/blueprint
// payloads) and ```ts/js (source) both belong in the Inspector, not a chat bubble.
+ /** A partially-received turn, as the user should see it: block payloads are
+ * documents bound for the canvas, so neither a completed nor a half-written
+ * one may render as the agent's message. */
+ function streamingChatText(t: string): string {
+ return stripStreamingBlocks(t);
+ }
function chatText(t: string): string {
if (!isBuildSession) return t;
return (t || "")
@@ -7869,9 +8022,24 @@
// the tab persisted sessionId=null. Switching away and back then resolved the tab to no
// run at all, and a restart restored it blank — the session only reachable from the
// drawer. Binding here covers every path that can own a run by the time we send.
+ // Every run this turn marks as working, so the failure path below can undo
+ // exactly what it did — a run left in `workingRuns` spins forever.
+ let markedRunId: string | null = null;
+ // The turn's clock starts when the user presses send. A cold start spawns a
+ // CLI process first, and that wait is theirs — counting from the spawn would
+ // hide the slowest part of it.
+ const sentAt = Date.now();
if (dispatchTabId && targetRunId) {
bindWorkspaceTabToSession(dispatchTabId, targetRunId, dispatchTranscript);
- }
+ // Track it as working immediately: the tab dot, and the spinner restored on
+ // a switch-back, both read this set — waiting for the first run_state event
+ // would leave a just-started turn looking idle from any other tab.
+ markRunWorking(targetRunId, sentAt);
+ markedRunId = targetRunId;
+ rememberTabRun(dispatchTabId, targetRunId);
+ }
+ // A silent turn's text is never shown, so it must never be buffered either.
+ if (hidden && targetRunId) hiddenRuns.add(targetRunId);
sending = true;
chatError = null;
authExpired = null; // a fresh send clears any prior expired-session recovery card
@@ -7953,10 +8121,17 @@
"session_actor",
);
bindWorkspaceTabToSession(dispatchTabId, run.id, dispatchTranscript);
+ markRunWorking(run.id, sentAt);
+ markedRunId = run.id;
+ rememberTabRun(dispatchTabId, run.id);
+ if (hidden) hiddenRuns.add(run.id);
if (!hidden) {
pendingSessionNames.set(run.id, { prompt: t, autoTitle: cleanPromptTitle(t) });
}
if (activeTabId === dispatchTabId) {
+ // The user may have switched away and back while this run was spawning,
+ // which cleared the spinner for a turn that is very much alive.
+ if (!hidden) sending = true;
runId = run.id;
// Fresh run: no snapshot to reconcile against, so arm it immediately.
resetEventReconciler(true); // seq space belongs to this run alone
@@ -8009,6 +8184,11 @@
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
+ // The turn never started, so no terminal `run_state` is coming to clean up
+ // after it. Undo the working mark here — regardless of which tab is in
+ // front, since the sidebar spinner and the tab dot are workspace-wide and
+ // would otherwise spin for the rest of the session.
+ if (markedRunId) forgetRunActivity(markedRunId);
if (activeTabId === dispatchTabId) {
handleTurnError(msg, hidden ? "" : t);
sending = false;
@@ -8405,12 +8585,15 @@
// Attached (colored) first, then most-recent. Keep the complete library here;
// individual views can paginate/limit their rendering without discarding sessions.
realSessions = sortSessions(mapped);
- // seed the working set from persisted status (live run_state events keep it fresh after);
- // the open session's own spinner is driven by `sending` in the render, so it's fine if a
- // just-spawned active run still reads "idle" here.
- workingRuns = new Set(
- runs.filter((r) => String(r.status).toLowerCase() === "running").map((r) => r.id),
- );
+ // Seed the working set from persisted status (live run_state events keep it
+ // fresh after) — but MERGE, never replace. `dispatch` calls this function
+ // itself, unawaited, right after marking its run in-flight, and on the warm
+ // path that run is still `idle` in the DB (the process is alive and waiting).
+ // Replacing the set therefore erased the mark until the first run_state
+ // arrived, and a tab switch inside that window read the turn as dead again.
+ // This set is no longer just a sidebar dot: it seeds the spinner on restore
+ // (`tabIsWorkingOnOpen`), so an in-memory mark outranks a stale row.
+ workingRuns = mergeWorkingRuns(workingRuns, runs);
maybeRestoreLastSession(realSessions); // cold-start: bring the last session (+ its page) back
} catch {
/* backend unavailable — leave empty */
@@ -8534,10 +8717,13 @@
// vendor is transparently handed over to the current model (auto-handover).
openedSessionAgent = realSessions.find((s) => s.id === id)?.agent ?? "claude";
messages = workspaceTranscriptCache.get(id) ?? [];
- streaming = "";
+ streaming = streamingByRun.get(id) ?? ""; // partial answer, from its start
subagentTasks = new Map(); // opening a different session → drop the prior panel
chatError = null;
- sending = false; // don't inherit a running turn from the previous session (stuck "working")
+ // Not "false": inheriting the PREVIOUS session's spinner is wrong, but so is
+ // blanking THIS one's. Seed from the tracked working set (corrected below by
+ // the run's real state) so a turn still in flight keeps showing as alive.
+ sending = tabIsWorkingOnOpen(id, workingRuns);
turnStopping = false;
hideTurn = false;
buildDirectiveSent = false;
@@ -8675,7 +8861,10 @@
// sessions (Claude-specific blueprint/Inspector must not break), a session
// already mid-turn (can't cleanly seed while a turn streams), and any handover
// already armed. openedSessionAgent is cleared so it fires exactly once.
- const running = st === "running" || st === "pending";
+ // Same vocabulary as the restore below and the live event handler: a cold
+ // spawn is mid-turn, so it must not be handed to another vendor. Judging by
+ // `running`/`pending` alone let a handover fire during the whole spawn.
+ const running = runStateIsWorking(st);
if (
!isBuildSession &&
!running &&
@@ -8689,20 +8878,33 @@
) {
openedSessionAgent = null; // handed over — don't re-arm
beginHandover(agentFor(model) === "codex" ? "openai" : "anthropic");
+ // Detaching leaves no run to watch, so the optimistic seed has nothing
+ // left to correct it — clear it here or the new conversation opens with
+ // a spinner for a turn that is not running.
+ sending = false;
+ clearWatchdog();
return; // detached: no run to resume/watch; next dispatch spawns the new run
}
// if the run is still mid-turn, reflect that it's working and keep the watchdog live
- if (restoredPendingAsk) {
- sending = false;
- clearWatchdog();
- } else if (running) {
+ // Authoritative turn state — also CORRECTS the optimistic seed taken when
+ // the tab was shown, so an over-eager spinner can never get stuck on.
+ const activity = restoredTurnActivity({ hasPendingAsk: !!restoredPendingAsk, runState: st });
+ if (activity === "working") {
sending = true;
actorLive = true;
armWatchdog();
+ } else {
+ sending = false;
+ clearWatchdog();
}
} catch (e) {
if (openGeneration === sessionOpenGeneration && activeSession === id) {
chatError = e instanceof Error ? e.message : String(e);
+ // The seed above assumed a live turn. This path never learned otherwise,
+ // and leaving it set would lock the composer behind a spinner for the
+ // rest of the session over one failed read.
+ sending = false;
+ clearWatchdog();
}
} finally {
if (sessionHydrationEventBuffers.get(id) === hydrationEvents) {
@@ -8719,6 +8921,9 @@
} catch {
/* some may be mid-turn; delete the rest */
}
+ // The runs are gone; nothing may still be described as in-flight.
+ for (const id of ids) forgetRunActivity(id);
+ lastRunByTab.clear();
for (const frame of retainedItemFrames) dropRetainedItemFrame(frame.tabId);
const blank = blankWorkspaceTab();
workspaceTabs = [blank];
@@ -11895,7 +12100,7 @@
class="brain-md"
style="font-size:13.5px;color:#6E6858;line-height:1.5;"
>