Chat

Pro

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# Activate your Pro license (once per machine)npx koalaui-cli@latest login <key># Add this component (its dependencies come along)npx koalaui-cli@latest add chat

Usage#

This example is part of Koala UI Pro

The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.

Have a license? Unlock it here

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.

import {  Conversation,  ConversationScrollButton,  Message,  MessageAvatar,  MessageBody,  MessageHeader,  MessageName,  MessageTime,  MessageReasoning,  MessageReasoningTrigger,  MessageReasoningContent,  MessageReasoningStep,  MessageReasoningFiles,  MessageReasoningFile,  MessageReasoningFilesMore,  MessageReasoningQueries,  MessageReasoningQuery,  MessageReasoningSources,  MessageReasoningSource,  MessageContent,  MessageTyping,  MessageActions,  MessageAction,} from "@/components/ui/chat"<Conversation>  <Message>    <MessageAvatar />    <MessageBody>      <MessageHeader>        <MessageName />        <MessageTime />      </MessageHeader>      <MessageReasoning>        <MessageReasoningTrigger />        <MessageReasoningContent>          <MessageReasoningStep>            <MessageReasoningQueries>              <MessageReasoningQuery />            </MessageReasoningQueries>            <MessageReasoningSources>              <MessageReasoningSource />            </MessageReasoningSources>            <MessageReasoningFiles>              <MessageReasoningFile />              <MessageReasoningFilesMore />            </MessageReasoningFiles>          </MessageReasoningStep>        </MessageReasoningContent>      </MessageReasoning>      <MessageContent>        <MessageTyping />      </MessageContent>      <MessageActions>        <MessageAction />      </MessageActions>    </MessageBody>  </Message>  <ConversationScrollButton /></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 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>.

Agent trace#

For a coding agent, write each step as one line with title, in the present while it runs (“Reading 3 of 6 files”) and in the past once done (“Read 6 files about sessions and cookies”). The live line takes full ink and a slow sweep; finished lines go muted. Give a step an icon for its kind, a search or the final check. Mark an approach that went nowhere with status="dead-end": a cross on the rail, and the line fades back. Set elapsed on the trigger and it counts “Thinking 9s”, then settles to “Thought for 14s”. Press Run again to watch the full trace.

Why does everyone get signed out after a deploy?
Searched the codebase
session.tsmiddleware.tsSignIn.tsxCookies · Next.js docs+3 more
Read 6 files about sessions and cookies
Dead end: the edge runtime can't reach the session store
Traced 9 places that sign the cookie
Ran 31 auth tests
3 tests rotate the secret
Merged the test results
Found the secret regenerated on every build
Drafted a 2-step fix
Finished thinking · 2 files to change · 2 steps
SESSION_SECRET isn't set in production, so the build generates a new one each time and every existing cookie stops verifying. Set it once in the environment and read it in session.ts instead of falling back to a random value.

Jump to latest#

Drop a ConversationScrollButton as the last child of a Conversation. It stays hidden while the reader is at the newest turn; scroll up and a pill fades in, centered over the thread's bottom edge (a sticky row, so it never scrolls with the history and takes no room in it). Pressing it glides back down and resumes following. The follow survives streaming and resizing: a turn that grows by itself, or a panel that changes size, keeps a reader who was at the bottom there, and the thread's own glide never flashes the pill on the way down. Stream the answer below, then scroll up while it writes.

The Conversation must be the element that scrolls: give it a height (here min-h-0 flex-1 in a sized column; in an AI Panel, <AIPanelBody scrollable={false}>).

How do I add a loading state to the data table?
Wrap the body in a Skeleton while the query is pending, and keep the header rendered so the columns don't jump when the rows land.
And an empty state?
Drop an EmptyState into the table body when the result has no rows. Give it an action that clears the filters, since that is usually why it's empty.
Can the toolbar stay pinned while the table scrolls?
Yes: the DataTableToolbar sits outside the scroll viewport, so only the rows move. Add a sticky header if you also want the column titles to stay.
What about very wide tables on a phone?

API reference#

Conversation

The scrollable thread, a role="log" polite live region; give it a height or max-height to scroll. Forwards all native <div> props. Every part accepts className, merged last.

PropTypeDefaultDescription
variant"plain" | "bubble""plain"The style of every turn: the assistant answer as a page, or every turn a bubble.
stickToBottombooleantrueFollows new turns while the reader is already at the bottom; scrolling up is never yanked back. The first pass lands instantly, later ones glide. Turn it off for a static transcript that opens at the top.
Data attributeDescription
data-variant"plain" or "bubble".

ConversationScrollButton

The "jump to latest" pill: place it as the last child of a Conversation. It shows only while the reader has scrolled away from the newest turn, floats centered over the thread's bottom edge, and on press glides back down and resumes following. The Conversation must be the element that scrolls (min-h-0 flex-1, or AIPanelBody scrollable={false}). Takes every Button prop except asChild.

