Chat
Agent chat
The pieces of an agent transcript: messages, reasoning, tool calls, attachments, and a composer that knows about streaming.
import { ChatMessage, ChatThinking, ChatToolCall, ChatAttachment, ChatCompaction, PromptInput } from '@anomalia/ui'; Notes
- A chat is the one screen where chrome has to get out of the way, so this set is deliberately roundest and lightest in the whole system: a 22px composer slab, a 16px bubble with one corner cut to 5px, and hairlines in the margin instead of panels for everything the agent did on the way to the answer.
- User turns are filled bubbles; assistant turns are full-width prose with no bubble at all. Wrapping a thousand words of output in a chat bubble is what makes an agent UI feel like a toy.
- Reasoning and tool calls share one voice: a rule down the left margin and a line of small type, the print convention for an aside. Stack them flush in a zero-gap column and the rules join, so the whole passage of working reads as one thing rather than as four widgets.
- Both expand. Give
ChatThinkingchildren and the label gains a chevron that opens the trace; giveChatToolCallchildren and the line opens on the arguments and result, verbatim, in mono. Without children neither is focusable — a button that expands nothing wastes a tab stop. - A tool call is a line, never a chip or a card. A pill truncates the argument that made the call worth reading, and a bordered panel gives a footnote the same weight as the answer above it.
- Every piece of small type comes up a step under 640px. Secondary is a reason to be quiet, never a reason to be unreadable in one hand — the 11px margin notes go to 12.5px, the traces to 13.5px, and the body to 15px.
- The composer takes focus as a border-colour change and nothing else. A 3px ring around a slab that round reads as an error state, and the border shift already clears 3:1 for SC 1.4.11.
- It carries no shadow, because it is meant to be the bottom edge of the conversation rather than a panel floating over it. Put it inside the scrolling thread as the last child,
position: sticky; bottom: 0, on the same background the turns scroll on with a short fade above it. In flow the layout reserves exactly its height; overlaying it instead needs a magic bottom padding that goes wrong the moment an attachment makes the composer taller. - Buttons dropped into
leading/trailinginherit the composer’s shape through--an-icon-btn-radiusand--an-btn-radius, so a plainIconButtonlands in the right language without extra props. - In the composer, Enter sends and Shift+Enter breaks the line (
submitOn="modifier"flips that). Whitespace-only submissions are impossible. - While streaming, Send becomes a red Stop in the same position — a stop control anywhere else is one nobody finds in time.
- Show a filling context window as
ChatCompaction, not as a percentage. A ring counting to 100 asks the reader to worry about a number they cannot act on; the line says what actually happened and opens to show the summary that replaced those turns.
Examples
Transcript
One turn of an agent transcript: a compaction rule, attachments, a reasoning aside, three tool calls, then the answer. The user turn is a bubble; the assistant turn is a document, because wrapping long output in a bubble is what makes an agent UI feel like a toy.
Six posts for week 35, nothing on Thursday. The reel moves to Friday so it can use the shoot footage, and Monday opens with the carousel the brief calls for.
What changed
- Thursday is now blocked — the shoot runs all day.
- The reel is queued behind the footage, not ahead of it.
- Two LinkedIn posts still need your copy before they can be scheduled.
| Day | Channel | Format |
|---|---|---|
| Mon | Carousel | |
| Wed | Article | |
| Fri | Reel |
Re-run it yourself with anomalia weekly-plan lindaria produce --week 0, or:
POST /api/v1/brands/lindaria/weekly-plan
{ "week": 35, "skip": ["2026-08-27"] } Composer
A rounded slab, not a form field: the textarea is chromeless, focus is only a border-colour shift, and the round send button is the single filled thing in the whole control. Enter sends, Shift+Enter breaks the line, the box grows to 200px and then scrolls. While the agent answers, Send becomes a red Stop in the same place — a stop button somewhere else is one nobody finds in time.
Enter to send · Shift+Enter for a new line
Model: Opus · idle
API
ChatMessage
| Prop | Type | Default | Description |
|---|---|---|---|
role | 'user' | 'assistant' | 'system' | 'assistant' | Drives the whole layout. |
streaming | boolean | false | Inline caret; holds the action row back until the turn ends. |
error | string | null | — | Announced failure for the turn. |
before / actions / avatar | Snippet | — | Tool calls and attachments, the hover action row, the identity glyph. |
ChatThinking
| Prop | Type | Default | Description |
|---|---|---|---|
active | boolean | false | Shimmers the label and shows the preview line. |
label | string | 'thinking' | Lowercase by convention — it is an annotation, not a heading. |
preview | string | — | One line of the trace, shown while collapsed and active. |
duration | number | null | — | Seconds, rendered once it stops. |
children | Snippet | — | The trace. Omit and the label is inert rather than a button that does nothing. |
ChatToolCall
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Tool identity — rendered in mono. |
status | 'pending' | 'running' | 'success' | 'error' | 'success' | `running` spins and gains contrast; `success` recedes; `error` tints and says so in words. |
summary / duration | string / number | — | The identifying argument, and seconds taken. |
children | Snippet | — | Arguments and result. Present, the pill becomes a toggle; absent, it is a plain status chip. |
ChatCompaction
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | null | — | How many turns were folded into the summary. |
label | string | — | Overrides the generated wording — the place to hang a translation. |
children | Snippet | — | The summary that replaced those turns; makes the rule expandable. |
ChatAttachment
| Prop | Type | Default | Description |
|---|---|---|---|
file | ChatFile | — | `{ id, name, size?, kind?, url? }`. Images get a thumbnail, everything else its extension. |
onremove | (id) => void | — | Shows the ✕. Omit inside a sent message. |
progress | number | null | — | Upload progress as a hairline, without resizing the chip. |
PromptInput
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Bindable text. |
submitOn | 'enter' | 'modifier' | 'enter' | Enter, or ⌘/Ctrl+Enter for long-form composers. |
streaming | boolean | false | Turns Send into a red Stop, in the same place. |
minHeight / maxHeight | number | 44 / 200 | Autosize floor and ceiling in px; past the ceiling the field scrolls instead of eating the transcript. |
attachments / leading / trailing | Snippet | — | Staged files, and the slots for model or mode pickers. |
onsubmit / onstop | (value) => void / () => void | — | Commit and cancel. |
Every component also forwards the native attributes of its root element, plus class, so anything not listed here still works.