Skip to content

Chat

Agent chat

The pieces of an agent transcript: messages, reasoning, tool calls, attachments, and a composer that knows about streaming.

ChatMessage ChatThinking ChatToolCall ChatAttachment ChatCompaction PromptInput
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 ChatThinking children and the label gains a chevron that opens the trace; give ChatToolCall children 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 / trailing inherit the composer’s shape through --an-icon-btn-radius and --an-btn-radius, so a plain IconButton lands 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.

brand-brief-q3.pdf 277 KB
Plan next week from this brief. Keep Thursday free — we are shooting.
09:14
Anomalia Anomalia
plan.produce posts=6 channels=ig,li Running

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.
DayChannelFormat
MonInstagramCarousel
WedLinkedInArticle
FriInstagramReel

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"] }
09:14
Anomalia Anomalia
Scheduling the six posts and reserving render capacity for Friday

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.

brand-brief-q3.pdf 277 KB
moodboard.png 1.4 MB

Enter to send · Shift+Enter for a new line

Model: Opus · idle

API

ChatMessage

PropTypeDefaultDescription
role'user' | 'assistant' | 'system''assistant'Drives the whole layout.
streamingbooleanfalseInline caret; holds the action row back until the turn ends.
errorstring | nullAnnounced failure for the turn.
before / actions / avatarSnippetTool calls and attachments, the hover action row, the identity glyph.

ChatThinking

PropTypeDefaultDescription
activebooleanfalseShimmers the label and shows the preview line.
labelstring'thinking'Lowercase by convention — it is an annotation, not a heading.
previewstringOne line of the trace, shown while collapsed and active.
durationnumber | nullSeconds, rendered once it stops.
childrenSnippetThe trace. Omit and the label is inert rather than a button that does nothing.

ChatToolCall

PropTypeDefaultDescription
namestringTool 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 / durationstring / numberThe identifying argument, and seconds taken.
childrenSnippetArguments and result. Present, the pill becomes a toggle; absent, it is a plain status chip.

ChatCompaction

PropTypeDefaultDescription
countnumber | nullHow many turns were folded into the summary.
labelstringOverrides the generated wording — the place to hang a translation.
childrenSnippetThe summary that replaced those turns; makes the rule expandable.

ChatAttachment

PropTypeDefaultDescription
fileChatFile`{ id, name, size?, kind?, url? }`. Images get a thumbnail, everything else its extension.
onremove(id) => voidShows the ✕. Omit inside a sent message.
progressnumber | nullUpload progress as a hairline, without resizing the chip.

PromptInput

PropTypeDefaultDescription
valuestringBindable text.
submitOn'enter' | 'modifier''enter'Enter, or ⌘/Ctrl+Enter for long-form composers.
streamingbooleanfalseTurns Send into a red Stop, in the same place.
minHeight / maxHeightnumber44 / 200Autosize floor and ceiling in px; past the ceiling the field scrolls instead of eating the transcript.
attachments / leading / trailingSnippetStaged files, and the slots for model or mode pickers.
onsubmit / onstop(value) => void / () => voidCommit and cancel.

Every component also forwards the native attributes of its root element, plus class, so anything not listed here still works.