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. // 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. 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 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. 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. * Write the model down, once both halves are known.
@@ -629,7 +641,9 @@ async function handleClaudeCodeChat(
session.isGenerating = true; 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. * One description of the turn, used by both paths below.