ComponentsChat
Chat
Build an AI conversation with messages, a prompt box, attachments, and tool results.
On this page
Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.
View registry filesImport the component into your page or component file.
import { Chat, PromptInput, ChatMessage } from "@dethink/components";Try a prompt, switch conversations, or ask for approval before reading documents. Everything runs locally with sample responses.
Launch strategy
Your space to think things through
Show sourceexamples/chat/workspace.tsx
"use client";
import { useMemo, useState } from "react";
import {
ArrowUpRight,
BookOpen,
Check,
ChevronRight,
Copy,
FileText,
Lightbulb,
MessageSquare,
PanelLeft,
ShieldCheck,
Sparkles,
Terminal,
} from "lucide-react";
import {
Avatar,
Badge,
Button,
Chat,
CitationCard,
ConversationList,
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
IconButton,
type ChatMessageActionHandlers,
type ChatPartRenderer,
} from "@dethink/components";
import { MarkdownMessage } from "@dethink/components/chat-markdown";
import { chatModels, useChatDemo } from "./use-chat-demo";
const renderPart: ChatPartRenderer = (part) => {
if (part.type === "text") return <MarkdownMessage text={part.text} />;
if (part.type === "citation")
return <CitationCard citation={part.citation} />;
return undefined;
};
const suggestions = [
{
icon: BookOpen,
title: "Find a clearer direction",
prompt: "Help me plan a thoughtful product launch",
},
{
icon: Terminal,
title: "Build something useful",
prompt: "Show me code for a chat component",
},
{
icon: ShieldCheck,
title: "Keep me in control",
prompt: "Ask for approval before reading the launch documents",
},
];
export function ChatWorkspace({
embedded = false,
startEmpty = false,
}: {
embedded?: boolean;
startEmpty?: boolean;
}) {
const demo = useChatDemo(startEmpty);
const Heading = embedded ? "h3" : "h2";
const { current, update, retry, version } = demo;
const [historyOpen, setHistoryOpen] = useState(false);
const [copied, setCopied] = useState("");
const conversations = Object.values(demo.records).map((conversation) => ({
id: conversation.id,
title: conversation.title,
description:
conversation.id === "launch"
? "Launch strategy & next steps"
: "A little space to think",
unread: conversation.unread,
running: ["running", "submitted", "stopping"].includes(
conversation.state.run?.status ?? "",
),
}));
const actions = useMemo<ChatMessageActionHandlers>(
() => ({
onCopy: async (text) => {
if (!navigator.clipboard)
throw new Error(
"Clipboard unavailable. Select and copy the text instead.",
);
await navigator.clipboard.writeText(text);
},
onFeedback: () => true,
onEdit: (message, text) =>
update(message.conversationId, (conversation) => ({
...conversation,
state: {
...conversation.state,
messages: conversation.state.messages.map((item) =>
item.id === message.id
? { ...item, parts: [{ id: "text", type: "text", text }] }
: item,
),
},
})),
onRetry: retry,
onRegenerate: retry,
onVersionChange: version,
}),
[update, retry, version],
);
function setDraft(text: string) {
demo.update(current.id, (conversation) => ({
...conversation,
draft: text,
}));
}
const history = (
<ConversationList
conversations={conversations}
value={current.id}
onValueChange={(id) => {
demo.select(id);
setHistoryOpen(false);
}}
onNew={() => {
demo.create();
setHistoryOpen(false);
}}
onRename={(id, title) =>
demo.update(id, (conversation) => ({ ...conversation, title }))
}
onDelete={demo.remove}
className="h-full"
/>
);
return (
<div
data-slot="chat-workspace"
className={`border-border bg-background flex min-h-0 min-w-0 overflow-hidden rounded-2xl border shadow-sm ${embedded ? "h-[660px]" : "h-[min(860px,calc(100dvh-9rem))] min-h-[580px]"}`}
>
{!embedded && (
<aside className="border-border bg-muted/25 hidden w-64 shrink-0 flex-col border-e p-4 lg:flex">
<div className="mb-8 flex items-center gap-2.5 px-1 pt-2">
<span className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-xl">
<Sparkles className="size-4" />
</span>
<span className="text-sm font-semibold tracking-tight">
dethink
<span className="text-muted-foreground ms-1.5 font-normal">
assistant
</span>
</span>
</div>
<div className="min-h-0 flex-1">{history}</div>
<div className="border-border mt-5 border-t pt-4">
<div className="bg-background/70 border-border/70 rounded-xl border p-3">
<div className="flex items-center gap-2 text-xs font-medium">
<Lightbulb className="text-primary size-3.5" />
Room for a good idea
</div>
<p className="text-muted-foreground mt-2 text-[11px] leading-5">
A calm space to explore, make connections, and move things
forward.
</p>
</div>
<div className="mt-4 flex items-center gap-2.5 px-1">
<Avatar name="Alex Morgan" size="sm" />
<div className="min-w-0 text-xs">
<p className="font-medium">Alex Morgan</p>
<p className="text-muted-foreground mt-0.5 text-[10px]">
Personal workspace
</p>
</div>
<Badge variant="soft" className="ms-auto text-[10px]">
Demo
</Badge>
</div>
</div>
</aside>
)}
<Chat
conversationId={current.id}
messages={current.state.messages}
run={current.state.run}
renderPart={renderPart}
messageActions={actions}
className="h-full min-w-0 flex-1"
activity={{ items: current.state.activity, onApproval: demo.approve }}
scroll={{
initialPosition: demo.scroll.current.get(current.id),
onPositionChange: (position) =>
demo.scroll.current.set(current.id, position),
}}
header={
<header className="border-border flex min-h-16 shrink-0 items-center justify-between gap-3 border-b px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className={embedded ? "" : "lg:hidden"}>
<Drawer
open={historyOpen}
onOpenChange={setHistoryOpen}
direction="left"
>
<DrawerTrigger
variant="ghost"
size="icon"
aria-label="Open conversation history"
>
<PanelLeft className="size-4" />
</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close history"
>
<DrawerHeader>
<DrawerTitle>Your conversations</DrawerTitle>
<DrawerDescription>
Pick up where you left off.
</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 flex-1 p-5">{history}</div>
</DrawerContent>
</Drawer>
</div>
<div className="min-w-0">
<Heading className="truncate text-sm font-semibold">
{current.title}
</Heading>
<p className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-[10px]">
<span className="bg-success size-1.5 rounded-full" />
Your space to think things through
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground hidden text-[10px] sm:block">
Sample conversation
</span>
<IconButton
aria-label="Copy conversation"
title="Copy conversation"
size="sm"
onClick={() => {
const text = current.state.messages
.map(
(message) =>
`${message.role}: ${message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")}`,
)
.join("\n\n");
if (!navigator.clipboard) {
setCopied("Clipboard unavailable");
return;
}
void navigator.clipboard.writeText(text).then(
() => setCopied("Conversation copied"),
() => setCopied("Clipboard permission denied"),
);
}}
>
{copied === "Conversation copied" ? <Check /> : <Copy />}
</IconButton>
</div>
</header>
}
emptyState={
<div className="flex min-h-64 flex-col items-center justify-center py-8 text-center">
<div className="border-primary/15 bg-primary/5 text-primary mb-5 flex size-14 items-center justify-center rounded-2xl border">
<Sparkles className="size-6" />
</div>
<p className="text-muted-foreground mb-2 text-xs">
A little clarity starts here
</p>
<h3 className="text-2xl font-semibold tracking-tight sm:text-3xl">
What’s on your mind?
</h3>
<p className="text-muted-foreground mt-3 max-w-sm text-sm leading-6">
Bring a question, an unfinished idea, or something you’d like to
make better.
</p>
<div className="mt-8 grid w-full max-w-lg gap-2 text-start">
{suggestions.map((item) => (
<button
key={item.title}
type="button"
onClick={() => setDraft(item.prompt)}
className="border-border bg-background hover:border-primary/30 hover:bg-primary/5 focus-visible:ring-ring group flex items-center gap-3 rounded-xl border p-3 text-start outline-none focus-visible:ring-2 motion-safe:transition-[background-color,border-color]"
>
<item.icon
aria-hidden="true"
className="text-primary size-4"
/>
<span className="flex-1 text-xs font-medium">
{item.title}
</span>
<ArrowUpRight
aria-hidden="true"
className="text-muted-foreground size-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5 motion-safe:group-hover:-translate-y-0.5 rtl:rotate-270"
/>
</button>
))}
</div>
</div>
}
prompt={{
value: current.draft,
onValueChange: setDraft,
onSend: demo.begin,
onStop: demo.stop,
attachments: current.attachments,
onFiles: (files) => demo.addFiles(current.id, files),
onAttachmentRemove: (attachment) => {
demo.clearTimers(attachment.id);
demo.update(current.id, (conversation) => ({
...conversation,
attachments: conversation.attachments.filter(
(item) => item.id !== attachment.id,
),
}));
},
onAttachmentRetry: (attachment) =>
demo.upload(current.id, attachment, true),
fileRules: {
accept: "image/png,image/jpeg,image/webp,.txt,.md,.pdf",
maxFiles: 4,
maxFileSize: 10 * 1024 * 1024,
},
allowAttachmentOnly: true,
models: chatModels,
modelId: current.modelId,
onModelChange: (modelId) =>
demo.update(current.id, (conversation) => ({
...conversation,
modelId,
})),
footer: "Sample responses · no messages or files leave this demo",
}}
/>
{!embedded && (
<aside className="border-border bg-muted/10 hidden w-56 shrink-0 flex-col border-s p-5 xl:flex">
<div className="mb-6 flex items-center gap-2 text-xs font-semibold">
<BookOpen className="size-3.5" />A little context
</div>
<p className="text-muted-foreground text-[11px] leading-5">
Good answers start with the right context. Try a document-backed
request.
</p>
<button
type="button"
onClick={() =>
setDraft("Ask for approval before reading the launch documents")
}
className="border-border bg-background hover:border-primary/40 focus-visible:ring-ring mt-4 rounded-xl border p-3 text-start outline-none focus-visible:ring-2 motion-safe:transition-colors"
>
<FileText className="text-primary mb-3 size-5" />
<p className="text-xs font-medium">Launch playbook</p>
<p className="text-muted-foreground mt-1 text-[10px]">
6 pages · Sample document
</p>
<span className="text-primary mt-3 inline-flex items-center gap-1 text-[10px] font-medium">
Explore with approval <ChevronRight className="size-3" />
</span>
</button>
<div className="border-border mt-7 border-t pt-5">
<p className="mb-3 text-xs font-semibold">Try the interactions</p>
<div className="space-y-1">
{[
{
label: "Thinking & slow streaming",
text: "Give me a slow response about our launch",
},
{
label: "Code & formatted answers",
text: "Show me code for a chat component",
},
{
label: "Recover from an error",
text: "Simulate a connection error",
},
].map((item) => (
<Button
key={item.label}
size="xs"
variant="ghost"
className="h-auto w-full justify-start px-0 py-2 text-start text-[11px] whitespace-normal"
onClick={() => setDraft(item.text)}
>
{item.label}
</Button>
))}
<Button
size="xs"
variant="ghost"
className="h-auto w-full justify-start px-0 py-2 text-[11px]"
onClick={() =>
demo.addFiles(current.id, [
new File(["Sample upload"], "reference-fail.txt", {
type: "text/plain",
}),
])
}
>
Recover a failed upload
</Button>
</div>
</div>
<div className="text-muted-foreground mt-auto pt-8 text-[10px] leading-5">
<MessageSquare className="mb-2 size-4" />
Switch conversations while a response is running. Your draft and
progress stay with each one.
</div>
</aside>
)}
<span className="sr-only" role="status">
{copied}
</span>
</div>
);
}
export function EmbeddedCopilot() {
return (
<div className="mx-auto max-w-xl">
<ChatWorkspace embedded startEmpty />
</div>
);
}A controlled transcript and accessible composer, ready for your application's data.
Show sourceexamples/chat/basic.tsx
"use client";
import { useState } from "react";
import { Chat, Button, type ChatMessageData } from "@dethink/components";
export function ChatBasic() {
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [draft, setDraft] = useState("");
return (
<Chat
conversationId="basic"
messages={messages}
className="border-border h-[440px] rounded-2xl border"
header={
<div className="border-border border-b px-6 py-4 text-sm font-semibold">
Your thinking partner
</div>
}
emptyState={
<div className="flex min-h-44 flex-col items-center justify-center gap-3 text-center">
<h3 className="text-lg font-semibold">
A little help goes a long way.
</h3>
<p className="text-muted-foreground max-w-xs text-sm">
Turn a rough idea into a clear next step.
</p>
<Button
size="sm"
variant="outline"
onClick={() => setDraft("Help me plan a product launch")}
>
Plan a product launch
</Button>
</div>
}
prompt={{
value: draft,
onValueChange: setDraft,
onSend: ({ text }) => {
const id = crypto.randomUUID();
setMessages((current) => [
...current,
{
id,
conversationId: "basic",
role: "user",
parts: [{ id: "text", type: "text", text }],
},
{
id: `${id}-reply`,
conversationId: "basic",
role: "assistant",
name: "Dethink",
parts: [
{
id: "text",
type: "text",
text: "Start with the outcome you want to create, then choose the smallest next step. Who is this for, and what should become easier for them?",
},
],
},
]);
},
footer: "Local example · responses are sample content",
}}
/>
);
}Selected context: “Make the first week count.”
Show sourceexamples/chat/composer-tools.tsx
"use client";
import { useState } from "react";
import { Button, PromptInput } from "@dethink/components";
export function ChatComposerTools() {
const [draft, setDraft] = useState("");
const [sent, setSent] = useState("");
return (
<div className="mx-auto max-w-xl space-y-3">
<p className="text-muted-foreground border-border rounded-lg border p-3 text-sm">
Selected context: “Make the first week count.”
</p>
<PromptInput
conversationId="context"
value={draft}
onValueChange={setDraft}
onSend={({ text }) => setSent(text)}
actions={
<Button
size="xs"
variant="ghost"
onClick={() =>
setDraft(
(value) =>
`${value}${value ? "\n\n" : ""}Context: “Make the first week count.”`,
)
}
>
Use selected context
</Button>
}
footer="The action slot can also host an explicitly activated SoundInput. No microphone is requested here."
/>
<p role="status" className="text-muted-foreground text-xs">
{sent ? `Sample prompt accepted: ${sent}` : ""}
</p>
</div>
);
}New conversation
Your space to think things through
Show sourceexamples/chat/workspace.tsx
"use client";
import { useMemo, useState } from "react";
import {
ArrowUpRight,
BookOpen,
Check,
ChevronRight,
Copy,
FileText,
Lightbulb,
MessageSquare,
PanelLeft,
ShieldCheck,
Sparkles,
Terminal,
} from "lucide-react";
import {
Avatar,
Badge,
Button,
Chat,
CitationCard,
ConversationList,
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
IconButton,
type ChatMessageActionHandlers,
type ChatPartRenderer,
} from "@dethink/components";
import { MarkdownMessage } from "@dethink/components/chat-markdown";
import { chatModels, useChatDemo } from "./use-chat-demo";
const renderPart: ChatPartRenderer = (part) => {
if (part.type === "text") return <MarkdownMessage text={part.text} />;
if (part.type === "citation")
return <CitationCard citation={part.citation} />;
return undefined;
};
const suggestions = [
{
icon: BookOpen,
title: "Find a clearer direction",
prompt: "Help me plan a thoughtful product launch",
},
{
icon: Terminal,
title: "Build something useful",
prompt: "Show me code for a chat component",
},
{
icon: ShieldCheck,
title: "Keep me in control",
prompt: "Ask for approval before reading the launch documents",
},
];
export function ChatWorkspace({
embedded = false,
startEmpty = false,
}: {
embedded?: boolean;
startEmpty?: boolean;
}) {
const demo = useChatDemo(startEmpty);
const Heading = embedded ? "h3" : "h2";
const { current, update, retry, version } = demo;
const [historyOpen, setHistoryOpen] = useState(false);
const [copied, setCopied] = useState("");
const conversations = Object.values(demo.records).map((conversation) => ({
id: conversation.id,
title: conversation.title,
description:
conversation.id === "launch"
? "Launch strategy & next steps"
: "A little space to think",
unread: conversation.unread,
running: ["running", "submitted", "stopping"].includes(
conversation.state.run?.status ?? "",
),
}));
const actions = useMemo<ChatMessageActionHandlers>(
() => ({
onCopy: async (text) => {
if (!navigator.clipboard)
throw new Error(
"Clipboard unavailable. Select and copy the text instead.",
);
await navigator.clipboard.writeText(text);
},
onFeedback: () => true,
onEdit: (message, text) =>
update(message.conversationId, (conversation) => ({
...conversation,
state: {
...conversation.state,
messages: conversation.state.messages.map((item) =>
item.id === message.id
? { ...item, parts: [{ id: "text", type: "text", text }] }
: item,
),
},
})),
onRetry: retry,
onRegenerate: retry,
onVersionChange: version,
}),
[update, retry, version],
);
function setDraft(text: string) {
demo.update(current.id, (conversation) => ({
...conversation,
draft: text,
}));
}
const history = (
<ConversationList
conversations={conversations}
value={current.id}
onValueChange={(id) => {
demo.select(id);
setHistoryOpen(false);
}}
onNew={() => {
demo.create();
setHistoryOpen(false);
}}
onRename={(id, title) =>
demo.update(id, (conversation) => ({ ...conversation, title }))
}
onDelete={demo.remove}
className="h-full"
/>
);
return (
<div
data-slot="chat-workspace"
className={`border-border bg-background flex min-h-0 min-w-0 overflow-hidden rounded-2xl border shadow-sm ${embedded ? "h-[660px]" : "h-[min(860px,calc(100dvh-9rem))] min-h-[580px]"}`}
>
{!embedded && (
<aside className="border-border bg-muted/25 hidden w-64 shrink-0 flex-col border-e p-4 lg:flex">
<div className="mb-8 flex items-center gap-2.5 px-1 pt-2">
<span className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-xl">
<Sparkles className="size-4" />
</span>
<span className="text-sm font-semibold tracking-tight">
dethink
<span className="text-muted-foreground ms-1.5 font-normal">
assistant
</span>
</span>
</div>
<div className="min-h-0 flex-1">{history}</div>
<div className="border-border mt-5 border-t pt-4">
<div className="bg-background/70 border-border/70 rounded-xl border p-3">
<div className="flex items-center gap-2 text-xs font-medium">
<Lightbulb className="text-primary size-3.5" />
Room for a good idea
</div>
<p className="text-muted-foreground mt-2 text-[11px] leading-5">
A calm space to explore, make connections, and move things
forward.
</p>
</div>
<div className="mt-4 flex items-center gap-2.5 px-1">
<Avatar name="Alex Morgan" size="sm" />
<div className="min-w-0 text-xs">
<p className="font-medium">Alex Morgan</p>
<p className="text-muted-foreground mt-0.5 text-[10px]">
Personal workspace
</p>
</div>
<Badge variant="soft" className="ms-auto text-[10px]">
Demo
</Badge>
</div>
</div>
</aside>
)}
<Chat
conversationId={current.id}
messages={current.state.messages}
run={current.state.run}
renderPart={renderPart}
messageActions={actions}
className="h-full min-w-0 flex-1"
activity={{ items: current.state.activity, onApproval: demo.approve }}
scroll={{
initialPosition: demo.scroll.current.get(current.id),
onPositionChange: (position) =>
demo.scroll.current.set(current.id, position),
}}
header={
<header className="border-border flex min-h-16 shrink-0 items-center justify-between gap-3 border-b px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className={embedded ? "" : "lg:hidden"}>
<Drawer
open={historyOpen}
onOpenChange={setHistoryOpen}
direction="left"
>
<DrawerTrigger
variant="ghost"
size="icon"
aria-label="Open conversation history"
>
<PanelLeft className="size-4" />
</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close history"
>
<DrawerHeader>
<DrawerTitle>Your conversations</DrawerTitle>
<DrawerDescription>
Pick up where you left off.
</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 flex-1 p-5">{history}</div>
</DrawerContent>
</Drawer>
</div>
<div className="min-w-0">
<Heading className="truncate text-sm font-semibold">
{current.title}
</Heading>
<p className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-[10px]">
<span className="bg-success size-1.5 rounded-full" />
Your space to think things through
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground hidden text-[10px] sm:block">
Sample conversation
</span>
<IconButton
aria-label="Copy conversation"
title="Copy conversation"
size="sm"
onClick={() => {
const text = current.state.messages
.map(
(message) =>
`${message.role}: ${message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")}`,
)
.join("\n\n");
if (!navigator.clipboard) {
setCopied("Clipboard unavailable");
return;
}
void navigator.clipboard.writeText(text).then(
() => setCopied("Conversation copied"),
() => setCopied("Clipboard permission denied"),
);
}}
>
{copied === "Conversation copied" ? <Check /> : <Copy />}
</IconButton>
</div>
</header>
}
emptyState={
<div className="flex min-h-64 flex-col items-center justify-center py-8 text-center">
<div className="border-primary/15 bg-primary/5 text-primary mb-5 flex size-14 items-center justify-center rounded-2xl border">
<Sparkles className="size-6" />
</div>
<p className="text-muted-foreground mb-2 text-xs">
A little clarity starts here
</p>
<h3 className="text-2xl font-semibold tracking-tight sm:text-3xl">
What’s on your mind?
</h3>
<p className="text-muted-foreground mt-3 max-w-sm text-sm leading-6">
Bring a question, an unfinished idea, or something you’d like to
make better.
</p>
<div className="mt-8 grid w-full max-w-lg gap-2 text-start">
{suggestions.map((item) => (
<button
key={item.title}
type="button"
onClick={() => setDraft(item.prompt)}
className="border-border bg-background hover:border-primary/30 hover:bg-primary/5 focus-visible:ring-ring group flex items-center gap-3 rounded-xl border p-3 text-start outline-none focus-visible:ring-2 motion-safe:transition-[background-color,border-color]"
>
<item.icon
aria-hidden="true"
className="text-primary size-4"
/>
<span className="flex-1 text-xs font-medium">
{item.title}
</span>
<ArrowUpRight
aria-hidden="true"
className="text-muted-foreground size-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5 motion-safe:group-hover:-translate-y-0.5 rtl:rotate-270"
/>
</button>
))}
</div>
</div>
}
prompt={{
value: current.draft,
onValueChange: setDraft,
onSend: demo.begin,
onStop: demo.stop,
attachments: current.attachments,
onFiles: (files) => demo.addFiles(current.id, files),
onAttachmentRemove: (attachment) => {
demo.clearTimers(attachment.id);
demo.update(current.id, (conversation) => ({
...conversation,
attachments: conversation.attachments.filter(
(item) => item.id !== attachment.id,
),
}));
},
onAttachmentRetry: (attachment) =>
demo.upload(current.id, attachment, true),
fileRules: {
accept: "image/png,image/jpeg,image/webp,.txt,.md,.pdf",
maxFiles: 4,
maxFileSize: 10 * 1024 * 1024,
},
allowAttachmentOnly: true,
models: chatModels,
modelId: current.modelId,
onModelChange: (modelId) =>
demo.update(current.id, (conversation) => ({
...conversation,
modelId,
})),
footer: "Sample responses · no messages or files leave this demo",
}}
/>
{!embedded && (
<aside className="border-border bg-muted/10 hidden w-56 shrink-0 flex-col border-s p-5 xl:flex">
<div className="mb-6 flex items-center gap-2 text-xs font-semibold">
<BookOpen className="size-3.5" />A little context
</div>
<p className="text-muted-foreground text-[11px] leading-5">
Good answers start with the right context. Try a document-backed
request.
</p>
<button
type="button"
onClick={() =>
setDraft("Ask for approval before reading the launch documents")
}
className="border-border bg-background hover:border-primary/40 focus-visible:ring-ring mt-4 rounded-xl border p-3 text-start outline-none focus-visible:ring-2 motion-safe:transition-colors"
>
<FileText className="text-primary mb-3 size-5" />
<p className="text-xs font-medium">Launch playbook</p>
<p className="text-muted-foreground mt-1 text-[10px]">
6 pages · Sample document
</p>
<span className="text-primary mt-3 inline-flex items-center gap-1 text-[10px] font-medium">
Explore with approval <ChevronRight className="size-3" />
</span>
</button>
<div className="border-border mt-7 border-t pt-5">
<p className="mb-3 text-xs font-semibold">Try the interactions</p>
<div className="space-y-1">
{[
{
label: "Thinking & slow streaming",
text: "Give me a slow response about our launch",
},
{
label: "Code & formatted answers",
text: "Show me code for a chat component",
},
{
label: "Recover from an error",
text: "Simulate a connection error",
},
].map((item) => (
<Button
key={item.label}
size="xs"
variant="ghost"
className="h-auto w-full justify-start px-0 py-2 text-start text-[11px] whitespace-normal"
onClick={() => setDraft(item.text)}
>
{item.label}
</Button>
))}
<Button
size="xs"
variant="ghost"
className="h-auto w-full justify-start px-0 py-2 text-[11px]"
onClick={() =>
demo.addFiles(current.id, [
new File(["Sample upload"], "reference-fail.txt", {
type: "text/plain",
}),
])
}
>
Recover a failed upload
</Button>
</div>
</div>
<div className="text-muted-foreground mt-auto pt-8 text-[10px] leading-5">
<MessageSquare className="mb-2 size-4" />
Switch conversations while a response is running. Your draft and
progress stay with each one.
</div>
</aside>
)}
<span className="sr-only" role="status">
{copied}
</span>
</div>
);
}
export function EmbeddedCopilot() {
return (
<div className="mx-auto max-w-xl">
<ChatWorkspace embedded startEmpty />
</div>
);
}The same public components, connected to AI SDK 6. This local endpoint exercises text, tools, approval, sources, files, errors, and cancellation without credentials.
Show sourceexamples/chat/sdk-example.tsx
"use client";
import { useMemo, useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
isToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai";
import {
Button,
Chat,
type ChatRun,
type ChatMessageActionHandlers,
type ChatPartRenderer,
} from "@dethink/components";
import { MarkdownMessage } from "@dethink/components/chat-markdown";
import { mapSdkActivity, createSdkMessageMapper } from "./sdk-adapter";
const transport = new DefaultChatTransport({ api: "/api/chat-demo" });
const renderPart: ChatPartRenderer = (part) =>
part.type === "text" ? <MarkdownMessage text={part.text} /> : undefined;
export function ChatSdkExample() {
const [attempt, setAttempt] = useState<ChatRun>({
id: "initial",
conversationId: "sdk",
status: "idle",
});
const [draft, setDraft] = useState("");
const sdk = useChat({
id: "dethink-sdk-demo",
transport,
experimental_throttle: 32,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
onError: (error) =>
setAttempt((run) => ({ ...run, status: "error", error: error.message })),
onFinish: ({ isAbort, isError }) =>
setAttempt((run) => ({
...run,
status: isAbort ? "stopped" : isError ? "error" : "completed",
})),
});
const last = sdk.messages.at(-1);
const approvalPending =
last?.role === "assistant" &&
last.parts.some(
(part) => isToolUIPart(part) && part.state === "approval-requested",
);
const run = useMemo<ChatRun>(
() => ({
...attempt,
status:
attempt.status === "stopping" || attempt.status === "stopped"
? attempt.status
: sdk.status === "error"
? "error"
: approvalPending
? "running"
: sdk.status === "submitted"
? "submitted"
: sdk.status === "streaming"
? "running"
: attempt.status,
label:
sdk.status === "streaming" &&
last?.parts.some((part) => part.type === "text")
? "Writing…"
: undefined,
}),
[attempt, sdk.status, approvalPending, last],
);
const mapMessage = useMemo(() => createSdkMessageMapper(), []);
const messages = useMemo(
() =>
sdk.messages.map((message) => {
const active = message === last && message.role === "assistant";
return mapMessage(message, "sdk", active ? run : undefined);
}),
[sdk.messages, last, run, mapMessage],
);
const { clearError, regenerate } = sdk;
const messageActions = useMemo<ChatMessageActionHandlers>(() => {
const retry = () => {
clearError();
setAttempt({
id: crypto.randomUUID(),
conversationId: "sdk",
status: "submitted",
});
void regenerate({ body: { recover: true } });
};
return { onRetry: retry, onRegenerate: retry };
}, [clearError, regenerate]);
const activity = mapSdkActivity(
last?.role === "assistant" ? last : undefined,
run,
);
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
setDraft("Ask for approval before reading the sample document")
}
>
Try approval
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDraft("Simulate an error")}
>
Try an error
</Button>
</div>
<Chat
className="border-border h-[560px] rounded-2xl border"
conversationId="sdk"
messages={messages}
run={run}
renderPart={renderPart}
activity={{
items: activity,
onApproval: async (decision) => {
await sdk.addToolApprovalResponse({
id: decision.approvalId,
approved: decision.approved,
});
},
}}
emptyState={
<p className="text-muted-foreground py-8 text-center text-sm">
Send a message to exercise a real AI SDK stream using local sample
data.
</p>
}
prompt={{
value: draft,
onValueChange: setDraft,
onSend: ({ text }) => {
sdk.clearError();
setAttempt({
id: crypto.randomUUID(),
conversationId: "sdk",
status: "submitted",
});
void sdk.sendMessage({ text });
return true;
},
onStop: async () => {
setAttempt((current) => ({ ...current, status: "stopping" }));
await sdk.stop();
setAttempt((current) => ({ ...current, status: "stopped" }));
},
footer: "AI SDK 6 · local streaming endpoint · no model credentials",
}}
messageActions={messageActions}
/>
</div>
);
}Controlled data, small composable parts, and explicit callbacks. Returning false or rejecting keeps the draft or reports the action error.
| Prop | What it does | Default |
|---|---|---|
conversationId / messagesstring / readonly ChatMessageData[] | Stable conversation identity and a controlled transcript of named text, citation, attachment, and custom parts. | Required |
runChatRun | Host-supplied submitted, running, stopping, completed, stopped, or error state. No transport runs inside Chat. | undefined |
activity{ items, onApproval?, renderResult? } | Ordered public activity events, explicit tool decisions, and trusted custom results. | undefined |
promptPromptInputProps | Draft ownership, send/stop callbacks, file validation and upload state, model selection, actions, and helper content. | undefined |
renderPartChatPartRenderer | Optional rich renderer. Return undefined for the built-in part renderer. Markdown uses a separate optional package entry. | Plain text |
messageActionsChatMessageActionHandlers | Copy, feedback, edit, retry, regenerate, and version-change callbacks. Unsupported actions remain hidden. | undefined |
historyMessageListProps | Older-history loading/error states and optional measured windowing with overscan and focused-row retention. | Full transcript |
scrollMessageScrollerProps | Per-conversation initialPosition/onPositionChange and scroll-region labels. Reading older messages detaches following. | Follow latest |
header / emptyState / composerReactNode | Compose your workspace header, first-use suggestions, or a fully custom composer. | undefined |
Follow the latest response until you scroll away. Older history, media resizing, and new chunks preserve the reader's position.
Windowing is opt-in. Keep it off for browser find, full transcript copy, and complete screen-reader traversal. Keep completed message references stable while streaming updates to the active message.
Messages use a named, semantic transcript. Enter sends, Shift + Enter adds a line, and IME composition never submits. The transcript keeps focus where you left it, with a separate status channel for meaningful activity updates.