Agent chat
Talk to an agent. Its words are roman and yours arrive in italic; it asks before it acts, in a question you must answer, and it tells you what it is doing as a sentence that inks in.
Report an issue with Agent chatExample
Ada, the timetable agent
Ready
I'm Ada. I draft timetables from what you already have. What are we working on?
Send it: Ada writes back, asks before she opens anything, reads, and asks you to choose.
Ada, the timetable agent
Draft ready
Draft the winter timetable for Halden.
Read last season’s timetable? Allowed once.
The winter boats leave from three harbours. Which way should it read?
By harbour.
Wrote the draft, 6 pages.
By harbour it is. The first draft is in the Halden folder.
Ada
Thinking
Draft the winter timetable for Halden.
Ada is writing
Read last season’s timetable? Waiting for your answer.
Read last season’s timetable? Allowed once.
Read last season’s timetable? Denied.
Ada, the timetable agent
Draft ready
Draft the winter timetable for Halden.
Read last season’s timetable? Allowed once.
The winter boats leave from three harbours. Which way should it read?
By harbour.
Wrote the draft, 6 pages.
By harbour it is. The first draft is in the Halden folder.
آدا، وكيلة الجداول
المسودة جاهزة
أعدّي جدول الشتاء لهالدن.
فُتح جدول الموسم الماضي، ١٤ صفحة.
حسب الميناء. المسودة الأولى في طريقها.
Install
Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, agent-state, attachment, button, dialog, field, kbd, marker, progress, radio-group, and thread along.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/agent-chat.jsonUsage
This example also uses Message.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/ message.json"use client"import * as React from "react"import {AgentChat,AgentChatChoice,AgentChatComposer,AgentChatHeader,AgentChatNote,AgentChatPermission,AgentChatThread,AgentChatWork,} from "@/components/ui/agent-chat"import { type AgentStateValue } from "@/components/ui/agent-state"import { Attachment, AttachmentList } from "@/components/ui/attachment"import { Dialog } from "@/components/ui/dialog"import { Message, MessageAvatar, MessageBody, MessageBubble, MessageHeader, MessageTyping } from "@/components/ui/message"type File_ = { id: string; name: string; size?: string }type Entry =| { k: "say"; from: "them" | "you"; text: string; time: string; arriving?: boolean; files?: File_[] }| { k: "note"; text: string }| { k: "ask" }| { k: "work" }| { k: "choice" }function Say({ from, text, time, arriving, files, who = "Ada" }: Omit<Extract<Entry, { k: "say" }>, "k"> & { who?: string }) {const you = from === "you"return (<Message from={from} arriving={arriving}><MessageAvatar alt={you ? "You" : who} /><MessageHeader name={you ? "You" : who} time={time} /><MessageBody><MessageBubble align={you ? "end" : "start"}>{text}</MessageBubble>{files?.length ? (<AttachmentList variant="enclosure">{files.map((f) => <Attachment key={f.id} name={f.name} size={f.size} />)}</AttachmentList>) : null}</MessageBody></Message>)}function Typing({ writing, who = "Ada" }: { writing: boolean; who?: string }) {return (<Message><MessageAvatar alt={who} /><MessageBody><MessageTyping writing={writing} label={`${who} is writing`} /></MessageBody></Message>)}const ways = [{ value: "time", label: "By departure time" },{ value: "harbour", label: "By harbour" },{ value: "weekday", label: "By weekday" },]const later = ["Noted. I'll fold that into the draft.", "Done. The change is in the draft.", "Good. I'll keep the Sunday boats as they were."]const size = (b: number) => (b < 1e6 ? `${Math.max(1, Math.round(b / 1e3))} KB` : `${(b / 1e6).toFixed(1)} MB`)/** Ask Ada for the winter timetable: she writes back, asks before she reads anything, reads, and asks you to choose. */function Live() {const [entries, setEntries] = React.useState<Entry[]>([{ k: "say", from: "them", time: "09:40", text: "I'm Ada. I draft timetables from what you already have. What are we working on?" },])const [value, setValue] = React.useState("Draft the winter timetable for Halden.")const [files, setFiles] = React.useState<File_[]>([{ id: "f0", name: "winter-routes.pdf", size: "1.2 MB" }])const [agent, setAgent] = React.useState<{ state: AgentStateValue; words?: string }>({ state: "ready" })const [writing, setWriting] = React.useState(false)const [decision, setDecision] = React.useState<"pending" | "allowed" | "denied">("pending")const [work, setWork] = React.useState(0)const [answer, setAnswer] = React.useState<string>()const timers = React.useRef<number[]>([])const minute = React.useRef(41)const said = React.useRef(0)React.useEffect(() => () => timers.current.forEach(clearTimeout), [])const wait = (ms: number, fn: () => void) => void timers.current.push(window.setTimeout(fn, ms))const clock = () => `09:${String(minute.current++).padStart(2, "0")}`const push = (e: Entry) => setEntries((all) => [...all, e])const reply = (text: string, then?: () => void) => {setAgent({ state: "thinking" })setWriting(true)wait(1100, () => {setWriting(false) // the periods go first, then her words write in, before thempush({ k: "say", from: "them", time: clock(), text, arriving: true })if (then) then()else setAgent({ state: "ready" })})}const choose = () => {push({ k: "choice" })setAgent({ state: "input", words: "Needs your choice" })}const read = (step = 0) => {setWork(step / 8)if (step < 8) return wait(380, () => read(step + 1))push({ k: "note", text: "Read timetable-2025.pdf, 14 pages." })choose()}return (<AgentChat aria-label="Ada, the timetable agent" className="w-full"><AgentChatHeader title="Ada, the timetable agent" state={agent.state} status={agent.words} /><AgentChatThread label="Conversation with Ada">{entries.map((e, i) =>e.k === "say" ? (<Say key={i} {...e} />) : e.k === "note" ? (<AgentChatNote key={i}>{e.text}</AgentChatNote>) : e.k === "ask" ? (<AgentChatPermissionkey={i}title="Read last season's timetable?"description="Ada opens timetable-2025.pdf in the Halden folder and reads it. Nothing is changed or sent."scope="Halden folder, read only"decision={decision}onDecision={(d) => {setDecision(d)if (d === "allowed") {setAgent({ state: "working", words: "Reading last season's timetable" })push({ k: "work" })read()} else reply("Then I'll start from the times on the ferry office board.", choose)}}/>) : e.k === "work" ? (<AgentChatWork key={i} value={work} max={1} label="Reading last season's timetable" />) : (<AgentChatChoicekey={i}question="Which way should the winter timetable read?"options={ways}answer={answer}onConfirm={(v) => {setAnswer(v)const label = ways.find((w) => w.value === v)?.label ?? vpush({ k: "say", from: "you", time: clock(), text: `${label}.` })reply(`${label} it is. The first draft is on its way.`, () => setAgent({ state: "done", words: "Draft ready" }))}}/>),)}<Typing writing={writing} /></AgentChatThread><AgentChatComposerlabel="Write to Ada"placeholder="Ask for a draft, a change, a check"value={value}onValueChange={setValue}busy={agent.state === "thinking" || agent.state === "working"}attachments={files}onAttach={(list) => setFiles((all) => [...all, ...list.map((f, i) => ({ id: `${f.name}-${Date.now()}-${i}`, name: f.name, size: size(f.size) }))])}onRemoveAttachment={(id) => setFiles((all) => all.filter((f) => f.id !== id))}onStop={() => {timers.current.forEach(clearTimeout)timers.current = []setWriting(false)setAgent({ state: "ready" })push({ k: "note", text: "Stopped." })}}onSend={(text) => {push({ k: "say", from: "you", time: clock(), text, files })setValue("")setFiles([])if (said.current++ === 0)reply("I'll need last season's timetable for that, so I'll ask before I open it.", () => {push({ k: "ask" })setAgent({ state: "input", words: "Needs your permission" })})else reply(later[(said.current - 2) % later.length])}}/></AgentChat>)}/** A finished exchange, its work and receipts hung out at the far side as Weingart's callouts. */function Callouts({ className, dir, lang }: { className?: string; dir?: "rtl"; lang?: string }) {if (dir === "rtl")return (<AgentChat variant="callout" aria-label="آدا" dir={dir} lang={lang} className={className}><AgentChatHeader title="آدا، وكيلة الجداول" state="done" status="المسودة جاهزة" /><AgentChatThread label="المحادثة مع آدا"><Say who="آدا" from="you" time="09:41" text="أعدّي جدول الشتاء لهالدن." /><AgentChatNote>فُتح جدول الموسم الماضي، ١٤ صفحة.</AgentChatNote><Say who="آدا" from="them" time="09:44" text="حسب الميناء. المسودة الأولى في طريقها." /></AgentChatThread><AgentChatComposerlabel="اكتب إلى آدا"sendLabel="أرسل"stopLabel="أوقف"attachLabel="أرفق"sendsLabel="يرسل"dropLabel={(n) => `أفلت لإرفاق ${n}.`}onAttach={() => {}}onSend={() => {}}/></AgentChat>)return (<AgentChat variant="callout" aria-label="Ada, finished" className={className}><AgentChatHeader title="Ada, the timetable agent" state="done" status="Draft ready" /><AgentChatThread label="Finished conversation with Ada"><Say from="you" time="09:41" text="Draft the winter timetable for Halden." files={[{ id: "f0", name: "winter-routes.pdf", size: "1.2 MB" }]} /><AgentChatNote data-decision="allowed">Read last season’s timetable? <span className="ot-yours">Allowed once.</span></AgentChatNote><AgentChatWork value={1} max={1} label="Read timetable-2025.pdf, 14 pages" /><Say from="them" time="09:43" text="The winter boats leave from three harbours. Which way should it read?" /><Say from="you" time="09:43" text="By harbour." /><AgentChatNote>Wrote the draft, 6 pages.</AgentChatNote><Say from="them" time="09:44" text="By harbour it is. The first draft is in the Halden folder." /></AgentChatThread><AgentChatComposer label="Write to Ada" placeholder="Ask for a change" onSend={() => {}} /></AgentChat>)}export default function Example() {return (<div className="grid w-full max-w-[72rem] gap-(--ot-space-8) lg:grid-cols-2"><div className="grid content-start gap-(--ot-space-4)"><Live /><p className="ot-pp text-(--ot-pencil)">Send it: Ada writes back, asks before she opens anything, reads, and asks you to choose.</p></div><Callouts /></div>)}const w = "w-[34rem] max-w-full"Contract
- Underneath: native, composed from
thread,message,attachment,field,dialog,radio-group,progress,marker,agent-state,buttonandkbd. The composer is the one new piece. - Anatomy:
<section class="ot-agent-chat" data-variant="callout">holdingheader.ot-agent-chat-head(anh2and aot-agentstatus),.ot-agent-chat-thread(aot-thread) andform.ot-agent-compose(data-overwhile files are dragged over it) with aot-fieldtextarea,ul.ot-attachments.ot-agent-encl(enclosure), and.ot-agent-compose-actions(Attach, a hidden multiple file input,.ot-agent-compose-hintwith twoot-kbd, then Send or Stop). In the thread:div.ot-agent-notearound aot-markerstatus (a note, or a permission's receipt withdata-decision="pending | allowed | denied") or a sentenceot-progress(the work), anddiv.ot-agent-choice(data-answered) holding a verticalot-choiceand a bracket Continue. - After "It has to be design.": the agent's words are ours, in roman; yours arrive in italic, in the thread and on the composer's ruled lines. When it asks permission the question is heavy and narrow and the answers are large in italic (the dialog's reply surface, an alert). Deny has the focus and Escape denies, so nothing is allowed by default; the question and your answer stay behind as a receipt, "Read last season's timetable? Allowed once.", the answer italic because it's yours. A choice is the radio words in a column; answered, only your word stays inked. The work is a sentence that inks in at the size of the messages and ends in a full stop.
- The composer: Enter starts a new line, ⌘ Enter (Ctrl Enter elsewhere,
aria-keyshortcuts) sends. Files come by Attach or by dropping them on the composer and are written under the box as a letter's "Encl. (2)"; dragged over, the count takes the incoming files and a pencil italic line says "Let go to enclose all 2." before anything is added. While the agent is busy, Send gives way to a bracket Stop and the box stays open for your next words. An error hangs from the box as the field's callout. Send and Stop keep the far end, wrapped or not; below 22rem the key hint gives way. Every word is a prop with an English default (sendLabel,stopLabel,attachLabel,sendsLabel,dropLabel(count); on the permissionallowLabel,denyLabel,allowedLabel,deniedLabel,metaLabel,pendingLabel), so a right-to-left page reads in its own language. - Callout (
data-variant="callout"), after the Weingart letter: the notes, receipts and the work leave the column and hang at the far side, reversed out of ink pills (the tokens are swapped inside, so the work sentence inks in reverse), each on a hairline leader that reaches back toward the conversation and ends in a dot. - Typing: keep a
MessageTypingmounted last in the thread and drivewriting; when the reply comes, set writing false and add the newMessagewitharrivingbefore it. The header'sot-agentis the one live status for where the agent is. - Right to left the whole column mirrors and the leaders reach the other way. Reduced motion: the parts' own still frames. Forced colours: the pills are Canvas with a CanvasText edge and leader.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
AgentChat variant | "default" | "callout" | "default" | default: the agent's work and your receipts run in the conversation as quiet lines, after "It has to be design." callout: after Weingart's letter, they leave the column and hang at the far side, reversed out of pills, each on a leader line that ends in a dot. |
AgentChatHeader | { title: ReactNode; state: AgentStateValue; status?: string } | The agent's name, and where it is, as an AgentState: ready, thinking, working, input (your turn, the one accent) or done. status gives other words for it: "Reading the brief". | |
AgentChatThread | ThreadProps | The conversation: a Thread that takes the room the header and composer leave. Messages, notes, the work, a permission and a choice are its direct children; keep a MessageTyping mounted last and drive writing, and when the reply comes set writing false and add the Message with arriving before it. | |
AgentChatComposer | AgentChatComposerProps | A native form with a ruled textarea. label and onSend are required. Enter starts a line; ⌘ Enter (Ctrl Enter) sends. Native form props are accepted except onSubmit; use onSend for sending. | |
Composer label / placeholder | { label: string; placeholder?: string } | label names the textarea and is shown above it. placeholder supplies a prompt inside the empty box, in the roman voice. | |
Composer value / defaultValue | string | defaultValue: "" | Controlled text or its initial uncontrolled value. An uncontrolled composer clears after sending; when supplying value, clear it in your onSend handler. |
Composer onValueChange | (value: string) => void | Called as the person edits the textarea. Supply it with value to keep controlled text up to date. | |
Composer onSend | (value: string) => void | Required. Receives the current text unchanged on an accepted send. Sending needs non-whitespace text or an attachment, and is disabled while busy or disabled. onSend owns submission; the component does not upload files. | |
Composer busy / onStop | { busy?: boolean; onStop?: () => void } | busy: false | busy prevents sending while keeping the box open for the next words. With onStop, Send becomes Stop and calls that handler; without it, Send stays disabled. |
Composer attachments | readonly { id: string; name: string; size?: string; state?: "uploading" | "processing" | "done" | "error"; progress?: number; status?: string }[] | [] | Files displayed under the textarea as Encl. (2). id identifies a file for removal, name is its filename and size is formatted text. state, progress (0–1) and status are passed to Attachment; an omitted state displays as done. |
Composer onAttach / accept | { onAttach?: (files: File[]) => void; accept?: string } | onAttach enables Attach and file dropping and receives the chosen or dropped files. accept is the native file picker's filter; it does not validate dropped files. Add the files to attachments to display them. | |
Composer onRemoveAttachment | (id: string) => void | Adds a removal action to each displayed file. Update attachments in the callback to remove it. Removal is unavailable while disabled. | |
Composer error / maxLength / disabled | { error?: ReactNode; maxLength?: number; disabled?: boolean } | disabled: false | error hangs a Field callout from the textarea. maxLength sets its native character limit and shows a count. disabled prevents editing, sending, attaching and removing files; Stop remains available when supplied while busy. |
Composer sendLabel / stopLabel / attachLabel / sendsLabel | string | "Send" / "Stop" / "Attach" / "sends" | Translate the action words and the word after the keyboard shortcut. Each visible action word also names its button. |
Composer dropLabel | (count: number) => string | The enclosure prompt while files are dragged over. count includes existing attachments and incoming files. Defaults to "Let go to enclose it." for one, or "Let go to enclose all 2." for two. | |
AgentChatPermission | { title: string; decision: "pending" | "allowed" | "denied"; onDecision: (decision: "allowed" | "denied") => void; description?: ReactNode; scope?: ReactNode; allowLabel?: string; denyLabel?: string; allowedLabel?: string; deniedLabel?: string; metaLabel?: string; pendingLabel?: string } | The agent asks before it acts. Pending, it is an alert dialog: the question heavy and narrow, the answers large in italic. Deny has the focus and Escape denies, so nothing is allowed by default. Update decision in onDecision to keep the answer as a receipt. description explains the request; scope is the fact over it. The label defaults are allowLabel ("Allow once"), denyLabel ("Deny"), allowedLabel ("Allowed once"), deniedLabel ("Denied"), metaLabel ("Permission", over the question) and pendingLabel ("Waiting for your answer."). | |
AgentChatChoice | { question: ReactNode; options: readonly { value: string; label: string }[]; onConfirm: (value: string) => void; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; confirmLabel?: string; answer?: string } | The question, options and onConfirm are required. value and onValueChange control selection; defaultValue starts an uncontrolled choice. Nothing is selected by default. confirmLabel defaults to Continue and onConfirm receives the selected value. Set answer to that value to disable the radios, remove confirmation and keep the chosen word in italic. Accepts native div props except defaultValue, which belongs to the selection. | |
AgentChatWork | Omit<ProgressProps, "variant"> | What the agent is doing, as a Progress sentence that inks in as it goes and ends in a full stop. | |
AgentChatNote | MarkerProps | A quiet line from the agent's side: "Read timetable-2025.pdf, 14 pages." Takes Marker props, with dot defaulting to true. className styles the outer note; the remaining props go to Marker. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Agent chat | "It has to be design." (theirs roman, yours italic) | The agent's words roman, yours italic; its question heavy and narrow, your answer large in italic and kept as a receipt |
| Callout agent chat | Weingart letter's callouts | The work and the receipts hang at the far side in reversed pills on leader lines that end in dots |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Agent chat | Count in | Files dragged over are counted into the enclosure line before you let go; the rest is its parts' own motion, each answering you or the agent |