Chatbot
A complete chat app surface: streaming Markdown, reasoning, tool calls with approvals, message actions, an empty state and a composer with attachments, model picker and reasoning effort. One install, a few lines to wire to AI SDK.
On this page
The demo runs real AI SDK useChat on a mock transport, so it needs no API key. Send any message to play the script again. Approve the edit to watch the tests run, or deny it to get the patch instead.
Installation
The block installs to components/chatcn/chatbot/chatbot.tsx together with every component it composes and the useChatcnChat hook. It is your code from here: change the header, drop the model picker, add a sidebar.
Wire it to AI SDK
Pass the whole useChatcnChat result as chat. It is useChat from AI SDK with chatcn messages and actions attached, and approvals resume the run on their own.
"use client"
import { DefaultChatTransport } from "ai"
import { Chatbot } from "@/components/chatcn/chatbot/chatbot"
import { useChatcnChat } from "@/hooks/use-chatcn-chat"
export default function Page() {
const chat = useChatcnChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
})
return (
<Chatbot
chat={chat}
fullHeight
suggestions={["Find why the runs page is slow", "Write tests for getRuns"]}
/>
)
}The route handler is plain AI SDK. Tools with needsApproval show Allow and Deny in the thread; the answer goes back through addToolApprovalResponse and the run continues.
import { anthropic } from "@ai-sdk/anthropic"
import { convertToModelMessages, isStepCount, streamText, tool, type UIMessage } from "ai"
import { z } from "zod"
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json()
const result = streamText({
model: anthropic("claude-sonnet-4-5"),
messages: await convertToModelMessages(messages),
stopWhen: isStepCount(8),
tools: {
read_file: tool({
description: "Read a file from the repository",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => readFile(path),
}),
edit_file: tool({
description: "Replace text in a file",
inputSchema: z.object({ path: z.string(), old: z.string(), new: z.string() }),
needsApproval: true,
execute: async (input) => editFile(input),
}),
},
})
return result.toUIMessageStreamResponse({ sendReasoning: true })
}Model and reasoning effort
The picker and the effort control are UI only. Keep their values in state and send them with each request through the transport body, which AI SDK reads at send time.
const [model, setModel] = useState("claude-sonnet-4-5")
const [effort, setEffort] = useState("medium")
const settings = useRef({ model, effort })
settings.current = { model, effort }
const chat = useChatcnChat({
transport: new DefaultChatTransport({ api: "/api/chat", body: () => settings.current }),
})
<Chatbot
chat={chat}
models={models}
model={model}
onModelChange={setModel}
reasoningEffort={effort}
onReasoningEffortChange={setEffort}
/>Without AI SDK
Any runtime works. Pass messages in the chatcn contract, a status and the actions you support; missing actions hide their controls (no regenerate, no Retry button).
<Chatbot
messages={messages}
status={status}
actions={{ send, stop, approve, deny }}
onNewChat={reset}
/>Examples
Empty state
A heading, the composer and up to four starters. Picking one sends it. On the first send the composer glides from the middle to the bottom of the new thread, so it reads as one object moving. On phones the composer stays at the bottom and the starters scroll in one row above it.
What it handles
| Moment | What the block shows |
|---|---|
| Empty | EmptyState heading and description, the composer, Suggestions |
| Sent, no tokens yet | ThinkingIndicator: nothing for 300 ms, then Thinking for at least 400 ms |
| Reasoning | Reasoning: Thinking with a live summary, then Thought for 4 s |
| Tool calls | Each tool's own renderer through renderToolPart, unknown tools fall back |
| Approval | Allow, Always allow and Deny with a note, wired to approve and deny |
| Answer | Streaming Markdown with code blocks, tables and math |
| Done | Copy, thumbs up or down with reasons, and Retry response |
| Stopped | Unfinished tools show Cancelled and the message says Stopped |
| Error | MessageError matched to the cause (network, rate limit, context, filter) with Retry |
Props
| Prop | Type | Notes |
|---|---|---|
chat | ChatbotChat | The useChatcnChat() result: messages, status, error, actions |
messages | ChatcnMessage[] | Overrides chat. AI SDK messages work as is |
status | ChatbotStatus | ready, submitted, streaming or error, as in useChat |
error | Error | Shown under the last message with the fix |
actions | ChatcnActions | send, stop, regenerate, approve, deny. Missing ones hide |
title | ReactNode | Defaults to New chat, then the first line of the first message |
header | ReactNode | Replaces the header. null renders none |
headerActions | ReactNode | Extra controls before New chat |
footer | ReactNode | Under the composer: a note, usage or a hint |
onNewChat | () => void | Defaults to stopping and clearing chat messages |
emptyTitle | ReactNode | Empty state heading |
emptyDescription | ReactNode | One line under the heading |
suggestions | ChatbotSuggestion[] | Strings or { label, prompt, icon }. Up to four |
models | ModelOption[] | No picker without models |
providers | ModelProvider[] | Group names and order |
model | string | Controlled model, with onModelChange |
defaultModel | string | Uncontrolled starting model |
reasoningEffort | string | Controlled effort, with onReasoningEffortChange |
defaultReasoningEffort | string | Uncontrolled. Any effort prop shows the control |
placeholder | string | Defaults to Ask anything… |
attachments | boolean | Paste, drop or pick files. On by default |
accept | string | Same syntax as <input accept> |
maxFileSize | number | Bytes. Defaults to 20 MB |
uploadFile | function | (file, onProgress) => Promise<{ url }>. Otherwise files go inline as data URLs |
toolRenderers | ToolRendererMap | Defaults to every chatcn tool renderer |
toolVariant | inline or card | Inline keeps long traces quiet |
onFeedback | function | (messageId, value, reasons?) after a thumbs up or down |
fullHeight | boolean | Fill the viewport and stay above the keyboard |
autoFocus | boolean | Focus the composer on mount |
Keyboard
| Key | Action |
|---|---|
Enter | Send. Never while an IME is composing |
Shift Enter | New line |
Esc in the composer | Stop generating |
⌘ Shift O (Ctrl Shift O) | New chat |
Tab | Through the thread, message actions, approvals and the composer controls |
| Arrow keys in Low, Medium, High | Change reasoning effort, without animation |
Accessibility
- The thread is a focusable
logthat stays silent while tokens stream. Each finished answer is read once through a polite live region. - Thinking is a
status, so screen readers hear it without it taking focus. - Approvals are real buttons with a reason above them; denial never relies on color.
- Errors use
role=alertand say how to fix the problem, with the raw detail behind a disclosure. - Every icon-only control has a label and a tooltip. Touch screens get 44 px targets.
- Use
fullHeightfor a full-page chat: the composer stays above the on-screen keyboard and clear of the home indicator.
Motion
- On the first send the composer moves from the empty state to the bottom over 280 ms with the default ease-out. Reduced motion skips it.
- Starters stagger in 40 ms apart, once. Messages fade in; restored history appears at once.
- Send turns into the stop square in the same footprint with a 150 ms blur crossfade.
- Tokens append with no per-word effects. The thread sticks to the bottom only while you are there.