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:
chatpi.assistantpi.tool_resultpi.compactionsignal.*
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 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
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.
.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 insideA 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 onesreaderNotice(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 supplieshasSummaryto keep calls with outcomes visible.assistantBlockShownis the per-block rule both it and thepiAssistantsnippet use. Results already folded into their call stay out too, and so does asignal.thread.child_finishedwhose 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 optionalmaxGapMsdefaults 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, entry envelope, and thread payloads.