"use client";
import * as React from "react";
import {
ArrowPathIcon,
ArrowUpIcon,
BoltIcon,
DocumentDuplicateIcon,
HandThumbDownIcon,
HandThumbUpIcon,
PaperClipIcon,
SparklesIcon,
StopIcon,
TrashIcon,
XMarkIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
FeedbackBar,
FeedbackBarAction,
FeedbackBarActions,
FeedbackBarClose,
FeedbackBarContent,
FeedbackBarLabel,
FeedbackBarPrompt,
} from "@/components/pandacoderz-ui/feedback-bar";
import {
Message,
MessageAction,
MessageActionGroup,
MessageActions,
MessageAvatar,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/pandacoderz-ui/message";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorSearch,
ModelSelectorTrigger,
} from "@/components/pandacoderz-ui/model-selector";
import {
PromptInput,
PromptInputAction,
PromptInputActionGroup,
PromptInputActions,
PromptInputTextarea,
} from "@/components/pandacoderz-ui/prompt-input";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/pandacoderz-ui/reasoning";
import {
Suggestion,
SuggestionList,
Suggestions,
} from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import {
Thread,
ThreadContent,
ThreadScrollToBottom,
} from "@/components/pandacoderz-ui/thread";
import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
ToolTrigger,
} from "@/components/pandacoderz-ui/tool";
import { mockTransport } from "./mock-transport";
import type { ChatMessage, ChatModel, ChatTransport } from "./types";
import { messageText } from "./types";
import { useChat } from "./use-chat";
const Sparkles = ({ className }: { className?: string }) => (
<SparklesIcon size={16} className={className} />
);
const Bolt = ({ className }: { className?: string }) => (
<BoltIcon size={16} className={className} />
);
const defaultModels: ChatModel[] = [
{ value: "claude-fable-5-1", title: "Claude Fable 5.1", description: "Most capable", icon: Sparkles },
{ value: "claude-opus-5", title: "Claude Opus 5", description: "Deep reasoning", icon: Sparkles },
{ value: "claude-sonnet-5", title: "Claude Sonnet 5", description: "Balanced", icon: Bolt },
{ value: "claude-haiku-4-5", title: "Claude Haiku 4.5", description: "Fastest", icon: Bolt },
];
const defaultSuggestions = [
"How do Astro islands work?",
"Should I stream responses?",
"Run the tests",
"What is this library?",
];
export type AIChatProps = {
/** Produces chat events. Defaults to a scripted mock that never hits the network. */
transport?: ChatTransport;
models?: ChatModel[];
defaultModel?: string;
suggestions?: string[];
/** Title shown in the header. */
title?: string;
className?: string;
};
export default function AIChat({
transport = mockTransport,
models = defaultModels,
defaultModel = models[0]?.value ?? "",
suggestions = defaultSuggestions,
title = "Assistant",
className,
}: AIChatProps) {
const [model, setModel] = React.useState(defaultModel);
const [draft, setDraft] = React.useState("");
const [feedback, setFeedback] = React.useState<"hidden" | "shown" | "done">("hidden");
const { messages, status, send, stop, regenerate, clear } = useChat({
transport,
model,
});
const isStreaming = status === "streaming";
const isEmpty = messages.length === 0;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
React.useEffect(() => {
if (lastAssistant?.status === "done" && feedback === "hidden") {
setFeedback("shown");
}
}, [lastAssistant?.status, feedback]);
const submit = React.useCallback(
(text: string) => {
if (isStreaming) return;
send(text);
setDraft("");
},
[isStreaming, send],
);
return (
<div
data-slot="ai-chat"
className={cn(
"flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs",
className,
)}
>
<header className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground">
<SparklesIcon size={14} className="flex" />
</span>
{title}
</div>
<div className="flex items-center gap-1">
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="ghost" />
<ModelSelectorContent className="w-72" align="end">
<ModelSelectorSearch placeholder="Search models" />
<ModelSelectorEmpty />
<ModelSelectorGroup>
<ModelSelectorLabel>Models</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
title={m.title}
description={m.description}
icon={m.icon}
disabled={m.disabled}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Clear conversation"
onClick={() => {
clear();
setFeedback("hidden");
}}
disabled={isEmpty}
>
<TrashIcon size={16} className="flex" />
</Button>
</div>
</header>
<div className="relative min-h-0 flex-1">
{isEmpty ? (
<EmptyState suggestions={suggestions} onSelect={submit} />
) : (
<Thread className="h-full">
<ThreadContent className="mx-auto max-w-3xl">
{messages.map((m) => (
<ChatTurn
key={m.id}
message={m}
isLast={m.id === messages[messages.length - 1]?.id}
onRegenerate={regenerate}
canRegenerate={!isStreaming}
/>
))}
{feedback === "shown" && !isStreaming ? (
<div className="mx-auto w-full max-w-[90%]">
<FeedbackBar>
<FeedbackBarContent>
<FeedbackBarPrompt>
<FeedbackBarLabel>Was this response helpful?</FeedbackBarLabel>
</FeedbackBarPrompt>
<FeedbackBarActions>
<FeedbackBarAction asChild tooltip="Good">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Good" onClick={() => setFeedback("done")}>
<HandThumbUpIcon size={16} className="flex" />
</Button>
</FeedbackBarAction>
<FeedbackBarAction asChild tooltip="Bad">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Bad" onClick={() => setFeedback("done")}>
<HandThumbDownIcon size={16} className="flex" />
</Button>
</FeedbackBarAction>
</FeedbackBarActions>
<FeedbackBarClose tooltip="Dismiss">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Dismiss" onClick={() => setFeedback("done")}>
<XMarkIcon size={16} className="flex" />
</Button>
</FeedbackBarClose>
</FeedbackBarContent>
</FeedbackBar>
</div>
) : null}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
)}
</div>
<div className="shrink-0 px-4 pb-4 pt-2">
<div className="mx-auto max-w-3xl">
<PromptInput onSubmit={submit}>
<PromptInputTextarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={isStreaming ? "Generating…" : "Ask anything"}
/>
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip="Attach files">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Attach files">
<PaperClipIcon size={16} className="flex" />
</Button>
</PromptInputAction>
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="ghost" className="h-8 text-muted-foreground" />
<ModelSelectorContent className="w-72" align="start" side="top">
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
title={m.title}
description={m.description}
icon={m.icon}
disabled={m.disabled}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorContent>
</ModelSelector>
</PromptInputActionGroup>
<PromptInputActionGroup>
{isStreaming ? (
<PromptInputAction asChild tooltip="Stop">
<Button size="icon-sm" variant="secondary" className="rounded-full" aria-label="Stop" onClick={stop}>
<StopIcon size={16} className="flex" />
</Button>
</PromptInputAction>
) : (
<PromptInputAction asChild tooltip={{ content: "Send", shortcut: "↵" }}>
<Button
size="icon-sm"
className="rounded-full"
aria-label="Send"
disabled={!draft.trim()}
onClick={() => submit(draft)}
>
<ArrowUpIcon size={16} className="flex" />
</Button>
</PromptInputAction>
)}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
<p className="mt-2 text-center text-xs text-muted-foreground">
Responses are scripted for this demo. Plug in a transport to go live.
</p>
</div>
</div>
</div>
);
}
function EmptyState({
suggestions,
onSelect,
}: {
suggestions: string[];
onSelect: (value: string) => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-brand-soft text-brand">
<SparklesIcon size={22} className="flex" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-medium tracking-tight">How can I help today?</h2>
<p className="text-sm text-muted-foreground">
Pick a suggestion or type your own question.
</p>
</div>
<Suggestions onSelect={onSelect}>
<SuggestionList>
{suggestions.map((s) => (
<Suggestion key={s}>{s}</Suggestion>
))}
</SuggestionList>
</Suggestions>
</div>
);
}
function ChatTurn({
message,
isLast,
onRegenerate,
canRegenerate,
}: {
message: ChatMessage;
isLast: boolean;
onRegenerate: () => void;
canRegenerate: boolean;
}) {
const [copied, setCopied] = React.useState(false);
if (message.role === "user") {
return (
<Message from="user">
<MessageStack>
<MessageContent>{messageText(message)}</MessageContent>
</MessageStack>
</Message>
);
}
const isStreaming = message.status === "streaming";
const hasText = message.parts.some((p) => p.type === "text");
return (
<Message from="assistant">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack>
{message.parts.map((part, i) => {
if (part.type === "reasoning") {
return (
<Reasoning key={i} isStreaming={!part.done} className="px-2">
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type === "tool") {
return (
<div key={part.id} className="px-2">
<Tool status={part.status}>
<ToolTrigger name={part.name} />
<ToolContent>
<ToolInput payload={part.input} />
<ToolOutput
payload={part.output ?? null}
showWhen={["completed", "error"]}
errorText={part.errorText}
/>
</ToolContent>
</Tool>
</div>
);
}
return (
<MessageContent key={i}>
<MessageMarkdown isAnimating={isStreaming}>{part.text}</MessageMarkdown>
</MessageContent>
);
})}
{isStreaming && !hasText ? (
<div className="px-2">
<TextShimmer className="text-sm text-muted-foreground" invertLight spread={12}>
{message.parts.length === 0 ? "Thinking…" : "Writing…"}
</TextShimmer>
</div>
) : null}
{!isStreaming && hasText ? (
<MessageActions className="px-1">
<MessageActionGroup>
<MessageAction asChild tooltip={copied ? "Copied" : "Copy"}>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Copy"
onClick={async () => {
try {
await navigator.clipboard.writeText(messageText(message));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
>
<DocumentDuplicateIcon size={16} className="flex" />
</Button>
</MessageAction>
{isLast ? (
<MessageAction asChild tooltip="Regenerate">
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Regenerate"
onClick={onRegenerate}
disabled={!canRegenerate}
>
<ArrowPathIcon size={16} className="flex" />
</Button>
</MessageAction>
) : null}
<MessageAction asChild tooltip="Good response">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Good response">
<HandThumbUpIcon size={16} className="flex" />
</Button>
</MessageAction>
<MessageAction asChild tooltip="Bad response">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Bad response">
<HandThumbDownIcon size={16} className="flex" />
</Button>
</MessageAction>
</MessageActionGroup>
</MessageActions>
) : null}
</MessageStack>
</Message>
);
}"use client";
import * as React from "react";
import type {
ChatEvent,
ChatMessage,
ChatTransport,
MessagePart,
ToolPart,
} from "./types";
let idCounter = 0;
const nextId = () => `msg-${Date.now().toString(36)}-${++idCounter}`;
function applyEvent(parts: MessagePart[], event: ChatEvent): MessagePart[] {
const next = [...parts];
const last = next[next.length - 1];
switch (event.type) {
case "reasoning-delta": {
if (last?.type === "reasoning" && !last.done) {
next[next.length - 1] = { ...last, text: last.text + event.text };
} else {
next.push({ type: "reasoning", text: event.text, done: false });
}
return next;
}
case "reasoning-done": {
if (last?.type === "reasoning") {
next[next.length - 1] = { ...last, done: true };
}
return next;
}
case "tool-start": {
next.push({
type: "tool",
id: event.id,
name: event.name,
status: "running",
input: event.input,
});
return next;
}
case "tool-result":
case "tool-error": {
return next.map((p) => {
if (p.type !== "tool" || p.id !== event.id) return p;
const tool: ToolPart =
event.type === "tool-result"
? { ...p, status: "completed", output: event.output }
: { ...p, status: "error", errorText: event.errorText };
return tool;
});
}
case "text-delta": {
if (last?.type === "text") {
next[next.length - 1] = { ...last, text: last.text + event.text };
} else {
next.push({ type: "text", text: event.text });
}
return next;
}
default:
return next;
}
}
export type UseChatOptions = {
transport: ChatTransport;
model: string;
initialMessages?: ChatMessage[];
};
export function useChat({ transport, model, initialMessages = [] }: UseChatOptions) {
const [messages, setMessagesState] = React.useState<ChatMessage[]>(initialMessages);
const [status, setStatus] = React.useState<"idle" | "streaming">("idle");
const messagesRef = React.useRef(messages);
const abortRef = React.useRef<AbortController | null>(null);
const modelRef = React.useRef(model);
modelRef.current = model;
/** Keep a synchronous mirror so send/regenerate never read stale state. */
const setMessages = React.useCallback(
(next: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[])) => {
const resolved = typeof next === "function" ? next(messagesRef.current) : next;
messagesRef.current = resolved;
setMessagesState(resolved);
},
[],
);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStatus("idle");
setMessages((prev) =>
prev.map((m) => (m.status === "streaming" ? { ...m, status: "done" } : m)),
);
}, [setMessages]);
const run = React.useCallback(
async (history: ChatMessage[]) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const assistantId = nextId();
const assistant: ChatMessage = {
id: assistantId,
role: "assistant",
parts: [],
status: "streaming",
createdAt: Date.now(),
};
setMessages([...history, assistant]);
setStatus("streaming");
const update = (fn: (m: ChatMessage) => ChatMessage) =>
setMessages((prev) => prev.map((m) => (m.id === assistantId ? fn(m) : m)));
try {
for await (const event of transport({
messages: history,
model: modelRef.current,
signal: controller.signal,
})) {
if (controller.signal.aborted) break;
if (event.type === "done") break;
if (event.type === "error") {
update((m) => ({
...m,
status: "error",
parts: [...m.parts, { type: "text", text: `Something went wrong: ${event.message}` }],
}));
break;
}
update((m) => ({ ...m, parts: applyEvent(m.parts, event) }));
}
} finally {
update((m) => (m.status === "streaming" ? { ...m, status: "done" } : m));
if (abortRef.current === controller) {
abortRef.current = null;
setStatus("idle");
}
}
},
[transport, setMessages],
);
const send = React.useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const user: ChatMessage = {
id: nextId(),
role: "user",
parts: [{ type: "text", text: trimmed }],
status: "done",
createdAt: Date.now(),
};
const history = [...messagesRef.current, user];
setMessages(history);
void run(history);
},
[run, setMessages],
);
const regenerate = React.useCallback(() => {
const prev = messagesRef.current;
const lastUserIndex = prev.map((m) => m.role).lastIndexOf("user");
if (lastUserIndex === -1) return;
const history = prev.slice(0, lastUserIndex + 1);
setMessages(history);
void run(history);
}, [run, setMessages]);
const clear = React.useCallback(() => {
stop();
setMessages([]);
}, [stop, setMessages]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { messages, status, send, stop, regenerate, clear };
}import type { ChatEvent, ChatTransport } from "./types";
import { messageText } from "./types";
/**
* A scripted transport that streams canned responses token by token.
* It never touches the network, so the docs site stays static.
*
* Scripts are picked by keyword; unknown prompts fall back to a generic
* reply that still shows reasoning + a tool call.
*/
type Script = {
match: RegExp;
reasoning?: string;
tool?: {
name: string;
input: unknown;
output?: unknown;
errorText?: string;
durationMs?: number;
};
text: string;
};
const scripts: Script[] = [
{
match: /astro|island/i,
reasoning:
"The user is asking about Astro islands. I should explain the mental model briefly, then show a minimal example of a client directive.",
tool: {
name: "search_docs",
input: { query: "astro islands client directives", limit: 2 },
output: {
results: [
{ title: "Islands architecture", url: "https://docs.astro.build/en/concepts/islands/" },
{ title: "Template directives", url: "https://docs.astro.build/en/reference/directives-reference/" },
],
},
durationMs: 900,
},
text: `An **island** is an interactive component rendered inside an otherwise static HTML page. Astro ships zero JavaScript by default and hydrates only the islands you opt into.
\`\`\`astro
---
import Chat from "@/blocks/ai-chat/chat";
---
<Chat client:load />
\`\`\`
| Directive | When it hydrates |
| --- | --- |
| \`client:load\` | Immediately on page load |
| \`client:idle\` | After the browser is idle |
| \`client:visible\` | When scrolled into view |
Use \`client:visible\` for anything below the fold.`,
},
{
match: /stream|token|latency/i,
reasoning:
"They want to know whether to stream. Streaming lowers perceived latency without changing cost. I'll recommend streaming and show the handler pattern.",
text: `Stream it. The total cost is identical, but the first token arrives in a few hundred milliseconds instead of several seconds.
The only UI requirement is a functional state update so out-of-order renders never drop a chunk:
\`\`\`ts
for await (const chunk of stream) {
setText((prev) => prev + chunk);
}
\`\`\`
Pair it with a stick-to-bottom scroll container so the newest text stays in view.`,
},
{
match: /test|jest|vitest/i,
reasoning:
"The user wants tests. I'll run the suite via a tool call so they see a realistic failure path, then explain the fix.",
tool: {
name: "run_tests",
input: { command: "pnpm vitest run", cwd: "." },
errorText: "Exit code 1: 1 of 12 tests failed (prompt-input › submits on Enter)",
durationMs: 1400,
},
text: `One test failed: the Enter key handler calls \`onSubmit\` before the controlled value has propagated.
Fix it by reading from \`e.currentTarget.value\` instead of the stale prop:
\`\`\`tsx
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.(e.currentTarget.value);
}
\`\`\`
Re-run the suite and it should be green.`,
},
{
match: /.*/,
reasoning:
"A general question. I'll answer directly and keep it short, with one example the user can copy.",
tool: {
name: "lookup",
input: { topic: "general" },
output: { ok: true, sources: 3 },
durationMs: 700,
},
text: `Here's the short version:
1. Every component in this library is a **composable primitive** you own, not a package dependency.
2. Install one with \`npx shadcn@latest add <registry-url>\` and edit the file directly.
3. This chat block combines nine of them: thread, message, prompt input, suggestions, model selector, reasoning, tool, text shimmer, and feedback bar.
Ask me about *Astro islands*, *streaming*, or *tests* to see the other scripted paths.`,
},
];
function sleep(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
function jitter(base: number) {
return base + Math.random() * base;
}
let toolCounter = 0;
export const mockTransport: ChatTransport = async function* ({
messages,
signal,
}) {
const last = [...messages].reverse().find((m) => m.role === "user");
const prompt = last ? messageText(last) : "";
const script = scripts.find((s) => s.match.test(prompt)) ?? scripts.at(-1)!;
try {
await sleep(jitter(250), signal);
if (script.reasoning) {
for (const piece of chunk(script.reasoning)) {
yield { type: "reasoning-delta", text: piece } satisfies ChatEvent;
await sleep(jitter(18), signal);
}
yield { type: "reasoning-done" };
await sleep(jitter(150), signal);
}
if (script.tool) {
const id = `tool-${++toolCounter}`;
yield { type: "tool-start", id, name: script.tool.name, input: script.tool.input };
await sleep(script.tool.durationMs ?? 800, signal);
if (script.tool.errorText) {
yield { type: "tool-error", id, errorText: script.tool.errorText };
} else {
yield { type: "tool-result", id, output: script.tool.output };
}
await sleep(jitter(150), signal);
}
for (const piece of chunk(script.text)) {
yield { type: "text-delta", text: piece };
await sleep(jitter(14), signal);
}
yield { type: "done" };
} catch (err) {
if (signal.aborted) return;
yield { type: "error", message: err instanceof Error ? err.message : String(err) };
}
};/**
* Transport-agnostic chat model. The block renders these; a transport
* produces `ChatEvent`s from a conversation. Swap `mockTransport` for a
* real one that talks to your API and the UI does not change.
*/
export type ChatRole = "user" | "assistant";
export type ReasoningPart = {
type: "reasoning";
text: string;
done: boolean;
};
export type ToolPart = {
type: "tool";
id: string;
name: string;
status: "pending" | "ready" | "running" | "completed" | "error";
input?: unknown;
output?: unknown;
errorText?: string;
};
export type TextPart = {
type: "text";
text: string;
};
export type MessagePart = ReasoningPart | ToolPart | TextPart;
export type ChatMessage = {
id: string;
role: ChatRole;
parts: MessagePart[];
status: "streaming" | "done" | "error";
createdAt: number;
};
export type ChatEvent =
| { type: "reasoning-delta"; text: string }
| { type: "reasoning-done" }
| { type: "tool-start"; id: string; name: string; input?: unknown }
| { type: "tool-result"; id: string; output: unknown }
| { type: "tool-error"; id: string; errorText: string }
| { type: "text-delta"; text: string }
| { type: "error"; message: string }
| { type: "done" };
export type TransportInput = {
messages: ChatMessage[];
model: string;
signal: AbortSignal;
};
export type ChatTransport = (input: TransportInput) => AsyncIterable<ChatEvent>;
export type ChatModel = {
value: string;
title: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
disabled?: boolean;
};
/** Extract plain text from a message for copy / transport payloads. */
export function messageText(message: ChatMessage): string {
return message.parts
.filter((p): p is TextPart => p.type === "text")
.map((p) => p.text)
.join("");
}What’s inside
The block wires nine components into one screen:
- Thread keeps the viewport pinned to new tokens.
- Message renders user bubbles and assistant markdown with an action bar.
- Reasoning shows thinking while it streams, then collapses.
- Tool displays each tool call with status, input, and output.
- Prompt Input with attach, model, send, and stop actions.
- Suggestions on the empty state.
- Model Selector in the header and inside the input.
- Text Shimmer as the “Thinking…” placeholder.
- Feedback Bar after the first completed reply.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/ai-chat.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/ai-chat.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/ai-chat.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/ai-chat.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import {
ArrowPathIcon,
ArrowUpIcon,
BoltIcon,
DocumentDuplicateIcon,
HandThumbDownIcon,
HandThumbUpIcon,
PaperClipIcon,
SparklesIcon,
StopIcon,
TrashIcon,
XMarkIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
FeedbackBar,
FeedbackBarAction,
FeedbackBarActions,
FeedbackBarClose,
FeedbackBarContent,
FeedbackBarLabel,
FeedbackBarPrompt,
} from "@/components/pandacoderz-ui/feedback-bar";
import {
Message,
MessageAction,
MessageActionGroup,
MessageActions,
MessageAvatar,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/pandacoderz-ui/message";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorSearch,
ModelSelectorTrigger,
} from "@/components/pandacoderz-ui/model-selector";
import {
PromptInput,
PromptInputAction,
PromptInputActionGroup,
PromptInputActions,
PromptInputTextarea,
} from "@/components/pandacoderz-ui/prompt-input";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/pandacoderz-ui/reasoning";
import {
Suggestion,
SuggestionList,
Suggestions,
} from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import {
Thread,
ThreadContent,
ThreadScrollToBottom,
} from "@/components/pandacoderz-ui/thread";
import {
Tool,
ToolContent,
ToolInput,
ToolOutput,
ToolTrigger,
} from "@/components/pandacoderz-ui/tool";
import { mockTransport } from "./mock-transport";
import type { ChatMessage, ChatModel, ChatTransport } from "./types";
import { messageText } from "./types";
import { useChat } from "./use-chat";
const Sparkles = ({ className }: { className?: string }) => (
<SparklesIcon size={16} className={className} />
);
const Bolt = ({ className }: { className?: string }) => (
<BoltIcon size={16} className={className} />
);
const defaultModels: ChatModel[] = [
{ value: "claude-fable-5-1", title: "Claude Fable 5.1", description: "Most capable", icon: Sparkles },
{ value: "claude-opus-5", title: "Claude Opus 5", description: "Deep reasoning", icon: Sparkles },
{ value: "claude-sonnet-5", title: "Claude Sonnet 5", description: "Balanced", icon: Bolt },
{ value: "claude-haiku-4-5", title: "Claude Haiku 4.5", description: "Fastest", icon: Bolt },
];
const defaultSuggestions = [
"How do Astro islands work?",
"Should I stream responses?",
"Run the tests",
"What is this library?",
];
export type AIChatProps = {
/** Produces chat events. Defaults to a scripted mock that never hits the network. */
transport?: ChatTransport;
models?: ChatModel[];
defaultModel?: string;
suggestions?: string[];
/** Title shown in the header. */
title?: string;
className?: string;
};
export default function AIChat({
transport = mockTransport,
models = defaultModels,
defaultModel = models[0]?.value ?? "",
suggestions = defaultSuggestions,
title = "Assistant",
className,
}: AIChatProps) {
const [model, setModel] = React.useState(defaultModel);
const [draft, setDraft] = React.useState("");
const [feedback, setFeedback] = React.useState<"hidden" | "shown" | "done">("hidden");
const { messages, status, send, stop, regenerate, clear } = useChat({
transport,
model,
});
const isStreaming = status === "streaming";
const isEmpty = messages.length === 0;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
React.useEffect(() => {
if (lastAssistant?.status === "done" && feedback === "hidden") {
setFeedback("shown");
}
}, [lastAssistant?.status, feedback]);
const submit = React.useCallback(
(text: string) => {
if (isStreaming) return;
send(text);
setDraft("");
},
[isStreaming, send],
);
return (
<div
data-slot="ai-chat"
className={cn(
"flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs",
className,
)}
>
<header className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground">
<SparklesIcon size={14} className="flex" />
</span>
{title}
</div>
<div className="flex items-center gap-1">
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="ghost" />
<ModelSelectorContent className="w-72" align="end">
<ModelSelectorSearch placeholder="Search models" />
<ModelSelectorEmpty />
<ModelSelectorGroup>
<ModelSelectorLabel>Models</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
title={m.title}
description={m.description}
icon={m.icon}
disabled={m.disabled}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Clear conversation"
onClick={() => {
clear();
setFeedback("hidden");
}}
disabled={isEmpty}
>
<TrashIcon size={16} className="flex" />
</Button>
</div>
</header>
<div className="relative min-h-0 flex-1">
{isEmpty ? (
<EmptyState suggestions={suggestions} onSelect={submit} />
) : (
<Thread className="h-full">
<ThreadContent className="mx-auto max-w-3xl">
{messages.map((m) => (
<ChatTurn
key={m.id}
message={m}
isLast={m.id === messages[messages.length - 1]?.id}
onRegenerate={regenerate}
canRegenerate={!isStreaming}
/>
))}
{feedback === "shown" && !isStreaming ? (
<div className="mx-auto w-full max-w-[90%]">
<FeedbackBar>
<FeedbackBarContent>
<FeedbackBarPrompt>
<FeedbackBarLabel>Was this response helpful?</FeedbackBarLabel>
</FeedbackBarPrompt>
<FeedbackBarActions>
<FeedbackBarAction asChild tooltip="Good">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Good" onClick={() => setFeedback("done")}>
<HandThumbUpIcon size={16} className="flex" />
</Button>
</FeedbackBarAction>
<FeedbackBarAction asChild tooltip="Bad">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Bad" onClick={() => setFeedback("done")}>
<HandThumbDownIcon size={16} className="flex" />
</Button>
</FeedbackBarAction>
</FeedbackBarActions>
<FeedbackBarClose tooltip="Dismiss">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Dismiss" onClick={() => setFeedback("done")}>
<XMarkIcon size={16} className="flex" />
</Button>
</FeedbackBarClose>
</FeedbackBarContent>
</FeedbackBar>
</div>
) : null}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
)}
</div>
<div className="shrink-0 px-4 pb-4 pt-2">
<div className="mx-auto max-w-3xl">
<PromptInput onSubmit={submit}>
<PromptInputTextarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={isStreaming ? "Generating…" : "Ask anything"}
/>
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip="Attach files">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Attach files">
<PaperClipIcon size={16} className="flex" />
</Button>
</PromptInputAction>
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="ghost" className="h-8 text-muted-foreground" />
<ModelSelectorContent className="w-72" align="start" side="top">
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
title={m.title}
description={m.description}
icon={m.icon}
disabled={m.disabled}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorContent>
</ModelSelector>
</PromptInputActionGroup>
<PromptInputActionGroup>
{isStreaming ? (
<PromptInputAction asChild tooltip="Stop">
<Button size="icon-sm" variant="secondary" className="rounded-full" aria-label="Stop" onClick={stop}>
<StopIcon size={16} className="flex" />
</Button>
</PromptInputAction>
) : (
<PromptInputAction asChild tooltip={{ content: "Send", shortcut: "↵" }}>
<Button
size="icon-sm"
className="rounded-full"
aria-label="Send"
disabled={!draft.trim()}
onClick={() => submit(draft)}
>
<ArrowUpIcon size={16} className="flex" />
</Button>
</PromptInputAction>
)}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
<p className="mt-2 text-center text-xs text-muted-foreground">
Responses are scripted for this demo. Plug in a transport to go live.
</p>
</div>
</div>
</div>
);
}
function EmptyState({
suggestions,
onSelect,
}: {
suggestions: string[];
onSelect: (value: string) => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-brand-soft text-brand">
<SparklesIcon size={22} className="flex" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-medium tracking-tight">How can I help today?</h2>
<p className="text-sm text-muted-foreground">
Pick a suggestion or type your own question.
</p>
</div>
<Suggestions onSelect={onSelect}>
<SuggestionList>
{suggestions.map((s) => (
<Suggestion key={s}>{s}</Suggestion>
))}
</SuggestionList>
</Suggestions>
</div>
);
}
function ChatTurn({
message,
isLast,
onRegenerate,
canRegenerate,
}: {
message: ChatMessage;
isLast: boolean;
onRegenerate: () => void;
canRegenerate: boolean;
}) {
const [copied, setCopied] = React.useState(false);
if (message.role === "user") {
return (
<Message from="user">
<MessageStack>
<MessageContent>{messageText(message)}</MessageContent>
</MessageStack>
</Message>
);
}
const isStreaming = message.status === "streaming";
const hasText = message.parts.some((p) => p.type === "text");
return (
<Message from="assistant">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack>
{message.parts.map((part, i) => {
if (part.type === "reasoning") {
return (
<Reasoning key={i} isStreaming={!part.done} className="px-2">
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type === "tool") {
return (
<div key={part.id} className="px-2">
<Tool status={part.status}>
<ToolTrigger name={part.name} />
<ToolContent>
<ToolInput payload={part.input} />
<ToolOutput
payload={part.output ?? null}
showWhen={["completed", "error"]}
errorText={part.errorText}
/>
</ToolContent>
</Tool>
</div>
);
}
return (
<MessageContent key={i}>
<MessageMarkdown isAnimating={isStreaming}>{part.text}</MessageMarkdown>
</MessageContent>
);
})}
{isStreaming && !hasText ? (
<div className="px-2">
<TextShimmer className="text-sm text-muted-foreground" invertLight spread={12}>
{message.parts.length === 0 ? "Thinking…" : "Writing…"}
</TextShimmer>
</div>
) : null}
{!isStreaming && hasText ? (
<MessageActions className="px-1">
<MessageActionGroup>
<MessageAction asChild tooltip={copied ? "Copied" : "Copy"}>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Copy"
onClick={async () => {
try {
await navigator.clipboard.writeText(messageText(message));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
>
<DocumentDuplicateIcon size={16} className="flex" />
</Button>
</MessageAction>
{isLast ? (
<MessageAction asChild tooltip="Regenerate">
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
aria-label="Regenerate"
onClick={onRegenerate}
disabled={!canRegenerate}
>
<ArrowPathIcon size={16} className="flex" />
</Button>
</MessageAction>
) : null}
<MessageAction asChild tooltip="Good response">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Good response">
<HandThumbUpIcon size={16} className="flex" />
</Button>
</MessageAction>
<MessageAction asChild tooltip="Bad response">
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Bad response">
<HandThumbDownIcon size={16} className="flex" />
</Button>
</MessageAction>
</MessageActionGroup>
</MessageActions>
) : null}
</MessageStack>
</Message>
);
}"use client";
import * as React from "react";
import type {
ChatEvent,
ChatMessage,
ChatTransport,
MessagePart,
ToolPart,
} from "./types";
let idCounter = 0;
const nextId = () => `msg-${Date.now().toString(36)}-${++idCounter}`;
function applyEvent(parts: MessagePart[], event: ChatEvent): MessagePart[] {
const next = [...parts];
const last = next[next.length - 1];
switch (event.type) {
case "reasoning-delta": {
if (last?.type === "reasoning" && !last.done) {
next[next.length - 1] = { ...last, text: last.text + event.text };
} else {
next.push({ type: "reasoning", text: event.text, done: false });
}
return next;
}
case "reasoning-done": {
if (last?.type === "reasoning") {
next[next.length - 1] = { ...last, done: true };
}
return next;
}
case "tool-start": {
next.push({
type: "tool",
id: event.id,
name: event.name,
status: "running",
input: event.input,
});
return next;
}
case "tool-result":
case "tool-error": {
return next.map((p) => {
if (p.type !== "tool" || p.id !== event.id) return p;
const tool: ToolPart =
event.type === "tool-result"
? { ...p, status: "completed", output: event.output }
: { ...p, status: "error", errorText: event.errorText };
return tool;
});
}
case "text-delta": {
if (last?.type === "text") {
next[next.length - 1] = { ...last, text: last.text + event.text };
} else {
next.push({ type: "text", text: event.text });
}
return next;
}
default:
return next;
}
}
export type UseChatOptions = {
transport: ChatTransport;
model: string;
initialMessages?: ChatMessage[];
};
export function useChat({ transport, model, initialMessages = [] }: UseChatOptions) {
const [messages, setMessagesState] = React.useState<ChatMessage[]>(initialMessages);
const [status, setStatus] = React.useState<"idle" | "streaming">("idle");
const messagesRef = React.useRef(messages);
const abortRef = React.useRef<AbortController | null>(null);
const modelRef = React.useRef(model);
modelRef.current = model;
/** Keep a synchronous mirror so send/regenerate never read stale state. */
const setMessages = React.useCallback(
(next: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[])) => {
const resolved = typeof next === "function" ? next(messagesRef.current) : next;
messagesRef.current = resolved;
setMessagesState(resolved);
},
[],
);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStatus("idle");
setMessages((prev) =>
prev.map((m) => (m.status === "streaming" ? { ...m, status: "done" } : m)),
);
}, [setMessages]);
const run = React.useCallback(
async (history: ChatMessage[]) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const assistantId = nextId();
const assistant: ChatMessage = {
id: assistantId,
role: "assistant",
parts: [],
status: "streaming",
createdAt: Date.now(),
};
setMessages([...history, assistant]);
setStatus("streaming");
const update = (fn: (m: ChatMessage) => ChatMessage) =>
setMessages((prev) => prev.map((m) => (m.id === assistantId ? fn(m) : m)));
try {
for await (const event of transport({
messages: history,
model: modelRef.current,
signal: controller.signal,
})) {
if (controller.signal.aborted) break;
if (event.type === "done") break;
if (event.type === "error") {
update((m) => ({
...m,
status: "error",
parts: [...m.parts, { type: "text", text: `Something went wrong: ${event.message}` }],
}));
break;
}
update((m) => ({ ...m, parts: applyEvent(m.parts, event) }));
}
} finally {
update((m) => (m.status === "streaming" ? { ...m, status: "done" } : m));
if (abortRef.current === controller) {
abortRef.current = null;
setStatus("idle");
}
}
},
[transport, setMessages],
);
const send = React.useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const user: ChatMessage = {
id: nextId(),
role: "user",
parts: [{ type: "text", text: trimmed }],
status: "done",
createdAt: Date.now(),
};
const history = [...messagesRef.current, user];
setMessages(history);
void run(history);
},
[run, setMessages],
);
const regenerate = React.useCallback(() => {
const prev = messagesRef.current;
const lastUserIndex = prev.map((m) => m.role).lastIndexOf("user");
if (lastUserIndex === -1) return;
const history = prev.slice(0, lastUserIndex + 1);
setMessages(history);
void run(history);
}, [run, setMessages]);
const clear = React.useCallback(() => {
stop();
setMessages([]);
}, [stop, setMessages]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { messages, status, send, stop, regenerate, clear };
}import type { ChatEvent, ChatTransport } from "./types";
import { messageText } from "./types";
/**
* A scripted transport that streams canned responses token by token.
* It never touches the network, so the docs site stays static.
*
* Scripts are picked by keyword; unknown prompts fall back to a generic
* reply that still shows reasoning + a tool call.
*/
type Script = {
match: RegExp;
reasoning?: string;
tool?: {
name: string;
input: unknown;
output?: unknown;
errorText?: string;
durationMs?: number;
};
text: string;
};
const scripts: Script[] = [
{
match: /astro|island/i,
reasoning:
"The user is asking about Astro islands. I should explain the mental model briefly, then show a minimal example of a client directive.",
tool: {
name: "search_docs",
input: { query: "astro islands client directives", limit: 2 },
output: {
results: [
{ title: "Islands architecture", url: "https://docs.astro.build/en/concepts/islands/" },
{ title: "Template directives", url: "https://docs.astro.build/en/reference/directives-reference/" },
],
},
durationMs: 900,
},
text: `An **island** is an interactive component rendered inside an otherwise static HTML page. Astro ships zero JavaScript by default and hydrates only the islands you opt into.
\`\`\`astro
---
import Chat from "@/blocks/ai-chat/chat";
---
<Chat client:load />
\`\`\`
| Directive | When it hydrates |
| --- | --- |
| \`client:load\` | Immediately on page load |
| \`client:idle\` | After the browser is idle |
| \`client:visible\` | When scrolled into view |
Use \`client:visible\` for anything below the fold.`,
},
{
match: /stream|token|latency/i,
reasoning:
"They want to know whether to stream. Streaming lowers perceived latency without changing cost. I'll recommend streaming and show the handler pattern.",
text: `Stream it. The total cost is identical, but the first token arrives in a few hundred milliseconds instead of several seconds.
The only UI requirement is a functional state update so out-of-order renders never drop a chunk:
\`\`\`ts
for await (const chunk of stream) {
setText((prev) => prev + chunk);
}
\`\`\`
Pair it with a stick-to-bottom scroll container so the newest text stays in view.`,
},
{
match: /test|jest|vitest/i,
reasoning:
"The user wants tests. I'll run the suite via a tool call so they see a realistic failure path, then explain the fix.",
tool: {
name: "run_tests",
input: { command: "pnpm vitest run", cwd: "." },
errorText: "Exit code 1: 1 of 12 tests failed (prompt-input › submits on Enter)",
durationMs: 1400,
},
text: `One test failed: the Enter key handler calls \`onSubmit\` before the controlled value has propagated.
Fix it by reading from \`e.currentTarget.value\` instead of the stale prop:
\`\`\`tsx
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.(e.currentTarget.value);
}
\`\`\`
Re-run the suite and it should be green.`,
},
{
match: /.*/,
reasoning:
"A general question. I'll answer directly and keep it short, with one example the user can copy.",
tool: {
name: "lookup",
input: { topic: "general" },
output: { ok: true, sources: 3 },
durationMs: 700,
},
text: `Here's the short version:
1. Every component in this library is a **composable primitive** you own, not a package dependency.
2. Install one with \`npx shadcn@latest add <registry-url>\` and edit the file directly.
3. This chat block combines nine of them: thread, message, prompt input, suggestions, model selector, reasoning, tool, text shimmer, and feedback bar.
Ask me about *Astro islands*, *streaming*, or *tests* to see the other scripted paths.`,
},
];
function sleep(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
function jitter(base: number) {
return base + Math.random() * base;
}
let toolCounter = 0;
export const mockTransport: ChatTransport = async function* ({
messages,
signal,
}) {
const last = [...messages].reverse().find((m) => m.role === "user");
const prompt = last ? messageText(last) : "";
const script = scripts.find((s) => s.match.test(prompt)) ?? scripts.at(-1)!;
try {
await sleep(jitter(250), signal);
if (script.reasoning) {
for (const piece of chunk(script.reasoning)) {
yield { type: "reasoning-delta", text: piece } satisfies ChatEvent;
await sleep(jitter(18), signal);
}
yield { type: "reasoning-done" };
await sleep(jitter(150), signal);
}
if (script.tool) {
const id = `tool-${++toolCounter}`;
yield { type: "tool-start", id, name: script.tool.name, input: script.tool.input };
await sleep(script.tool.durationMs ?? 800, signal);
if (script.tool.errorText) {
yield { type: "tool-error", id, errorText: script.tool.errorText };
} else {
yield { type: "tool-result", id, output: script.tool.output };
}
await sleep(jitter(150), signal);
}
for (const piece of chunk(script.text)) {
yield { type: "text-delta", text: piece };
await sleep(jitter(14), signal);
}
yield { type: "done" };
} catch (err) {
if (signal.aborted) return;
yield { type: "error", message: err instanceof Error ? err.message : String(err) };
}
};/**
* Transport-agnostic chat model. The block renders these; a transport
* produces `ChatEvent`s from a conversation. Swap `mockTransport` for a
* real one that talks to your API and the UI does not change.
*/
export type ChatRole = "user" | "assistant";
export type ReasoningPart = {
type: "reasoning";
text: string;
done: boolean;
};
export type ToolPart = {
type: "tool";
id: string;
name: string;
status: "pending" | "ready" | "running" | "completed" | "error";
input?: unknown;
output?: unknown;
errorText?: string;
};
export type TextPart = {
type: "text";
text: string;
};
export type MessagePart = ReasoningPart | ToolPart | TextPart;
export type ChatMessage = {
id: string;
role: ChatRole;
parts: MessagePart[];
status: "streaming" | "done" | "error";
createdAt: number;
};
export type ChatEvent =
| { type: "reasoning-delta"; text: string }
| { type: "reasoning-done" }
| { type: "tool-start"; id: string; name: string; input?: unknown }
| { type: "tool-result"; id: string; output: unknown }
| { type: "tool-error"; id: string; errorText: string }
| { type: "text-delta"; text: string }
| { type: "error"; message: string }
| { type: "done" };
export type TransportInput = {
messages: ChatMessage[];
model: string;
signal: AbortSignal;
};
export type ChatTransport = (input: TransportInput) => AsyncIterable<ChatEvent>;
export type ChatModel = {
value: string;
title: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
disabled?: boolean;
};
/** Extract plain text from a message for copy / transport payloads. */
export function messageText(message: ChatMessage): string {
return message.parts
.filter((p): p is TextPart => p.type === "text")
.map((p) => p.text)
.join("");
}The registry item pulls in every component it depends on.
Usage
import AIChat from "@/components/blocks/ai-chat/chat";
export default function Page() {
return (
<div className="h-dvh p-4">
<AIChat />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
The demo uses mockTransport, an async generator that yields scripted events. To connect a real model, write a transport with the same shape:
import type { ChatTransport } from "@/components/blocks/ai-chat/types";
export const apiTransport: ChatTransport = async function* ({ messages, model, signal }) {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages, model }),
signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n").filter(Boolean)) {
yield JSON.parse(line); // { type: "text-delta", text: "…" } etc.
}
}
yield { type: "done" };
};Then pass it in:
<AIChat transport={apiTransport} />Event types
| Event | Payload | Effect |
|---|---|---|
reasoning-delta |
{ text } |
Appends to the current reasoning part. |
reasoning-done |
Marks reasoning complete (collapses the block). | |
tool-start |
{ id, name, input? } |
Adds a running tool card. |
tool-result |
{ id, output } |
Completes the tool with output. |
tool-error |
{ id, errorText } |
Marks the tool as failed. |
text-delta |
{ text } |
Appends assistant text. |
error |
{ message } |
Ends the turn with an error message. |
done |
Ends the turn. |
API Reference
| Prop | Type | Description |
|---|---|---|
transport |
ChatTransport |
Event source. Defaults to mockTransport. |
models |
ChatModel[] |
Options for the model selector. |
defaultModel |
string |
Initially selected model id. |
suggestions |
string[] |
Empty-state prompt chips. |
title |
string |
Header title. Default “Assistant”. |
className |
string |
Extra classes on the root. |