a user can mint a long-lived key for an app or a device instead of carrying a 30-day session, so multiple logins on the mobile apps are per-device revocable rather than one shared token. identity was being decided independently in userMiddleware and originScopeMiddleware, each verifying the token itself. teaching only one of them a new credential format is how those two stop agreeing, so both now call resolveAuthToken and neither knows what a bearer string is. verified: a member's key returns the same status as their jwt on every route tried, 403s included. a key carries its holder's full authority — not an escalation, it equals what the password could already do. scoping wants a scopes column, not a change here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
612 lines
36 KiB
Markdown
612 lines
36 KiB
Markdown
# 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`.
|