# 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 `<div onClick>` — 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 `<Link>`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 `<button>` inside an `<a>`
is invalid HTML and breaks cmd-click on the row — so rename and delete sit next to the link in a flex
wrapper, revealed on hover _and on keyboard focus_ (`focus-within:opacity-100`, which the old
hover-only version did not have).

---

## 6. "No sessions" versus "the read failed"

**Where:** the session list, when the transcript directory cannot be read.

**What changed:** those two states rendered identically — an empty list. One of them is your fault and
the other one you can act on. `useClaudeSessions` now returns the query's `error`, and the list renders
an `ErrorBlock` with a retry, not an empty state.

---

## 7. Rename and delete say when they fail

**Where:** the session list row actions.

**What changed:** both were fire-and-forget. A failed rename left the row showing the old title with no
explanation, which reads as "the rename didn't take" rather than "the rename failed". Both now toast the
real error.

This is subtler than it sounds: `useClient` rejects with a plain `{ status, message }` object, **not an
`Error`**, so the reflex `err instanceof Error ? err.message : 'unknown'` reports every API failure in
the app as "unknown error". That check now lives in `helpers/error-text.ts` and gets it right.

---

## 8. Deep links that point at a deleted transcript

**Where:** open `/chat/<some-id-that-no-longer-exists>`.

**What changed:** the fetch failed, the code fell back to a bare id, and you got an empty chat pane —
indistinguishable from a new conversation. It still falls back (the pane is usable), but now it says
why. Most often the id is stale: the transcript was deleted or pruned out from under the link.

---

## 9. The read-aloud button

**Where:** the speaker icon on an assistant message.

**What to do:** if TTS is down, press it.

**What changed:** every error was swallowed. The spinner stopped, the speaker icon came back, and a TTS
service that was simply not running looked exactly like a button that did nothing. Both the synthesis
failure and the playback failure now say so.

---

## 10. The background task tray

**Where:** above the composer, whenever a session has background tasks.

**What changed, three things:**

- **Failed tasks had the same icon as stopped ones** — both `CircleSlash`. The two outcomes you most
  need to tell apart were the one glyph. Failed is now an `X`.
- **Status colours now come from the shared `tone.ts`.** A completed task was green in the transcript
  and grey in the tray; same status, two vocabularies.
- **The expanded panel is capped against the viewport**, `max-h-[min(16rem,25vh)]`. The composer is
  `shrink-0` and the transcript above it is `flex-1 min-h-0`, so every pixel the panel takes comes out
  of the conversation — a flat `max-h-64` could leave almost no transcript visible in a short panel.

---

## 11. The attach menu did half of what it offered

**Where:** the paperclip, in both the composer and the launcher.

**What changed:** the menu had four entries and two of them — **Text File** and **PDF** — had no
`onSelect` at all. Clicking them closed the menu and did nothing.

**Text File now works.** It reads the file and inlines it into the composer as a fenced block with the
filename on the fence, which is the form an agent reads best. Guards: 256 KB limit, a NUL-byte check
that catches a binary file whatever its extension claimed, and a four-backtick fence when the file
contains a three-backtick fence of its own (otherwise the block closes early and the rest of the file
renders as prose).

**PDF was removed rather than fixed.** There is no PDF text extraction anywhere in the platform, client
or server, so that entry could not be made honest without building one first. Worth a decision: it can
be built, but it is its own piece of work.

---

## 12. Contrast on the New Chat button

**Where:** top of the session list.

**What changed:** it was `bg-duck-teal text-duck-yellow` — roughly 2:1 contrast, in _both_ themes.
`--duck-yellow` has no dark override at all. It is now `bg-primary text-primary-foreground`.

---

## 13. The chat URL: `?cwd=` is gone, and a session decides its own directory

**Where:** the address bar, everywhere in `/chat`. Also the **Run agent** dialog in the file browser
right-click menu.

**What to do:**

1. Open `/chat`, pick a project in the pwd dropdown. The URL is now `/chat/g/home/pastilhas/dockers/…` —
   a readable path, not `?cwd=%2Fhome%2Fpastilhas%2F…`.
2. Click a conversation. The URL becomes just `/chat/<uuid>` — **no group at all**. Copy it.
3. Paste it into a fresh tab. It should open the same conversation, with the list beside it scoped to
   that conversation's project, and the pwd dropdown showing that project.
