Installation
npx shadcn@latest add @pixeldosa/ai-chat-experienceRequires `cn` at `@/lib/utils`. Installs the whole chat stack it composes. Keep `turns` in your state and update the latest assistant turn as your stream reports reasoning, tool calls, text and completion. Pass your markdown renderer as `renderText`.
Usage
"use client";
import { AIChatExperience } from "@/components/blocks/ai-chat-experience";
<AIChatExperience
className="h-dvh"
turns={turns}
onSend={({ text }) => send(text)}
onStop={abort}
onRetry={retry}
onContinue={resume}
onRegenerate={regenerate}
renderText={(text) => <Markdown>{text}</Markdown>}
empty={{ title: "What are we working on?", suggestions: starters }}
/>
Why it exists
Every part of an assistant screen exists as a component. What teams get wrong is how the parts behave together: two spinners at once, reasoning that keeps ticking under the answer, stale follow-ups from three questions ago, a Stop button that disappears, or a screen reader that reads every token. This block makes those decisions once.
What it decides
- One busy signal at a time. Agent Presence covers the gap before anything else exists. Then the reasoning trace or the running tool call says what's happening, and once the answer starts, the message's cursor takes over.
- Reasoning stops when the answer starts, whatever the stream says.
- Send becomes Stop exactly while the latest answer streams. You can still type the next question.
- Follow-ups belong to the latest finished answer. Older ones disappear. In
fillmode a follow-up lands in the composer to edit. - Code is safe mid-stream. Fenced code becomes Code Blocks, and Copy waits for the fence to close.
- Sources sit under the answer, or inline if you use
contentwith<Cite>. - Screen readers hear each answer once, when it finishes.
- Nothing is thrown away. Failed and stopped turns keep their text, with Retry or Continue.
Turn data
Your stream updates the latest assistant turn. Everything is plain data:
type ChatTurn = {
id: string;
role: "user" | "assistant";
text?: string; // fenced code becomes Code Blocks
content?: ReactNode; // replaces text, e.g. with <Cite> markup
status?: "streaming" | "done" | "failed" | "stopped";
error?: string;
reasoning?: { steps: string[]; streaming?: boolean; durationMs?: number };
toolCalls?: ToolCall[];
sources?: CitationSource[];
suggestions?: Suggestion[];
};
Examples
Mapping stream events, whatever your runtime:
on("reasoning", (step) => patch(id, (t) => ({ reasoning: { steps: [...(t.reasoning?.steps ?? []), step] } })));
on("tool-start", (call) => patch(id, (t) => ({ toolCalls: [...(t.toolCalls ?? []), { ...call, status: "running" }] })));
on("text", (chunk) => patch(id, (t) => ({ text: (t.text ?? "") + chunk })));
on("finish", () => patch(id, { status: "done" }));
on("error", (error) => patch(id, { status: "failed", error: error.message }));
With structured controls in the composer:
<AIChatExperience
turns={turns}
controls={[{ id: "tone", label: "Tone", options: ["Neutral", "Friendly"], defaultValue: "Neutral" }]}
onSend={({ text, values }) => send(text, values)}
/>
Props
| Prop | Type | Description |
|---|---|---|
turns | ChatTurn[] | Required. |
onSend | ({ text, values, attachments }) => void | Required. |
onStop | () => void | Shown as Stop while the latest turn streams. |
onRetry / onContinue / onRegenerate | (turnId) => void | |
onApplyCode | (code, language) => void | Promise<void> | Adds Apply to code blocks. |
suggestionMode | "fill" | "send" | Defaults to "fill". |
renderText | (text) => ReactNode | Your markdown renderer. |
assistantName | string | Defaults to "Assistant". |
empty | { title, description?, suggestions? } | Shown before the first turn. |
controls, attachments, onAttachFiles, onRemoveAttachment, placeholder | Passed to the composer. | |
onLoadOlder | () => void | Near the top of the history. |
headingLevel | 2 | 3 | 4 | 5 | Level of the empty-state title and of the Sources headings in answers. Defaults to 2. |
expression | "full" | "subtle" | "off" | How much of the agent shows. full adds its drawn head as the assistant's avatar, subtle shows the head only beside "Thinking", off uses dots. Defaults to "subtle". |
Accessibility
- The screen is a labelled section, and the conversation is a focusable, scrollable region.
- Each turn is an article, and turns are busy while streaming.
- One polite announcement per finished, failed or stopped answer.
- Everything else is inherited from the components it composes.
When not to use
For one agent run with questions and approvals rather than a conversation, use Thinking Experience. For generating a single artifact, use Prompt Composer with the result
shown directly.
why it's built this way
Engineering Notes
A Block, so an orchestration rather than new UI: every element is an existing component, and the contribution is the rules for combining them. Busy signals are exclusive. `presenceFor` shows Agent Presence only in the gaps where nothing else reports progress (before reasoning or tools exist, or between the last tool and the first token). A streaming reasoning trace or running tool call already says what is happening, and once text exists the Message's quantized cursor takes over, because two simultaneous 'working' indicators read as noise and make neither trustworthy. Reasoning is forced to stop streaming once answer text arrives, regardless of what the reasoning data says, since a ticking trace under a streaming answer misreports what the model is doing. `busy` derives from the latest assistant turn alone, so Send becomes Stop for exactly that window. The composer is controlled by the block, which lets a `fill` suggestion write into it while the user keeps editing during a stream. Suggestions render only on the latest assistant turn in 'done' status, and older turns' suggestions are hidden, so stale follow-ups never compete with current ones. Text goes through `splitCodeFences`, so an unclosed fence streams as a Code Block. Screen reader announcements are derived by diffing each turn's previous status: a transition out of 'streaming' produces one message (replied with a summary, failed, or stopped) and never a token stream. Turn data is plain objects, so it maps from any runtime (AI SDK, LangGraph, a custom SSE stream) without a dependency.
how it moves, and when it doesn't
Motion Notes
Inherited, not invented. The block adds no motion of its own. Agent Presence's line form moves while the agent thinks or works. Reasoning Stream and Tool Call Group shimmer only their verbs while running. The Message cursor pulses on the shared cell keyframe while text streams. Suggestions arrive with their staggered fade once an answer is done. The Message Scroller follows instantly while pinned and scrolls smoothly only on 'Jump to latest'. Because each part applies the same tokens and the turn-taking grammar, the whole screen has one rhythm: motion while the machine works, stillness when it's the user's turn. Every part handles prefers-reduced-motion itself, so the block needs no separate fallback.