Files
platform/docs/chat-sessions-list.md
T
pastilhasandClaude Opus 5 3837af657d live sessions default to agents only, and you ask by name for a commander
`?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>
2026-08-22 22:11:12 +00:00

7.9 KiB

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.