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-sidebar

Each 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

PartItemWhat it is
ChatThreadchatThe scrolling log: follows new text unless you scroll up, then offers Jump to latest
ChatMessagechatOne message: who sent it, when, its content and an actions slot
ChatMessageActionschatCopy, Retry, Edit and Good or Bad under a message, as one toolbar
ChatBranchchat"2 of 3" with Previous and Next version, for regenerated answers
ChatEmpty, ChatSuggestionschatThe empty thread's heading, and prompts to start from
ChatTaskschatA plan: each task's status in words, and "1 of 3 done"
ChatShimmerchat"Thinking…" with a shimmer
ChatResponsechat-responseThe model's Markdown, completed while it streams; raw HTML never renders
ChatCodeBlockchat-responseHighlighted code with Wrap and Copy (ChatResponse uses it for fenced code)
ChatReasoningchat-partsThe model's thinking: open while it streams, then "Thought for 4s"
ChatToolchat-partsA tool call: its status in words, input, output or error
ChatSources, ChatCitationchat-parts"Used 2 sources", and a numbered link with a hover card
ChatImagechat-partsAn image with its size reserved and an optional caption
ChatComposerchat-composerThe message box: Enter sends, commands, mentions, attachments, a toolbar
ChatModelPicker, ChatUsagechat-composerA model Select, and a meter that reads "18.4K of 200K tokens"
ChatThreadListchat-sidebarThe chats: choose one, New chat, Rename, Delete (asks first)
ChatLauncherchat-sidebarA floating button that opens the assistant in a panel

Use

tsx
<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's label ("Conversation") names its scroll region, which holds a role="log". The region is capped at --chat-thread-height (26rem by default).
  • ChatMessage takes from ("user", "assistant" or "system"), name (You, Assistant or System by default), avatar ({ src?, fallback }, named by name) and time (a Date, shown in the viewer's short time format). streaming marks it aria-busy and draws a caret after the last block.
  • ChatMessageActions shows Copy when text is given, Retry with onRetry, Edit with onEdit, and Good and Bad with onFeedbackChange. Pressing the pressed one again gives null. Copy announces "Copied". label names the toolbar ("Message actions").
  • ChatBranch takes index (from 0), count and onIndexChange, and renders nothing for fewer than two versions. "2 of 3" is announced as it changes.
  • ChatTasks takes tasks ({ id, title, status, detail? }), title ("Plan") and defaultOpen (on). Statuses pending, running, done and failed read as To do, In progress, Done and Failed.

Answers

  • ChatResponse takes the Markdown as its child, with GitHub tables, task lists and strikethrough. With streaming, half-written Markdown is completed (remend), so **bold shows as bold, not stars.
  • Raw HTML in the Markdown is dropped, never rendered: model output is untrusted. http and https links 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: or blob:. Any other image shows as a link ("Image: chart"), because its address could carry what the model read to another server. allowImage(url) returns true for 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 ChatCodeBlock yourself with code, language and filename (which then names the block and its Copy button).
  • ChatReasoning opens while streaming is 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" with seconds (rounded, at least 1), and "Reasoning" without.
  • ChatTool's status is pending, running, approval, done, error or denied, read as Queued, Running, Needs your approval, Done, Failed and Not allowed. It opens itself for approval and error unless you pass defaultOpen. title replaces name in the header; input and output show as JSON (strings as they are); error shows in place of the output.
  • ChatTool has no Allow or Deny buttons of its own. Put yours in its children, which render in the panel, and give Allow a data-agent-confirm (the web element does).
  • ChatSources takes sources ({ href, title, description? }) and renders nothing for an empty list. ChatCitation takes index and one source: a link named "Source 1: title" whose hover card shows the title, host and description.
  • ChatImage needs src, alt, width and height, so the space is held before it loads; caption is 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 running is on, Enter and Send don't send. With onStop too, Send becomes Stop.
  • commands show after "/" at the start of the message and mentions after "@", each { value, label, description? }. Choosing one inserts the trigger and the label ("/summarize "); onSubmit gets that text, not the item's value.
  • attachments is off by default. { accept?, maxFiles?, maxSize? } (maxSize in bytes) adds a paperclip, takes files dropped on the box or pasted into it, and lists them as chips with Remove. UI SDK's FileInput announces files it rejects; they never join.
  • tools render in the composer's toolbar, beside Send. label ("Message") names the box; placeholder defaults to "Message…".
  • Up brings back messages sent from this box since it mounted.
  • ChatModelPicker takes models ({ value, label, description? }), value or defaultValue (the first model when neither is given), onValueChange and label ("Model", visually hidden).
  • ChatUsage takes used and limit, with unit ("tokens") and label ("Context used"). The meter carries data-level="medium" past 70% and "high" past 90%.

Chats and the launcher

  • ChatThreadList takes threads ({ id, title, updatedAt? }), value (the open chat, marked aria-current="page") and onValueChange(id). label names the list ("Chats").
  • onNew adds New chat. onRename(id, title) and onDelete(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 calling onDelete.
  • updatedAt shows as "4 min ago" and is the chat button's description. It's worked out when the list renders, not kept current.
  • ChatLauncher takes label ("Open assistant"), title ("Assistant") and the panel's children. 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 signal and 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

KeysWhereDoes
Entermessage boxSend; with a "/" or "@" menu open, choose the item
Shift+Entermessage boxA new line
Up, Downmessage boxWhat you sent before, when the caret is on the first or last line
"/" at the start, "@"message boxCommands, mentions: arrows move, Enter or Tab choose, Escape closes
Tabthread, code, tablesOne stop each; arrows and Page keys scroll it
Arrowsmessage actions, suggestionsMove along the toolbar (one Tab stop)
Enter, Spacereasoning, tool, sources, planOpen or close it
Enter, Escaperename fieldSave, cancel (leaving the field saves too)
EscapelauncherClose 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") with insert, plus Send (or Stop) and "Attach files" (setValue, addFiles, removeFile, clear, open). insert writes only while the box has focus; without focus it reports success and writes nothing. There's no setValue on 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 to press Copy message, Retry message, Edit message, Good response or Bad response; "Feedback on this response" also takes setValue. 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 press Rename or Delete. Rename shows a "Rename title" field; an agent's setValue fills 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 no allow.

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.