Chat Thread
block / ai
A provider-agnostic AI chat: a log of turns with streaming replies, tool calls as collapsible blocks, Stop, Retry in place and a composer — driven by five events you map any model stream onto, with Markdown rendered as a tree and never as HTML.
- type
- block
- files
- 2
- registry deps
- button, textarea, skeleton
- npm deps
- lucide-react
installs
- lib/automindz/chat.ts
- components/automindz/chat-thread.tsx
- Five events; map any provider stream onto them in onSend
- Streaming, tool calls, Stop and Retry in place — all owned by the component
- Markdown rendered as a tree, never HTML; unsafe hrefs dropped
Wire one thing: onSend(text, { signal, messages }), returning an AsyncIterable<ChatEvent> — five events, in lib/automindz/chat.ts: { type: "text", delta }, { type: "tool_call", id, name, input? }, { type: "tool_result", id, output?, ok?, reason? }, { type: "error", reason }, { type: "done" }. The file's header maps each provider onto them in a line: Anthropic content_block_delta/tool_use/message_stop, OpenAI delta.content/delta.tool_calls/finish_reason, Vercel AI fullStream part types. Pass the signal to your fetch or SDK call: Stop aborts it, and events from a stream that ignores it are dropped after that moment so a stopped turn cannot keep growing. Throw from your iterator to fail the turn — it is rendered in place as a role="alert" with the error's message and a Retry that re-sends the same user message and **replaces** the failed turn; there is no toast. The component owns the rest: the user turn, the streaming assistant turn (aria-busy so assistive tech waits for the whole message), the stopped label, done when your iterator ends even if you never emit it. **Messages are parts, not strings** — a tool call sits where it happened, as a native <details> named after the tool with Running / Done / Failed in words and the JSON input and output inside (truncated past 2 000 characters). **Markdown is a tree, never HTML**: paragraphs, fenced code (an unterminated fence is already code while the model types it), flat lists, inline code, bold and links whose hrefs are http(s):, mailto: or relative — anything else keeps the label and loses the href, because model output is untrusted, and there is no dangerouslySetInnerHTML to sanitise. Headings, tables and nested lists are not parsed; pass renderContent(text) to swap in a full renderer. initialMessages seeds history and onMessagesChange fires after every change (persist from there); it is uncontrolled after mount. label names the role="log". States: loading draws message-shaped skeletons at real heights with the composer disabled; no messages renders emptyTitle/emptyDescription with suggestions as buttons that send; disabled with disabledReason blocks sending, says why under the field via aria-describedby, and leaves the history readable. The log sticks to the bottom only while the reader is already there; scrolled up, a Jump to latest button appears instead. Enter sends, Shift+Enter breaks a line, IME composition is respected, and while streaming the Send button *is* Stop. Copy on a finished reply puts the text parts on the clipboard, tools excluded. Default height is h-[28rem]; override with className. No stylesheet, no npm dependency beyond lucide-react; every spinner and the caret carry motion-reduce: alternatives.
Variations
one state per section
- 01A thread with a tool call in it
- 02Empty, with suggestions
- 03History still loading
- 04A reply that fails, and the retry that fixes it
- 05Stopped mid-reply
- 06A long thread: sticking to the bottom, and not
- 07Disabled, with the reason
- 08Under reduced motion
A thread with a tool call in it
The seeded turn shows the shape: text before the call, the call as a collapsed <details> named after the tool with its status in words, the answer after it — parts in the order they happened, never flattened into one string. Type anything and the reply streams through the same onSend contract a real adapter fills: a short sentence, a search_docs call that runs for a second, its result, then Markdown arriving a word at a time with the fenced block already rendered as code before its closing fence lands. Copy appears under a finished reply and puts the text parts on the clipboard, tools excluded.
Let me check the checkout route.
search_docsDone
{
"query": "checkout price validation",
"limit": 3
}{
"hits": [
{
"path": "app/api/checkout/route.ts",
"score": 0.92
}
]
}The route only accepts Price IDs that already exist in lib/automindz/pricing.ts, so a tampered client cannot check out an arbitrary price:
const allowed = new Set(
PRICING.flatMap((tier) => [tier.monthlyPriceId, tier.yearlyPriceId])
)
if (!allowed.has(body.priceId)) {
return new Response("Unknown price", { status: 400 })
}Two more things it decides for you:
- Quantity is clamped to 100, so nobody buys ten thousand seats by editing a request.
- Success and cancel paths must start with
/— see the registry contract for the reasoning.
Empty, with suggestions
No messages is a state with its own copy, not a blank log, and suggestions are real buttons that send — the fastest way to a first turn and the only affordance a reader who does not know what to ask has. This is distinct from loading (below) and from a failed load, which would be your own error boundary: the component has no data to fail at.
Ask the registry
Pick a prompt or type your own. The reply here is scripted, so nothing leaves the page.
History still loading
Two turns' worth of skeleton at the real heights — a user bubble, a three-line reply, a user bubble — with the composer disabled and the log marked aria-busy, so when the thread lands nothing under it moves and nobody has typed into a conversation that was not there yet.
A reply that fails, and the retry that fixes it
The stream throws after six words. The turn keeps what arrived, turns into a role="alert" box with the error's own message, and offers Retry **in place** — which re-sends the same user message and replaces the failed turn rather than appending a second attempt under it. Here the scripted stream succeeds on the second call; a real one would too if the failure was a 529. Nothing here is a toast, because the failure belongs to the turn it happened in.
The first reply fails
The scripted stream throws after six words; press Retry and it completes.
Stopped mid-reply
While a reply streams the Send button becomes Stop, because a disabled Send beside a new Stop puts two controls where one action makes sense. Press it: the AbortSignal handed to onSend fires, the scripted sleeps reject, the caret goes, the words that had landed stay, and the turn is labelled Stopped rather than deleted. Events from a stream that ignores the signal are dropped after that moment, so a stopped turn cannot keep growing.
Press Stop while it streams
Eleven words a second, six paragraphs. Send, then press the square.
A long thread: sticking to the bottom, and not
The log opens scrolled to the latest turn. Scroll up to re-read something and send a message: the thread does **not** pull you back down — a chat that yanks the scroll position while you are reading is a chat you cannot read — and a Jump to latest button appears over the log instead. Scroll to the bottom yourself and the stickiness resumes.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
It renders the error variant of empty-state in place of the rows, with the reason and a retry, so the table never reads as empty when it is actually broken. Loading, empty and no-results are three separate states with three separate messages.
Disabled, with the reason
disabled alone is a grey composer and a question; disabledReason answers it, under the field, linked with aria-describedby. The history stays readable and copyable — the block is on sending, not on reading.
Let me check the checkout route.
search_docsDone
{
"query": "checkout price validation",
"limit": 3
}{
"hits": [
{
"path": "app/api/checkout/route.ts",
"score": 0.92
}
]
}The route only accepts Price IDs that already exist in lib/automindz/pricing.ts, so a tampered client cannot check out an arbitrary price:
const allowed = new Set(
PRICING.flatMap((tier) => [tier.monthlyPriceId, tier.yearlyPriceId])
)
if (!allowed.has(body.priceId)) {
return new Response("Unknown price", { status: 400 })
}Two more things it decides for you:
- Quantity is clamped to 100, so nobody buys ten thousand seats by editing a request.
- Success and cancel paths must start with
/— see the registry contract for the reasoning.
Under reduced motion
Turn on Reduce Motion and reload: the caret is a still block instead of a pulse, the tool's Running spinner is a still glyph, and nothing else in the item moves — text arriving is content, not animation, and it arrives the same. There is no stylesheet to import and nothing here needs one.
Let me check the checkout route.
search_docsDone
{
"query": "checkout price validation",
"limit": 3
}{
"hits": [
{
"path": "app/api/checkout/route.ts",
"score": 0.92
}
]
}The route only accepts Price IDs that already exist in lib/automindz/pricing.ts, so a tampered client cannot check out an arbitrary price:
const allowed = new Set(
PRICING.flatMap((tier) => [tier.monthlyPriceId, tier.yearlyPriceId])
)
if (!allowed.has(body.priceId)) {
return new Response("Unknown price", { status: 400 })
}Two more things it decides for you:
- Quantity is clamped to 100, so nobody buys ten thousand seats by editing a request.
- Success and cancel paths must start with
/— see the registry contract for the reasoning.
