# Chat UI

## Working design

A thread has a Postgres record and a durable stream of entries. Keep the canonical names: stream, entry, payload. An entry's type is its `payload.type`.

The renderer has six branches:

- `chat`
- `pi.assistant`
- `pi.tool_result`
- `pi.compaction`
- `signal.*`

These are five exact types and one family of distinct signal types. Typing text a few old threads hold (`pi.message_start`, `pi.message_update`, `pi.chunk`) is never rendered. Unsupported entries show a warning naming their type and the full raw entry JSON; they never silently disappear.

A `chat` payload contains `text` and optional `attachments`. Each attachment has its own `type`: `image` or `file`. These are attachment types, not additional entry types; they render inside the `chat` branch. Text can be empty when attachments are present, as with an image-only upload. The [chat payload and attachment schemas](../packages/core/schemas/message.ts) own these shapes.

Entry components receive the full canonical `ArbeThreadEntry`, narrowed to the type they handle, including its author, timestamp, and payload. Don't introduce renamed message, part, or row concepts without a demonstrated need.

## The machine

```text
stream (raw entries, never changed)
  │
  ▼
transforms (pure functions of entries, chat-transforms.ts; a switch picks which run)
  foldChunks ─ toolResultsByCallId ─ visibleEntries ─ groupByDay ─ groupByAuthor
  │
  ▼
entry snippets (one per payload.type: which blocks does this entry have?)
  chat · piAssistant · toolResultBlocks · signal
  │
  ▼
block snippets (one per block type: how does one block look?)
  text(+markdown) · summary · thinking · toolCall(+summary, folded result) · image · attachment · handover · presence · raw
  │            ▲
  │            ├── summaries.ts (pure, data → words + link: summarizeSignal, summarizeToolResult, summarizeToolOutput)
  │            └── chat-markdown.ts (renderChatMarkdown: safe HTML, #thread links)
  ▼
markup (data-* hooks) ── CSS (bubbles, dim plumbing, error color)
```

Switches on `ChatView` reach two places only: they choose which transforms run and which blocks a snippet draws. Nothing else reads them.

## Markup

Stream, day, run, entry, block. Every entry has the same shape: a body made of blocks, under a header only when a standalone tool result names its tool.

```text
.stream[data-bubbles=all|humans][data-own-right]  optional styling hooks
└─ section.day[data-day="<local day>"]
   ├─ DayDivider
   └─ section.run[data-agent-id][data-agent-kind][data-own][data-author-color="author-N"]
      ├─ header.author                      avatar (optional) + name + first entry time (HH:MM), outside the bubble;
      │                                     the name opens your thread with that author (or toggles details if it's this one);
      │                                     hovering the name shows its id and, for a bot, the latest model and usage summed
      │                                     over the run; hovering the time shows the full date to the second
      ├─ div.entries                        run body, one optional bubble
         └─ article.entry[data-entry-type="<payload.type>"][data-view="blocks|raw"]
            ├─ header                       only on a standalone tool result: its tool name
            ├─ .body
            │  └─ .block[data-block-type="…"] rendered blocks, or one raw JSON block
            └─ button.compact               raw toggle, top-right; only with the raw switch on
      └─ .annotation[data-annotation-type][data-entry-id]  under the bubble, never inside
```

A branch decides which rendered blocks an entry has. A `text` block renders one paragraph per blank-line gap and keeps single line breaks. The `text` of a `chat` entry and the `text` blocks of a `pi.assistant` entry instead render as markdown through `renderChatMarkdown` (`chat-markdown.ts`), with `#handle` mentions linked through the `threadHandles` prop; such a block carries `data-markdown`. Tool result text, thinking, summaries, and tool arguments stay plain. For `pi.assistant` a block is one of the message's content blocks, and `data-block-type` is the block's own type: `text`, `thinking`, or `toolCall`. A thinking block folds behind a `Thinking:` excerpt and shows only when the thinking layer is on. With the tool layer on, a `toolCall` block shows `name(args)` on one line and folds long arguments behind a truncated summary; its `data-tool-call-id` matches the `data-tool-call-id` on the result's `.entry`. `pi.tool_result` works the same way: its message's content blocks (`text` or `image`) become the entry's blocks. The tool name, call id, and error flag describe the whole entry, so they sit on `.entry` as `data-tool-name`, `data-tool-call-id`, and `data-error`, and the name shows in the header. `chat` has `text`, `attachment`, and `handover`. A `handover` chat is a finished coding child's closing report, posted by the house's system agent: its block replaces the `text` block with the child's outcome and a link to its thread, the report folded underneath. A `signal.*` entry has one `summary` block, the one-line summary from `summarizeSignal` in `summaries.ts`. `pi.compaction` has no blocks yet. The toggle replaces rendered blocks with one `raw` block containing the full entry JSON; entries without rendered blocks default to raw. An entry with no author shows `system`.

A folded result keeps its own raw toggle inside the call block.

A reply still streaming renders like any `pi.assistant` entry, with `data-streaming` on its `.entry` naming what it is doing from its last content block: `thinking`, `writing`, or `tools`. While nothing it holds would render under the current switches, it shows one dim `presence` block ("thinking…", "using tools…") with a pulsing dot; while writing, a CSS caret pulses after its last text block. Both go when the final entry lands, and hold still under `prefers-reduced-motion`.

`Chat.svelte` owns the thread page — loading, the composer, the reply dial — and draws entries through `ChatEntries.svelte`, the only renderer. It passes `emptyText`, which says how many signals are hidden when that is why the thread looks empty.

