Chat

The AI conversation thread, the second piece of the AI module. The assistant answers as a document, the Notion and ChatGPT style; the user turn stays a bubble. Streaming and reasoning included.

Explain how the density system works in this codebase.

Density is a single axis, comfortable or compact, that flows through React context so every component sizes from one source of truth.

How it works

  • DensityProvider puts the active density on the context.
  • Components read it with the useDensity hook.
  • Each maps the value to its own spacing, hit areas, and font size.

Why a context

  • One toggle re-densifies a whole subtree, with no prop drilling.
  • Marketing and app shells can run different densities side by side.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add chat

Anatomy#

Every part is a named export, never dot-notation, so each one crosses the server and client boundary cleanly. Conversation is the scrollable thread; each Message carries its role to its parts through context, so one prop drives the side, tint, and meta alignment. Compose only the parts a turn needs.

anatomy.tsxTSX
<Conversation>  <Message role="assistant">    <MessageAvatar name="Koala" />    <MessageBody>      <MessageHeader>        <MessageName>Koala</MessageName>        <MessageTime>2:34 PM</MessageTime>      </MessageHeader>      <MessageReasoning>        <MessageReasoningTrigger>Thought for 6s</MessageReasoningTrigger>        <MessageReasoningContent>…</MessageReasoningContent>      </MessageReasoning>      <MessageContent>…</MessageContent>      <MessageActions>        <MessageAction aria-label="Copy">…</MessageAction>      </MessageActions>    </MessageBody>  </Message></Conversation>

Usage#

usage.tsxTSX
import {  Conversation,  Message,  MessageAvatar,  MessageBody,  MessageContent,} from "@/components/ui/chat"export function Thread() {  return (    <Conversation className="max-h-[32rem]">      <Message role="user">        <MessageBody>          <MessageContent>Where is the theme applied on load?</MessageContent>        </MessageBody>      </Message>      <Message role="assistant">        <MessageAvatar name="Koala" />        <MessageBody>          <MessageContent>In app/layout.tsx, via a blocking script before paint.</MessageContent>        </MessageBody>      </Message>    </Conversation>  )}

Roles#

A message's role is the only switch. assistant aligns left with an avatar and a muted bubble; user aligns right with a solid blue bubble. Both use a soft, uniform radius on all four corners.

Why is pnpm build failing on CI but not locally?
K
CI runs Node 18, but the config calls structuredClone, which only landed in Node 20. Bump the workflow to Node 20 and the build passes.

With avatar (bubble)#

The default is the document style above. For a chat with an identity, the “robotized” look, set variant="bubble" on the Conversation: every turn gets a bubble, and you pair the assistant with a MessageAvatar, name, time, and actions. A per-message variant overrides the conversation default.

Add an empty state to the data table when there are no rows.
K
Koala2:34 PM

Done. I dropped an EmptyState into the table body:

  • Renders when rows.length === 0.
  • Reuses our EmptyState (icon, title, action).
  • Stays hidden while loading so the skeleton shows.
Nice. Now wire it to the loading prop.
K

Rich content#

MessageContent is markup-agnostic. Drop in plain text or your own rendered markdown; the bubble styles the common elements (paragraphs, headings, lists, inline code, pre blocks, and links) through descendant selectors, tinted to read on either bubble.

K

Define the component recipe with tv and read its variants like this:

const button = tv({
  base: "inline-flex items-center justify-center",
  variants: { size: { sm: "h-8 px-3", md: "h-10 px-4" } },
})

See the foundations for the full house style.

Actions#

MessageActions holds a row of MessageAction buttons for copy, regenerate, and feedback. Each wraps our Button, so it inherits the press scale, the focus ring, and a tooltip drawn from its aria-label.

K
I dropped forwardRef from Button and now pass ref as a regular prop, per the React 19 rule. Want me to apply the same change across the other primitives?

Typing indicator#

While a response streams, drop a MessageTyping inside a MessageContent. Three dots ripple left to right, and hold still under reduced-motion.

K

Reasoning#

The collapsible “thinking” block, the way Claude and ChatGPT show it. While streaming the icon pulses and the trigger reads “Investigating…”; once done, it settles to a summary like “Investigated 14 sources” that anyone can expand. For a simple answer, pass plain text; for an agentic run, the block becomes a log of what it did. Group each phase in a MessageReasoningStep (an optional label captions it, a dot pins onto the rail so a column reads as a timeline, and status="active" pulses the in-progress one). Show the searches it ran with MessageReasoningQuery and the URLs it consulted with MessageReasoningSource, as compact favicon and domain chips by default or a bordered title list via layout="list". The domain and favicon are derived from the href. Built on Radix Collapsible, so keyboard and ARIA come for free. Press Ask again to watch it stream through the phases.

Dark mode flashes the wrong theme on first load. Can you trace the hydration flicker?
K

Reproducing the flash of the wrong theme. The theme looks read on the client after hydration, so the first paint always uses the default.

Searching the codebase
useTheme localStoragedata-theme on <html>suppressHydrationWarning
The theme is applied in a useEffect, so the server sends the default and the client only corrects it after the first paint. Set data-theme from localStorage in a small blocking script in the head, and add suppressHydrationWarning to <html>.

API reference#

Conversation

The scrollable thread viewport (role="log", a polite live region, so appended turns are announced). A flex column with a gap; set a height or max-height to make it scroll. variant ("plain" default, "bubble") sets the style for every turn inside. stickToBottom (default true) follows new turns, pinning the viewport to the newest message as the thread grows, and only while the reader is already at the bottom, so scrolling up to reread history is never yanked back down. Turn it off for a static transcript that should open at the top. Forwards all native div props.

Message

One turn. role?: "assistant" | "user" (default "assistant") drives alignment and bubble tint, and flows to every part through context. variant overrides the conversation default for this turn.

MessageAvatar

Wraps our Avatar. Takes src or name (initials fallback) plus any Avatar prop (e.g. size); children override the fallback, for example with an icon.

MessageBody · MessageHeader · MessageName · MessageTime · MessageContent

MessageBody is the vertical column that aligns to the message side; MessageContent is the bubble (markup-agnostic). Header, Name, and Time are the optional meta row above the bubble.

MessageActions · MessageAction · MessageTyping

MessageAction is a ghost icon Button (all Button props apply, and the aria-label drives both the name and a tooltip); MessageTyping is the three-dot streaming indicator.

MessageReasoning · MessageReasoningTrigger · MessageReasoningContent

The collapsible thinking block (Radix Collapsible). MessageReasoning takes streaming (pulses the icon, defaults the label to “Thinking…”) plus Collapsible's open / defaultOpen / onOpenChange. The trigger's children are the label; the content holds the chain of thought behind a left rail.

MessageReasoningStep · MessageReasoningQueries · MessageReasoningQuery

MessageReasoningStep is one phase of an agentic reasoning log. label captions it, status?: "active" | "done" (default "done") turns the rail dot into a brand pulse, and marker={false} drops the dot. MessageReasoningQuery is a monospace search pill; wrap several in a MessageReasoningQueries row.

MessageReasoningSources · MessageReasoningSource

MessageReasoningSources groups the consulted URLs. layout?: "chips" | "list" (default "chips") switches between compact domain pills and a bordered title list, and flows to each source through context. MessageReasoningSource takes a required href (the domain and favicon derive from it), an optional title for the list layout, and an optional favicon override (it falls back to a globe glyph). It renders an anchor that opens in a new tab.

FAQ#

role is the only switch: assistant aligns left with an avatar and a muted bubble, while user aligns right with a solid blue bubble. The value flows to every part through context, and both bubbles use a soft, uniform radius on all four corners.