From d98a63ce17519983dcf58c27432723e2f4b96e75 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 21 Jun 2026 02:19:54 +0900 Subject: feat(chat): message queue + steering — mid-turn injection at tool-result boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the message-queue + steering handoff (wire@0.8.0, transport-contract@0.12.0). Re-pinned file: deps + re-mirrored .dispatch/*.reference.md. - fold steering AgentEvent into the transcript as a provisional user bubble (after the tool-result it followed; no de-dup — the queue surface carried it) - add rendererId: "message-queue" custom renderer (pure parser + MessageQueueList) rendered as a compact panel above the Composer (hidden when queue is empty) - add ChatStore.queueMessage / AppStore.queueMessage — sends chat.queue WS op (trim/validate non-empty; auto-starts a turn if idle) - Composer switches to chat.queue while generating (button → Queue, placeholder → Steer the conversation...) - exhaustiveness guards updated for steering + chat.queue - carry-to-new-turn needs no special handling (normal new turn) 664 tests green. --- src/features/surface-host/logic/message-queue.ts | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/features/surface-host/logic/message-queue.ts (limited to 'src/features/surface-host/logic/message-queue.ts') diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts new file mode 100644 index 0000000..a8e1567 --- /dev/null +++ b/src/features/surface-host/logic/message-queue.ts @@ -0,0 +1,45 @@ +import type { QueuedMessage } from "@dispatch/wire"; + +/** + * Pure parser for the `rendererId: "message-queue"` custom-field payload. + * + * The message-queue extension's per-conversation surface emits ONE `custom` + * field with `rendererId: "message-queue"` and `payload: QueuePayload` + * (`{ messages: QueuedMessage[] }` — the current queue snapshot). This parser + * validates the untyped `payload: unknown` at the network seam so a + * hostile/partial payload can never crash the renderer (graceful skip → null). + * + * Empty `messages` is a valid, parseable state (the queue is empty — nothing to + * render); the caller hides the panel. Null is returned only for a malformed + * payload shape. + */ +export interface MessageQueueData { + readonly messages: readonly QueuedMessage[]; +} + +function isQueuedMessage(v: unknown): v is QueuedMessage { + if (typeof v !== "object" || v === null) return false; + const o = v as Record; + return ( + typeof o.id === "string" && + typeof o.text === "string" && + typeof o.queuedAt === "number" && + Number.isFinite(o.queuedAt) + ); +} + +export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null { + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record; + const raw = obj.messages; + if (!Array.isArray(raw)) return null; + const messages: QueuedMessage[] = []; + for (const entry of raw) { + if (!isQueuedMessage(entry)) return null; + messages.push(entry); + } + return { messages }; +} + +/** The `rendererId` the message-queue extension's `custom` surface field uses. */ +export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; -- cgit v1.2.3