From d59bc0d1523fa4c7cd56aad569af2d0706bce5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 17 Aug 2026 17:30:44 +0000 Subject: [PATCH] one sessions panel with three views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- .../ChatHistory/defaultLayout.test.ts | 62 ++ .../Dashboard/ChatHistory/defaultLayout.ts | 30 +- .../Screens/Dashboard/ChatHistory/index.tsx | 36 +- src/servers/api/chat/chat.ts | 35 +- src/servers/api/chat/claude-sessions.ts | 24 + .../src/apps/ChatHistory/LiveSessions.tsx | 77 --- .../src/apps/ChatHistory/PwdSelector.tsx | 100 ---- .../src/apps/ChatHistory/SessionList.tsx | 534 +++++++++--------- .../src/apps/ChatHistory/SessionRow.tsx | 300 ++++++++++ .../src/apps/ChatHistory/SessionTree.tsx | 188 ++++++ .../officerdev/src/apps/ChatHistory/index.ts | 11 +- src/workspaces/state/src/useClaudeSessions.ts | 45 +- 12 files changed, 959 insertions(+), 483 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.test.ts delete mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx delete mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.test.ts b/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.test.ts new file mode 100644 index 00000000..7f907922 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.test.ts @@ -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); + }); +}); diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts index ef4f017a..ec460458 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts @@ -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 }, ], }; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 77434c7c..2d6f47d0 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -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) => { { // Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL; diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 917200e7..f84eab6c 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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()); + + 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 diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 469f5dc5..222e5f67 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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. * diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx deleted file mode 100644 index c74f7f52..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useParams } from 'react-router'; -import { Activity, Loader2, Radio } from 'lucide-react'; -import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data'; -import { useLiveSessions } from 'state/useLiveSessions'; -import { chatSessionPath } from './chat-routes'; - -/** - * What the agent is actually running, as opposed to what it has ever run. - * - * The list below this one reads transcripts from disk — history, and thousands of it. This reads the - * agent's in-memory map over the sidecar protocol, so it is the only view that can tell you a - * conversation is still working while nothing is on screen. That distinction is the whole point: after - * a `pm2 restart officer`, or from a browser that has never seen the session, officer itself has no - * record of a live turn and only the agent can say. - */ -export const LiveSessions = () => { - // Only to highlight the row you already have open; the titles come from the server now. - const { sessionId } = useParams<{ sessionId: string }>(); - const { live, isLoading, error, refetch } = useLiveSessions(); - - if (isLoading && live.length === 0) return ; - if (error) { - return ( - refetch()} className="cursor-pointer text-sm underline"> - Try again - - } - /> - ); - } - - if (live.length === 0) { - return ; - } - - return ( - - {live.map((session) => { - // The route opens Claude's id, not the agent's session key — they are different things and only - // the agent holds the mapping. No id yet means a conversation whose first turn has not landed: - // shown, but deliberately not a link, since there is nothing to open. - const transcriptId = session.claudeSessionId; - - return ( - - generating - - ) : ( - - idle - - ), - // The field worth surfacing: it is what keeps a session alive with nothing on screen, and - // what makes it unsafe to restart the agent sidecar. - session.pendingTasks > 0 ? ( - - {session.pendingTasks} background task{session.pendingTasks === 1 ? '' : 's'} - - ) : null, - ]} - /> - ); - })} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx deleted file mode 100644 index 3f566300..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useState } from 'react'; -import { FolderOpen, ChevronDown, Check, CornerDownLeft, FolderSearch } from 'lucide-react'; -import { useChatPwds } from 'state/useClaudeSessions'; -import { DirPickerModal } from './DirPickerModal'; - -type PwdSelectorProps = { - value: string | null; // null = the caller's own home - onChange: (cwd: string | null) => void; -}; - -// A shorter, friendlier label for a working directory. -const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~'); -const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd; - -export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => { - const { pwds, defaultCwd } = useChatPwds(); - const [open, setOpen] = useState(false); - const [browse, setBrowse] = useState(false); - const [custom, setCustom] = useState(''); - - const isDefaultActive = value === null || value === defaultCwd; - const label = isDefaultActive ? 'home' : basename(value!); - - const pick = (cwd: string | null) => { - onChange(cwd); - setOpen(false); - setCustom(''); - }; - const submitCustom = () => { - const p = custom.trim(); - if (p) pick(p); - }; - - return ( -
- - - {open && ( - <> -
setOpen(false)} /> -
-
- {pwds.map((p) => { - const selected = p.isDefault ? isDefaultActive : value === p.cwd; - return ( - - ); - })} -
- -
- setCustom(ev.target.value)} - onKeyDown={(ev) => ev.key === 'Enter' && submitCustom()} - placeholder="Enter a path…" - className="min-w-0 flex-1 bg-transparent px-1 text-xs outline-none placeholder:opacity-40" - /> - -
-
- - )} - - setBrowse(false)} onSelect={(p) => pick(p)} /> -
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 69934715..3e4dea1e 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -1,127 +1,160 @@ -import { useRef, useState, useCallback } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react'; -import { toast } from '@/components/ui/sonner'; -import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data'; -import { useSelectedChatSession } from '../../channels'; +import { MessageSquare, Plus, RefreshCw } from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { errorText } from 'helpers/error-text'; -import { useClaudeSessions } from 'state/useClaudeSessions'; -import type { SelectedSession } from './ChatDetailPanel'; -import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; -import { PwdSelector } from './PwdSelector'; +import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions'; +import type { LiveSession } from 'state/useLiveSessions'; +import { useLiveSessions } from 'state/useLiveSessions'; +import { useSelectedChatSession } from '../../channels'; +import { chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; +import { DirPickerModal } from './DirPickerModal'; +import { LiveMarker, SessionRow, shortenPath, useScrollActiveIntoView, useSessionRowActions } from './SessionRow'; +import { SessionTree } from './SessionTree'; -/** - * Which agent owns a conversation, said on EVERY row. - * - * Only OpenCode rows carried a badge before, which marks one harness and leaves the other inferred from - * its absence — fine when you already know the list mixes two, useless when you don't. The list is - * merged from two stores (Claude transcripts, OpenCode's session db) and the harness decides where a - * reply is dispatched, so it is a property of the row, not a footnote on the odd one out. - * - * `harness` is absent on older Claude rows, so anything that is not OpenCode reads as Claude — the same - * default the server applies. - */ -const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) => - harness === 'opencode' ? ( - OpenCode - ) : ( - Claude - ); +/* + The /chat sidebar: one panel, three views. + + It used to be TWO panels in a vertical 25/75 split — Live above, Sessions below — plus a directory + dropdown in the list's header. Three problems, and they compounded: + + - the Live panel was empty almost always, so a quarter of the sidebar was permanently blank + - the two had disjoint indicators, so a conversation with a turn running looked ordinary in the history + - the dropdown showed one directory at a time, which turned "where was I working on that" into a search + + Now: 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. See `docs/chat-sessions-list.md`. +*/ + +type View = 'tree' | 'latest' | 'live'; + +const TABS: { value: View; label: string }[] = [ + { value: 'tree', label: 'Tree' }, + { value: 'latest', label: 'Latest' }, + { value: 'live', label: 'Live' }, +]; + +/* + Which view and which nodes are open, in localStorage. + + Not the URL: which tab is showing is a view preference, and putting it there would make every session link + carry it. Not `usePanelConfig` either, which is the framework's own per-panel store — it returns a NO-OP + setter in a `locked` workspace, and /chat is locked, so writes would silently vanish. And not + `useDashboardState`, whose PATCH route only accepts layout-shaped objects under `screens/…`. + + localStorage also happens to be the right scope: a phone and a desktop wanting different tabs is reasonable. +*/ +const VIEW_KEY = 'chat.sessions.view'; +const EXPANDED_KEY = 'chat.sessions.expanded'; -// Reads the /chat conversation list from Claude's own transcript store (source of truth). -// Clicking a session loads its transcript and continues the real Claude session via --resume. export const SessionList = () => { const navigate = useNavigate(); - // Which session is open, and which project group we're browsing, both come from the route. Reading - // them there rather than from the selection channel means the highlight and the group are correct on - // a deep link and on back/forward, before any panel has published. + // Which session is open, and which project group we're browsing, both come from the route. Reading them + // there rather than from the selection channel means the highlight and the group are correct on a deep + // link and on back/forward, before any panel has published. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); - const [selected, setSelected] = useSelectedChatSession(); - // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows - // that session among its neighbours instead of snapping the list back to the default group. Null = - // the caller's own home. - const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; - const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); - const [editingId, setEditingId] = useState(null); - const [editValue, setEditValue] = useState(''); - const [confirmingId, setConfirmingId] = useState(null); + const groupCwd = cwdFromSplat(splat); + const [, setSelected] = useSelectedChatSession(); - const scrolledRef = useRef(false); - const selectedRef = useCallback((node: HTMLDivElement | null) => { - if (node && !scrolledRef.current) { - scrolledRef.current = true; - node.scrollIntoView({ block: 'center' }); - } - }, []); + const [view, setView] = useLocalStorageState(VIEW_KEY, 'tree'); + const [expanded, setExpanded] = useLocalStorageState(EXPANDED_KEY, []); + const [browsing, setBrowsing] = useState(false); - // No group in a session link, deliberately: the id already determines the directory. It used to - // carry `?cwd=`, which is how a link could name a group the session doesn't belong to. - const linkTo = chatSessionPath; + const { pwds, defaultCwd } = useChatPwds(); + const { live: liveSessions } = useLiveSessions(); - const startRename = (id: string, current: string) => { - setConfirmingId(null); - setEditingId(id); - setEditValue(current); + /* + Latest is fetched only while Latest is showing. + + `?all=1` scans every project group, so it is the most expensive read this panel can make — and the Tree + tab, which is the default, never needs it. `enabled` keeps it from running until you ask. + */ + const latest = useClaudeSessions(null, { all: true, enabled: view === 'latest' }); + + /* + Mutations are wired to the ALL query regardless of which tab is showing. + + Rename and delete are per-session and directory-agnostic (`?all=1` mode sends no cwd, so the server falls + back to its by-id scan), and the invalidation clears every sessions key plus the pwd counts. So one set of + handlers serves all three views, and a rename in Tree refreshes Latest too. + */ + const actions = useSessionRowActions(latest); + + const activeRef = useScrollActiveIntoView(); + + /* + A group in the URL opens that directory in the Tree. + + `/chat/g/` is how the old dropdown addressed a directory and it is still a live URL shape, so it has + to mean something here. Expanding rather than filtering keeps the rest of the tree visible, which is the + whole reason the dropdown was replaced. Derived rather than written into storage, so a link never + permanently rearranges someone's tree. + */ + const treeExpanded = useMemo( + () => (groupCwd && !expanded.includes(groupCwd) ? [...expanded, groupCwd] : expanded), + [expanded, groupCwd], + ); + + /** Live rows joined to a transcript id, so any view can mark a row as live. */ + const liveById = useMemo( + () => new Map(liveSessions.filter((s) => s.claudeSessionId).map((s) => [s.claudeSessionId!, s])), + [liveSessions], + ); + + const startChatIn = (cwd: string | null) => { + // The directory rides the selection because that is what the composer runs its first turn in; the route + // follows. Doing only the route ran the first turn in the default directory during the resolve window. + setSelected({ id: `new:${Date.now()}`, cwd }); + navigate(chatNewPath(cwd), { replace: true }); }; - // Rename and delete used to swallow their failures whole. `useClient` only raises a dialog for 401, - // 403 and 5xx, so a 404 — the likely one here, since a transcript can vanish from disk under you — - // put the row back exactly as it was and said nothing, which reads as "the rename didn't take". - const commitRename = async () => { - const id = editingId; - const title = editValue.trim(); - setEditingId(null); - if (!id || !title) return; - try { - await renameSession(id, title); - } catch (err) { - toast.error(`Could not rename the session: ${errorText(err)}`); - } - }; - - const handleDelete = async (id: string) => { - setConfirmingId(null); - try { - await deleteSession(id); - if (selected?.id === id) setSelected(null); - // Back to the group's list, not the default one — deleting the open session shouldn't also move - // you out of the project you were working in. - if (sessionId === id) navigate(chatListPath(activeCwd), { replace: true }); - } catch (err) { - toast.error(`Could not delete the session: ${errorText(err)}`); - } + const refresh = () => { + void latest.refetch(); + // Tree nodes and the live poll have their own queries; the mutation invalidator already covers every + // sessions key, so a refresh here is a refetch of what is on screen plus that. }; return ( -
-
- { - setSelected(null); // sessions belong to a cwd — clear the open one when switching - navigate(chatListPath(cwd), { replace: true }); - }} - /> -
+
+
+ setView(v as View)}> + + {TABS.map((tab) => ( + + {tab.label} + {/* The count that decides whether Live is worth a click. Only when non-zero — a permanent + `0` is the same as no information. */} + {tab.value === 'live' && liveSessions.length > 0 && ( + {liveSessions.length} + )} + + ))} + + + + {/* Right-aligned, per the agreed layout. Refresh first: it is the one you reach for without looking. */} +
+ {/* Opens the browser rather than starting a chat immediately. The tree only lists directories that + already have history, so this is the only way to start somewhere new — which is exactly why the + old free-text path input and "Browse…" lived in the dropdown this replaced. */}
- {error ? ( - refetch()} - className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground" - > - Try again - - } + {view === 'tree' && ( + - ) : isLoading && sessions.length === 0 ? ( - // Loading and empty used to be the same grey line of text, so a slow read looked like an - // account with no history. - - ) : sessions.length === 0 ? ( - - ) : ( - - {sessions.map((session) => { - const isActive = sessionId === session.id; - const isEditing = editingId === session.id; - const isConfirming = confirmingId === session.id; - - if (isEditing) { - return ( -
- setEditValue(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter') commitRename(); - if (ev.key === 'Escape') setEditingId(null); - }} - onBlur={commitRename} - aria-label="Session title" - className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none" - /> - - -
- ); - } - - return ( -
- {/* The row is the link and the actions are its siblings — a - -
- ) : ( - // focus-within, not just hover: keyboard users could never reach these at all. -
- - -
- )} -
- ); - })} - )} + + {view === 'latest' && ( + <> + {latest.error ? ( + void latest.refetch()} + className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground" + > + Try again + + } + /> + ) : latest.isLoading && latest.sessions.length === 0 ? ( + // Loading and empty used to be the same grey line of text, so a slow read looked like an + // account with no history. + + ) : latest.sessions.length === 0 ? ( + + ) : ( + + {latest.sessions.map((session) => ( + + ))} + + )} + + )} + + {view === 'live' && }
+ + {browsing && ( + setBrowsing(false)} + onSelect={(cwd) => { + setBrowsing(false); + startChatIn(cwd); + }} + /> + )}
); }; + +/* + The Live tab. + + Its own component because it reads a different store: `/chat/live` is the agent's in-memory map, polled, + and its rows are keyed by officer's session handle rather than by a transcript id. A live session that has + not yet been assigned a transcript id is rendered but is NOT a link — there is nothing to link to for the + few seconds before the harness announces one, and a row that navigates nowhere is worse than one that + plainly cannot be clicked. +*/ +const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => { + const { live: sessions, isLoading, error, refetch } = useLiveSessions(); + + if (error) { + return ( + void refetch()} + className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground" + > + Try again + + } + /> + ); + } + if (isLoading && sessions.length === 0) return ; + if (!sessions.length) { + return ( + + ); + } + + return ( + + {sessions.map((session) => ( + + ))} + + ); +}; + +/** + * A live row, which is not a `SessionRow`. + * + * It cannot be: the live payload has no `updatedAt`, no message count and no `partCount`, so rendering it + * through the shared row would mean inventing four fields. What it has that a history row does not is + * `pendingTasks` — background work that keeps a session alive with nothing on screen, and the thing that + * makes restarting the agent sidecar unsafe. + */ +const LiveRow = ({ session, activeId, home }: { session: LiveSession; activeId?: string; home: string | null }) => { + const transcriptId = session.claudeSessionId; + return ( + , + session.pendingTasks > 0 ? ( + + {session.pendingTasks} background task{session.pendingTasks === 1 ? '' : 's'} + + ) : null, + session.cwd ? {shortenPath(session.cwd, home)} : null, + ]} + /> + ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx new file mode 100644 index 00000000..38bf7578 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionRow.tsx @@ -0,0 +1,300 @@ +import { useCallback, useRef, useState } from 'react'; +import { Check, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react'; +import { toast } from '@/components/ui/sonner'; +import { DataRow, MetaItem, RelativeTime } from '@/components/Data'; +import { errorText } from 'helpers/error-text'; +import type { ClaudeSessionSummary } from 'state/useClaudeSessions'; +import { chatSessionPath } from './chat-routes'; + +/* + One conversation, rendered the same way in all three tabs. + + Extracted from SessionList when the sidebar became tabbed. Three views showing the same rows is exactly + the setup where indicators drift — the old Live panel and the old Sessions panel had entirely disjoint + sets, so a generating session looked completely different depending on which of the two you happened to + be looking at, and neither told you what the other knew. +*/ + +/** + * Which agent owns a conversation, said on EVERY row. + * + * Only OpenCode rows carried a badge before, which marks one harness and leaves the other inferred from its + * absence — fine when you already know the list mixes two, useless when you don't. + * + * `harness` is absent on older Claude rows, so anything that is not OpenCode reads as Claude — the same + * default the server applies. + */ +export const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) => + harness === 'opencode' ? ( + OpenCode + ) : ( + Claude + ); + +/** + * The tier a conversation is on — `opus`, `opus[1m]`, `fable`. + * + * The harness prefix is stripped: `HarnessBadge` beside it already says Claude, so `claude-code/opus` would + * be repeating itself in a column measured in pixels. + * + * Absent, not blank, when the model is unknown. A session that has taken no turn since `chat_session_models` + * landed has nothing recorded, and an empty pill would read as a model whose name failed to load. + */ +export const ModelBadge = ({ model }: { model?: string | null }) => { + if (!model) return null; + return ( + {model.split('/').pop()} + ); +}; + +/** + * This conversation has an agent behind it right now. + * + * The whole reason the two panels merged: liveness used to be visible only in a separate list, so the + * history could show a row as ordinary while a turn was running in it. `generating` and merely `live` are + * distinguished because they mean different things — a live session with no turn in flight is one you can + * send to immediately, while a generating one will queue. + */ +export const LiveMarker = ({ generating }: { generating: boolean }) => + generating ? ( + // Rendered inline rather than through `MetaItem`, which takes no icon class — and the spin is the point: + // the old Live panel used this same `Loader2` with no `animate-spin`, so the one indicator that meant + // "work is happening right now" was a static icon. + + + generating + + ) : ( + + + live + + ); + +/** Turn an absolute path into something that fits a sidebar: `/home/me/x` → `~/x`. */ +export const shortenPath = (path: string, home: string | null): string => + home && path.startsWith(home) ? `~${path.slice(home.length)}` || '~' : path; + +/** + * Rename and delete, shared by every view that renders rows. + * + * Held here rather than per-view because the state is "which row is mid-edit", and with three tabs over the + * same conversations a per-view copy would let two of them disagree about it. Only one row can be editing at + * a time anywhere in the panel, which is also what a user expects. + */ +export function useSessionRowActions(actions: { + renameSession: (id: string, title: string) => Promise; + deleteSession: (id: string) => Promise; +}) { + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(''); + const [confirmingId, setConfirmingId] = useState(null); + + const startRename = useCallback((id: string, current: string) => { + setConfirmingId(null); + setEditingId(id); + setEditValue(current); + }, []); + + // Rename and delete used to swallow their failures whole. `useClient` only raises a dialog for 401, 403 + // and 5xx, so a 404 — the likely one here, since a transcript can vanish from disk under you — put the + // row back exactly as it was and said nothing, which reads as "the rename didn't take". + const commitRename = useCallback(async () => { + const id = editingId; + const title = editValue.trim(); + setEditingId(null); + if (!id || !title) return; + try { + await actions.renameSession(id, title); + } catch (err) { + toast.error(`Could not rename the session: ${errorText(err)}`); + } + }, [editingId, editValue, actions]); + + const handleDelete = useCallback( + async (id: string) => { + setConfirmingId(null); + try { + await actions.deleteSession(id); + } catch (err) { + toast.error(`Could not delete the session: ${errorText(err)}`); + } + }, + [actions], + ); + + return { + editingId, + editValue, + setEditValue, + setEditingId, + confirmingId, + setConfirmingId, + startRename, + commitRename, + handleDelete, + }; +} + +export type SessionRowActions = ReturnType; + +type SessionRowProps = { + session: ClaudeSessionSummary; + /** The open conversation, from the URL. */ + activeId?: string; + /** Live state for this row, when it has one. */ + live?: { isGenerating: boolean } | null; + /** Show the directory this conversation lives in — Latest and Live span directories, Tree does not. */ + home?: string | null; + showPath?: boolean; + actions: SessionRowActions; + /** Called with the node of the active row, so a view can scroll it into view once. */ + activeRef?: (node: HTMLDivElement | null) => void; +}; + +export const SessionRow = ({ + session, + activeId, + live, + home = null, + showPath = false, + actions, + activeRef, +}: SessionRowProps) => { + const isActive = activeId === session.id; + const isEditing = actions.editingId === session.id; + const isConfirming = actions.confirmingId === session.id; + + if (isEditing) { + return ( +
+ actions.setEditValue(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void actions.commitRename(); + if (ev.key === 'Escape') actions.setEditingId(null); + }} + onBlur={() => void actions.commitRename()} + aria-label="Session title" + className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none" + /> + + +
+ ); + } + + return ( +
+ {/* The row is the link and the actions are its siblings — a + +
+ ) : ( + // focus-within, not just hover: keyboard users could never reach these at all. +
+ + +
+ )} +
+ ); +}; + +/** Scroll the open conversation into view once per mount, and never fight the user afterwards. */ +export function useScrollActiveIntoView() { + const scrolled = useRef(false); + return useCallback((node: HTMLDivElement | null) => { + if (node && !scrolled.current) { + scrolled.current = true; + node.scrollIntoView({ block: 'center' }); + } + }, []); +} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx new file mode 100644 index 00000000..886ddfc7 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionTree.tsx @@ -0,0 +1,188 @@ +import { ChevronRight, MessageSquare, Plus } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { DataList, EmptyBlock, LoadingBlock } from '@/components/Data'; +import type { ClaudePwd } from 'state/useClaudeSessions'; +import { useClaudeSessions } from 'state/useClaudeSessions'; +import type { LiveSession } from 'state/useLiveSessions'; +import { chatNewPath } from './chat-routes'; +import { SessionRow, shortenPath, type SessionRowActions, useScrollActiveIntoView } from './SessionRow'; +import { useSelectedChatSession } from '../../channels'; + +/* + Directories as an accordion, each expanding to its own conversations. + + This replaces the directory DROPDOWN. The dropdown could only show one directory at a time and hid the + rest behind a click, which made "where was I working on that" a search rather than a glance. The accordion + shows every directory that has history at once, with its counts, and opens the one you point at. + + ── The one rule that keeps it cheap ── + + A collapsed node issues NO request. Conversations are read by scanning transcripts off disk, so rendering + twenty directories eagerly would be twenty scans for rows nobody asked to see — and on this machine a + single transcript is 59MB. `useClaudeSessions(cwd, { enabled })` is the whole mechanism; the hook is + mounted per directory but disabled until its node opens, and react-query keeps each directory's answer + under its own key so closing and reopening is free. +*/ + +type DirectoryNodeProps = { + pwd: ClaudePwd; + home: string | null; + expanded: boolean; + onToggle: () => void; + /** Live sessions in THIS directory, already filtered by the parent. */ + live: LiveSession[]; + activeId?: string; + actions: SessionRowActions; +}; + +const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions }: DirectoryNodeProps) => { + const navigate = useNavigate(); + const [, setSelected] = useSelectedChatSession(); + const activeRef = useScrollActiveIntoView(); + + // Disabled while collapsed — see the note above. This is the difference between a tree that opens instantly + // and one that scans every project group on mount. + const { sessions, isLoading } = useClaudeSessions(pwd.cwd, { enabled: expanded }); + + const liveById = new Map(live.filter((s) => s.claudeSessionId).map((s) => [s.claudeSessionId!, s])); + + const startHere = () => { + // Same handshake the panel's New Chat does: publish the directory on the selection, because that is what + // the composer runs its first turn in, THEN navigate. Doing only the second ran the first turn in the + // default directory during the window before the route resolved. + setSelected({ id: `new:${Date.now()}`, cwd: pwd.cwd }); + navigate(chatNewPath(pwd.cwd), { replace: true }); + }; + + return ( +
+
+ + +
+ + {expanded && ( +
+ {isLoading && sessions.length === 0 ? ( + + ) : sessions.length === 0 ? ( + // Reachable: `/chat/pwds` counts transcripts per group, and a group can hold only files this + // reader skips. Better than an accordion that opens onto nothing. +
No readable conversations here.
+ ) : ( + + {sessions.map((session) => ( + + ))} + + )} +
+ )} +
+ ); +}; + +type SessionTreeProps = { + pwds: ClaudePwd[]; + home: string | null; + liveSessions: LiveSession[]; + expanded: string[]; + onExpandedChange: (next: string[]) => void; + activeId?: string; + actions: SessionRowActions; +}; + +export const SessionTree = ({ + pwds, + home, + liveSessions, + expanded, + onExpandedChange, + activeId, + actions, +}: SessionTreeProps) => { + if (!pwds.length) { + return ( + + ); + } + + const toggle = (cwd: string) => + onExpandedChange(expanded.includes(cwd) ? expanded.filter((c) => c !== cwd) : [...expanded, cwd]); + + /* + Live rows are grouped by `cwd`, which the poll already carries. + + Deliberately NOT via `liveSessionTitle`, which resolves a live session's directory by reading its whole + transcript — its own comment warns against calling it "from anywhere hotter" than a ten-second poll over + a handful of sessions, and a per-directory count on every render is exactly that. + + A live session whose `claudeSessionId` is still null counts toward its directory here but matches no row + inside, which is correct: the header is asking "is anything running in here" and the answer is yes. + */ + const liveByCwd = new Map(); + for (const session of liveSessions) { + if (!session.cwd) continue; + liveByCwd.set(session.cwd, [...(liveByCwd.get(session.cwd) ?? []), session]); + } + + return ( +
+ {pwds.map((pwd) => ( + toggle(pwd.cwd)} + live={liveByCwd.get(pwd.cwd) ?? []} + activeId={activeId} + actions={actions} + /> + ))} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts index 893f8375..576f9678 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts @@ -1,11 +1,9 @@ import type { AppRegistryMeta } from '../../AppRegistry'; -import { MessageSquare, List, Radio } from 'lucide-react'; +import { MessageSquare, List } from 'lucide-react'; import { SessionList } from './SessionList'; -import { LiveSessions } from './LiveSessions'; import { ChatDetailPanel } from './ChatDetailPanel'; export { SessionList }; -export { LiveSessions }; export { ChatDetailPanel }; export type { SelectedSession } from './ChatDetailPanel'; export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes'; @@ -18,13 +16,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [ component: SessionList, availableOnPanel: false, }, - { - key: 'chat-live', - name: 'Live', - icon: Radio, - component: LiveSessions, - availableOnPanel: false, - }, { key: 'chat-detail', name: 'Chat', diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 1110eb36..edd1cfa2 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -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(`/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(`/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 {