record a resumed session's model when it switches, not when the turn ends

`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.

Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.

Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.

Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (ed3806f9). Written down because the shape
came out of a conversation, not out of the code.
This commit is contained in:
2026-08-17 17:17:52 +00:00
parent 813bc200bf
commit fc67973876
2 changed files with 124 additions and 3 deletions
+107
View File
@@ -0,0 +1,107 @@
# The /chat sessions list — plan of record
Agreed with the owner on 2026-08-17. Written down because the build spans several commits and the shape came
out of a conversation rather than out of the code, so a context reset must not cost the reasoning.
**Status: not started.** The storage this depends on landed in `813bc200`; nothing in the list itself has
changed yet.
---
## What it becomes
Today the left column is **two panels** — `chat-live` on top at 25%, `chat-session-list` below at 75%
(`Screens/Dashboard/ChatHistory/defaultLayout.ts`). The split was deliberate ("they answer different
questions from different sources") but the top quarter is usually empty, because live sessions are rare.
The target is **one panel, three tabs**:
1. **Merge the two panels into one**, called Sessions.
2. Where the directory picker sits now, put **three tabs: Tree, Latest, Live**.
3. **Refresh and New Chat move to the right of the panel header.**
4. **Tree** — a collapsed accordion of directories that have sessions.
- Header per directory: full path from `~`, total session count, live session count.
- Click the header to expand/collapse.
- Expanded shows that directory's session rows, with today's indicators **plus a visible live marker**.
- Each directory header also offers **New Chat here**.
5. **Latest** — one scroll area, every session, newest first. Rows **also show their directory path**.
6. **Live** — same rendering as Latest, filtered to sessions that are live right now.
The global **New Chat** button opens the **browse component** (`DirPickerModal`), because the tree only
covers directories that already have sessions and starting somewhere new has to stay possible.
---
## Decisions taken, and why
**Tab + expanded-accordion state lives in panel state, not the URL.** Which session is open stays in the URL
(`/chat/<id>`), per `docs/navigation-audit.md`. Which _tab_ is showing is a view preference, and putting it in
the URL would make every session link carry it. `/chat/g/<path>` keeps working and maps to "Tree tab, that
directory expanded", so existing links and the group-scoped composer are unaffected.
**A live session with no transcript id stays unlinked, in the Live tab only.** `LiveSession.claudeSessionId`
is null between "Officer started a turn" and "the harness announced its transcript uuid" — a startup race of
a few seconds, and only for a brand-new chat (a resumed one knows its id up front). With `/chat/live` polling
every 10s it is nearly unobservable. It renders as a row titled "Starting…" that is not a link, which is
what `LiveSessions.tsx` already does. It cannot appear in Tree or Latest because those are built from
transcripts that do not exist yet. Nothing to design around; it resolves on the next poll.
**An all-sessions endpoint is worth adding.** Latest needs every session ordered by date, and
`GET /chat/sessions` answers for **one** directory. `listClaudePwds` already walks the whole projects tree,
so the walk exists — this is a second consumer of it, not a new class of work.
**Live counts per directory are a client-side join.** `/chat/live` already carries `cwd`, so the tree header's
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
still null contributes to no directory (see above).
---
## Landmines
**The layout migration must go first, alone, and inverted.** Merging the panels means every persisted
`screens/chat` layout still names `chat-live` — an appType the code no longer has. `normalizeLayout` replaces
an unknown appType with `appTypes.fallback` and `WorkspaceView` **writes the repair back**, so on one page
load every user's chat layout becomes N copies of the fallback panel, permanently. That is exactly the bug
that made `/offscale` render three server pickers on 2026-08-17 (`ed3806f9`).
There is also an existing effect in `Screens/Dashboard/ChatHistory/index.tsx` that force-overwrites any
layout **lacking** `chat-live`. It must be inverted in the same commit — replace layouts that _have_ it —
or the two guards fight and the screen rewrites itself on every render.
**`liveSessionTitle` reads a whole transcript per live session.** Its own comment says it is fine "against a
poll every ten seconds over a handful of live sessions — but it is worth knowing before this is called from
anywhere hotter". The tree's per-directory live count must not call it; the count only needs `cwd`, which the
payload already carries.
**Tree must not fetch every directory eagerly.** One `/chat/sessions?cwd=` per _expanded_ directory, not per
listed one. `useClaudeSessions`'s query key is already per-cwd, so N hooks cache correctly — but a collapsed
accordion of twenty directories must issue zero session queries.
---
## Already in place
- **`chat_session_models`** (`813bc200`) — one row per session holding Officer's model id. `getChatSessionModels(ids)`
is batched for exactly this: the model column on list rows is one query for a page, not one per row.
The session **summary** does not carry `model` yet; adding it is part of this work.
- **`/chat/pwds`** already returns `{cwd, sessionCount, updatedAt}` per directory — the accordion header's
path and total count, with no new endpoint.
- **`cwd` is on every session row**, so Latest's path-per-row is free.
- **Tabs primitive** (`components/Tabs/Tabs.tsx`) degrades to a `<Select>` below `lg`, which suits the
existing mobile mode. **Accordion** (`components/ui/accordion.tsx`) is Radix and unused by /chat so far.
## Inherited fixes to make in passing
- The Live tab's "generating" `Loader2` has no `animate-spin`, so it does not spin (`LiveSessions.tsx:55-63`).
- The Live panel has no header at all today, so its `refetch` is reachable only from the error state.
## Sequence
1. `transcriptId` seeding fix in `createMessageHandler` — a resumed session currently records its model only
on the closing `result`, so a switch is lost if the turn dies. One line, unrelated to the list, but it is
the storage this list reads.
2. Layout migration: one panel, inverted guard. Nothing else.
3. All-sessions endpoint + `model` on the summary.
4. The three tabs, in order Latest → Live → Tree (Latest is the simplest and proves the endpoint; Tree is the
only one with new interaction).
5. Header: Refresh + New Chat right-aligned; New Chat opens the browse modal; per-directory New Chat here.
+17 -3
View File
@@ -318,7 +318,7 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
}
// Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it.
function createMessageHandler(sessionId: string, model: string) {
function createMessageHandler(sessionId: string, model: string, resumeSessionId?: string) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -339,7 +339,19 @@ function createMessageHandler(sessionId: string, model: string) {
keyed by the transcript id or it will never join to a list row — and the transcript id is not known when
the turn starts, only when the harness announces it. That is what this waits for.
*/
let transcriptId: string | null = null;
/*
Seeded from the resume id, and that seed is what makes a switch durable.
This handler is built PER TURN, so without the seed `transcriptId` starts null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was still nothing to key the row on. A resumed session then recorded its model only on the closing
`result`, which means a turn that died took the switch with it: the sidecar log showed
`fable -> opus[1m]` while the stored row still said `opus`. Observed on this machine.
A brand-new conversation still has to wait for `session:claude` — it has no id yet by definition — but a
resumed one already knows, and resumed is the case where a switch actually happens.
*/
let transcriptId: string | null = resumeSessionId ?? null;
/**
* Write the model down, once both halves are known.
@@ -629,7 +641,9 @@ async function handleClaudeCodeChat(
session.isGenerating = true;
const onMessage = createMessageHandler(sessionId, model);
// The resume id is passed so a tier switch on a RESUMED conversation is recorded when it happens rather
// than when the turn ends — see the seed in `createMessageHandler`.
const onMessage = createMessageHandler(sessionId, model, msg.resumeSessionId);
/**
* One description of the turn, used by both paths below.