PropTypeDefaultDescription
labelstring"Jump to latest"The pill's text, also its accessible name.
childrenReact.ReactNode-Replaces the default arrow and label.
variantButtonProps["variant"]"outline"Any Button variant.
size"sm" | "md" | "lg" | "xl""sm"The Button size.
Data attributeDescription
data-state"visible" or "hidden", on the sticky row that wraps the button.

Message

One turn; its role flows to every part. Forwards all native <div> props.

PropTypeDefaultDescription
role"assistant" | "user""assistant"Who sent it: drives the side, the alignment and the bubble tint.
variant"plain" | "bubble"-Overrides the style of the conversation for this turn.
Data attributeDescription
data-role"assistant" or "user".

MessageAvatar

The sender avatar, an Avatar that takes every Avatar root prop. Children replace the initials (with an icon, say).

PropTypeDefaultDescription
srcstring-The image URL.
namestring-The alt text, and the source of the initials fallback.
size"xs" | "sm" | "md" | "lg" | "xl""sm"The Avatar size.

MessageBody

The column holding header, content and actions, aligned to the message side. Forwards all native <div> props.

MessageHeader

The optional meta row above the content. Forwards all native <div> props.

MessageName

The sender name. Forwards all native <span> props.

MessageTime

The muted timestamp. Forwards all native <span> props.

MessageContent

The bubble (or, for a plain assistant turn, the page), markup-agnostic. Forwards all native <div> props.

MessageActions

The row of actions under a turn. Forwards all native <div> props.

MessageAction

One action (copy, regenerate): a Button that takes every Button prop; its aria-label names it and feeds the tooltip.

PropTypeDefaultDescription
variantButtonProps["variant"]"ghost"Any Button variant.
size"sm" | "md" | "lg" | "xl""sm"The Button size.
iconOnlybooleantrueA square icon button.

MessageTyping

Three animated dots, a role="status" labelled "Assistant is typing"; put it in a MessageContent. Forwards all native <div> props.

MessageReasoning

The collapsible thinking block, over Radix Collapsible.Root: forwards all its props.

PropTypeDefaultDescription
streamingbooleanfalseThe model is still thinking: the icon pulses and the default label reads "Thinking…".
openboolean-Open state, controlled.
defaultOpenbooleanfalseOpen state on first render, uncontrolled.
onOpenChange(open: boolean) => void-Called when it opens or closes.
Data attributeDescription
data-state"open" or "closed", from Radix.

MessageReasoningTrigger

The one-line toggle, over Radix Collapsible.Trigger. Children are the label ("Thought for 6s"); without them it rolls between "Thinking…" and "Reasoning".

PropTypeDefaultDescription
elapsedboolean | number-true keeps its own clock: "Thinking 9s" while streaming, then "Thought for 14s". A number shows that many seconds. Ignored when you pass children.
Data attributeDescription
data-state"open" or "closed". Turns the caret.

MessageReasoningContent

The chain of thought behind a left rail, over Radix Collapsible.Content. className lands on the inner <div>.

Data attributeDescription
data-state"open" or "closed".

MessageReasoningStep

One phase of the reasoning log, with a node on the rail. Forwards native <div> props except title.

PropTypeDefaultDescription
titleReact.ReactNode-The one line an agent-trace step reads as. Full ink and a sweep while active, muted once done, fainter as a dead end.
labelReact.ReactNode-A quiet caption above the step ("Searching").
status"active" | "done" | "dead-end""done"active turns the dot into an ink pulse; dead-end swaps it for a cross and fades the title.
markerbooleantrueDraws the rail node (the dot, or the icon).
iconReact.ReactNode-A glyph in place of the dot (14px unless it carries its own size-*), such as a search or the final check.
Data attributeDescription
data-status"active", "done" or "dead-end".

MessageReasoningFiles

A wrapping row of file pills. Forwards all native <div> props.

MessageReasoningFile

One file or page the model opened: a pill, not a link. Forwards all native <span> props.

PropTypeDefaultDescription
name*string-The file name; its extension picks the glyph (ts, tsx, css, md…). Children replace it as the label.
iconReact.ReactNode-Overrides the derived glyph.

MessageReasoningFilesMore

The "+3 more" that closes a row of files. Forwards all native <span> props.

MessageReasoningQueries

A wrapping row of query pills. Forwards all native <div> props.

MessageReasoningQuery

One search the model ran: a monospace pill with a magnifier. Forwards all native <span> props.

MessageReasoningSources

The URLs the model consulted. Forwards all native <div> props.

PropTypeDefaultDescription
layout"chips" | "list""chips"Compact domain pills, or a bordered list of titled rows. Flows to each source.
Data attributeDescription
data-layout"chips" or "list".

MessageReasoningSource

One source, an <a> that opens in a new tab. Forwards native <a> props except title.

PropTypeDefaultDescription
href*string-The URL; the domain and favicon derive from it.
titleReact.ReactNode-The headline in the list layout; the domain by default.
faviconstring-Overrides the derived favicon; a globe shows if it fails.

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.