@zuilib/ai
v0.5.0
Published
ZUI UI for AI-assisted enterprise apps: assistant dock, thread, streaming markdown, tool calls, approvals, diff review, structured output, citations, provider adapters
Maintainers
Readme
@zuilib/ai
The UI that AI-assisted enterprise apps need, on ZUI tokens and
@zuilib/primitives. Provider-agnostic: no SDK, no network. Stream events
in, wire callbacks out.
| Import | What |
| --- | --- |
| @zuilib/ai/assistant-dock | Floating, context-aware assistant dock (not a chatbot); runs suggestions as tasks, streams events into them |
| @zuilib/ai/thread | Thread: a list of Messages, every part rendered by its card |
| @zuilib/ai/streaming-markdown | Markdown that grows as tokens arrive, with useStreamingText and citation chips |
| @zuilib/ai/tool-call-card | One tool invocation: args, status, result, elapsed time |
| @zuilib/ai/approval-card | Propose-then-confirm with a risk badge and a confirmation step |
| @zuilib/ai/diff-review | Unified or split line diff with per-hunk accept / reject; texts, hunks or a patch |
| @zuilib/ai/structured-output-form | Model output as editable ZUI form controls |
| @zuilib/ai/citation | Inline [1] chips and a source list |
| @zuilib/ai/prompt-input | The chat input: auto-growing textarea, Enter-to-send, toolbar slot, send / stop button |
| @zuilib/ai/reasoning | "Working on it" status |
| @zuilib/ai/use-thread · /use-tool-call · /use-approval · /use-diff-decisions · /consume-run | The headless hooks and the run driver |
| @zuilib/ai/stream-events | The AssistantEvent stream model (types) |
| @zuilib/ai/adapters/ai-sdk · /anthropic · /sse | Provider streams and messages mapped to events (types only, no SDK) |
| @zuilib/ai/markdown-parser · /diff-engine · /thread-reducer · /safe-href · /icons | The pure helpers |
@import "tailwindcss";
@import "@zuilib/primitives/tailwind.css";
@import "@zuilib/ai/tailwind.css";Import rules
Default exports, one subpath per component (import ToolCallCard from '@zuilib/ai/tool-call-card'); the barrel @zuilib/ai re-exports the components, hooks and pure helpers by name. Adapters are not on the barrel: a host that talks to the AI SDK, Anthropic or an SSE gateway imports the matching @zuilib/ai/adapters/* subpath, so the root surface stays provider-free. React and React DOM are the required peers; Tailwind is optional and needed only for the Tailwind entry. Base UI, the ZUI primitives and tokens install transitively. The adapters and the pure helpers have no React import, so a server component or a store can use them.
How it composes
provider stream ──adapter──▶ AssistantEvent ──useThread / AssistantDock──▶ Message.parts ──Thread──▶ cardsAssistantEvent(@zuilib/ai/stream-events) is the streaming model:{type:'text', delta},{type:'tool-call', id, toolName, args?, status?},{type:'tool-result', callId, result?, error?, elapsedMs?},{type:'approval', id, title, description?, risk?, confirm?, preview?, expiresAt?, respond?},{type:'diff', id, original?, modified?, hunks?, patch?, ...},{type:'citation', sources},{type:'custom', kind, id?, data?},{type:'error', message},{type:'done', summary?}.ASSISTANT_EVENT_TYPESis the runtime list of the types, exhaustiveness-checked against the union.MessagePart(@zuilib/ai/message-parts) is the settled model: the same shapes, folded byapplyEvent(@zuilib/ai/thread-reducer) intoMessage.parts.Threadmaps parts to cards;AssistantDockdoes the same inside each task.- Adapters turn an AI SDK stream, an Anthropic Messages stream or a generic
text/event-streamintoAssistantEvents. - Custom parts are the extension point: a
customevent carries an openkindand adatapayload,applyEventfolds it into aCustomPart(an event with the sameidreplaces the part in place), and aPartRenderersentry under thatkindrenders it — inThreaddirectly or through the dock'srenderersprop. Akindwith no renderer is skipped (or handed torenderUnknownPart), so an old client survives a newer stream. The built-in type keys (text,tool-call,tool-result,approval,diff,citation— exported asBUILTIN_PART_TYPES) are reserved: acustompart whosekindmatches one never falls through to the built-in card (its shape would not match) and is treated as unknown instead.
An async generator can pause on an approval: the human's decision comes back as the value of the yield.
async function* run({ signal }: { signal: AbortSignal }): AsyncGenerator<AssistantEvent, void, ApprovalOutcome | undefined> {
yield { type: 'text', delta: 'Drafting…' }
const outcome = yield { type: 'approval', id: 'send', title: 'Send 12 emails', risk: 'high' }
if (outcome !== 'approved') return yield { type: 'done', summary: { summary: 'Nothing sent.' } }
yield { type: 'done', summary: { summary: <b>Sent.</b>, apply: { label: 'Open thread', run: openThread } } }
}Component API
Every component is a forwardRef client component ('use client'), default-exported (with a named export alongside) from its own subpath, styled only through the @zuilib/tokens names, built from @zuilib/primitives primitives, using logical CSS properties (ps-, me-, start-, text-start; pnpm check:logical enforces it), and carrying a data-slot on its root and every part. None of them calls a model.
AssistantDock — @zuilib/ai/assistant-dock
const suggestions: AssistantSuggestion[] = [
{ id: 'outreach', label: 'Draft outreach for 2 selected', run: async ({ signal }) => {
const draft = await api.draftOutreach(ids, { signal })
return { summary: draft.summary, apply: { label: 'Queue for approval', run: () => queue(draft) } }
} },
{ id: 'explain', label: 'Explain the change', run: ({ signal }) => fromSSE(fetch('/api/explain', { signal })) },
]
<AssistantDock open={open} onOpenChange={setOpen} shortcut="⌘J" shortcutKey="j" header={{ title: 'Accounts', subtitle: '2 selected' }} suggestions={suggestions} onAsk={(text, { signal }) => api.ask(text, { signal })} />A task runner, not a chatbot: a spark trigger in a corner and a panel with the header badge, three to five suggestions, the tasks it has run and an optional free-text input. A run returns a summary, a promise of one, or an async iterable of AssistantEvents; the task card shows the streamed parts (text through StreamingMarkdown, tool calls through ToolCallCard, approvals through ApprovalCard, diffs through DiffReview, citations through CitationList) and, once done, the summary and an Apply button. An approval event pauses the run until the human decides on the card; the decision resumes the iterator (as the yield's value, and through the event's respond callback).
| Prop | Type | Default |
|------|------|---------|
| open / defaultOpen / onOpenChange | boolean / boolean / (open) => void | controlled when open is given; defaultOpen (default false) seeds the uncontrolled dock |
| header | { title: string; subtitle?: string } | required, the header badge |
| suggestions | AssistantSuggestion[] | required: { id, label, description?, run: (options: { signal }) => RunResult } |
| onAsk | (text: string, options: { signal }) => RunResult | without it there is no input |
| shortcut / shortcutKey | string | the tooltip label ('⌘J') and the key bound with ⌘ / Ctrl on the document to toggle |
| position | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' |
| strategy | 'fixed' \| 'absolute' | 'fixed' pins the dock to the viewport; 'absolute' positions it inside the nearest relative ancestor (keeps a subtree theme, a demo frame) |
| width / height | number \| string | 20rem / content (body capped at 26rem); a number is pixels |
| resizable | boolean | false; a drag handle on the panel's free corner, arrow keys resize by 16px (min 240 x 200) |
| dismissOnOutsideClick | boolean | false; close on a pointer press outside the panel and trigger. A press in a popup opened from the dock (a portaled listbox, menu or popover reached through its button's aria-controls) counts as inside |
| modal | boolean | false; the panel is a role="dialog", Tab cycles inside it and Escape closes it from anywhere on the page. Not aria-modal: the panel renders in place and the page behind it stays in the accessibility tree |
| label / suggestionsLabel / placeholder | string | 'Assistant' / 'Suggested here' / 'Ask about what’s on screen…' |
| maxTasks | number | 20; the scrollback keeps this many tasks, newest first; past it the oldest finished task is dropped, never a running one |
| footer | ReactNode | under the input (a tool-use switch, a model picker) |
| renderers | PartRenderers | part renderers for every task's thread: override a built-in card, or render custom parts by their kind |
| onTasksChange | (tasks: AssistantTask[]) => void | after every change |
| id | string | base for the ids: the root is id, the panel ${id}-panel, the trigger ${id}-trigger |
| className / panelClassName / triggerClassName | string | panel layer / panel / trigger button |
RunSummary is { summary: ReactNode; apply?: { label: string; run: () => void } }. RunResult is RunSummary | AsyncIterable<AssistantEvent> | Promise<either> (RunOutput is the non-promise half). AssistantTask is { id: string, label, status: 'running' | 'done' | 'error' | 'stopped', parts: MessagePart[], result?, error? }. Every run / onAsk receives { signal } (RunOptions), aborted when the human presses Stop on the task or the dock unmounts (closing the panel leaves a task running); Stop also returns a streaming iterator, and the parts that arrived stay on the card. A rejected run, or an error event, is a failed task showing the message. Failed and stopped tasks keep a Retry button that calls the same run with a fresh signal. Focus moves into the panel on open (the input, else the first suggestion, else the panel); Escape in the panel closes it and returns focus to the trigger, except in a text field inside a task (an approval's type-to-confirm input keeps Escape); Enter in the input submits.
Phones: below sm (640px) the panel is a sheet across the bottom of the viewport (the top, for top-* positions) — full width, capped at 85dvh, padded past the safe-area inset, with a close button (assistant-dock-close) in its header, since the sheet covers the trigger. width, height and resizable apply from sm up; the trigger clears the home indicator and keeps a 44px target on touch screens.
Parts: AssistantDock.Trigger and AssistantDock.Panel read the root's context and throw outside <AssistantDock>; AssistantDock.Task ({ task, onStop?, onRetry?, onApprovalDecision?, onDecisionsChange?, renderers?, className? }) is a plain presentational card reusable in your own thread. Slots: assistant-dock (root, data-position, data-strategy, data-open, data-modal), assistant-dock-trigger-layer, assistant-dock-trigger, assistant-dock-panel-layer, assistant-dock-panel (data-resizable), assistant-dock-resize, assistant-dock-header, assistant-dock-title, assistant-dock-header-badge, assistant-dock-close (phones only), assistant-dock-body, assistant-dock-suggestions, assistant-dock-suggestion, assistant-dock-tasks, assistant-dock-task (data-status), assistant-dock-task-label, assistant-dock-task-status, assistant-dock-task-parts, assistant-dock-task-stop, assistant-dock-task-result, assistant-dock-task-apply, assistant-dock-task-error, assistant-dock-task-retry, assistant-dock-input, assistant-dock-footer.
Thread — @zuilib/ai/thread
const thread = useThread()
<Thread messages={thread.messages} onApprovalDecision={(id, outcome) => thread.decideApproval(id, outcome)} onDecisionsChange={(id, decisions) => thread.decideDiff(id, decisions)} />
<Thread messages={messages} renderers={{ text: MyMarkdown }} />| Prop | Type | Default |
|------|------|---------|
| messages | Message[] | required |
| renderers | PartRenderers | overrides of DEFAULT_PART_RENDERERS by part type; any other key renders custom parts of that kind |
| renderUnknownPart | (part: CustomPart) => ReactNode | rendered for a custom part whose kind has no renderer; without it the part is skipped |
| onApprovalDecision | (id, outcome: 'approved' \| 'rejected', message) => void | an approval part was decided |
| onDecisionsChange | (id, decisions, accepted: string, message) => void | a diff part's decisions changed |
| roleLabels | Partial<Record<role, string>> | You / Assistant / System / Tool, the article labels |
| emptyState | ReactNode | shown when there are no messages |
| children | (items: { id, node }[]) => ReactNode | render your own list (a virtualiser) from the already-wrapped messages |
| messageClassName / className | string | every message / the root |
The default map: text → StreamingMarkdown, tool-call → ToolCallCard (fed by its tool-result, which renders nothing itself), approval → ApprovalCard, diff → DiffReview, citation → CitationList. A custom part is looked up by its kind instead of its type, so renderers={{ chart: ChartCard }} adds a part type without touching the union; with no renderer for the kind the part is skipped, or handed to renderUnknownPart. The built-in type keys are reserved (BUILTIN_PART_TYPES): a custom part whose kind matches one is treated as unknown, never rendered by the built-in card. The root is role="log"; each message is an <article> (data-role) wrapping every part in thread-part (data-type, and data-kind on a custom part). Messages are keyed by id and memoised (Thread.Message), parts by a stable internal key (the part's id, else type and index), so a stream re-renders only the message it appends to; a custom renderer reads the callbacks with useThreadCallbacks(). Slots: thread, thread-empty, thread-message, thread-part.
Hooks — @zuilib/ai/use-thread and friends
useThread({ initialMessages? })→{ messages, streaming, error, appendUserMessage(text), startAssistantMessage(), applyEvent(event), consume(run, { signal? }), decideApproval(id, outcome), decideDiff(id, decisions), reset(), dispatch }: a reducer overAssistantEvents (threadReducerfrom@zuilib/ai/thread-reducer).consumeopens an assistant message and drives a run throughconsumeRun; anapprovalevent waits fordecideApproval.consumeRun(run, apply, { signal?, onEvent?, waitForApproval })(@zuilib/ai/consume-run) drives anyRunResultand resolves with thedonesummary. Abort returns the iterator and resolves, even while an approval is still undecided; anerrorevent returns the iterator too (so an SSE reader is released) and then rejects.useToolCall(run)(@zuilib/ai/use-tool-call) →{ status, args, result, error, elapsedMs, start(args), abort(), reset() }for a client-side tool, props-compatible withToolCallCard.abort()cancels the run in flight and returns the status toqueued(keepingargs);reset()also clears the args and result.useApproval({ onApprove?, onReject? })(@zuilib/ai/use-approval) →{ decision, busy, error, approve(), reject(), reset() }.useDiffDecisions(hunks, original?, initial?)(@zuilib/ai/use-diff-decisions) →{ decisions, accept(i), reject(i), acceptAll(), rejectAll(), reset(), undecided, accepted, complete }.
Adapters — @zuilib/ai/adapters/*
Types only and duck-typed: nothing is imported from ai or @anthropic-ai/sdk. Subpath-only — none of these reach the root barrel.
ai-sdk:fromAISDKUIMessage(message)/fromAISDKUIMessagesmap aUIMessage(v5 parts; v4contentandtool-invocationtolerated) to a ZUIMessage;fromAISDKStream(chunks)maps UI message stream chunks (text-delta,tool-input-start,tool-input-available,tool-output-available,tool-output-error,source-url,error,finish) or v4 protocol lines (0:"…",9:{…},a:{…},3:"…",d:{…}) to events;createAISDKMapper()is the stateful chunk → events function;parseUIMessageStreamLinethe v4 line parser. Sources are gathered into onecitationevent beforedone.anthropic:fromAnthropicStream(events)mapscontent_block_start/content_block_delta(text_delta,input_json_delta) /content_block_stop/message_stop/error; atool_useblock is a queuedtool-callat start and a running one with parsedargsat stop.anthropicToolResult(toolUseId, result, { error?, elapsedMs? })is the event to push after your app ran the tool.fromAnthropicMessage(message)maps a finishedMessage(or a user turn withtool_resultblocks).createAnthropicMapper()is the stateful mapper.sse:parseSSE(source)reads aResponse, aReadableStream<Uint8Array>or an iterable of strings / bytes into{ event?, data, id?, retry? }messages;fromSSE(source, map?)maps them to events.defaultSSEMap:[DONE]→done; anerrorevent →error; JSON with a knowntype(theASSISTANT_EVENT_TYPESlist from@zuilib/ai/stream-events, checked against the union so a new variant cannot silently drop here) is the event as is —customincluded, so a server streams{"type":"custom","kind":"chart","data":…}with no custom map; JSONdelta/text/contentstrings, a JSON string, or plain text under no event /text/message→ a text delta; anything else is skipped.
StreamingMarkdown — @zuilib/ai/streaming-markdown
<StreamingMarkdown content={answerSoFar} streaming={!done} />
<StreamingMarkdown content={full} charactersPerSecond={160} sources={sources} />
const { text, done } = useStreamingText(source, { enabled: true, charactersPerSecond: 80 })| Prop | Type | Default |
|------|------|---------|
| content | string | required, the markdown so far; append as tokens arrive |
| streaming | boolean | false; caret after the last block, aria-busy, data-streaming |
| charactersPerSecond | number | reveal content at this many characters per second (a demo, smoothing a chunky stream); the caret stays until the reveal catches up |
| onLinkClick | (event, href: string) => void | called with the sanitised href; preventDefault() to route yourself |
| linkComponent | ComponentType<{ href, className, 'data-slot', children }> | renders every link (a router Link) instead of <a target="_blank"> |
| sources | CitationSource[] | [n] / [^n] markers render as Citation chips over sources[n - 1]; a marker with no source stays text; without the prop markers are plain text |
| citationAnchor | PopoverAnchor \| false | 'top'; false renders the popover in place |
| announce | boolean | true; a visually hidden role="status" region receives each completed sentence, then completeLabel |
| announceDelay | number | 400 ms a completed sentence waits before it is announced |
| completeLabel | string | 'Response complete' |
Renders ATX and setext headings, paragraphs, bold / italic / inline code, links, images (<img loading="lazy">; an unsafe src renders the alt text), fenced and indented code (data-language), nested ordered / unordered / task lists (an item's nested content is ListItem.blocks), tables with column alignment, blockquotes and rules; no raw HTML. The parser (parseMarkdown(source, { partial, citations }), parseMarkdownIncremental, parseInline(source, { citations }), inlineToText from @zuilib/ai/markdown-parser, pure) tolerates an unfinished document: with partial (set while busy) a lone | header | row is already a table and an unmatched trailing ** / ` / [ / ![ is plain text; once the stream ends a header row without its separator is a paragraph, as in GFM. Blocks are keyed by character offset and parsed incrementally from the last committed parse (read in render, written in an effect), so settled blocks keep their DOM.
The document is never a live region: announcing a growing tree re-reads it. Instead the status region speaks each sentence (., !, ? before whitespace, or a line break) once, debounced, as plain text. useStreamingText runs one interval for the life of the hook (per enabled / charactersPerSecond) and reads the latest source through a ref, so a growing source never restarts it; a source that is not an extension of the previous one restarts the reveal.
Links are untrusted model output: safeHref (@zuilib/ai/safe-href) allows http, https, mailto, tel and relative / #anchor targets; any other scheme (javascript:, data:, file:, protocol-relative //host) renders the link text as plain text with no href (streaming-markdown-link-text); the same filter applies to image src. A safe link is <a target="_blank" rel="noreferrer noopener">.
Slots: streaming-markdown (root), -heading, -paragraph, -code, -pre, -list, -list-item, -table-wrap, -table, -blockquote, -rule, -link, -link-text, -image, -image-text, -status, -caret; citation chips carry the citation slots.
ToolCallCard — @zuilib/ai/tool-call-card
<ToolCallCard name="crm.search" status="success" args={{ query: 'vantage' }} result={{ id: 'ACC-1051' }} elapsedMs={340} />
<ToolCallCard name="crm.update" status="error" args={{ id: 'ACC-1051' }} error="403: write access requires approval" />| Prop | Type | Default |
|------|------|---------|
| name | string | required |
| description | ReactNode | one line under the name |
| args | unknown | a flat object of primitives renders as key / value rows, anything else as pretty JSON |
| status | 'queued' \| 'running' \| 'success' \| 'error' | required; badge + icon; running sets aria-busy |
| result | unknown | string / number as text, element as is, else JSON; shown on success, or while running as a partial |
| maxResultLength | number | 20000 characters of a text / JSON result before a Show all toggle; the result section carries data-truncated while cut |
| error | ReactNode | shown on error |
| elapsedMs | number | milliseconds, formatted 340ms / 1.2s |
| defaultOpen | boolean | open on error only; a later transition to error also opens the card unless the human has toggled it or defaultOpen is set |
| open / onOpenChange | boolean / (open) => void | controlled |
| statusLabels | Partial<Record<status, string>> | Queued / Running / Done / Failed |
The header is a disclosure <button> (aria-expanded; aria-controls while the body exists). A closed body is unmounted, not hidden, so nothing inside it can take focus or be read. ToolCallValue({ value, slot }) and formatDuration(ms) are exported. Slots: tool-call-card (root, data-status, data-state), -header, -status-icon, -name, -description, -status, -duration, -chevron, -body, -args, -args-value, -result (data-truncated), -result-value, -result-toggle, -error.
ApprovalCard — @zuilib/ai/approval-card
<ApprovalCard title="Email the Vantage sponsor" description="A short note before the renewal." risk="medium" preview={<p>{draft}</p>} onApprove={send} onReject={dismiss} onEdit={openEditor} />
<ApprovalCard title="Roll back notifier" risk="high" expiresAt={Date.now() + 600_000} onApprove={run} onReject={skip} />
<ApprovalCard title="Delete the workspace" risk="high" confirm="type" confirmationPhrase="DELETE" onApprove={purge} onReject={skip} />| Prop | Type | Default |
|------|------|---------|
| title | ReactNode | required |
| description / preview | ReactNode | the proposal: a draft, a diff, a table |
| risk | 'low' \| 'medium' \| 'high' | 'low'; badge, border tint; high puts the approve button in the danger tone and defaults confirm to 'double' |
| confirm | 'none' \| 'double' \| 'type' \| 'hold' | 'double' for high, else 'none' |
| confirmationPhrase / holdMs | string / number | 'APPROVE' / 1200 ms |
| onApprove / onReject | () => void \| Promise<unknown> | required; a returned promise holds the card busy until it settles |
| onEdit | () => void | adds an Edit button |
| busy | boolean | false; approve spins, the others lock, keys ignored |
| decision | 'undecided' \| 'approved' \| 'rejected' \| 'expired' | 'undecided'; a decided card adds the outcome badge, locks its buttons and the keys, sets data-decision |
| expiresAt | Date \| number \| string | past it the card shows as expired on its own |
| labels | Partial<ApprovalCardLabels> | every text on the card, one key at a time: approve, reject, edit, confirm (the armed button), busy (announced with the spinner), riskLow / riskMedium / riskHigh, approved / rejected / expired. Defaults in DEFAULT_APPROVAL_CARD_LABELS |
Confirmation: double arms the approve button on the first activation (its label becomes labels.confirm, data-armed; Escape, blur or five seconds disarm it) and approves on the second; type shows a field (approval-card-confirm-input) and enables Approve only when it holds confirmationPhrase (Enter in the field approves); hold approves only after the button was held (pointer or Space) for holdMs, with a progress bar (approval-card-hold) while held. High risk never approves on a single Enter.
Keyboard: the root is a focusable role="group" labelled by its title and described by the description and the visually hidden hint. With focus anywhere inside it, Enter activates approve and Escape rejects, except: a text field (input, textarea, select, contenteditable) and a nested popup (dialog, menu, listbox, combobox) keep both keys; a button keeps Enter for itself and Escape still rejects. A rejecting Escape is preventDefaulted and stopped, so an enclosing dialog does not close on it. Slots: approval-card (root, data-risk, data-confirm, data-armed, data-busy, data-decision), -header, -title, -description, -risk, -decision, -preview, -confirm-input, -actions, -edit, -reject, -approve (data-armed, data-holding), -hold, -hint.
DiffReview — @zuilib/ai/diff-review
<DiffReview original={document} modified={proposal} onDecisionsChange={(decisions, text) => setAccepted(text)} />
<DiffReview mode="split" original={before} modified={after} originalLabel="Current" modifiedLabel="Assistant" />
<DiffReview patch={unifiedDiffFromServer} onAcceptHunk={apply} />
<DiffReview original={a} modified={b} computeDiff={(request) => diffWorker.run(request)} />| Prop | Type | Default |
|------|------|---------|
| original / modified | string | the two texts |
| hunks | DiffHunk[] | precomputed hunks (diffHunks, or your own); wins over patch and the texts |
| patch | string | a unified diff, parsed with parsePatch |
| computeDiff | (request: DiffComputeRequest) => Promise<DiffHunksResult> | computes the diff asynchronously (typically a Web Worker behind a promise); the card is data-loading / aria-busy until it resolves |
| mode | 'unified' \| 'split' | 'unified' |
| context | number | 3 equal lines around each change |
| originalLabel / modifiedLabel | string | 'Original' / 'Proposed' |
| decisions | Record<number, 'accepted' \| 'rejected'> | by hunk index; controlled. Omit it and the component keeps the decisions itself |
| onDecisionsChange | (decisions, accepted: string) => void | every change, controlled or not, with the text the decisions produce |
| onAcceptHunk / onRejectHunk | (index: number, hunk: DiffHunk) => void | per-hunk buttons appear when either, or onDecisionsChange, is given |
| onAcceptAll / onRejectAll | () => void | header buttons appear when either, or onDecisionsChange, is given |
| maxLines | number | 5000 lines per side handled by the exact LCS table; beyond it the Myers walk runs |
| hardMaxLines | number | 200000 lines per side beyond which the diff is one replace hunk and truncatedNotice shows |
| truncatedNotice | ReactNode | 'This diff is too large…' |
| lineNumbers | boolean | true |
Without computeDiff, the diff runs on deferred inputs (useDeferredValue): an urgent update (typing into the texts) renders with the previous hunks first and the recomputation follows; the root carries data-loading meanwhile. The accepted text is applyDecisions(original, hunks, decisions) when original is known and applyHunkDecisions(hunks, decisions) (the hunks' own lines) for hunks / patch.
The diff helpers are re-exported here and from @zuilib/ai/diff-engine: diffLines(a, b, options), diffHunks(a, b, context, options), the …WithInfo variants returning { lines | hunks, truncated, algorithm: 'lcs' | 'myers' | 'replace' }, myersDiff(aLines, bLines) (linear-space Myers O(ND): a minimal edit script in O(N + M) memory, an explicit work stack, removals listed before additions within a run), parsePatch(text), toHunks, toSplitRows, applyDecisions, applyHunkDecisions, DEFAULT_MAX_DIFF_LINES, DEFAULT_HARD_MAX_DIFF_LINES. In a container narrower than 384px (Tailwind's @sm container breakpoint: a phone column, an assistant dock) mode="split" renders the unified layout instead; data-mode reports the layout actually shown. Slots: diff-review (root, data-mode, data-algorithm, data-loading), -header, -summary, -actions, -accept-all, -reject-all, -truncated, -empty, -hunk (data-decision), -hunk-header, -hunk-decision, -hunk-accept, -hunk-reject, -table, -row, -cell (data-type).
StructuredOutputForm — @zuilib/ai/structured-output-form
const fields: StructuredField[] = [
{ name: 'company', label: 'Company', type: 'string', required: true },
{ name: 'seats', label: 'Seats', type: 'number' },
{ name: 'term', label: 'Term', type: 'enum', options: ['12', '24', '36'] },
{ name: 'enterprise', label: 'Enterprise plan', type: 'boolean' },
]
<StructuredOutputForm fields={fields} value={value} onValueChange={setValue} errors={errors} onSubmit={save} />| Prop | Type | Default |
|------|------|---------|
| fields | StructuredField[] | required: { name, label, type, options?, description?, required?, placeholder? }; type is 'string' \| 'number' \| 'boolean' \| 'enum' or any name a renderField handles; enum options are strings or { value, label } |
| value | Record<string, string \| number \| boolean \| null \| undefined> | required, controlled |
| onValueChange | (value, changedField: string) => void | required: the next whole value and the field that changed |
| onSubmit | (value) => void | |
| generated | string[] | every field with a value on mount: the fields wearing the Generated badge until edited |
| onFieldChange | (name: string) => void | first edit of a generated field |
| errors | Partial<Record<string, ReactNode>> | validation messages by field name, rendered under the control as a FieldError; the field is marked invalid through Field |
| renderField | (props: StructuredFieldControlProps) => ReactNode \| undefined | called for every field first: return a control ({ field, value, onValueChange, invalid, generated }) to take the field over — a date picker, a currency input — inside the standard Field, or undefined to keep the built-in |
| submitLabel / generatedLabel | string | 'Use these values' / 'Generated' |
| footer | ReactNode | before the submit button |
| showSubmit | boolean | true |
Each field is the matching control (Input, NumberInput, Switch, NativeSelect) inside a Field, so labels, descriptions, ids and aria-describedby are wired; an unknown type with no renderField falls back to the text input, so a schema from a newer producer stays editable. Slots: structured-output-form (root <form>), -field (data-type, data-generated), -generated, -error, -footer, -submit.
Citation — @zuilib/ai/citation
<p>At-risk MRR fell 8.5% <Citation index={1} source={sources[0]} />.</p>
<CitationList sources={sources} />| Prop | Type | Default |
|------|------|---------|
| index | number | required, the number in the chip |
| source | CitationSource | required: { id?, title, url?, snippet?, origin? } |
| anchor | PopoverAnchor \| false | 'top'; false renders the panel in place under the chip (keeps a subtree theme; an anchored panel portals to <body>) |
| className | string | merged onto the chip |
The chip is a real button named "Source n: title" opening a small Popover with the title (a link, target="_blank", when url is given), origin and snippet. The root is a <span> and the panel holds no block elements, so it is phrasing content and sits inside a <p> without a hydration warning. CitationList (Citation.List) takes sources: CitationSource[], title ('Sources') and className. Slots: citation (root), citation-chip, citation-panel, citation-index, citation-title, citation-origin, citation-snippet, citation-list, citation-list-title, citation-list-items, citation-list-item.
Reasoning — @zuilib/ai/reasoning
<Reasoning detail="Comparing usage against last quarter" />| Prop | Type | Default |
|------|------|---------|
| label | ReactNode | 'Working on it' |
| detail | ReactNode | what the model is doing right now, under the label |
| size | 'sm' \| 'md' | 'md' |
| icon | ReactNode | the spark; replaces it |
A role="status" aria-live="polite" region with a pulsing spark and three bouncing dots on the tokens keyframes, still under prefers-reduced-motion. Slots: reasoning (root, data-size), -icon, -text, -label, -detail, -dots, -dot.
Message model — @zuilib/ai/message-parts
Pure types shared by the widgets: Message is { id, role: 'user' | 'assistant' | 'system' | 'tool', createdAt?, parts: MessagePart[] } and MessagePart is a discriminated union whose members carry the props of the card that renders them: TextPart { type: 'text', text, streaming? } → StreamingMarkdown; ToolCallPart { type: 'tool-call', id, toolName, args?, status, description? } and ToolResultPart { type: 'tool-result', callId, result?, error?, elapsedMs? } → ToolCallCard; CitationPart { type: 'citation', sources } → CitationList; ApprovalPart { type: 'approval', id, title, description?, risk?, confirm?, preview?, decision?, expiresAt? } → ApprovalCard; DiffPart { type: 'diff', id, original?, modified?, hunks?, patch?, originalLabel?, modifiedLabel?, decisions? } → DiffReview; CustomPart { type: 'custom', kind, id?, data? } → the PartRenderers entry registered under kind (the open variant: the literal type keeps the union discriminated, kind carries the extension). @zuilib/ai/thread-reducer holds the pure folding: applyEvent(parts, event), finalizeStreamingText, decideApproval, decideDiff, toolResultFor, threadReducer, createMessageId and EMPTY_THREAD.
Icons (SparkIcon, ToolIcon, PencilIcon, ExternalLinkIcon, SendIcon, StopIcon, PaperclipIcon) are at @zuilib/ai/icons.
