From 156ba52f3692f7a3abda4a84a63c0f31deb53b23 Mon Sep 17 00:00:00 2001 From: Amir SSV Labs Date: Thu, 6 Aug 2026 13:11:19 +0300 Subject: [PATCH 1/5] build: add @types/node so svelte-check can pass vite.config.ts reads process.env.TAURI_DEV_HOST, but no Node types are installed, so \ pm run check\ reports \Cannot find name 'process'\ - one error, on main, today. The repo's own pre-commit hook runs that check, so it cannot pass without this. Pinned to 22.17.0, matching the Node the Recall sidecar ships against. CI's typecheck leg is continue-on-error, so this does not change CI colour - it unblocks the local hook and makes the leg honest enough to ratchet later. --- package-lock.json | 22 ++++++++++++++++++++-- package.json | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6ae9a6b..c9753c72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencovibe", - "version": "0.5.8", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencovibe", - "version": "0.5.8", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@codemirror/lang-cpp": "^6.0.3", @@ -50,6 +50,7 @@ "@tailwindcss/typography": "^0.5.19", "@tauri-apps/cli": "^2.5.0", "@types/diff": "^7.0.2", + "@types/node": "22.17.0", "@types/turndown": "^5.0.6", "autoprefixer": "^10.4.20", "eslint": "^9.39.2", @@ -2294,6 +2295,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6320,6 +6331,13 @@ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unzipper": { "version": "0.10.14", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", diff --git a/package.json b/package.json index 303663e6..11099fd1 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@tailwindcss/typography": "^0.5.19", "@tauri-apps/cli": "^2.5.0", "@types/diff": "^7.0.2", + "@types/node": "22.17.0", "@types/turndown": "^5.0.6", "autoprefixer": "^10.4.20", "eslint": "^9.39.2", @@ -70,7 +71,6 @@ "vitest": "^4.0.18" }, "dependencies": { - "@recallai/desktop-sdk": "2.0.26", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", @@ -87,6 +87,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", + "@recallai/desktop-sdk": "2.0.26", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-notification": "^2.3.3", From e1c4d3243931b052f163e61841b32bd1becbd8cc Mon Sep 17 00:00:00 2001 From: Amir SSV Labs Date: Thu, 6 Aug 2026 13:12:31 +0300 Subject: [PATCH 2/5] fix(chat): keep a background turn visible, and its tab bound to its run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ask a question, switch to another chat, come back: the transcript showed the user's own message with nothing under it and no spinner, which is indistinguishable from a hung app. The turn was fine the whole time. Two invariants were broken. They are committed together because they are the same one seen from both ends: a running turn must look running, and the tab it runs in must stay attached to it. **A running turn must look running.** - showWorkspaceTabSurface set `sending = false` on every tab switch, and the authoritative restore only lands after getRun + a CLI sync + the transcript read. Both it and openSession now SEED from `workingRuns` — the set live run_state events already maintain for EVERY run, not just the open one, which is why the tab dot stayed right while the chat body looked dead — so the spinner is correct on the first frame. - openSession could only ever set `sending` true; the not-running case fell through, so an optimistic seed could stick. It is now an explicit three-way decision in a pure, tested `restoredTurnActivity`, and the paths that leave early without learning the run's real state (a failed getRun, a vendor handover) clear the seed instead of stranding it. - A run was only tracked as working once its first run_state arrived, so a user who switched away during the spawn came back to a tab that had bound its run but showed no activity. Dispatch marks it immediately, and — because a turn that never starts emits no terminal state — the failure path un-marks it, or the sidebar would spin for the session. `spawning` is the sharp edge: the event handler counts every non-terminal state as working, so a restore recognising only running/pending would call the whole cold-start window idle and tear down a live turn. TERMINAL_RUN_STATES is shared by both now, and both directions are tested. **A tab must stay bound to its run.** Evidence from a real occurrence: the answering run appeared against no tab in session-ui.json while the active tab held sessionId=null. With no binding, hydrateWorkspaceTabAfterPaint resolved the tab to no session and took a silent early return — no IPC at all, so the cached prompt rendered forever while the finished answer sat unreachable in the event log. Dispatch now records the run each tab sent, and the hydrate path repairs the binding from it rather than stopping silently. A second, weaker check in the event handler covers the case where the workspace still knows the run but the tab does not. Honest limit: which path drops the binding is still unidentified. This makes losing it recoverable and loud (both repairs log a warning naming tab and run) rather than fatal and invisible — it is not a root-cause fix. Co-Authored-By: Claude Fable 5 --- src/lib/turn-restore.test.ts | 70 +++++++++++++++++ src/lib/turn-restore.ts | 66 ++++++++++++++++ src/routes/+page.svelte | 145 +++++++++++++++++++++++++++++++---- 3 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 src/lib/turn-restore.test.ts create mode 100644 src/lib/turn-restore.ts diff --git a/src/lib/turn-restore.test.ts b/src/lib/turn-restore.test.ts new file mode 100644 index 00000000..d27ef6d5 --- /dev/null +++ b/src/lib/turn-restore.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + isTerminalRunState, + restoredTurnActivity, + 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("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..c236361b --- /dev/null +++ b/src/lib/turn-restore.ts @@ -0,0 +1,66 @@ +/** + * 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(), + ); +} + +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"; + const state = input.runState.trim().toLowerCase(); + // An absent state carries no claim that work is happening; treat it as over so + // a session with no run history cannot restore into a permanent spinner. + if (!state) return "idle"; + return isTerminalRunState(state) ? "idle" : "working"; +} + +/** + * 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 7f0d90b7..73bce8e8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -56,6 +56,7 @@ import { fmtElapsedMs, formatDurationSec } from "$lib/utils/format"; import { latestRunState, mergeSessionEvents } from "$lib/session-event-replay"; import { silentTurnDisposition } from "$lib/session-watchdog"; + import { isTerminalRunState, restoredTurnActivity, tabIsWorkingOnOpen } from "$lib/turn-restore"; import { parseScratchpadBlocks, stripScratchpadBlocks, @@ -360,6 +361,29 @@ 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()); + /** 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. */ + function markRunWorking(id: string): void { + if (!id || 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; + } + } 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 @@ -1424,6 +1448,16 @@ // 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); + } function workspaceTabId(): string { return `tab-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; @@ -1646,7 +1680,11 @@ webviewErr = null; canvasFrameLoading = false; streaming = ""; - sending = false; + // 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 = @@ -1672,7 +1710,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); }); @@ -1945,24 +2005,43 @@ // 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) { + 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. + if (activeTabId && !workspaceTabs.find((tab) => tab.id === activeTabId)?.sessionId) { + clientLog( + "warn", + `tab ${activeTabId} was unbound while run ${ev.run_id} streamed — rebinding`, + ); + bindWorkspaceTabToSession(activeTabId, String(ev.run_id)); + rememberTabRun(activeTabId, 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 ?? ""; @@ -7644,8 +7723,17 @@ // 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; 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); + markedRunId = targetRunId; + rememberTabRun(dispatchTabId, targetRunId); } sending = true; chatError = null; @@ -7728,10 +7816,16 @@ "session_actor", ); bindWorkspaceTabToSession(dispatchTabId, run.id, dispatchTranscript); + markRunWorking(run.id); + markedRunId = run.id; + rememberTabRun(dispatchTabId, 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 @@ -7784,6 +7878,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; @@ -8308,7 +8407,10 @@ streaming = ""; 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; @@ -8460,20 +8562,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) { From 628281754df899889285eb2b2be5c63872400f3f Mon Sep 17 00:00:00 2001 From: Amir SSV Labs Date: Thu, 6 Aug 2026 13:18:56 +0300 Subject: [PATCH 3/5] fix(chat): resume a partial answer whole, and never render block payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to a running turn showed a fragment of the answer starting mid-word, with document markup in it: ched tabs and came back