4. **The one that used to be broken:** in that fresh tab, send a message _immediately_, before anything
   settles. It should run in the project directory, not in the general chat directory.
5. Right-click a folder in the file browser → **Run agent** → run one → **Open in chat**. It should land
   on that folder's group list.
6. Mobile: open a conversation, hit back. You should return to the project's list, not to an empty
   default one.

**What changed, and why it is more than cosmetic.**

The group used to be a query parameter, and the session's working directory was read _back off that
query parameter_ to decide where the agent actually executes. So the address bar was the authority on
where code runs. Two consequences, both of which you had noticed as "sometimes it doesn't load in the
right place":

- A pasted or refreshed `/chat/<id>` arrives with **no `?cwd=` at all**. The screen then fetched the
  session and wrote the cwd into the URL — but there was a window before that landed, and a turn sent in
  that window ran in the default `general_chat_sessions` directory instead of the project.
- Even after it landed, the URL was hand-editable, so a `?cwd=` naming one project and a session
  belonging to another could disagree. Nothing reconciled them; the URL simply won.

The fix is not really "path instead of query string" — that part is presentation. It is that **there is
now one source of truth for a session's directory: the session.** `loadClaudeSessionById` already scans
every project group and reads the real cwd out of the transcript, so the id alone determines it. The
directory now travels on the selection (`SelectedSession.cwd`), which is what the composer reads. The URL
no longer carries it for a session, so it cannot contradict it.

A group, on the other hand, genuinely _is_ addressable state and belongs in the URL — so it is a path
suffix behind a `g/` discriminator: `/chat/g/home/me/project`, `/chat/new/g/home/me/project`. Spelled as
a path rather than a percent-encoded blob because Officer always sits behind a reverse proxy and `%2F` is
exactly the character proxies normalise or reject.

The vocabulary lives in one file, `apps/ChatHistory/chat-routes.ts` — `chatListPath`, `chatNewPath`,
`chatSessionPath`, `cwdFromSplat` — so a link built in a panel and a link built in a screen cannot drift.

**The agentic side, which you flagged.** `startAgentRun` used to return a literal
`chatUrl: '/chat?cwd=…'` — the server holding an opinion about frontend URL shape, and therefore holding
a copy that goes stale the moment that shape changes. It now returns just `cwd` (which it already did),
and `AgentRunnerModal` builds the link with `chatListPath`. One less place that knows what a chat URL
looks like.

**Also fixed in passing:** the permalink written after a turn completes (`useChat`) used to carry the
whole query string forward, which re-attached a stale `?cwd=` to the new session's URL. It now strips
`cwd` and leaves everything else alone.

**Not verified:** as with everything else here, none of this has been through a browser. The typecheck is
clean and the route ranking has been checked against React Router 7's scoring (a `/chat/g/*` pattern
scores 23 against `/chat/:sessionId`'s 17, so a group path can never be mistaken for a session id) — but
step 4 above is the one I most want you to actually try, because it is the bug this was for.

---

## 14. `/clear` no longer loses the thread

**What to click.** Open `/chat` on this project's group. Look at the top of the list. This conversation
should be titled **“Platform Arch 2”**, with a second meta line reading _continues Platform Arch_. Open
it: the header — which used to say “New chat” for every conversation you opened — now says “Platform
Arch 2”, and under it is a link back to the original. Click it, and you are in the conversation this one
grew out of.

Nothing was migrated to make that happen. It is derived from transcripts that were already on disk, so
it applied to your history the moment the server restarted.

**What was actually wrong.** You guessed right: `/clear` starts a genuinely new Claude session, and the
list was showing it as an unrelated conversation. Two separate defects were stacked on top of that.

The first was cosmetic but ugly: cleared sessions were titled `<command-name>/clear</command-name>`.
Claude records slash commands as ordinary user entries and — this is the part that fooled the original
code — **does not set `isMeta` on them**, so the “first human message” that becomes the title was the
`/clear` itself. Skipping `<command-name>` entries fixes it, and is why these sessions now fall back to
their first real message when no parent can be found.

The second is the interesting one. **Claude records no parent link anywhere.** I checked all five places
it could plausibly live: `compactMetadata` and `logicalParentUuid` (those are in-file compaction — see
below), `summary.leafUuid` (every one resolves inside its own transcript, never across files), the
per-session `slug` (a random name like `precious-brewing-kazoo`, not a lineage), and
`~/.claude/sessions/<pid>.json` (a live process registry, gone when the process is). So the link has to
be inferred, and this is the only inference in that file.

