Message

One turn in a conversation, presented according to who produced it. The three roles are not three skins of one bubble: an assistant answer is a document — prose, headings, lists, code, tables — and a bubble caps the structure it can carry, so assistant turns have no fill and no boundary. A user message keeps the bubble because a short, discrete utterance is worth separating from the answer around it. A system note is neither, and says something about the conversation rather than in it.

Message · An exchange
Why did the invoice import stop at 60%?
And can I resume it afterwards without redoing the rows that already landed successfully in the ledger?

The import halted because four rows failed validation. Two are missing a currency, and two have a due date before their issue date.

You can either fix the rows and resume, or skip them and import the remaining 146.

Switched to the extended context model

Three roles, one exchange. Each turn can carry a quiet __meta line — a timestamp here, a model name on the answer — which follows its turn's alignment rather than the thread's, so a stamp under an end-aligned bubble stays with the bubble. Whether it appears at all is the thread's call, since grouping is what knows when a stamp should not repeat. The answer sits on the page with no boundary, so its two paragraphs read as prose rather than as a long bubble; the questions are bubbled and end-aligned, and the longer one shows the cap — a bubble stops at min(80%, container.sm) so it always leaves a gutter and never reads as a document with rounded corners. The system note is a marker, not a turn. Every action is a ghost icon Button — the row is composition, not new chrome. Width comes from the surface, never from a Message.

Rhythm

A turn owns its internal spacing so the thread's gap can always be the larger break. Left alone, the host's paragraph margins were 24px against a 16px thread gap — a paragraph read as a bigger event than a change of speaker, and the bubbles looked glued to the answer below them.

BreakOwnerValueBecause
Between block children of one turnMessagespace.3Pinned, so a host stylesheet cannot invert the hierarchy
Between turnsMessagespace.6A speaker change is the bigger break. The thread sets no gap of its own, or the two add
Between consecutive turns from one speakerMessagespace.2Adjacency knows the previous author, so a run closes up; grouping that needs more than adjacency — repeated avatars, day separators — stays ChatThread’s

Roles

The role decides the whole presentation, which is why it is an axis and not a colour variant. Nothing here is a fill choice: it is a decision about what kind of thing the turn is.

RoleModifierPresentationBecause
Assistant--assistantNo fill, no boundary, text.primary at font.size.copy-smThe default. An answer is a document, and structure needs room a bubble does not give
User--usercontrol.bg bubble at radius.container, end-aligned, capped at min(80%, container.sm)Short and discrete — worth separating from the answer around it, which the cap is what preserves
System--systemCentred text.secondary at font.size.captionA statement about the conversation, not a turn in it

Streaming

Streaming is aria-busy on the root. A highlight sweeps across the answer, and the whole footer is withheld rather than shown disabled — there is nothing complete to copy, rate, or stamp yet, and a disabled Copy invites waiting for it to light up.

Message · Still arriving

Checking the four failed rows against the import schema

The whole footer is present in the markup and hidden by the state — the stamp goes with the buttons, because a timestamp under a half-finished answer claims the turn is done, so nothing has to be added when the turn finishes. A band of full opacity travels horizontally across the answer, so letters light up as it passes and fall back once it has gone — the text reads as arriving rather than arrived. It is masked rather than clipped: a gradient clipped to the glyphs would replace the text’s colour outright, breaking selection highlighting and anything nested that paints with currentColor. The band and the trailing LoadingDots are one gesture: the dots sit in the sweep’s path at the end of the text, so they light in turn as it passes over them and settle behind it. The sweep stops under prefers-reduced-motion, where the text rests one step quieter than a finished turn.

Footer

Metadata and controls share one line. Two lines of secondary information push the next turn further away than a turn's own paragraphs are from each other, which inverts the hierarchy above. The footer owns the alignment, so both follow their turn rather than the thread.

TurnOrderOverflowUsage
UserStamp, then actionsEdit and copy earn a slot; the row is short enough not to need a menu
AssistantActions, then i-ellipsisA MenuCopy and regenerate stay out; sources, branching, and read aloud go behind the trigger, with the stamp as the menu’s group label

Usage notes

The bubble question is the one that matters. Everything else follows from it.

VerdictRule
DoLet the thread own the measure. A Message that sets its own width fights the surface it sits in, and two turns end up disagreeing about the reading line.
DoRender a failed generation as an Alert inside the content, not as a styled Message — Alert already owns persistent in-flow error presentation.
DoKeep the decision about identity in the thread — grouping is what knows when a stamp should stop repeating — and use __meta for where it lands. A Message that draws its own avatar makes grouping impossible; one with nowhere to put a stamp forces every thread to invent a place.
DoGive every icon-only action an accessible name and a Tooltip — the name is what a screen reader announces, the tooltip is what a sighted user needs, and an icon row without both is a guessing game either way.
Don’tPut an assistant answer in a bubble. It is a document — headings, lists, code, tables, and citations all stop working at the width a bubble implies.
Don’tGrow the footer past two or three actions. Anything rarer belongs behind an i-ellipsis Menu, which is also where a crowded timestamp can go.
Don’tUse the system role for assistant content that happens to be short. It is for statements about the conversation — a model change, a cleared history.