Chat
A conversation built on UI SDK: thread that follows new messages, messages with avatars, actions and versions, suggestions, plans and a thinking shimmer.
A chat interface on UI SDK, in five items: the thread and its messages, Markdown answers
that stream, the parts around an answer (reasoning, tool calls, sources), the message box,
and the list of chats. Every part takes plain props, so your model's stream goes into your
own state and the kit draws it. The chat-app template puts them together into a page.
pnpm dlx shadcn@latest add superzero11/uiset/chat superzero11/uiset/chat-response superzero11/uiset/chat-parts superzero11/uiset/chat-composer superzero11/uiset/chat-sidebarEach of the last four brings chat with it. chat-response also adds react-markdown,
remark-gfm, remend and shiki.
The behavior is UI SDK's. The thread follows new text with createStickToBottom; the
message box brings back what you sent with createInputHistory; "/" and "@" are
TriggerMenu; attachments are FileInput; reasoning, tool calls and sources are
Collapsible; the model picker is a Select; usage is a Meter.
Parts
| Part | Item | What it is |
|---|---|---|
ChatThread | chat | The scrolling log: follows new text unless you scroll up, then offers Jump to latest |
ChatMessage | chat | One message: who sent it, when, its content and an actions slot |
ChatMessageActions | chat | Copy, Retry, Edit and Good or Bad under a message, as one toolbar |
ChatBranch | chat | "2 of 3" with Previous and Next version, for regenerated answers |
ChatEmpty, ChatSuggestions | chat | The empty thread's heading, and prompts to start from |
ChatTasks | chat | A plan: each task's status in words, and "1 of 3 done" |
ChatShimmer | chat | "Thinking…" with a shimmer |
ChatResponse | chat-response | The model's Markdown, completed while it streams; raw HTML never renders |
ChatCodeBlock | chat-response | Highlighted code with Wrap and Copy (ChatResponse uses it for fenced code) |
ChatReasoning | chat-parts | The model's thinking: open while it streams, then "Thought for 4s" |
ChatTool | chat-parts | A tool call: its status in words, input, output or error |
ChatSources, ChatCitation | chat-parts | "Used 2 sources", and a numbered link with a hover card |
ChatImage | chat-parts | An image with its size reserved and an optional caption |
ChatComposer | chat-composer | The message box: Enter sends, commands, mentions, attachments, a toolbar |
ChatModelPicker, ChatUsage | chat-composer | A model Select, and a meter that reads "18.4K of 200K tokens" |
ChatThreadList | chat-sidebar | The chats: choose one, New chat, Rename, Delete (asks first) |
ChatLauncher | chat-sidebar | A floating button that opens the assistant in a panel |
Use
<ChatThread label="Conversation">
{turns.length === 0 && (
<ChatEmpty title="What are we working on?">
<ChatSuggestions suggestions={["Draft release notes"]} onSelect={send} />
</ChatEmpty>
)}
{turns.map((turn) =>
turn.from === "user" ? (
<ChatMessage key={turn.id} from="user">{turn.text}</ChatMessage>
) : (
<ChatMessage
key={turn.id}
from="assistant"
avatar={{ fallback: "AI" }}
streaming={turn.streaming}
actions={!turn.streaming && (
<ChatMessageActions text={turn.text} onRetry={() => retry(turn.id)}
feedback={turn.feedback} onFeedbackChange={(f) => rate(turn.id, f)} />
)}
>
{turn.reasoning && (
<ChatReasoning streaming={turn.thinking} seconds={turn.seconds}>
{turn.reasoning}
</ChatReasoning>
)}
{turn.tools.map((tool) => (
<ChatTool key={tool.id} name={tool.name} status={tool.status}
input={tool.input} output={tool.output} />
))}
<ChatResponse streaming={turn.streaming}>{turn.text}</ChatResponse>
<ChatSources sources={turn.sources} />
</ChatMessage>
),
)}
</ChatThread>
<ChatComposer
running={running}
onStop={stop}
onSubmit={({ text, files }) => send(text, files)}
commands={[{ value: "summarize", label: "summarize", description: "Shorten the thread" }]}
mentions={projectFiles.map((f) => ({ value: f.path, label: f.name }))}
attachments={{ accept: "image/*,.pdf", maxFiles: 5, maxSize: 10 * 1024 * 1024 }}
tools={
<>
<ChatModelPicker models={models} value={model} onValueChange={setModel} />
<ChatUsage used={used} limit={200000} />
</>
}
/>
ChatThread'slabel("Conversation") names its scroll region, which holds arole="log". The region is capped at--chat-thread-height(26rem by default).ChatMessagetakesfrom("user","assistant"or"system"),name(You, Assistant or System by default),avatar({ src?, fallback }, named byname) andtime(aDate, shown in the viewer's short time format).streamingmarks itaria-busyand draws a caret after the last block.ChatMessageActionsshows Copy whentextis given, Retry withonRetry, Edit withonEdit, and Good and Bad withonFeedbackChange. Pressing the pressed one again givesnull. Copy announces "Copied".labelnames the toolbar ("Message actions").ChatBranchtakesindex(from 0),countandonIndexChange, and renders nothing for fewer than two versions. "2 of 3" is announced as it changes.ChatTaskstakestasks({ id, title, status, detail? }),title("Plan") anddefaultOpen(on). Statusespending,running,doneandfailedread as To do, In progress, Done and Failed.
Answers
ChatResponsetakes the Markdown as its child, with GitHub tables, task lists and strikethrough. Withstreaming, half-written Markdown is completed (remend), so**boldshows as bold, not stars.- Raw HTML in the Markdown is dropped, never rendered: model output is untrusted.
httpandhttpslinks open in a new tab and say so to screen readers. Wide tables scroll in a focusable region. - Images in an answer load when they're same-origin,
data:orblob:. Any other image shows as a link ("Image: chart"), because its address could carry what the model read to another server.allowImage(url)returnstruefor the ones to load. - Fenced code with a language is highlighted by shiki, loaded the first time it's needed,
one language at a time, in the skin's colors. A language shiki doesn't know shows as
plain text. Each block has a Wrap toggle and a Copy button named "Copy ts". Render
ChatCodeBlockyourself withcode,languageandfilename(which then names the block and its Copy button). ChatReasoningopens whilestreamingis on and closes when it ends, unless a person or an agent opened or closed it; then it stays as they left it. It reads "Thinking…" while streaming, "Thought for 4s" withseconds(rounded, at least 1), and "Reasoning" without.ChatTool'sstatusispending,running,approval,done,errorordenied, read as Queued, Running, Needs your approval, Done, Failed and Not allowed. It opens itself forapprovalanderrorunless you passdefaultOpen.titlereplacesnamein the header;inputandoutputshow as JSON (strings as they are);errorshows in place of the output.ChatToolhas no Allow or Deny buttons of its own. Put yours in itschildren, which render in the panel, and give Allow adata-agent-confirm(the web element does).ChatSourcestakessources({ href, title, description? }) and renders nothing for an empty list.ChatCitationtakesindexand onesource: a link named "Source 1: title" whose hover card shows the title, host and description.ChatImageneedssrc,alt,widthandheight, so the space is held before it loads;captionis optional.
The composer
onSubmit({ text, files })gets the trimmed text and the attached files, then the box clears. With no text and no files, nothing is sent.- While
runningis on, Enter and Send don't send. WithonStoptoo, Send becomes Stop. commandsshow after "/" at the start of the message andmentionsafter "@", each{ value, label, description? }. Choosing one inserts the trigger and the label ("/summarize ");onSubmitgets that text, not the item'svalue.attachmentsis off by default.{ accept?, maxFiles?, maxSize? }(maxSizein bytes) adds a paperclip, takes files dropped on the box or pasted into it, and lists them as chips with Remove. UI SDK'sFileInputannounces files it rejects; they never join.toolsrender in the composer's toolbar, beside Send.label("Message") names the box;placeholderdefaults to "Message…".- Up brings back messages sent from this box since it mounted.
ChatModelPickertakesmodels({ value, label, description? }),valueordefaultValue(the first model when neither is given),onValueChangeandlabel("Model", visually hidden).ChatUsagetakesusedandlimit, withunit("tokens") andlabel("Context used"). The meter carriesdata-level="medium"past 70% and"high"past 90%.
Chats and the launcher
ChatThreadListtakesthreads({ id, title, updatedAt? }),value(the open chat, markedaria-current="page") andonValueChange(id).labelnames the list ("Chats").onNewadds New chat.onRename(id, title)andonDelete(id)add a "More for title" menu to each chat. Rename edits in place: Enter or leaving the field saves, Escape cancels, and an empty or unchanged title isn't sent. Delete asks in an alert dialog ("Delete “Launch plan”?") before callingonDelete.updatedAtshows as "4 min ago" and is the chat button's description. It's worked out when the list renders, not kept current.ChatLaunchertakeslabel("Open assistant"),title("Assistant") and the panel'schildren. The panel opens above its button and stays open while you click around the page; Escape, the button and Close shut it.
The chat-app template
pnpm dlx shadcn@latest add superzero11/uiset/chat-app<ChatAppTemplate assistant={assistant} /> is the kit as a page. assistant is an async
generator given { text, files, model, history, signal } that yields text, reasoning,
reasoning-end, tool (the same id updates the card) and sources events; the sample
one streams a placeholder. It also takes models, commands, mentions, suggestions,
userName ("You"), userAvatar and limit (200000, for the usage meter).
- It runs one answer at a time. Stop aborts
signaland isn't shown as a failure; any other error adds "The answer stopped early. Try again." - It starts on the second entry in
models(the sample's "Balanced"). - New chats are numbered ("Untitled chat 2") until the first message, cut to 40 characters, becomes the title. Deleting the last chat starts a new one.
- Retry sends the question before that answer again, as a new turn.
- Usage is an estimate per chat (
estimateTokens: 900 plus 4 per character of each message sent), until your model reports its own.
Templates shows a model connected.
Keys
| Keys | Where | Does |
|---|---|---|
| Enter | message box | Send; with a "/" or "@" menu open, choose the item |
| Shift+Enter | message box | A new line |
| Up, Down | message box | What you sent before, when the caret is on the first or last line |
| "/" at the start, "@" | message box | Commands, mentions: arrows move, Enter or Tab choose, Escape closes |
| Tab | thread, code, tables | One stop each; arrows and Page keys scroll it |
| Arrows | message actions, suggestions | Move along the toolbar (one Tab stop) |
| Enter, Space | reasoning, tool, sources, plan | Open or close it |
| Enter, Escape | rename field | Save, cancel (leaving the field saves too) |
| Escape | launcher | Close it |
Enter doesn't send while an input method is composing. Copying, version changes and attached or rejected files are announced.
For agents
The bridge sees UI SDK components, so messages themselves aren't in its snapshot: give your agent the conversation from your own state. What it does see, by name:
- Message box: a trigger menu named by
label("Message") withinsert, plus Send (or Stop) and "Attach files" (setValue,addFiles,removeFile,clear,open).insertwrites only while the box has focus; without focus it reports success and writes nothing. There's nosetValueon the box. - Toolbar: "Model" is a select an agent sets by a model's label or value; "Context
used" is a meter it reads (
value,min,max). - Answers: each reasoning, tool call, sources list and plan is a collapsible named by
its header ("Thought for 4s", "Searched the docs, Done", "Used 2 sources"), with
setOpen. "Message actions" is a toolbar topressCopy message, Retry message, Edit message, Good response or Bad response; "Feedback on this response" also takessetValue. Code blocks have "Wrap lines" and "Copy ts"; citations are hover cards ("Source 1: AbortController"). - Chats: New chat, each chat as a button named by its title, and "More for title" to
pressRename or Delete. Rename shows a "Rename title" field; an agent'ssetValuefills it, but it's saved only on Enter or when focus leaves it. - Delete chat in the dialog carries
data-agent-confirm("Deletes the chat “Launch plan” and its messages"), so a step on it waits for the person, or fails with "Needs a person" when the bridge has noallow.
Names repeat from one answer to the next ("Message actions", "Wrap lines", "Previous
version"). With two answers on the page, a step that names one fails and lists the refs;
retry with a ref, or give each ChatMessageActions its own label. Nothing in the kit is
data-agent-hidden.
Web Components
<uiset-chat>, <uiset-chat-composer>, <uiset-chat-threads>,
<uiset-chat-model-picker> and <uiset-chat-usage> are the kit as custom elements.
<uiset-chat> draws a whole conversation from AI SDK's messages shape, with Allow and
Deny on tool calls that wait for approval. The launcher has no element. See
Web Components.