From faeb8fe6a2983cd9fc9ecb9167e16d625ccd56d0 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 27 May 2026 19:08:41 +0900 Subject: fix(frontend): structuredClone→$state.snapshot, WS reconnect resyncs from API, surface callback errors instead of swallowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/lib/tabs.svelte.ts | 78 ++++++++++++++++++++++++++++---- packages/frontend/src/lib/types.ts | 5 ++ packages/frontend/src/lib/ws.svelte.ts | 19 ++++++-- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index a86b855..7850218 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -296,11 +296,13 @@ function createTabStore() { * * Reactivity contract: `appendEventToChunks` mutates the chunks array in * place, but Svelte 5 `$state` only triggers updates when we reassign at the - * `tabs` array level. We deep-clone the message's chunks via - * `structuredClone`, mutate the clone, then write it back through - * `updateMessages` — which rebuilds the parent arrays. This is the same - * pattern the old per-event handlers used (shallow copy + new arrays), just - * centralized through the core helper. + * `tabs` array level. We snapshot the message's chunks via + * `$state.snapshot` (Svelte's own safe clone — strips reactive proxies and + * falls back gracefully where native `structuredClone` would throw + * `DataCloneError` on a `$state` proxy), mutate the snapshot, then write + * it back through `updateMessages`. The previous use of `structuredClone` + * here threw silently and was swallowed by the WS try/catch — left chunks + * empty for every streaming turn. */ function applyChunkEvent(tabId: string, event: AgentEvent): void { ensureAssistantMessage(tabId); @@ -311,7 +313,7 @@ function createTabStore() { updateMessages(tabId, (msgs) => msgs.map((m) => { if (m.id !== currentId) return m; - const cloned = structuredClone(m.chunks); + const cloned = $state.snapshot(m.chunks) as Chunk[]; // The frontend's local AgentEvent is structurally compatible with // core's for every variant the helper cares about; the variants // where shapes differ (tab-created, done, status, message-*) are @@ -331,13 +333,13 @@ function createTabStore() { const tab = getTabById(tabId); if (!tab) return; // We need to mutate the messages array (applySystemEvent does in-place - // push). Build a shallow-cloned IdentifiedMessage[] view, run the - // helper, then write it back. We construct fresh ChatMessage objects - // for any newly-pushed messages by reading the resulting view. + // push). Build a shallow-cloned IdentifiedMessage[] view via + // `$state.snapshot` (safe against Svelte 5 reactive proxies; native + // `structuredClone` would throw), run the helper, then write it back. const view: IdentifiedMessage[] = tab.messages.map((m) => ({ id: m.id, role: m.role, - chunks: structuredClone(m.chunks), + chunks: $state.snapshot(m.chunks) as Chunk[], })); applySystemEvent(view, sysEvent, generateId); @@ -359,6 +361,34 @@ function createTabStore() { updateTab(tabId, { messages: rebuilt }); } + /** + * Reload a tab's messages from the API. Used after a WS reconnect when + * we detect the backend finished work while we were disconnected — the + * persisted chunks are the source of truth; in-memory state may be + * missing events. + */ + async function reloadTabMessagesFromApi(tabId: string): Promise { + try { + const res = await fetch(`${config.apiBase}/tabs/${tabId}/messages`); + if (!res.ok) return; + const data = (await res.json()) as { + messages: Array<{ id?: string; role: string; chunks?: Chunk[] }>; + }; + const reloaded: ChatMessage[] = data.messages.map((m) => ({ + id: m.id ?? generateId(), + role: m.role as ChatMessage["role"], + chunks: Array.isArray(m.chunks) ? m.chunks : [], + isStreaming: false, + })); + updateTab(tabId, { + messages: reloaded, + currentAssistantId: null, + }); + } catch (err) { + console.warn("[reloadTabMessagesFromApi] failed:", err); + } + } + function handleEvent(event: AgentEvent & { tabId?: string }): void { const tabId = event.tabId; @@ -376,6 +406,34 @@ 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. + const backend = event.statuses; + for (const t of tabs) { + const backendStatus = backend[t.id] ?? "idle"; + if (t.agentStatus === "running" && backendStatus !== "running") { + void reloadTabMessagesFromApi(t.id); + } + 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. + updateMessages(t.id, (msgs) => + msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)), + ); + updateTab(t.id, { currentAssistantId: null }); + } + } + break; + } case "reasoning-delta": case "text-delta": case "tool-call": diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 1512b39..1043f64 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -70,6 +70,11 @@ export type ConnectionStatus = "connecting" | "connected" | "disconnected"; 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 } | { type: "text-delta"; delta: string } | { type: "reasoning-delta"; delta: string } | { diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts index 0970311..ff142ad 100644 --- a/packages/frontend/src/lib/ws.svelte.ts +++ b/packages/frontend/src/lib/ws.svelte.ts @@ -40,13 +40,24 @@ function createWebSocketClient(url: string) { }; ws.onmessage = (event: MessageEvent) => { + let data: AgentEvent; try { - const data = JSON.parse(event.data as string) as AgentEvent; - for (const cb of callbacks) { + data = JSON.parse(event.data as string) as AgentEvent; + } catch (err) { + // Genuinely malformed WS payload — these are rare and harmless. + console.warn("[ws] ignored malformed message:", err); + return; + } + // Run callbacks OUTSIDE the parse try so callback throws are visible. + // The previous catch-everything wrapper silently swallowed bugs in + // downstream handlers (notably the `structuredClone` of a Svelte 5 + // $state proxy throwing DataCloneError), making them undiagnosable. + for (const cb of callbacks) { + try { cb(data); + } catch (err) { + console.error("[ws] callback threw for event:", data, err); } - } catch { - // ignore malformed messages } }; -- cgit v1.2.3