Conversation

Keeps a chat thread on its newest line until someone scrolls back.

Examples

An answered question with a failed call

Ana
Why did ticket 4809 get escalated?
support-agent

Ticket 4809 asked for a refund past the 30-day window, so triage sent it to a person.

Ana
What should we do about ticket 4812?
support-agent

Ticket 4812 is on the refund path. The order shipped damaged and arrived 3 days ago, inside the 30-day window.

Refund $48.00 to Maya Chen and send her the drafted reply.

2 files changed14 added 3 removed

Waiting off screen

Ana
Refund Maya Chen if the order shipped damaged.
support-agentWaiting on you

The order shipped damaged. Refunding needs your go-ahead.

Refund $48.00?

Returns $48.00 to Maya Chen's original payment method. It cannot be undone.

Refund $48.00 on ord_4812

Ana
Why did ticket 4809 get escalated?
support-agent

Ticket 4809 asked for a refund past the 30-day window, so triage sent it to a person.

Ana
What should we do about ticket 4812?
support-agent

Ticket 4812 is on the refund path. The order shipped damaged and arrived 3 days ago, inside the 30-day window.

Refund $48.00 to Maya Chen and send her the drafted reply.

2 files changed14 added 3 removed

Stopped mid-answer

Ana
Why did ticket 4809 get escalated?
support-agent

Ticket 4809 asked for a refund past the 30-day window, so triage sent it to a person.

Ana
What should we do about ticket 4812?
support-agentStopped

Ticket 4812 is on the refund path.

Row

  • Retrying nowattempt 2The limit resets every minute.

    retry

  • Out of credits for this workspaceAdd credits to continue.

    error

  • stopped

  • 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-label
string default 'Conversation'
ConversationTimeline rows, renderRow, onRetry
ConversationRow[]
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, pinned
string, boolean
ConversationTail
div props
ConversationJump waiting, labels
boolean, {latest?, waitingOnYou?} default true
useConversation()
hook
labels
ConversationLabels default English 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)

Install

For your coding agent

GitHub

Please star me pookie

141

A new design system every week

Leave your email before you copy the prompt.