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:
@@ -4,10 +4,11 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getUserSettings, getChatSessionModel } from 'officerdb';
|
||||
import { getUserSettings, getChatSessionModel, getChatSessionModels } from 'officerdb';
|
||||
import {
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
listAllClaudeSessions,
|
||||
loadClaudeSession,
|
||||
loadClaudeSessionById,
|
||||
deleteClaudeSession,
|
||||
@@ -108,12 +109,38 @@ chatRouter.get('/pwds', async (ctx) => {
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
|
||||
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
||||
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
||||
// the row renderer) is indifferent to which directories the rows came from.
|
||||
const all = ctx.req.query('all') === '1';
|
||||
|
||||
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({
|
||||
...s,
|
||||
harness: 'claude' as const,
|
||||
}));
|
||||
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
|
||||
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
|
||||
const opencode = who.isOwner ? await listOpenCodeSessions(cwd) : [];
|
||||
//
|
||||
// `all` does not widen this: OpenCode's listing is already per-cwd with no all-directories form, and
|
||||
// inventing one by looping would need a directory list it does not publish. So Latest is Claude-only
|
||||
// for now, which is honest — every OpenCode session is still reachable from its own directory in Tree.
|
||||
const opencode = who.isOwner && !all ? await listOpenCodeSessions(cwd) : [];
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return ctx.json({ sessions });
|
||||
|
||||
/*
|
||||
The model each session is on, batched.
|
||||
|
||||
One query for the whole page rather than one per row — that is what `getChatSessionModels` exists for.
|
||||
A session with no row yet resolves to null and the row shows no model, which is the same "unknown rather
|
||||
than guessed" rule the picker follows. Never fatal: a database that will not answer this must not take
|
||||
the conversation list down with it.
|
||||
*/
|
||||
const models = await getChatSessionModels(sessions.map((s) => s.id)).catch(() => new Map<string, string>());
|
||||
|
||||
return ctx.json({
|
||||
sessions: sessions.map((s) => ({ ...s, model: models.get(s.id) ?? null })),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /chat/sessions/:id[?cwd=][&limit=&before=] — one conversation's transcript. Routes by harness
|
||||
|
||||
@@ -966,6 +966,30 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every conversation in every directory, newest first.
|
||||
*
|
||||
* For the sessions list's **Latest** tab, which asks a question the per-directory listing cannot: what have
|
||||
* I been working on lately, wherever it was. The alternative was one request per directory from the client,
|
||||
* which turns one screen into twenty round trips and still cannot sort across them.
|
||||
*
|
||||
* ── Why it loops directories rather than scanning flat ──
|
||||
*
|
||||
* `/clear` chains are merged into one row, and a chain lives inside a single project group. Scanning every
|
||||
* transcript flat and merging afterwards would let two unrelated chains in different directories collapse
|
||||
* into each other, because the lineage heuristics match on time and title rather than on path. Looping
|
||||
* `listClaudeSessions` per directory keeps merging scoped exactly the way the per-directory list does, so
|
||||
* the same conversation is one row in both views.
|
||||
*
|
||||
* The cost is one group scan per directory — the same total work as expanding every node of the tree, and
|
||||
* bounded by how many directories you have ever run `claude` in.
|
||||
*/
|
||||
export function listAllClaudeSessions(who: ChatIdentity): ClaudeSessionSummary[] {
|
||||
return listClaudePwds(who)
|
||||
.flatMap((pwd) => listClaudeSessions(who, pwd.cwd))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-level facts: the title it is shown under, and how many parts it spans.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user