diff --git a/frontend/web/src/lib/ws.ts b/frontend/web/src/lib/ws.ts index 0eca450..3bf7e4b 100644 --- a/frontend/web/src/lib/ws.ts +++ b/frontend/web/src/lib/ws.ts @@ -5,6 +5,7 @@ import { parseAdminChannelMessage, parseAgent, parseAwakenTool, + parseBackthinkItem, parseGraph, parsePersistedMessage, parseTool, @@ -95,6 +96,14 @@ function dispatch(ev: { type: string; data: unknown }): void { if (msg) s.pushChannelEvent(msg); break; } + case "backthink": { + // design/12 §4 — a background-think pass result was persisted. Head-insert into + // the store so the admin backthink panel appends it in realtime (rows keyed by + // id → React reconciles incrementally, no whole-page re-render). + const item = parseBackthinkItem(r); + if (item) s.addBackthink(item); + break; + } case "thinking.start": { pendingThinking = ""; break; diff --git a/frontend/web/src/lib/wsParse.ts b/frontend/web/src/lib/wsParse.ts index 44802f9..123f13e 100644 --- a/frontend/web/src/lib/wsParse.ts +++ b/frontend/web/src/lib/wsParse.ts @@ -1,4 +1,4 @@ -import { MODES, type AdminChannelMessage, type AgentInfo, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types"; +import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types"; import { now, uid } from "./util"; /** @@ -183,3 +183,27 @@ export function parseAdminChannelMessage(v: unknown): AdminChannelMessage | null createdAt: typeof r.createdAt === "number" ? r.createdAt : now(), }; } + +/** Decode a background-think `backthink` WS payload into a BackthinkItem. The bus + * publishes the raw `backthink.Record` (Go default field names ID/Trigger/At/ + * Conclusion/ShouldPush/PushResult); the admin wire uses camelCase, so this reads + * both defensively and returns null when the id cannot be resolved. */ +export function parseBackthinkItem(v: unknown): BackthinkItem | null { + const r = toRecord(v); + if (!r) return null; + const idRaw = r.ID ?? r.id ?? r.record_id ?? r.recordId; + if (idRaw === undefined || idRaw === null) return null; + const atRaw = r.At ?? r.at; + let at = 0; + if (typeof atRaw === "number") at = atRaw; + else if (typeof atRaw === "string") { + const t = Date.parse(atRaw); + if (!Number.isNaN(t)) at = t; + } + const trigger = typeof r.Trigger === "string" ? r.Trigger : typeof r.trigger === "string" ? r.trigger : ""; + const conclusion = typeof r.Conclusion === "string" ? r.Conclusion : typeof r.conclusion === "string" ? r.conclusion : ""; + const pr = toRecord(r.PushResult) ?? {}; + const shouldNotify = (r.ShouldPush ?? r.shouldNotify ?? r.should_push) === true; + const pushed = (pr.Pushed ?? pr.pushed ?? r.pushed ?? r.push_pushed) === true; + return { id: String(idRaw), trigger, conclusion, shouldNotify, pushed, at }; +}