handoff: two chat fixes for the next session

This commit is contained in:
2026-08-18 03:39:41 +00:00
parent 2f31d464be
commit 0868e637c0
+113
View File
@@ -0,0 +1,113 @@
# 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`.