From fc67973876658154b3d44fa3ac8a670b19a53360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 17 Aug 2026 17:17:52 +0000 Subject: [PATCH] record a resumed session's model when it switches, not when the turn ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- docs/chat-sessions-list.md | 107 ++++++++++++++++++++++++++++++ src/servers/api/chat/websocket.ts | 20 +++++- 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 docs/chat-sessions-list.md diff --git a/docs/chat-sessions-list.md b/docs/chat-sessions-list.md new file mode 100644 index 00000000..d04e7f8b --- /dev/null +++ b/docs/chat-sessions-list.md @@ -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/`), 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/` 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 `