registry
@automindzsheets 01–77shadcn 4.x
SHEET 56

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

01

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.

fluid
You
How does the checkout route stop someone buying the wrong price?
Assistant

Let me check the checkout route.

search_docsDone
Input
{
  "query": "checkout price validation",
  "limit": 3
}
Output
{
  "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.
02

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.

fluid

Ask the registry

Pick a prompt or type your own. The reply here is scripted, so nothing leaves the page.

03

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.

fluid
04

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.

fluid

The first reply fails

The scripted stream throws after six words; press Retry and it completes.

05

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.

fluid

Press Stop while it streams

Eleven words a second, six paragraphs. Send, then press the square.

06

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.

fluid
You
Question 1: what does `data-table` do when a load fails?
Assistant

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.

You
Question 2: what does `data-table` do when a load fails?
Assistant

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.

You
Question 3: what does `data-table` do when a load fails?
Assistant

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.

You
Question 4: what does `data-table` do when a load fails?
Assistant

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.

You
Question 5: what does `data-table` do when a load fails?
Assistant

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.

You
Question 6: what does `data-table` do when a load fails?
Assistant

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.

You
Question 7: what does `data-table` do when a load fails?
Assistant

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.

07

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.

fluid
You
How does the checkout route stop someone buying the wrong price?
Assistant

Let me check the checkout route.

search_docsDone
Input
{
  "query": "checkout price validation",
  "limit": 3
}
Output
{
  "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.

You have used this month's 200 messages. Upgrade to keep going, or wait until 1 October.

08

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.

fluid
You
How does the checkout route stop someone buying the wrong price?
Assistant

Let me check the checkout route.

search_docsDone
Input
{
  "query": "checkout price validation",
  "limit": 3
}
Output
{
  "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.