Thread
A conversation is a list of Messages and each message is a
list of parts. Thread maps every part to the card that renders it,
so the composition is one component and one map: text →
StreamingMarkdown, tool-call → ToolCallCard (fed by its
tool-result), approval → ApprovalCard, diff → DiffReview,
citation → CitationList. Override any entry with renderers — or
add your own part types through it (see
custom parts).
import Thread from '@zuilib/ai/thread'
import {useThread} from '@zuilib/ai/use-thread'
From an event stream
useThread is a reducer over AssistantEvents. appendUserMessage appends
the question, consume(run) opens an assistant message and folds every
event of the run into it; an approval event pauses the run until
decideApproval(id, outcome), which the card inside the thread calls through
onApprovalDecision.
From messages you already have
messages is the whole model; nothing else is required. A message from a
store, a server component or an adapter
(fromAISDKUIMessage, fromAnthropicMessage) renders the same way.
Renderers and virtualisation
renderers overrides one or more entries of DEFAULT_PART_RENDERERS; a
renderer receives {part, message, index} and can read the thread's
callbacks with useThreadCallbacks(). Messages are keyed by id and
memoised (Thread.Message), parts by partKey (the part's own id
when it has one, else its type and index), so a stream re-renders only
the message it appends to. For a long conversation give children a
function: it receives every message already wrapped and keyed, in order,
to hand to a virtualiser.
<Thread messages={messages} renderers={{text: MyMarkdown}}>
{(items) => <Virtualizer items={items} render={(item) => item.node} />}
</Thread>
Custom parts
The part union is open: a {type: 'custom', kind, id?, data?} event
(CustomPartEvent) folds into a CustomPart, and the renderers entry
registered under its kind renders it — adding a part type is a map
entry, not a fork of the library. An event with the same id replaces
the part in place, so a server can stream a growing payload. A kind
with no renderer is skipped (or handed to renderUnknownPart), so an old
client survives a newer stream. The built-in type keys — text,
tool-call, tool-result, approval, diff, citation, exported as
BUILTIN_PART_TYPES — are reserved: a custom part whose kind names one is
never rendered by the built-in card (its shape would not match); it is
treated as unknown instead. The same renderers map threads through
AssistantDock into every task. defaultSSEMap already passes
{"type":"custom",...} JSON through, so the whole path is one renderer
away:
const ChartPart = ({part}) => <MiniChart {...part.data} />
<Thread messages={messages} renderers={{chart: ChartPart}} />
// server: data: {"type":"custom","kind":"chart","id":"c1","data":{"rows":[…]}}
Hooks
useThread({initialMessages?})→{messages, streaming, error, appendUserMessage, startAssistantMessage, applyEvent, consume, decideApproval, decideDiff, reset, dispatch}.consumeRun(run, apply, {signal?, onEvent?, waitForApproval})drives anyRunResulton its own. Abort settles it even while an approval is waiting; anerrorevent closes the iterator (releasing an SSE reader) before it rejects.useToolCall(run)→{status, args, result, error, elapsedMs, start(args), abort(), reset()}, the props aToolCallCardtakes.abort()cancels the run in flight and returns the status toqueuedwithargskept;reset()clears everything.useApproval({onApprove?, onReject?})→{decision, busy, error, approve(), reject(), reset()}.useDiffDecisions(hunks, original?, initial?)→{decisions, accept, reject, acceptAll, rejectAll, reset, undecided, accepted, complete}.
The pure fold is @zuilib/ai/thread-reducer: applyEvent(parts, event),
finalizeStreamingText, decideApproval, decideDiff, toolResultFor,
threadReducer and EMPTY_THREAD, usable in a store or on the
server.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
messagesrequired | Message[] | — | |
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 | — | |
onDecisionsChange | (id, decisions, accepted: string, message) => void | — | |
roleLabels | Partial<Record<role, string>> | You / Assistant / System / Tool | |
emptyState | ReactNode | — | Shown when there are no messages |
children | (items: {id, node}[]) => ReactNode | — | Render the wrapped messages yourself |
messageClassName / className | string | — |
The root is role="log"; each message is an <article> labelled by its
role (data-role), each part a thread-part (data-type). Slots:
thread, thread-empty, thread-message, thread-part.