# 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: 1. **Client-side.** The turn's `result`/assistant messages already reach `useChat`. If a message carries a model, prefer it over the bare harness for display only — do not write it back to `selectedModel`, or the next turn will request a tier the user never picked. 2. **Server-side.** `GET /chat/sessions/:id` already returns `officerModel`, resolved from the stored row with a family-guess fallback. A live session could report the same thing on `session: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()` in `websocket.ts` refuses to persist a model without a `/` precisely because a bare harness overwrote a correctly backfilled `claude-code/opus` and 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.model` holds 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-proxy` and 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 `` 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 is `bun 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`.