Files
platform/NEXT-SESSION.md
T

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:

  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 <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 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.