# Chat UI walkthrough — 2026-08-06
A guided tour of the twelve changes made to the web chat on 2026-08-06, in the order they are easiest
to click through. Each item says **where to look**, **what to do**, and **what changed** — and, where
it matters, what the old behaviour actually was, because several of these are only visible if you know
what was broken.
**Before starting:** `pm2 restart officer`, then hard-refresh the browser (Ctrl/Cmd-Shift-R). The
frontend is served bundled from `index.gen.html`; without the hard refresh you will be looking at the
old JS and none of this will be there.
**Honesty note up front: none of this has been rendered in a browser.** `bunx tsgo` is clean and
prettier is clean after every change, and the diff algorithm has real unit-test coverage run under
`bun`. Everything visual is reasoned from the source and the CSS token values, not seen. Treat this
document as a list of claims to check, not a list of things known to work.
Commits, oldest first: `d7f5cd5`, `8c27901`, `306def1`, `9daf420`, `9eb8fa1`.
---
## 1. Dark mode: the white-on-white labels
**Where:** any chat session that has already started — the provider label beside the model selector,
and the active provider tab.
**What to do:** switch to dark mode and open an existing conversation.
**What changed:** the label was `bg-duck-dark/80 text-white`. `--duck-dark` is `#14532d` in light mode
but **`#f1f5f9` in dark** — it inverts to near-white. So the label was white text on a near-white
background for every started session, which is why it looked like the label had simply gone missing.
This turned out to be a whole class of defect rather than one bug, so `duck-dark` was swept out of the
chat entirely — 13 files — and replaced with the semantic tokens that carry their own dark variants:
`foreground`, `foreground/80`, `muted-foreground`, `border`, `border-input`, `bg-muted`. Raw
`text-red-500` / `bg-red-500` went to `destructive` at the same time.
Two things were deliberately **not** swept: `--duck-teal` (it has a real dark override, so it works),
and the `bg-gray-900 text-green-400` terminal look on bash output and log tails, which is meant to look
like a terminal in both themes.
---
## 2. Dead air between sending and the first token
**Where:** the transcript, immediately after you press send.
**What to do:** send a message with thinking turned on, and watch the gap.
**What changed:** the streaming bubble returned `null` when its text was empty, so the entire wait
between send and the first token — tens of seconds with extended thinking — rendered _nothing_. No
bubble, no spinner, no acknowledgement that the message went anywhere. It now shows the bubble
immediately with three pulsing dots.
---
## 3. Text that broke in the wrong places
**Where:** any tool call with a long shell command; any user message containing a pasted URL.
**What changed:** `break-all` → `break-words`. `break-all` splits mid-identifier, so a path or a flag
would break across lines in the middle of a word and become unreadable. The user bubble also gained
`break-words`, so a pasted URL now stays inside the pane instead of pushing it wide.
Sub-12px labels also went up to `text-xs`. There were several `text-[10px]` and `text-[11px]` labels
that were legible on the machine they were written on and not much else.
---
## 4. Edit and Write tool calls render as real diffs
**Where:** the transcript, any `Edit` or `Write` tool call. Click it to expand.
**What to do:** ask for a small edit to a file and expand the tool row.
**What changed:** the biggest single change of the day. Expanding an `Edit` used to print a `key: value`
dump — `old_string: …`, `new_string: …` — as raw text, and reading what actually changed meant diffing
two blobs by eye. It now renders a proper diff: added lines tinted with `bg-success/10`, removed with
`bg-destructive/10`, context in muted grey.
**Collapsed rows now carry the stat** — `+12 −4` in the row itself, so you can see the size of an edit
without opening it.
Details worth knowing:
- **No line numbers, deliberately.** An `Edit`'s `old_string`/`new_string` are fragments with no file
position attached. Any number printed beside them would be invented, and a plausible-looking wrong
line number is worse than none.
- **Long diffs clamp at 40 lines** in the view and the differ refuses anything over 800 lines outright,
falling back to the old input dump rather than locking the tab computing an LCS over a huge file.
- **Copy gives you the code, not the diff.** The `+`/`−` gutter is `select-none`, so selecting a diff
and copying gets the source lines. The copy button on a `Bash` row gives the command; on a `Write`
row it gives the resulting file text — never the `key: value` dump.
- The differ is 85 lines of LCS in
`src/workspaces/officerdev/src/apps/Chat/components/line-diff.ts`, written rather than pulled in —
jsdiff would be a runtime dependency shipped to the browser to run a textbook algorithm. It has unit
tests; they caught a real bug (`''.split('\n')` is `['']`, not `[]`, so every new-file diff opened
with a phantom deleted blank line).
---
## 5. The session list is a list of links now
**Where:** the left pane of `/chat`.
**What to do:** **cmd-click a session.** It should open in a new tab. Middle-click it. Tab to it with
the keyboard.
**What changed:** rows were `
` — the "opaque click" anti-pattern `docs/navigation-audit.md`
names. The id lived in a closure, not the DOM, so there was no cmd-click, no middle-click, no
link-focus, and nothing to copy the address of. Rows are now real ``s built on the shared
`DataRow`, and they carry the query string, so `?cwd=` survives the click.
The whole list was rebuilt on the shared data primitives (`DataList`, `DataRow`, `RelativeTime`,
`LoadingBlock`, `EmptyBlock`, `ErrorBlock`) — the same vocabulary the other rebuilt screens use.
Two files went away with it: `SessionBar.tsx` and `SessionContextMenu.tsx`, both unused.
**Note the row action buttons are siblings of the anchor, not inside it.** A `