**What it infers from.** A cleared transcript opens with `/clear`, and `/clear` happens inside one
process — the old transcript's last write and the new one's first write are the same moment. Measured
here: 4ms apart. So the parent is the conversation in the same group that was writing to disk at the
instant this one began.

Two things about that rule are not obvious, and both were found by running it against your real history
rather than by reasoning:

- **The window is symmetric.** Clearing makes Claude summarise the conversation it is ending, and that
  costs a model call — so the parent's _final_ record can land a few seconds **after** the child's first
  one. One session here sits exactly there, and a before-only window silently lost it.
- **It matches on activity, never on “when did it end”.** My first version compared the child's birth to
  the parent's last-modified time. That threw away any parent you later went back and **resumed**, because
  resuming moves its end time days past its child's birth. Two of your three cleared sessions were in
  that state and found no parent at all. Each transcript now carries the set of minutes it wrote in, which
  survives resumption.

**Where it says nothing.** If two transcripts in the group were active in that minute, it names neither —
no parent is a far smaller mistake than the wrong parent, because the wrong parent also **renames** the
conversation. One session of yours (in `~/dockers`) still shows no parent: its nearest candidate was
active three hours away, which means the conversation it came from is genuinely not on disk any more.
That is the rule working, not failing.

**It is read-only, on purpose.** Nothing is written back into Claude's store, so the numbering is a
display-time guess that costs nothing if it is wrong and disappears on the next read. An explicit title —
Claude's own summary, or a rename you typed — always wins and is never overwritten. My suggestion is to
live with it for a week; if it never guesses wrong, we can promote it to a real `summary` record so the
name sticks in the terminal too. That is deliberately not built yet.

**`/compact` needed nothing.** I had assumed it forked a session like `/clear` does. It does not — it
appends to the same transcript and keeps the same session id (63 in-file compactions in the parent of
this conversation alone). So compaction was already invisible in the list, correctly.

**Also fixed in passing:** the chat header was hardcoded to `sessionTitle={undefined}`, so it read “New
chat” above every conversation you opened, resumed or not. It now takes the title from the same place
the list row does, which is also what keeps the two from disagreeing about the numbering.

**Not verified:** the inference itself I ran against your real transcripts and checked case by case (the
numbers above are measurements, not estimates). The UI — the meta line, the header, the link back — has
not been through a browser.

---

## 15. A cleared conversation is one conversation again

**Where:** `/chat`. Look at the session list for `officer.dev`. It has one row fewer than it did
yesterday, and “Platform Arch 2” is gone — there is a single **Platform Arch** carrying a `2 parts`
badge. Open it and scroll up past the point where you cleared: the earlier conversation is above,
with a line across the transcript reading **context cleared — nothing above this is in memory**.

**What changed.** Item 14 could tell that one conversation followed another, and said so in a meta
line. This takes the obvious next step and stops showing them as two things at all. A `/clear` chain
is now one row and one transcript.

**The list** collapses each chain to its **newest** link, not its oldest, because that is the only one
you can carry on — a cleared session is finished, and `--resume` on it would fork a second branch. So
the row's id, its link, its rename and its delete all address the head. Everything else on the row
belongs to the whole chain: the root's start time, the summed message count, and the root's title. That
last one is why the numbering disappeared from view — a conversation shouldn't rename itself every time
you clear it, and `Platform Arch 2` was only ever a way of saying "this is still Platform Arch". Rename
still wins over all of it, and it is the head that stores it. The numbered titles are still computed and
still show up if you open a middle part directly from an old link.

**The transcript** is spliced server-side, in `loadChainTranscript`. This mattered for a boring reason:
the chat client pages by index into whatever the server calls the transcript — `?before=` and `?limit=`
plus a `total` — so a longer transcript simply pages further back and the client needed no changes at
all. Only ancestors are spliced in, never descendants, which keeps the returned id resumable.

**The divider is the point, not a decoration.** The risk in merging is that the conversation now looks
unbroken to you and is emphatically not: ask the agent about anything above that line and it has never
seen it. So the seam says what was lost rather than just drawing a rule. It is a new `divider` variant
on the `ChatMessage` union — the existing `system` role renders as a collapsible "System prompt" block
and would have been the wrong thing entirely.