`.stream` is the scroller, wrapped in a positioned `.scroller` that also holds the jump button. `ScrollFollow` (`scroll-follow.svelte.ts`) owns the behaviour and knows nothing about entries: while the reader sits at the bottom, every change (a new entry, a switch) keeps it pinned there; scrolled up, it stays put and counts what ChatEntries reports as appended, and `JumpToLatest` shows "↓ N new" until they jump or scroll back down. ChatEntries counts finished entries from others that land after the last one it had seen (`tailArrivals`); earlier entries loaded above never count. Your own send jumps to the bottom.

Styling hooks: `data-bubbles` on `.stream`, `data-author-color` on `.run` (a stable per-author colour, `messageAuthorColor`), `data-entry-type`, `data-view` and `data-streaming` for the entry, `data-block-type` and `data-markdown` for its blocks. Bubbles use the same markup as the plain view: one padded box around each run's entries, with its author and time above it. Your own runs use the accent background, other humans' runs their author colour; the plain view marks no one. Every run's box shrinks to its text, up to 85% of the stream, whoever wrote it. With `data-own-right`, your own runs (`.run[data-own]`) sit on the right instead, like a messaging app.

`ChatEntries.svelte` mirrors this with two kinds of snippet. An entry snippet, one per `payload.type`, decides which blocks the entry has. A block snippet, one per block type, decides how that block looks and owns its `.block` wrapper. To change how a tool call looks, edit `toolCallBlock`; to change what an assistant entry shows, edit `piAssistant`.

## Summaries

A summary turns one entry or block into words and, where a page exists, a link. `summaries.ts` owns the shared `Summary` shape: `summarizeSignal` handles signals, `summarizeHandover` a finished coding child (shared by the `child_finished` signal and the handover chat), and `summarizeToolResult` parses a tool result entry's structured `details` and hands them to `summarizeToolOutput`, the per-tool table. Reads, searches, errors, and unrecognized results have no tool summary. A signal summary links where a page exists (a finished child thread, a single changed file when `houseId` is known) and names the actor of a file change from the entry's author.

Both render through `summaryBlock`, a normal-color `summary` block containing a paragraph and an optional link, with no arrow. A signal renders that block directly. A `toolCall` block shows it first; `name(args)` and the folded result appear underneath only when the tool layer is on. Summaries keep tool-only assistant entries visible when the tool layer is off, whether result folding is on or off. The result index is always available to summaries; folding only controls where the result itself renders. `Chat` passes `houseId` to `ChatEntries` for house-scoped links.

## Annotations

An annotation is derived from an entry plus live thread state, so it cannot be a pure function of the stream. The parent computes it and passes it to `ChatEntries` keyed by entry id; it is drawn as `.annotation[data-annotation-type][data-entry-id]` under the run, outside its bubble, so it never reads as something the author wrote. One exists: the mention invite (`mention-invites.ts`), shown under a `chat` entry that names a bot who is not in the thread, with an add action and a per-browser dismissal.

## Options

Reader switches live on `ChatView` (`chat-view.svelte.ts`) and are flipped from the thread sidebar. Each is a named flag honoured by the renderer: `showSignals` filters the stream loop, `trimToolResults` collapses a tool result's text to its first line, `foldToolCalls` places indexed tool results inside their call blocks, `bubbles` boxes each run for everyone, humans only (`humans`, by the author's `kind`), or no one, cycled by one sidebar button; `alignOwnRight` puts your own runs on the right, `showAvatars` puts the author's avatar before their name, `renderMarkdown` renders conversation text as markdown, and `showRawToggles` shows the per-entry raw button (an entry already in raw view keeps its button). Trim, fold, bubbles for humans, right-aligned own runs, and markdown are on by default; signals, avatars and raw buttons off. A switch changes how an entry looks or which transform runs; the stream itself is never changed.

The bubbles switch sets `data-bubbles="all|humans"` on `.stream` (absent when off); with `humans`, only `.run[data-agent-kind="human"]` gets a box, and it only changes styling, not grouping or entry rendering.

## Transforms

- `visibleEntries` (`chat-transforms.ts`): signals show by their switch, except the ones `readerNotice` (`dispatch/reader-copy.ts`) says explain a missing reply (shared credit out, no model key, spend limit, mention-only bot, failed dispatch, failed coding run), which always show as one quiet line and collapse when they repeat in a row; with the tools layer off, tool results and assistant entries made only of tool calls without summaries stay out, as do thinking-only entries with the thinking layer off. ChatEntries supplies `hasSummary` to keep calls with outcomes visible. `assistantBlockShown` is the per-block rule both it and the `piAssistant` snippet use. Results already folded into their call stay out too, and so does a `signal.thread.child_finished` whose handover chat is in the stream, since the chat already tells it.
- `groupByDay` (`chat-transforms.ts`): entries are grouped into local calendar days, with nothing hidden or merged.
- `toolResultsByCallId` (`chat-transforms.ts`): indexes results for tool summaries regardless of folding. With folding on, a tool call also shows its result inside its block and the standalone result entry is not rendered.
- `layerCounts` (`chat-transforms.ts`): how many signals, thinking blocks and tool calls the stream holds, for the sidebar's layer buttons; counted on the raw stream, not on what is rendered.
- `groupByAuthor` (`chat-transforms.ts`): consecutive entries by one author form a run with a name and first-entry time; ChatEntries splits runs after gaps over five minutes. The optional `maxGapMs` defaults to no limit.

The signals toggle filters entries before grouping. Before a transform is added (folding a tool call and its result into one row, say), name it here.

Add rendering one type at a time as the need becomes clear.

Canonical types: [thread](../packages/core/schemas/thread.ts), [entry envelope](../packages/core/schemas/stream-entries/envelope.ts), and [thread payloads](../packages/core/schemas/stream-entries/thread.ts).
