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
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test';
import { defaultLayout, hasAppType } from './defaultLayout';
/**
* The chat screen's layout migration, and the two properties that make it safe.
*
* The effect in index.tsx reads:
*
* if (!hasAppType(workspace.value, 'chat-live')) return;
* workspace.setValue(defaultLayout);
*
* Both halves can fail catastrophically and neither fails loudly:
*
* - if the replacement still CONTAINED `chat-live`, the effect would fire on its own output, forever
* - if the test were not inverted, layouts naming the retired panel would never be migrated, and
* `normalizeLayout` would collapse it onto `appTypes.fallback` and persist that — which is what made
* /offscale render three copies of its server picker on 2026-08-17
*
* So these assert the shape rather than the effect: cheap, and they hold whoever edits the layout next.
*/
describe('chat defaultLayout', () => {
test('does not contain the appType the migration tests for, so the guard terminates', () => {
expect(hasAppType(defaultLayout, 'chat-live')).toBe(false);
});
test('holds exactly the two panels the screen allows', () => {
// `appTypes.allowed` in index.tsx must stay in step with this, or normalizeLayout rewrites the default
// itself on first render.
expect(hasAppType(defaultLayout, 'chat-session-list')).toBe(true);
expect(hasAppType(defaultLayout, 'chat-detail')).toBe(true);
});
test('the sidebar is one panel, not a split', () => {
// The merge is the point of the change: a vertical split here means the Live panel came back.
expect(defaultLayout.type).toBe('group');
if (defaultLayout.type !== 'group') throw new Error('unreachable');
expect(defaultLayout.children).toHaveLength(2);
expect(defaultLayout.children.every((child) => child.node.type === 'panel')).toBe(true);
});
test('hasAppType finds a panel at any depth', () => {
// It is the migration's only instrument, so a nested layout must not read as clean.
const nested = {
type: 'group' as const,
id: 'root',
direction: 'horizontal' as const,
children: [
{
node: {
type: 'group' as const,
id: 'side',
direction: 'vertical' as const,
children: [{ node: { type: 'panel' as const, id: 'l', appType: 'chat-live' }, size: 50 }],
},
size: 50,
},
],
};
expect(hasAppType(nested, 'chat-live')).toBe(true);
expect(hasAppType(nested, 'chat-detail')).toBe(false);
});
});
@@ -6,26 +6,26 @@ export function hasAppType(node: LayoutNode, appType: string): boolean {
return node.children.some((child) => hasAppType(child.node, appType));
}
// The left column is itself a split: what is running now on top, the whole history below. They answer
// different questions from different sources — the agent's in-memory map versus transcripts on disk —
// and the live one is small and usually empty, hence the lopsided 25/75.
/*
One sidebar panel, not two.
It was a vertical 25/75 split — `chat-live` above `chat-session-list` — on the reasoning that the two
answered different questions from different sources (the agent's in-memory map versus transcripts on
disk). True, and still true; what made it wrong was the arithmetic. Live sessions are rare, so the top
quarter of the sidebar was empty almost always, and the list that is used constantly got three quarters
of the space instead of all of it.
Both questions now live in one panel as tabs, which is the same information in the same place without
paying for the rarer one when it has nothing to say. `chat-live` no longer exists as a panel type — see
the migration guard in index.tsx, which exists because deleting a panel type from the code does not
delete it from anyone's saved layout.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'chat-history-root',
direction: 'horizontal',
children: [
{
node: {
type: 'group',
id: 'chat-sidebar',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'chat-live', appType: 'chat-live' }, size: 25 },
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 75 },
],
},
size: 35,
},
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 35 },
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
],
};
@@ -32,19 +32,31 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const navigate = useNavigate();
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
// Adopt a structural change to this screen's layout.
//
// `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened
// /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs
// which app a panel runs, never the tree, so adding the Live panel above the list would have been
// invisible to every existing user and visible only on a fresh account.
//
// Replacing outright is safe *here* specifically because the screen is `locked`: its structure is
// dictated by code and the only thing a user can have contributed is the column sizes, which is a
// cheap thing to lose once. Terminates because the replacement contains the panel it tests for.
/*
Adopt a structural change to this screen's layout.
`useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened /chat
keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs which app
a panel RUNS, never the tree.
── The test is INVERTED, and getting that wrong is destructive ──
This used to read `if (hasAppType(value, 'chat-live')) return;` — adopt the layout that HAS the Live
panel. Now that the two sidebar panels are merged, `chat-live` is the appType that no longer exists, so
the same line would refuse to migrate exactly the layouts that need it, and `normalizeLayout` would
collapse the stranded panel onto `appTypes.fallback` and WRITE THAT BACK. That is not hypothetical: it is
what made /offscale render three copies of its server picker on 2026-08-17 (`ed3806f9`), and a locked
screen has no app picker to recover with.
So: replace any layout still naming `chat-live`. Terminates because the replacement does not contain it.
Replacing outright is safe *here* specifically because the screen is `locked` — its structure is dictated
by code, and the only thing a user can have contributed is the column sizes, which is a cheap thing to
lose once.
*/
useEffect(() => {
if (!workspace.isLoaded) return;
if (hasAppType(workspace.value, 'chat-live')) return;
if (!hasAppType(workspace.value, 'chat-live')) return;
workspace.setValue(defaultLayout);
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
@@ -124,7 +136,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['chat-session-list', 'chat-live', 'chat-detail'], fallback: 'chat-detail' }}
appTypes={{ allowed: ['chat-session-list', 'chat-detail'], fallback: 'chat-detail' }}
mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => {
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;