- ds
- Agent
- Chat thread
Chat thread
User and assistant turns with metadata and a streaming caret.
The p95 jump starts at 09:14. Two things overlap: the retry burst from event-relay and the new index scan in orders_read.
I traced the checkout path from the edge to Postgres. The slow span is SELECT … FOR UPDATE, not the network. It holds a row lock for 840 ms while the retry queue drains.
Move the drain behind the queue and keep the lock short. That takes the p95 back under 300 ms without touching the edge.
Show code
<div class="chat">
<div class="chat-thread">
<div class="chat-turn chat-user">
<div class="chat-bubble">Trace the slow checkout requests from this morning and tell me where the time goes.</div>
</div>
<div class="chat-turn">
<div class="chat-assistant">
<div class="chat-author"><span class="avatar t-purple xs">A</span><b>axon</b><span>read 12 files</span></div>
<div class="chat-body">
<p>The p95 jump starts at 09:14. Two things overlap: the retry burst from <code>event-relay</code> and the new index scan in <code>orders_read</code>.</p>
<p>I traced the checkout path from the edge to Postgres. The slow span is <code>SELECT … FOR UPDATE</code>, not the network. It holds a row lock for 840 ms while the retry queue drains.</p>
</div>
<div class="chat-meta"><span>sonnet-4.5</span><span>18.4k in</span><span>1.2k out</span><span>42 s</span></div>
</div>
</div>
<div class="chat-turn">
<div class="chat-assistant" aria-live="polite" aria-busy="true">
<div class="chat-author"><span class="avatar t-purple xs">A</span><b>axon</b><span>writing</span></div>
<div class="chat-body"><p>Move the drain behind the queue and keep the lock short. That takes the p95 back under 300 ms without touching the edge.<span class="chat-caret" aria-hidden="true"></span></p></div>
<div class="chat-meta"><span>sonnet-4.5</span><span>19.1k in</span><span>streaming</span></div>
</div>
</div>
</div>
</div>A conversation is a column of turns, not a chat window with a second border. User turns sit on the right in a single quiet surface. Assistant turns run the full measure: a small author line, the prose, then the metadata that says which model answered and what it cost.
The caret is a span at the end of the last paragraph. Keep it aria-hidden and mark the live turn with aria-live="polite" on the container. It stops when the stream ends.
Compact density
14 passed, 2 skipped. The retry suite is green.
Code
<div class="chat compact">
<div class="chat-thread">
<div class="chat-turn chat-user"><div class="chat-bubble">Run the checkout tests.</div></div>
<div class="chat-turn">
<div class="chat-assistant">
<div class="chat-author"><span class="avatar t-purple xs">A</span><b>axon</b></div>
<div class="chat-body"><p>14 passed, 2 skipped. The retry suite is green.</p></div>
<div class="chat-meta"><span>2.4 s</span><span>412 tokens</span></div>
</div>
</div>
</div>
</div>.compact tightens the gap, the bubble padding and the type. Use it in a side panel or when a session has hundreds of short turns. The order and the metadata stay the same.
Empty, loading, error
What should we work on?
A local agent with tools and a session log. Start with the repository, or ask for a change.
Code
<div class="chat">
<div class="chat-empty">
<span class="chat-empty-mark">axon</span>
<h3>What should we work on?</h3>
<p>A local agent with tools and a session log. Start with the repository, or ask for a change.</p>
<div class="btn-row">
<button class="btn sm primary" type="button">Read the README</button>
<button class="btn sm" type="button">Run the tests</button>
</div>
</div>
<div class="chat-note center"><span class="chat-typing"><i></i><i></i><i></i></span> Reading 12 files</div>
<div class="chat-note down"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg><span>Tool call failed: the workspace is read-only.</span></div>
</div>The empty state offers two things to do, not a slogan. Loading is a live note with three dots. An error is one line, in the error colour, with the cause. A failed tool call belongs in a tool call card; the chat note is for the turn itself.
API
| Class | On | What it does |
|---|---|---|
.chat | div | The conversation. Holds a thread, or an empty and note column. |
.chat-thread | div | The measured column of turns, capped at --prose. |
.chat-turn | div | One turn. Add .chat-user for the right-aligned form. |
.chat-bubble | div | The user message surface. Keeps line breaks. |
.chat-assistant | div | The assistant turn: author, body, metadata. |
.chat-author | div | Avatar, name and the current activity. |
.chat-body | div | Prose. Headings, lists and inline code inherit the ds scale. |
.chat-meta | div | Model, tokens and duration in mono, separated by middots. |
.chat-caret | span | The streaming block cursor. Decorative, so hide it from assistive tech. |
.chat-typing | span | Three animated dots inside a live note. |
.chat-empty | div | The first-run state. A mark, a question, a line and two actions. |
.chat-note | div | A system line under the thread. .down for errors. |
.compact | .chat | Tighter gaps, padding and type. |
- Keep one measure for the thread, even on a wide screen.
- Show the model and token counts after the turn, not before it.
- Let the last paragraph carry the caret.
- Nested bubbles for tool calls and reasoning. Use the inline components.
- A second avatar on every assistant turn in a long session.
- An empty state with no action.
Accessibility
- The thread is plain reading order. Do not move focus when a turn arrives; announce the live turn with
aria-live="polite". - The caret and the typing dots are decorative. Give the live note a text label, such as "Reading 12 files".
- At 390px the bubble becomes full width and the middot separators fold away. The metadata still wraps, so no value is lost.
- Reduced motion stops the caret and the typing dots at a readable state.