**Delete now takes the whole chain**, and the inline confirm says so — `Delete all 2?` rather than
`Delete?`. Deleting only the head would have resurrected its parent as a separate row the moment its
child was gone, which reads as the delete half working. The chain is resolved from the transcript's own
directory rather than the requested one, because those two disagree routinely and the wrong group would
find no chain and quietly delete one part of several.

**What this removed.** The "continues X" line in the row and the link back in the chat header, both
built yesterday. There is nowhere to link to now — the previous conversation is scrolled up above you.
The header shows `continued across 2 sessions` instead, which is a fact about the thing you are reading
rather than a destination.

**Measured, not estimated:** `officer.dev` goes from 11 rows to 10, `dev-platform` from 2 to 1, each
merged transcript carrying exactly one divider at the expected index. Still read-only — nothing is
written back into Claude's store, so if the parent inference is ever wrong the damage is a list that
looks odd until the next read, not a corrupted transcript.

**Not verified:** the browser. Everything above is measured against your real transcripts through the
server code, not clicked through.

---

## 16. The tab you named stays named

`src/apps/officer-web/state/usePageTitle.ts`, `…/Layout/Header/Header.tsx`

The title in the middle of the header has been editable for a while, and it renames the browser tab as
you type — which is genuinely useful once you have six Officer tabs open and every one of them says
"Chat". It just didn't last. Two separate reasons: the name lived only in React Query, so a refresh
took it with the page; and `usePageTitleSync` had an effect that reset the title to the route default on
every navigation, so clicking anything at all wiped it even without a refresh.

**There is no tab id in the browser.** `chrome.tabs` gives an extension one, but page scripts are
deliberately not allowed to know which tab they are in, or that other tabs exist — it's the same
boundary that stops a page enumerating your windows. So there is no id to key the name on.

There doesn't need to be: **`sessionStorage` _is_ the per-tab store.** It's separate per tab, it
survives a refresh and in-place navigation, and it's discarded when the tab closes. That is exactly the
lifetime a tab name wants. (`localStorage` would be wrong in the obvious way — every tab would share
one name, which is the problem, not the fix.)

So the name is read from `sessionStorage` at module load and mirrored into a `useGlobal` entry; the
React Query copy is what re-renders the header, and the storage copy is what survives the reload. The
route title is no longer _assigned_ to the state, it's **derived** — `label ?? titleForPath(pathname)`.
Navigation therefore retitles the tab by itself when you haven't named it, and leaves it alone when you
have. Clearing the field is the one way back to the route name, and there's no third state to get stuck
in.

**The one hole is duplicate-tab**, which you use constantly — and duplicating a tab clones its
sessionStorage, so the copy would open wearing the original's name. Two tabs called "Platform Arch" is
precisely what naming one was meant to prevent.

The first fix for that was a guess, and the guess was wrong. It read
`performance.getEntriesByType('navigation')[0].type`, on the theory that a refresh reports `reload` and
a duplicate reports `navigate`, so a `navigate` arriving already holding a name did not earn it. Only
half of that is true: **`reload` means F5 or Ctrl-R and nothing else.** Pressing Enter in the address
bar is `navigate`. Following a link back into the app is `navigate`. Re-opening the URL after the server
was down — which is how you come back from every `pm2 restart` — is `navigate`. All of them threw the
name away, which read as "sessionStorage isn't surviving restarts". sessionStorage was fine; we were
deleting it on arrival.

So it asks now instead of guessing. Each tab stores an id beside its name, and a duplicate is a tab
whose id is **still held by a tab that is alive** — which the original can just say, over a
`BroadcastChannel`. The new document broadcasts `claim: <id>`; any live tab holding that id answers
`taken`; on hearing that, the copy mints a fresh id and gives up the name. A refresh has nobody to
answer, because the old document is destroyed before the new one's scripts run. A brand-new tab has no
id at all, so it can't be a copy of anything and never asks.

The answer arrives a beat late, so the header can show the inherited name for a frame before it clears.
That's the price of asking a real question instead of reading a tea leaf, and it's the right trade: this
still **fails soft toward keeping the name** — no `BroadcastChannel`, or nobody answering, means no
clone detected. A copy keeping a name is redundant. Losing a name you typed is a bug.

The header input also became a draft committed on blur/Enter rather than a live write. It wrote every
keystroke before, which was fine while the title was a plain string; now that an empty field means "use
the route name", deleting the last character would have snapped the input to "Chat" under the cursor.
Escape discards the draft.

