Conversation
Keeps a chat thread on its newest line until someone scrolls back.
Examples
Row
- Retrying nowattempt 2The limit resets every minute.
retry
- Out of credits for this workspaceAdd credits to continue.
error
- Interrupted
stopped
- Context compacted
compacted
Usage
When to use it
- Use
- A chat or run transcript grows while the person reads it.
- Use
- Any thread where new content arrives at the bottom.
- Not for
- A static list of items. Use Table.
Rules
9 rules
- Must
- Call snap() from useConversation() when the person sends. Because a detached view does not follow new rows, so the person's own message lands off screen.
- Must
- Move the view in code with scrollToEnd(); never set scrollTop. Because a scroll without a gesture never re-attaches, so the view reaches the end and then stops following the stream.
- Never
- Call scrollIntoView on new content; leave following to the component. Because it pulls a reader who scrolled up back to the end on every token, which the gesture-only detach exists to stop.
- Must
- Push a typed row (divider, retry, error) for an abort, compaction or retry; never write it into the answer text. Because text added to the answer is copied and read as the model's words, and no divider or retry row renders.
- Must
- Pass the error kind and let the row title it. Because kind picks a title such as "Out of credits for this workspace" and hides Retry for gone; a generic message tells the operator nothing.
- Must
- Push a thinking row the moment the person sends; it shows the agent turn. Because the agent turn appearing at once is the answer to "did it hear me", before any token.
- Must
- Put only the end-of-turn actions in ConversationTail in a thread; the running tool row carries the live state. Because the tail reserves one control height, so the swap shifts nothing, and a live phrase in the tail repeats the running row (activity-one-live).
- Must
- Pass status to ConversationProvider, the one place, and let the viewport announce a turn end; no per-row live regions. Because a live region per row reads every streamed token aloud, and a Conversation inside a provider reads status from the provider, not its own prop.
- Must
- Pass announce={false} on an Activity inside the thread. Because Activity announces by default, so a screen reader hears it on top of the viewport's one turn-end announcement.
Accessibility
6 checks
- The viewport is role="log" named "Conversation" with aria-live off and aria-busy while streaming.
- One status region announces "Response complete", "Stopped" or "Response failed".
- Arrow Up, Page Up and Home scroll and detach; End re-attaches even while content grows.
- The jump is a button, inert while near the end and nothing waits, so it leaves the tab order and the tree.
- Enter on Latest scrolls to the end; on "Waiting on you" it scrolls to the waiting call and focuses its first action.
- The jump reaches the waiting call by keyboard.
Edge cases
12 edge cases
- 3,000-word answer
- Offscreen rows skip layout; the last user row and the last row stay laid out.
- One short turn
- Sits at the bottom of the viewport beside the composer.
- No rows
- Renders empty; render an Empty state above the composer.
- 500 rows, prepend 2 turns
- The row under the reader keeps its place.
- Retry row
- Counts down, never negative; reads "Retrying now" at 0.
- Error after a partial answer
- The partial answer stays; a run-gone error has no retry.
- Abort
- A divider, never an error.
- Wheel up while streaming
- Stays detached, even within 5px of the end.
- Wheel inside a reasoning pane or code block
- Scrolls the pane; detaches the thread only at the pane top.
- Touch drag down
- Detaches; momentum does not re-attach until the end.
- Reduced motion
- Jumps scroll at once; the button cuts in and out.
- Waiting call off screen
- The jump reads "Waiting on you" with the count and goes there.
API
9 props
ConversationProvider status'idle' | 'streaming' | 'stopped' | 'error'default'idle'ConversationViewport aria-labelstringdefault'Conversation'ConversationTimeline rows, renderRow, onRetryConversationRow[]ConversationRow{type: 'user', text, meta?, name?, avatar?} | {type: 'assistant', content} | {type: 'thinking', label?, author?} | {type: 'retry', attempt, next, message?} | {type: 'diff', files, additions, deletions} | {type: 'error', kind, message?} | {type: 'divider', reason: 'compaction' | 'interrupted', label?}ConversationItem rowKey, pinnedstring, booleanConversationTaildiv propsConversationJump waiting, labelsboolean, {latest?, waitingOnYou?}defaulttrueuseConversation()hooklabelsConversationLabelsdefaultEnglish words
Tokens
10 tokens
--line-focus- 2px
--scrollbar-thumb- oklch(var(--ink-c) / 0.20)
--row- 40px
--row-sm- 32px
--line-row- oklch(var(--ink-c) / 0.08)
--line-frame- oklch(var(--ink-c) / var(--line-frame-alpha))
--elev-overlay- 0 0 0 1px var(--line-frame), 0 12px 24px -12px oklch(var(--text-on-field-c) / var(--shadow-alpha))
--dur-base- 160ms
--fail-wash- oklch(94.5% 0.027 26)
--surface-inverse- var(--ink)