5.6 KiB
Next session — two chat fixes
Disposable. Written 2026-08-18 ~05:00 as a handoff across a compaction. Delete it when both are done.
Both are in /chat. Do them in order — the first is small and well understood, the second is a design
question wearing a UI job's clothes.
1. The model dropdown says claude-code instead of the model that is answering
Symptom
In the composer, bottom right, the model select renders the literal string claude-code. It should
name a model — the owner expects "Opus".
Why, exactly
DEFAULT_MODEL in src/servers/api/chat/websocket.ts is the bare harness string claude-code, used when
the client sends no model and the account has no default. It routes correctly — isClaudeModel is a prefix
test and model.split('/')[1] is undefined, so no --model is passed and the CLI picks for itself.
It names no catalogue entry, so Radix's SelectValue matches no item and renders nothing. 773f0e1c added
a fallback in ModelSelector.tsx so a blank trigger became the raw string's last segment
(displayModel.split('/').pop()), which for claude-code is claude-code. Honest, and useless.
The fix, and the distinction it turns on
The server does not know which model answered — it deliberately did not choose one. The transcript does:
every assistant entry carries message.model (claude-opus-5), and officerModelForApiModel() in
src/servers/api/chat/model-aliases.ts already maps that back to claude-code/opus.
So: when the effective model is the bare harness, display what answered, not what was asked for. That is
the same effective-vs-requested distinction createMessageHandler already makes for tier switches.
Options, cheapest first:
- Client-side. The turn's
result/assistant messages already reachuseChat. If a message carries a model, prefer it over the bare harness for display only — do not write it back toselectedModel, or the next turn will request a tier the user never picked. - Server-side.
GET /chat/sessions/:idalready returnsofficerModel, resolved from the stored row with a family-guess fallback. A live session could report the same thing onsession:init.
Option 1 is smaller. Option 2 is more consistent with how a resumed session already works.
Traps
- Do not store the resolved value.
record()inwebsocket.tsrefuses to persist a model without a/precisely because a bare harness overwrote a correctly backfilledclaude-code/opusand made the picker render blank. Read that comment before touching either end. - This picker has been broken once already today. Verify: a session with no explicit model, a session on
opus[1m], and a resumed session all show something sensible. chat_session_models.modelholds Officer ids (claude-code/opus[1m]), not API ids. Do not mix them.
2. Usage popover on the "Claude Code" pill
What is wanted
Clicking the harness pill — the left-hand label in the composer's control row, which reads Claude Code and is inert once a conversation has started — should open a panel showing what Claude Code's own usage display shows:
- Limits: Session %, Weekly %, Fable Weekly %, each with a reset countdown
- Tokens by day, last seven days, as bars
- Tokens by model: Opus 5, Opus 4.8, and any others, with totals
The owner supplied a screenshot of the target and a reference implementation: https://github.com/basecamp/omarchy/tree/quattro/shell/plugins/agents
Read the reference FIRST — this is a data question, not a UI one
Officer does not have any of these numbers. Per-session cost is accumulated in memory in
websocket.ts (search session.meta.cost), it dies with pm2 restart officer, and nothing aggregates it
across sessions, days or models. There is no usage table.
So before writing any component, answer: where does omarchy get this? Two possibilities, and they are completely different jobs:
- It reads local Claude Code state — something under
~/.claude. Then this is a display job: a sidecar route reads the file, the panel renders it, and the numbers are the same ones the CLI shows. - It calls an Anthropic endpoint — then it needs the OAuth credential, which lives with
officer-anthropic-proxyand never leaves it, and the route belongs there rather than in the chat socket.
Do not build a schema for this until that is known. The wrong answer is inventing Officer's own token accounting to reproduce a number the CLI already computes.
Where the pill is
src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx — the provider label rendered when
providerStripLocked is true (a <span> with bg-muted, currently not a button). It sits left of the
background-job button and the model select.
Note the owner's framing: "in a running chat that is not responding to harness change anymore" — the pill is already inert in that state, which is exactly why it is free to become a button.
Constraints
- Owner-only data. These are the owner's subscription limits. A member must not see them — gate it.
- No palette classes. Semantic tokens only (
bg-muted,text-muted-foreground,bg-primary/15); the ratchet isbun run check:theme. - Do not block the composer. If the source is unreachable the popover says so; it must not spin.
Before you start
bunx tsgo is clean and bun test is at 7 pre-existing failures (registry/totality drift and a cliamp
socket — none yours). Compare against that baseline, not against zero.
Everything else from tonight is committed and pushed across four repositories: platform, officer-oas,
plugins/email, plugins/kiosk.