Skip to main content

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: textStreamingMarkdown, tool-callToolCallCard (fed by its tool-result), approvalApprovalCard, diffDiffReview, citationCitationList. 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.

Loading example

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.

Loading example

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 any RunResult on its own. Abort settles it even while an approval is waiting; an error event closes the iterator (releasing an SSE reader) before it rejects.
  • useToolCall(run){status, args, result, error, elapsedMs, start(args), abort(), reset()}, the props a ToolCallCard takes. abort() cancels the run in flight and returns the status to queued with args kept; 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

PropTypeDefaultDescription
messagesrequiredMessage[]
renderersPartRenderersOverrides of DEFAULT_PART_RENDERERS by part type; any other key renders custom parts of that kind
renderUnknownPart(part: CustomPart) => ReactNodeRendered 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
roleLabelsPartial<Record<role, string>>You / Assistant / System / Tool
emptyStateReactNodeShown when there are no messages
children(items: {id, node}[]) => ReactNodeRender the wrapped messages yourself
messageClassName / classNamestring

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.