one sessions panel with three views

The /chat sidebar was two panels in a vertical 25/75 split — Live above, Sessions below — plus a directory
dropdown in the list header. Three problems that compounded: the Live panel was empty almost always, so a
quarter of the sidebar was permanently blank; the two panels had entirely disjoint indicators, so a
conversation with a turn running looked ordinary in the history; and the dropdown showed one directory at a
time, which turned "where was I working on that" into a search.

Now one panel, three tabs. Tree answers "what have I got, by project". Latest answers "what was I just
doing". Live answers "what is running". Same rows, same indicators, one place.

Tree is an accordion over /chat/pwds, each header carrying the full path from ~, the session count, and a
live count — plus New Chat here, since the tree only lists directories that already have history. A
COLLAPSED NODE ISSUES NO REQUEST: sessions are read by scanning transcripts off disk, and eager mounting
would be one scan per directory for rows nobody asked to see. `useClaudeSessions(cwd, {enabled})` is the
whole mechanism. The per-directory live count comes from the poll's own `cwd`, never from
`liveSessionTitle`, which reads a whole transcript and whose own comment warns against calling it from
anywhere hotter than a ten-second poll.

Latest needs a new server mode, `GET /chat/sessions?all=1` → `listAllClaudeSessions`. It loops directories
rather than scanning flat, because /clear chains are merged per group and a flat scan would let two
unrelated chains in different directories collapse into each other. Fetched only while the tab is showing.

Rows now carry the model, from the batched `getChatSessionModels` — one query per page, which is what that
query was added for.

THE MIGRATION IS THE DANGEROUS PART AND IT IS INVERTED. Every persisted layout still names `chat-live`, an
appType that no longer exists; `normalizeLayout` would collapse it onto the fallback and WRITE THAT BACK,
which is what made /offscale render three server pickers this morning (ed3806f9). The existing guard adopted
layouts LACKING the panel, so it had to flip to adopting layouts that HAVE it. defaultLayout.test.ts pins
both halves: the replacement must not contain the appType the guard tests for, or the effect fires on its
own output forever.

Deletes LiveSessions.tsx and PwdSelector.tsx — both replaced. Fixes the inherited bug in passing: the
"generating" Loader2 never had `animate-spin`, so the one indicator meaning "work is happening right now"
was a static icon.

View and expanded-node state go in localStorage, not the URL (a tab is a preference, and every session link
would carry it), not usePanelConfig (a NO-OP setter in a locked workspace, and /chat is locked), and not
useDashboardState (its PATCH only accepts layout-shaped objects). /chat/g/<path> still works and opens that
directory in the tree.

Untested against a running server: officer has not been restarted since these changes, and I am not
restarting it mid-conversation. `?all=1` and the model field are verified only by typecheck.
This commit is contained in:
2026-08-17 17:30:44 +00:00
parent fc67973876
commit d59bc0d152
12 changed files with 959 additions and 483 deletions
+35 -10
View File
@@ -29,6 +29,14 @@ export type ClaudeSessionSummary = {
* conversation; absent or 1 is the ordinary case.
*/
partCount?: number;
/**
* Which model this conversation is on, in OFFICER's id space (`claude-code/opus[1m]`), or null.
*
* From `chat_session_models`, not from the transcript — the transcript records what the API served
* (`claude-opus-5`), which cannot distinguish `opus` from `opus[1m]`. Null means no turn has recorded one
* yet, and the row shows no model rather than guessing.
*/
model?: string | null;
};
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
@@ -95,21 +103,38 @@ export function useChatPwds() {
return { pwds: data?.pwds ?? [], defaultCwd: data?.default ?? null };
}
/** Sessions for a working directory, read from Claude's own transcript store (source of truth). */
export function useClaudeSessions(cwd?: string | null) {
/**
* Sessions for a working directory, read from Claude's own transcript store (source of truth).
*
* `options.all` widens it to EVERY directory, for the list's Latest tab. It is a separate query key, not a
* filter over the per-directory one: the two answer different questions and caching them together would make
* opening Latest invalidate every directory's list.
*
* `options.enabled` is what keeps the Tree tab cheap. The tree renders one hook per directory and must issue
* ZERO requests while a node is collapsed — twenty directories on screen is otherwise twenty transcript
* scans for rows nobody has asked to see.
*/
export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean; enabled?: boolean }) {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const q = cwdQuery(cwd);
const all = options?.all === true;
// `all` ignores cwd entirely — sending both would imply the server intersects them, and it does not.
const q = all ? '?all=1' : cwdQuery(cwd);
const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
queryKey: [SESSIONS_KEY, cwd ?? 'default'],
enabled: isAuthenticated,
queryKey: [SESSIONS_KEY, all ? '__all__' : (cwd ?? 'default')],
enabled: isAuthenticated && options?.enabled !== false,
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
staleTime: 30 * 1000,
});
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}${q}`);
// Per-session calls take the DIRECTORY query, never `?all=1`: the server uses cwd to locate the
// transcript, and `all` is meaningless to it. In `all` mode there is no single directory, so they send
// nothing and the server falls back to its by-id scan.
const sessionQ = all ? '' : q;
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}${sessionQ}`);
// Invalidate every cwd's list (also refreshes pwd counts) — cheap and avoids stale lists.
const invalidate = useCallback(() => {
@@ -119,18 +144,18 @@ export function useClaudeSessions(cwd?: string | null) {
const deleteSession = useCallback(
async (id: string) => {
await client.delete(`/chat/sessions/${id}${q}`);
await client.delete(`/chat/sessions/${id}${sessionQ}`);
invalidate();
},
[client, q, invalidate],
[client, sessionQ, invalidate],
);
const renameSession = useCallback(
async (id: string, title: string) => {
await client.patch(`/chat/sessions/${id}/title${q}`, { title });
await client.patch(`/chat/sessions/${id}/title${sessionQ}`, { title });
invalidate();
},
[client, q, invalidate],
[client, sessionQ, invalidate],
);
return {