---

## 17. Stop means stop, not "Claude Code returned an error"

Pressing stop mid-turn ended with a red destructive bubble reading **"Claude Code returned an error"**.
Nothing had gone wrong; you had told it to stop. Three changes, one per part of the problem.

**Where the lie came from.** The Agent SDK reports an `interrupt()` as an ordinary failed `result` —
`is_error` set, no text. Downstream that is indistinguishable from the harness genuinely falling over,
and `stream-parser.ts` correctly turned it into an error event. The only process that can tell the two
apart is the one that called `interrupt()`, so it now says so: `PersistentSession` carries an
`interrupted` flag, set in `interruptClaudeSession` **before** the `await` (the failed `result` can land
while `interrupt()` is still resolving), and the consumer loop rewrites an `error` event to `stopped`
while it is set. Any turn ending clears it, so a later real error can't wear it.

`{type:'stopped'}` was already in the wire protocol, already marked durable in `turn-stream.ts`, and
already emitted by OpenCode's runner — so this is the two harnesses converging on one behaviour rather
than a new message. The client had simply been settling to idle on it in silence, which looked the same
as the turn just ending. It now commits whatever the agent had said (it happened; it stays) and appends
a divider line: **INTERRUPTED BY USER**, muted, not red. `stream-parser.ts` keeps its error string —
that message is still right for an actual failure.

**Escape stops the turn**, as it does in Claude Code. The handler is bound to the chat's own subtree,
not the document: two chat panels can be generating at once and a document listener in each would make
one Escape stop both, quite apart from colliding with dialog dismissal. The composer handles its own
Escape and stops it bubbling, so a standalone `InputArea` still works and one keypress never fires two
stops.

**Your prompt comes back.** Interrupting almost always means "not like that" — you want to say it
differently — and retyping it out of the transcript is busywork. The composer is refilled with the text
exactly as typed, newlines and all (the raw input, not the trimmed-and-prefixed prompt that went to the
model), and refocused. It never overwrites: if you started composing something else while it ran, that
wins and the old prompt is dropped. Losing what you just typed to a stop you pressed would be the worse
failure. The stop button gets this too — it's the same function.

**History matches.** Claude records an interruption by writing `[Request interrupted by user]` (or
`…for tool use`) as the _user's_ next message; that is how the model is told on the next turn that it
was cut off. Replayed literally, your transcript showed a message you never typed. The server now maps
those two exact strings to the same `interrupted` role, so a reloaded session looks like a live one.
Matched whole-string only — that text appears _inside_ real messages too (this very conversation being
one), and those are genuinely yours. Nothing is written back into Claude's store; this is read-side
reinterpretation only.

**Not verified:** the browser. The reasoning above is from the code and from grepping your real
transcript store (54 plain markers, 11 tool-use ones); the flag's race behaviour in particular is
reasoned, not observed. Typecheck and the sidecar tests are clean.

---

## 18. Every code block has its own copy button

**Where:** any reply containing a fenced block — a command to run, a snippet to paste.

The bubble's copy button copies the _whole reply_. When the reply is prose ending in one command you're
meant to run, that's the wrong unit, and you end up selecting the line by hand — the one chore a command
in a chat exists to save you. Fenced blocks now carry a button in their top-right corner, **always
visible** — a control you have to discover by waving the pointer at it is one most people never find, and
on touch there is no hover to find it with at all. It sits at 70% white on the block's dark background
and brightens on hover; the block reserves right padding for it, so a long first line scrolls up to the
button rather than under it.

Inline `` `code` `` deliberately gets nothing: it's short enough to select, and a button per backticked
word would be noise.

Two details. The text is read from the rendered DOM (`textContent`) at click time rather than
reconstructed from the markdown AST — react-markdown hands the `pre` override a `<code>` element whose
children are strings, elements or nested arrays depending on which plugins ran, and reassembling that is
guesswork; `textContent` is exactly what's on screen. And the trailing newline is stripped, because it
belongs to the fence, not the command — pasted into a shell it would _run_ the thing rather than leave it
on the prompt for you to look at.

The block is wrapped in a positioned div, so `prose.css` moved the vertical margin onto the wrapper;
otherwise the `pre`'s own margin collapses through it and the `:first-child`/`:last-child` reset stops
working. The streaming bubble gets the same treatment, so a block doesn't gain a button when the turn
ends.

