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 {