summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 08:21:23 +0900
committerAdam Malczewski <[email protected]>2026-05-28 08:21:23 +0900
commitd2e2e67425e5106025ee8082a0768989b5de814f (patch)
tree831858182b4b3083beeb0cfa84968b5b73ded575 /packages
parent2f14260bb0f1a51d51e516feda285b68f793ae1b (diff)
downloaddispatch-d2e2e67425e5106025ee8082a0768989b5de814f.tar.gz
dispatch-d2e2e67425e5106025ee8082a0768989b5de814f.zip
feat: restore tab layout + in-flight chunks on browser reopen; agents keep running in background
Implements the 'background-running agents + restore-layout-on-reopen' feature. Full design and parallel-implementation plan in `plan-bg-restore.md`; Gemini code review (SHIP verdict, no findings) in `report.md`. User-visible behaviors: 1. Browser-close keeps agents alive. If an agent is mid-stream when the browser closes / reloads / loses the network, it continues processing on the backend. (This was already the case in code — agents run fire-and-forget in app.ts:77-79 — but it was previously pointless because the UI never restored the tab to receive the output.) 2. Layout restore on browser reopen. Every tab that existed at the time the window was closed is restored, in original `position` order, with full persisted message history. Tabs whose agents finished while disconnected appear with the completed message. Tabs whose agents are still running appear streaming live — the in-flight assistant message is reconstructed from the backend's in-memory `currentChunks` (sent over the wire on connect) and accumulates new deltas as they arrive. 3. Explicit tab-close cancels + forgets. Clicking the X still cancels the agent (existing `stopTab` in DELETE /tabs/:id) and archives the row (`is_open = 0`), so it is not restored. No change to that path. The gap that the implementation closes: previously, App.svelte:onMount unconditionally called `createNewTab()` with a fresh UUID, ignoring every existing row in the `tabs` table. Every browser open was a clean slate. The DB had the conversation history but no way for the UI to discover it. Implementation: • New `TabStatusSnapshot` interface in packages/core/src/types/index.ts (auto-exported via existing `export * from "./types"`): interface TabStatusSnapshot { status: AgentStatus; currentChunks?: Chunk[]; // present iff running currentAssistantId?: string; // present iff running } • `agent-manager.ts:getAllStatuses()` rewritten to return `Record<string, TabStatusSnapshot>` (was `Record<string, AgentStatus>`). For running tabs only, attaches a defensive shallow copy of `tabAgent.currentChunks` (the live streaming array the per-message loop appends to) plus the DB id of the in-flight assistant message. The defensive copy is the consumer's to mutate. Idle / error tabs get `{ status }` only. `GET /status` and the WS `onOpen` snapshot both pick up the new shape automatically — neither call site changed. • Frontend mirror of `TabStatusSnapshot` in packages/frontend/src/lib/types.ts; `AgentEvent.statuses` variant updated to use `Record<string, TabStatusSnapshot>`. • New `hydrateFromBackend()` on the tab store (packages/frontend/src/lib/tabs.svelte.ts). Sequence on app mount: 1. Bail with 0 if `tabs.length > 0` (hot-reload idempotency). 2. GET /tabs → list of `is_open=1` rows in `position` order. 3. GET /status → in-flight TabStatusSnapshot map. 4. GET /tabs/:id/messages for each tab in parallel via Promise.all → persisted ChatMessage[]. 5. Build the Tab objects, splicing the snapshot's live chunks into the in-flight assistant message for every running tab (two paths: merge into the existing DB row with matching id, or append a fresh in-flight message if no row matches). 6. `tabs = restored; activeTabId = restored[0]?.id ?? null;` Every fetch is wrapped in try/catch so one tab's failure can't destroy the whole restore pass. • WS `statuses` handler in `tabs.svelte.ts:handleEvent` rewritten for the new shape. Still fires `reloadTabMessagesFromApi` on the desync case (frontend thinks running, backend says idle — the pre-existing recovery path is preserved). When backend says running, seeds in-flight chunks into the assistant message matching `snap.currentAssistantId` (creating it if needed). When backend says non-running, clears `isStreaming` on the previous in-flight message and nulls `currentAssistantId`. • `App.svelte:onMount` now awaits `tabStore.hydrateFromBackend()` before deciding whether to fall back to `createNewTab()`. Fallback condition is the doubly-defensive `restored === 0 && tabStore.tabs.length === 0`. `wsClient.connect()` fires in parallel with hydration — the resulting WS `statuses` event is per-tab idempotent against the hydrated state, so there is no race even if it arrives mid-hydration. What was NOT done (deliberately, deferred to wishlist): • Pre-existing inconsistency: core `AgentStatus` includes "waiting_for_key" but frontend `TabStatusSnapshot.status` uses only the existing 3-state pattern ("idle" | "running" | "error"). Not introduced here; mirrored the existing precedent. • Restored tabs use defaults for `reasoningEffort`, `agentSlug`, `agentScope`, `agentModels`, `workingDirectory` — these are not in the DB `tabs` schema. Future schema expansion. • Per-delta DB flushing — not needed; the in-memory snapshot covers the gap between flushAssistant calls. • LocalStorage cache of tab ids — backend DB is the source of truth. Process notes: • Implemented via parallel programmer subagents (flash agents were requested but unavailable in this environment — substituted with "programmer" agents, which share the "reads a plan, implements a single step" charter). Backend (Segment A: getAllStatuses + 5 tests) and frontend (Segment B: types + hydrateFromBackend + statuses handler + onMount + 8 tests) ran disjoint-file-ownership in parallel. • Gemini code review (yolo mode for tool access, explicit prompt-level write restriction to `report.md` only) returned a SHIP verdict with no findings against the plan. • Self-review surfaced one followup gap that Gemini's earlier plan-mode pass also caught: no explicit test for `/tabs/:id/messages` failure isolation. Added a test covering both HTTP-500 and network-error variants alongside a healthy tab, asserting per-tab failures don't destroy the whole restore. Tests: • api/tests/agent-manager.test.ts: +5 (snapshot empty record, idle-tab field omission, running-tab field inclusion, defensive copy invariant, omits chunks for running tab with null currentChunks). 31 total (was 26). • frontend/tests/chat-store.test.ts: +9 (restore-with-messages, in-flight seeding, /tabs failure → 0 returned, empty /tabs array, idempotency when tabs already exist, idle-status when /status omits, running-snapshot statuses handler seeding, idle-snapshot statuses handler clearing, per-tab failure isolation across HTTP-500 and network-error). 44 total (was 35). Totals: 243 tests across 3 packages all green; typecheck clean on core + api + frontend; biome clean across 124 files.
Diffstat (limited to 'packages')
-rw-r--r--packages/api/src/agent-manager.ts37
-rw-r--r--packages/api/tests/agent-manager.test.ts156
-rw-r--r--packages/core/src/types/index.ts27
-rw-r--r--packages/frontend/src/App.svelte20
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts222
-rw-r--r--packages/frontend/src/lib/types.ts22
-rw-r--r--packages/frontend/tests/chat-store.test.ts382
7 files changed, 843 insertions, 23 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index efdd732..92caf81 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -37,6 +37,7 @@ import {
resolveApiKey,
type SkillDefinition,
type SystemChunkKind,
+ type TabStatusSnapshot,
TaskList,
updateMessage,
validateConfig,
@@ -711,10 +712,40 @@ export class AgentManager {
return this.tabAgents.get(tabId)?.status ?? "idle";
}
- getAllStatuses(): Record<string, AgentStatus> {
- const result: Record<string, AgentStatus> = {};
+ /**
+ * Snapshot of every tab the manager is currently tracking. Sent on WS
+ * connect and via GET /status so a freshly-loaded frontend can
+ * reconstruct any in-flight assistant turn without missing the chunks
+ * that arrived before its WS handshake completed.
+ *
+ * For each running tab, the snapshot includes:
+ * - status: "running"
+ * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks`
+ * (the live chunk array the streaming loop appends to). The
+ * consumer owns this copy and may mutate it freely.
+ * - currentAssistantId: the DB id of the in-flight assistant message
+ * row. The frontend aligns its local assistant message id with
+ * this so the next `done` event lands on the right message.
+ *
+ * For idle/error tabs, only `status` is present. Tabs not in
+ * `this.tabAgents` (e.g. tabs in the DB that have never been touched
+ * since server start) are absent from the returned record — the
+ * caller infers their status from the DB row (always "idle" at rest).
+ */
+ getAllStatuses(): Record<string, TabStatusSnapshot> {
+ const result: Record<string, TabStatusSnapshot> = {};
for (const [tabId, tabAgent] of this.tabAgents.entries()) {
- result[tabId] = tabAgent.status;
+ const snap: TabStatusSnapshot = { status: tabAgent.status };
+ if (tabAgent.status === "running") {
+ if (tabAgent.currentChunks) {
+ // Defensive shallow copy: callers may serialize/mutate.
+ snap.currentChunks = [...tabAgent.currentChunks];
+ }
+ if (tabAgent.currentAssistantId) {
+ snap.currentAssistantId = tabAgent.currentAssistantId;
+ }
+ }
+ result[tabId] = snap;
}
return result;
}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 71d43d8..ba14cad 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -658,4 +658,160 @@ describe("AgentManager", () => {
expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
});
+
+ // ─── getAllStatuses snapshot shape (for browser-reopen restore) ────
+ //
+ // The snapshot enriches the legacy `Record<string, AgentStatus>` shape
+ // with per-tab in-flight context so a fresh frontend can render the
+ // streaming assistant message correctly after a reload.
+
+ it("getAllStatuses returns an empty record when no tabs are tracked", () => {
+ const manager = new AgentManager();
+ expect(manager.getAllStatuses()).toEqual({});
+ });
+
+ it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => {
+ const manager = new AgentManager();
+ // Drive a full turn so the tab gets registered; default mock run
+ // settles back to idle by the time `await` resolves.
+ await manager.processMessage("tab-idle", "hi");
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-idle"]).toBeDefined();
+ expect(snap["tab-idle"]?.status).toBe("idle");
+ expect(snap["tab-idle"]).not.toHaveProperty("currentChunks");
+ expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId");
+ });
+
+ it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => {
+ const manager = new AgentManager();
+ // Reach into the private map to set up a synthetic running state.
+ // Justification: there is no public API to enter a sustained
+ // "running" state without actually streaming, and we want to
+ // assert the snapshot shape — not the streaming pipeline.
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running" | "idle" | "error";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: Array<{ type: string; text?: string }> | null;
+ currentAssistantId: string | null;
+ }
+ >;
+ };
+ inner.tabAgents.set("tab-running", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: [
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "partial answer" },
+ ],
+ currentAssistantId: "assistant-msg-id-7",
+ });
+
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-running"]).toBeDefined();
+ expect(snap["tab-running"]?.status).toBe("running");
+ expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7");
+ expect(snap["tab-running"]?.currentChunks).toEqual([
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "partial answer" },
+ ]);
+ });
+
+ it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => {
+ const manager = new AgentManager();
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: Array<{ type: string; text?: string }>;
+ currentAssistantId: string;
+ }
+ >;
+ };
+ const liveChunks = [{ type: "text", text: "live" }];
+ inner.tabAgents.set("tab-copy", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: liveChunks,
+ currentAssistantId: "msg-x",
+ });
+
+ const snap = manager.getAllStatuses();
+ // Mutate the snapshot's array
+ snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" });
+ // Live array must be untouched
+ expect(liveChunks).toEqual([{ type: "text", text: "live" }]);
+ });
+
+ it("getAllStatuses omits currentChunks when a running tab has none yet", () => {
+ const manager = new AgentManager();
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: null;
+ currentAssistantId: null;
+ }
+ >;
+ };
+ inner.tabAgents.set("tab-early", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: null,
+ currentAssistantId: null,
+ });
+
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-early"]?.status).toBe("running");
+ expect(snap["tab-early"]).not.toHaveProperty("currentChunks");
+ expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId");
+ });
});
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index e59eb68..0df56ca 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -95,6 +95,33 @@ export interface ToolResult {
export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key";
+/**
+ * Per-tab snapshot of live state, sent on WS connect and via
+ * `GET /status`. Carries enough information for a freshly-loaded
+ * frontend to reconstruct any in-flight assistant message.
+ *
+ * - `status` — always present; mirrors the in-memory `TabAgent.status`.
+ * - `currentChunks` — the live in-flight `Chunk[]` for the running
+ * assistant turn. Present iff `status === "running"` AND
+ * `TabAgent.currentChunks` is non-null. Defensively copied at
+ * snapshot time; the consumer owns the array.
+ * - `currentAssistantId` — DB id of the in-flight assistant message
+ * (the row that the eventual `flushAssistant` call will write/update).
+ * Present iff `status === "running"` AND `TabAgent.currentAssistantId`
+ * is set. The frontend uses this to align its local assistant
+ * message id with the persisted id so subsequent `done` and reload
+ * paths line up.
+ *
+ * Not part of `AgentEvent` itself: the `statuses` payload is a WS-
+ * connect-level snapshot, not an event the `Agent` emits. The frontend
+ * mirrors this type in `packages/frontend/src/lib/types.ts`.
+ */
+export interface TabStatusSnapshot {
+ status: AgentStatus;
+ currentChunks?: Chunk[];
+ currentAssistantId?: string;
+}
+
export type AgentEvent =
| { type: "status"; status: AgentStatus }
| { type: "text-delta"; delta: string }
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index 591266c..49db4ac 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -82,16 +82,24 @@ onMount(() => {
document.documentElement.setAttribute("data-theme", saved);
}
- // Connect WebSocket
+ // Connect WebSocket in parallel with hydration. The `statuses`
+ // snapshot delivered on WS open is idempotent against
+ // already-hydrated tabs (the handler reconciles per-tab).
wsClient.connect();
- // Initial models fetch
+ // Initial models fetch (fire-and-forget; UI tolerates models
+ // arriving later than tabs).
fetchModels();
- // Create initial tab
- if (tabStore.tabs.length === 0) {
- tabStore.createNewTab();
- }
+ // Restore tabs from the backend. The user's previous session is
+ // the source of truth; only fall back to a fresh tab if nothing
+ // was restored (first-ever load, or DB was wiped, or HTTP failed).
+ void (async () => {
+ const restored = await tabStore.hydrateFromBackend();
+ if (restored === 0 && tabStore.tabs.length === 0) {
+ await tabStore.createNewTab();
+ }
+ })();
return () => {
wsClient.disconnect();
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index 1d60e0b..326d288 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -18,6 +18,7 @@ import type {
LogEntry,
PermissionPrompt,
QueuedMessage,
+ TabStatusSnapshot,
TaskItem,
} from "./types.js";
import { wsClient } from "./ws.svelte.js";
@@ -399,6 +400,167 @@ export function createTabStore() {
}
}
+ /**
+ * Hydrate the tab store from the backend on app mount. Restores the
+ * full list of open tabs (every row with `is_open = 1` in the DB),
+ * loads each tab's persisted message history, and seeds the in-flight
+ * assistant message for any tab the backend is currently streaming.
+ *
+ * Wire calls:
+ * - GET /tabs → list of open tabs in `position` order
+ * - GET /tabs/:id/messages → persisted ChatMessage[] for each
+ * - GET /status → in-flight TabStatusSnapshot map
+ *
+ * Failure modes (all log + continue with whatever was successfully
+ * hydrated; callers fall back to creating a fresh tab if the final
+ * `tabs` array is empty):
+ * - /tabs request fails → no tabs restored
+ * - /tabs/:id/messages fails → that tab restored with empty messages
+ * - /status fails → tabs restored, in-flight streaming will be
+ * lost (will surface as a static "running" status until the next
+ * event arrives); harmless because the WS will broadcast `statuses`
+ * on reconnect anyway.
+ *
+ * Returns the number of tabs hydrated (0 on total failure, ≥1 on
+ * partial or full success). Caller uses this to decide whether to
+ * create a fresh tab.
+ *
+ * Idempotency: if `tabs.length > 0` when called, returns 0 without
+ * touching state — the caller already has tabs from elsewhere (e.g.
+ * a hot-reload that preserved Svelte state).
+ */
+ async function hydrateFromBackend(): Promise<number> {
+ if (tabs.length > 0) return 0;
+
+ // 1. Fetch the list of open tabs from the DB.
+ let tabRows: Array<{
+ id: string;
+ title: string;
+ keyId?: string | null;
+ modelId?: string | null;
+ parentTabId?: string | null;
+ }> = [];
+ try {
+ const res = await fetch(`${config.apiBase}/tabs`);
+ if (!res.ok) return 0;
+ const data = (await res.json()) as { tabs?: typeof tabRows };
+ tabRows = Array.isArray(data.tabs) ? data.tabs : [];
+ } catch {
+ return 0;
+ }
+
+ if (tabRows.length === 0) return 0;
+
+ // 2. Fetch the in-flight snapshot. Failure is non-fatal.
+ let statusMap: Record<string, TabStatusSnapshot> = {};
+ try {
+ const res = await fetch(`${config.apiBase}/status`);
+ if (res.ok) {
+ const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> };
+ if (data.statuses && typeof data.statuses === "object") {
+ statusMap = data.statuses;
+ }
+ }
+ } catch {
+ // Non-fatal: tabs still restore with idle status.
+ }
+
+ // 3. For each tab, fetch its persisted messages in parallel.
+ const messageFetches = tabRows.map(async (row) => {
+ try {
+ const res = await fetch(`${config.apiBase}/tabs/${row.id}/messages`);
+ if (!res.ok) return { id: row.id, messages: [] as ChatMessage[] };
+ const data = (await res.json()) as {
+ messages?: Array<{ id?: string; role: string; chunks?: Chunk[] }>;
+ };
+ const messages: ChatMessage[] = (data.messages ?? []).map((m) => ({
+ id: m.id ?? generateId(),
+ role: m.role as ChatMessage["role"],
+ chunks: Array.isArray(m.chunks) ? m.chunks : [],
+ isStreaming: false,
+ }));
+ return { id: row.id, messages };
+ } catch {
+ return { id: row.id, messages: [] as ChatMessage[] };
+ }
+ });
+
+ const messagesByTab = new Map<string, ChatMessage[]>();
+ for (const result of await Promise.all(messageFetches)) {
+ messagesByTab.set(result.id, result.messages);
+ }
+
+ // 4. Build the Tab objects, splicing in the in-flight snapshot for
+ // running tabs.
+ const restored: Tab[] = tabRows.map((row) => {
+ const snap = statusMap[row.id];
+ const messages = messagesByTab.get(row.id) ?? [];
+ const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle";
+
+ let currentAssistantId: string | null = null;
+ let finalMessages = messages;
+
+ if (agentStatus === "running" && snap?.currentAssistantId) {
+ currentAssistantId = snap.currentAssistantId;
+ // Find or create the in-flight assistant message. If the DB
+ // already has a row with this id (the backend appended on
+ // first flush and we picked it up via /tabs/:id/messages),
+ // merge the snapshot chunks on top — the snapshot is the
+ // live source of truth and may have chunks the DB doesn't.
+ // If there's no matching row, append a new in-flight
+ // assistant message holding only the snapshot chunks.
+ const existingIdx = finalMessages.findIndex((m) => m.id === snap.currentAssistantId);
+ if (existingIdx >= 0) {
+ finalMessages = finalMessages.map((m, i) =>
+ i === existingIdx
+ ? {
+ ...m,
+ chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks,
+ isStreaming: true,
+ }
+ : m,
+ );
+ } else {
+ finalMessages = [
+ ...finalMessages,
+ {
+ id: snap.currentAssistantId,
+ role: "assistant",
+ chunks: snap.currentChunks ? [...snap.currentChunks] : [],
+ isStreaming: true,
+ },
+ ];
+ }
+ }
+
+ return {
+ id: row.id,
+ title: row.title,
+ messages: finalMessages,
+ agentStatus,
+ keyId: row.keyId ?? null,
+ modelId: row.modelId ?? null,
+ reasoningEffort: "max",
+ currentAssistantId,
+ tasks: [],
+ injectedSkills: [],
+ parentTabId: row.parentTabId ?? null,
+ persistent: true,
+ agentSlug: null,
+ agentScope: null,
+ agentModels: null,
+ workingDirectory: null,
+ queuedMessages: [],
+ };
+ });
+
+ tabs = restored;
+ // Activate the first restored tab (the list is already ordered by
+ // `position` from the backend).
+ activeTabId = restored[0]?.id ?? null;
+ return restored.length;
+ }
+
function handleEvent(event: AgentEvent & { tabId?: string }): void {
const tabId = event.tabId;
@@ -417,25 +579,62 @@ export function createTabStore() {
break;
}
case "statuses": {
- // WS (re)connect snapshot. For any tab where the frontend thought
- // we were running but the backend says otherwise, we missed the
- // finishing events while disconnected — pull the persisted
- // chunks from the API to recover. Also sync agentStatus and
- // clear in-flight pointers on every desyncing tab.
+ // WS (re)connect snapshot. The shape was widened to
+ // TabStatusSnapshot (status + optional currentChunks +
+ // optional currentAssistantId) so the frontend can seed
+ // in-flight assistant messages on browser reopen.
const backend = event.statuses;
for (const t of tabs) {
- const backendStatus = backend[t.id] ?? "idle";
+ const snap = backend[t.id];
+ const backendStatus = snap?.status ?? "idle";
+
+ // Desync case: frontend thought it was streaming, backend
+ // has already moved on. Pull the persisted chunks so the
+ // final answer shows up.
if (t.agentStatus === "running" && backendStatus !== "running") {
void reloadTabMessagesFromApi(t.id);
}
+
+ // Status alignment.
if (t.agentStatus !== backendStatus) {
updateTab(t.id, { agentStatus: backendStatus });
}
- if (backendStatus !== "running" && t.currentAssistantId) {
- // Mark any in-flight assistant message as no-longer-streaming;
- // `reloadTabMessagesFromApi` (if it ran) will replace the
- // whole messages array, but if no reload was triggered we
- // still want streaming flags cleared.
+
+ if (backendStatus === "running") {
+ // Seed the in-flight assistant message from the snapshot.
+ // This handles the "browser just reopened mid-stream"
+ // path: the DB only has chunks up to the last
+ // flushAssistant call, but the snapshot has the live
+ // in-memory currentChunks.
+ if (snap?.currentAssistantId) {
+ const targetId = snap.currentAssistantId;
+ updateTab(t.id, { currentAssistantId: targetId });
+ updateMessages(t.id, (msgs) => {
+ const idx = msgs.findIndex((m) => m.id === targetId);
+ if (idx >= 0) {
+ return msgs.map((m, i) =>
+ i === idx
+ ? {
+ ...m,
+ chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks,
+ isStreaming: true,
+ }
+ : m,
+ );
+ }
+ return [
+ ...msgs,
+ {
+ id: targetId,
+ role: "assistant",
+ chunks: snap.currentChunks ? [...snap.currentChunks] : [],
+ isStreaming: true,
+ },
+ ];
+ });
+ }
+ } else if (t.currentAssistantId) {
+ // Not running: clear streaming flags.
updateMessages(t.id, (msgs) =>
msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)),
);
@@ -1303,6 +1502,7 @@ export function createTabStore() {
// WS callback uses in production. Not intended for use in
// components — they should rely on the WS subscription instead.
handleEvent,
+ hydrateFromBackend,
};
}
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 6051810..22837a0 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -76,13 +76,33 @@ export interface ChatMessage {
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
+/**
+ * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts).
+ *
+ * Sent on every WS (re)connect and via `GET /status`. The frontend uses
+ * this to:
+ * - reconcile its in-memory `agentStatus` with the backend's truth
+ * after a disconnect window;
+ * - reconstruct the in-flight assistant message for any tab the
+ * backend is currently streaming, so the user sees the partial
+ * thinking / text without waiting for the next delta.
+ *
+ * Wire-format symmetry MUST be kept with core. If you change one,
+ * change the other.
+ */
+export interface TabStatusSnapshot {
+ status: "idle" | "running" | "error";
+ currentChunks?: Chunk[];
+ currentAssistantId?: string;
+}
+
export type AgentEvent =
| { type: "status"; status: "idle" | "running" | "error" }
// Sent on every WS (re)connect: a snapshot of every tab the backend is
// currently tracking and its live status. The frontend uses this to
// detect desync after a reconnect (e.g. bun --watch restart killed the
// in-flight agent state, frontend missed `done` / `status:idle` events).
- | { type: "statuses"; statuses: Record<string, "idle" | "running" | "error"> }
+ | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> }
| { type: "text-delta"; delta: string }
| { type: "reasoning-delta"; delta: string }
| { type: "reasoning-end"; metadata?: Record<string, unknown> }
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index 47a6c97..41adb84 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -449,8 +449,8 @@ describe("tabStore — reactivity contract", () => {
// hiccup, etc.)
store.handleEvent({
type: "statuses",
- statuses: { [tabId]: "idle" },
- } as Parameters<typeof store.handleEvent>[0]);
+ statuses: { [tabId]: { status: "idle" } },
+ });
expect(store.tabs[0]?.agentStatus).toBe("idle");
expect(store.tabs[0]?.currentAssistantId).toBeNull();
@@ -672,3 +672,381 @@ describe("shell output parsing helper", () => {
expect(parseShellResult(JSON.stringify(42))).toBeNull();
});
});
+
+// ─── hydrateFromBackend ─────────────────────────────────────────
+//
+// Verifies the browser-reopen restore path: GET /tabs + GET /status +
+// GET /tabs/:id/messages combined into the in-memory tab store with
+// in-flight chunks seeded for any running tab.
+
+describe("hydrateFromBackend", () => {
+ it("restores tabs from /tabs with their persisted messages", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [
+ { id: "t1", title: "First", keyId: null, modelId: null, parentTabId: null },
+ { id: "t2", title: "Second", keyId: "k", modelId: "m", parentTabId: null },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ statuses: {} }),
+ });
+ }
+ if (url.endsWith("/tabs/t1/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ messages: [
+ { id: "m1", role: "user", chunks: [{ type: "text", text: "hello" }] },
+ {
+ id: "m2",
+ role: "assistant",
+ chunks: [{ type: "text", text: "hi back" }],
+ },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/tabs/t2/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ messages: [] }),
+ });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(2);
+ expect(store.tabs.length).toBe(2);
+ expect(store.tabs[0]?.id).toBe("t1");
+ expect(store.tabs[0]?.messages.length).toBe(2);
+ expect(store.tabs[1]?.id).toBe("t2");
+ expect(store.tabs[1]?.messages.length).toBe(0);
+ expect(store.activeTabId).toBe("t1");
+ });
+
+ it("seeds the in-flight assistant message from /status for a running tab", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [
+ { id: "tr", title: "Running tab", keyId: null, modelId: null, parentTabId: null },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ statuses: {
+ tr: {
+ status: "running",
+ currentAssistantId: "live-msg-id",
+ currentChunks: [
+ { type: "thinking", text: "still thinking" },
+ { type: "text", text: "partial " },
+ ],
+ },
+ },
+ }),
+ });
+ }
+ if (url.endsWith("/tabs/tr/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ messages: [{ id: "u1", role: "user", chunks: [{ type: "text", text: "go" }] }],
+ }),
+ });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(1);
+ const tab = store.tabs[0];
+ expect(tab?.agentStatus).toBe("running");
+ expect(tab?.currentAssistantId).toBe("live-msg-id");
+ // Two messages: the user message + the seeded in-flight assistant.
+ expect(tab?.messages.length).toBe(2);
+ const inflight = tab?.messages.find((m) => m.id === "live-msg-id");
+ expect(inflight).toBeDefined();
+ expect(inflight?.isStreaming).toBe(true);
+ expect(inflight?.chunks).toEqual([
+ { type: "thinking", text: "still thinking" },
+ { type: "text", text: "partial " },
+ ]);
+ });
+
+ it("returns 0 and leaves tabs empty when /tabs fails", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(0);
+ expect(store.tabs.length).toBe(0);
+ });
+
+ it("returns 0 and leaves tabs empty when /tabs returns an empty array", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ tabs: [] }) });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(0);
+ expect(store.tabs.length).toBe(0);
+ });
+
+ it("is a no-op when the store already has tabs (idempotency)", async () => {
+ const store = createTabStore();
+ // Pretend the store already has a tab (e.g. from a hot-reload).
+ // We do this by reaching into the store via the public API.
+ // Use the create path with mocked fetch failure (existing
+ // `createNewTab` already tolerates fetch failure — adds locally).
+ // (beforeEach already stubs fetch to reject, so createNewTab will
+ // proceed past the failed POST and add the tab locally.)
+ await store.createNewTab();
+ expect(store.tabs.length).toBe(1);
+
+ // Now swap to a fetch that would lie about there being 3 tabs;
+ // hydrateFromBackend must NOT call it. We use a fresh mock that
+ // rejects to catch any stray background async calls too.
+ let hydrateCallCount = 0;
+ const sentinelFetch = vi.fn((url: string) => {
+ // Allow background auto-agent/skill fetches that fire from
+ // createNewTab's void async closure (autoSelectDefaultAgent,
+ // autoCheckDefaultSkills) — they use /agents and /skills paths,
+ // not /tabs. Reject them so they don't interfere.
+ if (url.includes("/agents") || url.includes("/skills")) {
+ return Promise.reject(new Error("test: background fetch ignored"));
+ }
+ // Any /tabs call would mean hydrateFromBackend ran — count it.
+ hydrateCallCount++;
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ tabs: [{ id: "x" }, { id: "y" }, { id: "z" }] }),
+ });
+ });
+ vi.stubGlobal("fetch", sentinelFetch);
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(0);
+ expect(store.tabs.length).toBe(1);
+ expect(hydrateCallCount).toBe(0);
+ });
+
+ it("restores a tab with an idle status when /status omits it", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [{ id: "ti", title: "Idle", keyId: null, modelId: null, parentTabId: null }],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
+ }
+ if (url.endsWith("/tabs/ti/messages")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(1);
+ expect(store.tabs[0]?.agentStatus).toBe("idle");
+ expect(store.tabs[0]?.currentAssistantId).toBeNull();
+ });
+
+ it("restores a tab with empty messages when /tabs/:id/messages fails (per-tab failure isolation)", async () => {
+ // The hydrateFromBackend implementation wraps each per-tab
+ // messages fetch in a try/catch so one tab's failure can't
+ // destroy the whole restore pass. This test covers BOTH failure
+ // modes the try/catch protects against:
+ // - response.ok === false (HTTP error like 500)
+ // - the fetch rejects (network error)
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [
+ {
+ id: "tA",
+ title: "Healthy",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ },
+ {
+ id: "tB",
+ title: "Broken (HTTP 500)",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ },
+ {
+ id: "tC",
+ title: "Broken (network)",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
+ }
+ if (url.endsWith("/tabs/tA/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ messages: [{ id: "msg-a", role: "user", chunks: [{ type: "text", text: "ok" }] }],
+ }),
+ });
+ }
+ if (url.endsWith("/tabs/tB/messages")) {
+ // HTTP error path: response is not ok.
+ return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
+ }
+ if (url.endsWith("/tabs/tC/messages")) {
+ // Network error path: the fetch itself rejects.
+ return Promise.reject(new Error("simulated network failure"));
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(3);
+
+ // Healthy tab restored with its message.
+ const tA = store.tabs.find((t) => t.id === "tA");
+ expect(tA?.messages.length).toBe(1);
+ expect(tA?.messages[0]?.chunks).toEqual([{ type: "text", text: "ok" }]);
+
+ // Both broken tabs restored with empty message lists — neither
+ // crashed the hydration nor leaked an error chunk into the UI.
+ const tB = store.tabs.find((t) => t.id === "tB");
+ expect(tB).toBeDefined();
+ expect(tB?.messages.length).toBe(0);
+ expect(tB?.agentStatus).toBe("idle");
+
+ const tC = store.tabs.find((t) => t.id === "tC");
+ expect(tC).toBeDefined();
+ expect(tC?.messages.length).toBe(0);
+ expect(tC?.agentStatus).toBe("idle");
+ });
+});
+
+// ─── statuses WS event with the wider TabStatusSnapshot shape ───
+//
+// The handler must reconcile snapshot.status against the local tab,
+// and (when running) seed currentChunks into the in-flight assistant
+// message.
+
+describe("handleEvent statuses with TabStatusSnapshot", () => {
+ it("seeds the in-flight assistant message when a running snapshot arrives", async () => {
+ const store = createTabStore();
+ // Manually add a tab to the store via the existing createNewTab path
+ // (fetch was mocked to reject in beforeEach; createNewTab tolerates).
+ // We then drive a statuses event.
+ await store.createNewTab();
+ const tabId = store.tabs[0]?.id;
+ if (!tabId) throw new Error("test fixture: tab id missing");
+
+ store.handleEvent({
+ type: "statuses",
+ statuses: {
+ [tabId]: {
+ status: "running",
+ currentAssistantId: "live-x",
+ currentChunks: [{ type: "text", text: "live data" }],
+ },
+ },
+ });
+
+ const tab = store.tabs.find((t) => t.id === tabId);
+ expect(tab?.agentStatus).toBe("running");
+ expect(tab?.currentAssistantId).toBe("live-x");
+ const inflight = tab?.messages.find((m) => m.id === "live-x");
+ expect(inflight).toBeDefined();
+ expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]);
+ expect(inflight?.isStreaming).toBe(true);
+ });
+
+ it("clears in-flight pointers when snapshot says the tab is idle", async () => {
+ const store = createTabStore();
+ await store.createNewTab();
+ const tabId = store.tabs[0]?.id;
+ if (!tabId) throw new Error("test fixture: tab id missing");
+
+ // First put the tab into a running state with an in-flight message.
+ store.handleEvent({
+ type: "statuses",
+ statuses: {
+ [tabId]: {
+ status: "running",
+ currentAssistantId: "msg-a",
+ currentChunks: [{ type: "text", text: "x" }],
+ },
+ },
+ });
+ expect(store.tabs.find((t) => t.id === tabId)?.currentAssistantId).toBe("msg-a");
+
+ // Now snapshot says idle.
+ store.handleEvent({
+ type: "statuses",
+ statuses: { [tabId]: { status: "idle" } },
+ });
+ const tab = store.tabs.find((t) => t.id === tabId);
+ expect(tab?.agentStatus).toBe("idle");
+ expect(tab?.currentAssistantId).toBeNull();
+ const msgA = tab?.messages.find((m) => m.id === "msg-a");
+ expect(msgA?.isStreaming).toBe(false);
+ });
+});