**Not verified:** the browser.

---

## 19. The turn that never ends

**The symptom you described:** the last thing on screen is a tool call, the spinner runs forever, and
refreshing puts you back on the same dead conversation. It looked like a cancel you didn't press.

**What actually happens.** It is the agent sidecar restarting. `officer-agent` holds the persistent
`query()` for each session, so when that process goes, so does every turn it was running — and nothing
downstream finds out. The browser's socket is to `officer`, which is fine; officer's subscription is an
event-bus filter, which is also fine; there is simply never another event. The `pm2` logs make the two
cases plain: `[sidecar] disconnected from API server` with no `SIGINT` beside it is officer restarting
underneath a healthy agent (turn survives — that's the documented design), while `[agent] SIGINT
received` is the case that kills turns.

So the fix could not key off the sidecar _disconnecting_ — that fires on every `pm2 restart officer`,
when nothing is wrong. It keys off a **registration**: a registration socket lives and dies with its
process, so an agent appearing on it is an agent that has just started, and anything it was mid-turn on
is gone.

Two paths, because the tab can be in two states:

- **The tab is sitting there with a live socket.** The new agent registers, and every session officer
  still believes is generating gets checked and ended. On a freshly-restarted _officer_ this loop is
  empty — no sessions yet — which is right, because that case belongs to the other path.
- **The tab reconnects** (socket blip, or a refresh, or officer itself restarted). The client now sends
  `generating` in its resume handshake — its belief that a turn is in flight. Officer can't confirm that
  from its own memory, which died with the process, so it asks the agent over a new `claude:is-generating`
  command. The agent is the only party that knows.

Either way you get a seam across the transcript — **AGENT RESTARTED — TURN CUT OFF** — with a **Retry**
button beside it that sends the same prompt again.

The retry is the whole reason this is worth having, and it works because most of what looked like the
hard problem is already solved. `sessionKey → claude session_id` is written through to
`data/<email>/sidecar/claude-state.json` on every change — not just at shutdown, so it survives a
`SIGKILL` — and `createSession` passes it back as `resume:`. **A restarted agent costs you the turn, not
the conversation:** the next prompt picks the thread up from the transcript on disk with full context.
Retry just spares you scrolling up to copy what you'd said.

It's deliberately a seam and not a red error bubble. Nothing is broken and nothing is lost but the turn,
so the row's job is to say what happened and offer the one action that fixes it. The prompt is read back
out of the transcript rather than remembered separately, because this can land in a second window on the
same session — one that never sent it.

Making the turn _itself_ survive is the part that stays unsolved, and deliberately so. The Agent SDK
spawns `claude` as a child with piped stdio; re-adopting it after the sidecar dies would mean the CLI
becoming a detached grandchild talking over a socket, i.e. not using the SDK's process management at all.
That is a large, risky rewrite that buys exactly one turn — and Retry buys most of it for thirty lines.

The liveness check **fails toward alive**: a timeout, or no answer, is read as "still running". Telling
you a turn died while it is quietly typing would be a worse lie than a spinner that stays up a bit
longer. Only a registered agent answering "no", or no agent at all, counts as dead. OpenCode sessions are
left alone — that harness runs a turn per invocation and has no equivalent question.

The notice is also appended to `chat_session_events`, but **don't count on it surviving a refresh**. That
table is keyed by officer's own session id, while a session reopened from history is addressed by
Claude's transcript uuid; the two converge once a conversation has been resumed at least once, and don't
before that. The live case is the one that matters here and it is unaffected. Untangling those two ids is
a separate job.

**Not verified:** the browser, and the restart itself. The mechanism is reasoned from the code plus the
`pm2` logs that pinned the cause; typecheck and the full 365-test suite are clean.

---

## Things noticed and deliberately left alone

- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
  says so — leave it.
- **`useAttachments`** already toasts on both its failure paths and rolls back the optimistic row.
  Nothing needed.
- **An earlier audit claim of mine was wrong** and I want it on the record rather than quietly dropped:
  I said `pt-2` inside an `h-full` container caused an 8px overflow on the chat screen. With
  `border-box`, percentage heights resolve against the content box, so there is no overflow. I did not
  change it.
- **Mobile edit-in-invisible-panel** (dashboards) is unrelated to this work and still open; it is
  recorded under "Known and deliberately unfixed" in the workspace-root `CLAUDE.md`.
