summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-27 19:08:41 +0900
committerAdam Malczewski <[email protected]>2026-05-27 19:08:41 +0900
commitfaeb8fe6a2983cd9fc9ecb9167e16d625ccd56d0 (patch)
treea8c3287fdf6151cc69c30f4ca41e44f1e2c5378d
parent526187983f0a3202df532724545b5698fb0c567d (diff)
downloaddispatch-faeb8fe6a2983cd9fc9ecb9167e16d625ccd56d0.tar.gz
dispatch-faeb8fe6a2983cd9fc9ecb9167e16d625ccd56d0.zip
fix(frontend): structuredClone→$state.snapshot, WS reconnect resyncs from API, surface callback errors instead of swallowing
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts78
-rw-r--r--packages/frontend/src/lib/types.ts5
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts19
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<void> {
+ 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<string, "idle" | "running" | "error"> }
| { 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
}
};