`?kind=` was opt-in, so a client that said nothing got everything — which is the wrong default for the one caller that cannot cope. The phone swipes between live chats one at a time, has no room for tabs and no way to skip a row, so a commander in that list is a commander you are stuck on. Defaulting to `agent` means forgetting the parameter can only ever under-report. The two callers that want both now say `kind=all` explicitly, and that is a fair price: they are few, they are on screens built to show both kinds, and they are the ones positioned to notice when the answer is wrong. `counts` still describes every live session whatever the filter, so a filtered caller renders both tab numbers from one request rather than two. Also corrects useLiveSessions' comment, which said `isCommander` was derived from the cwd. It stopped being true when a commander gained the right to choose its own working directory — it comes from the `command_hq_sessions` row, and the directory answers nothing. Unrelated but caught by docs:check on the way past: the registry gained a nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which also left the generated platform skill two lines stale. Both corrected; the check is green again. The minesweeper feature itself is half-landed and not mine — `rfb.ts` fails typecheck and its registry test fails on a websocket that is not served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
7.9 KiB
Markdown
130 lines
7.9 KiB
Markdown
# 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: built.** `d59bc0d1` (one panel, three views), `640a74bc` (tabs, separator, sticky headings),
|
|
`773f0e1c` (blank-model fix), `5565867b` (indent). Storage landed in `813bc200`.
|
|
|
|
Not done: the OpenCode half of Latest (`?all=1` is Claude-only — OpenCode publishes no all-directories
|
|
listing), and nothing here has been exercised against a running server beyond the owner's own screenshots.
|
|
|
|
---
|
|
|
|
## 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).
|
|
|
|
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
|
|
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
|
|
accepted because the web client's own type is already `'agents' | 'commanders'`.
|
|
|
|
The default is the point. Commanders run background work and the phone swipes between live chats one at a
|
|
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
|
|
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
|
|
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
|
|
built to show both, and they are the ones positioned to notice if the answer is wrong.
|
|
|
|
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
|
|
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
|
|
costing more round trips than the client-side one it replaced.
|
|
|
|
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
|
|
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
|
|
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
|
|
about which of its rows is one.
|
|
|
|
---
|
|
|
|
## 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.
|