/** * Response blocks. * * Answers are documents, not strings. A capability decides how its information * is best communicated — a comparison is a table, a health check is a status * list, a funnel is a funnel — and the renderer draws it with design-system * components. That is what turns a reply into a small interactive report rather * than a wall of chat text. * * Blocks are plain data, so they serialize: a future remote service can return * exactly this shape and nothing in the UI changes. */ /** Assembles a document, flattening nested arrays and dropping empty slots. */ export const doc = (...blocks) => ({ blocks: blocks.flat(Infinity).filter(Boolean), }); /** A short paragraph. Supports `**bold**` and `_italic_` inline. */ export const text = (value) => value && { type: 'text', text: value }; /** A section label inside a response. */ export const heading = (value, sub) => value && { type: 'heading', text: value, sub }; /** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */ export const kpis = (items) => items?.length && { type: 'kpis', items }; /** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */ export const status = (items) => items?.length && { type: 'status', items }; /** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */ export const meters = (items) => items?.length && { type: 'meters', items }; /** * A comparison or record table. * `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]` * A cell may be a string, a number, or `{ value, tone, badge }`. */ export const table = (columns, rows, options = {}) => rows?.length && { type: 'table', columns, rows, ...options }; /** Stage progression. `steps: [{ label, count, rate?, lost? }]` */ export const funnel = (steps) => steps?.length && { type: 'funnel', steps }; /** Bulleted or numbered points. */ export const list = (items, options = {}) => items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options }; /** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */ /** * Findings. `items: [{ title, body, tone?, to?, hint? }]` * * `to` makes the card a link into the app — a recommended person opening their * own record, rather than a name the reader has to go and look up. It is a route * the app already has; nothing here invents an address, and an item without one * renders exactly as it always did. */ export const insights = (items) => items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) }; /** Recommended next steps. `items: [{ title, body }]` */ export const actions = (items) => items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) }; /** Inline tags. `items: [{ label, tone }]` or plain strings. */ export const badges = (items) => items?.filter(Boolean).length && { type: 'badges', items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)), }; /** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */ export const timeline = (items) => items?.length && { type: 'timeline', items }; /** * A section a skill definition declared, drawn in the panel. * * The one block whose shape is not decided here: `section` is the normalized * record the registry produced from a `.md` file, and `data` is what the shared * resolver read for it. The renderer hands both to the same component the page * mounts, so a flow asked for in conversation *is* the page's flow rather than * a chat-shaped imitation of it. * * Both halves are plain data, so a reply containing one still serializes into * the stored thread like every other block. */ export const skillSection = (section, data) => section && data && { type: 'skillSection', section, data }; /** A caveat. Always the last word on a claim, never the headline. */ export const note = (value) => value && { type: 'note', text: value }; /** * A write the agent has proposed and NOT performed. * * The only block in this file that carries a decision rather than information, * and the only one whose renderer has a button. Everything else here describes * something that already happened; this describes something that will happen if * a person says so. * * The payload comes from the server verbatim — title, summary, details, * warnings, token — and is rendered rather than reformatted. The wording was * composed beside the code that will do the writing, so a browser paraphrasing * it would be describing a different act than the one the token authorises. * * `token` is what the approval sends back. It authorises exactly one call, with * exactly those arguments, and it expires. */ export const confirmation = (payload) => payload?.token && { type: 'confirmation', ...payload }; /** A single sentence answer — used by short free-text replies. */ export const answer = (value) => doc(text(value)); /* ── Streaming ──────────────────────────────────────────────────────────── */ /** * Splits a document into the progressive snapshots the provider streams. * * Text and heading blocks reveal word by word; structured blocks appear whole, * because a half-drawn table reads as broken rather than as arriving. The result * is a response that composes itself in a readable order. */ export function toSnapshots({ blocks }) { const snapshots = []; const settled = []; for (const block of blocks) { if (block.type === 'text' || block.type === 'heading') { const tokens = String(block.text).split(/(\s+)/); // Three words per snapshot: fast enough to feel live, coarse enough to // avoid a reflow per character. for (let i = 6; i < tokens.length; i += 6) { snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]); } } settled.push(block); snapshots.push([...settled]); } return snapshots.length ? snapshots : [[]]; }