one sessions panel with three views

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

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

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

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

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

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

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

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

Untested against a running server: officer has not been restarted since these changes, and I am not
restarting it mid-conversation. `?all=1` and the model field are verified only by typecheck.
This commit is contained in:
2026-08-17 17:30:44 +00:00
parent fc67973876
commit d59bc0d152
12 changed files with 959 additions and 483 deletions
@@ -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 <LoadingBlock label="Asking the agent…" />;
if (error) {
return (
<ErrorBlock
title="Could not reach the agent"
message="The sidecar did not answer. Nothing is necessarily wrong with a running session — this view just cannot see it."
action={
<button onClick={() => refetch()} className="cursor-pointer text-sm underline">
Try again
</button>
}
/>
);
}
if (live.length === 0) {
return <EmptyBlock icon={Radio} title="Nothing running" hint="Sessions with a live agent appear here." />;
}
return (
<DataList>
{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 (
<DataRow
key={session.sessionKey}
to={transcriptId ? chatSessionPath(transcriptId) : undefined}
selected={transcriptId === sessionId}
title={session.title ?? 'Starting…'}
meta={[
session.isGenerating ? (
<MetaItem key="g" icon={Loader2}>
<span className="text-duck-teal">generating</span>
</MetaItem>
) : (
<span key="g" className="text-muted-foreground">
idle
</span>
),
// 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 ? (
<MetaItem key="t" icon={Activity}>
{session.pendingTasks} background task{session.pendingTasks === 1 ? '' : 's'}
</MetaItem>
) : null,
]}
/>
);
})}
</DataList>
);
};
@@ -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 (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
title={isDefaultActive ? (defaultCwd ?? 'Default') : value!}
className="flex items-center gap-1.5 max-w-[13rem] rounded-md border border-border px-2 py-1 text-xs text-foreground/80 hover:bg-muted cursor-pointer transition-colors"
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" />
<span className="truncate">{label}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 z-20 mt-1 w-80 rounded-md border border-border bg-background shadow-lg">
<div className="max-h-72 overflow-y-auto py-1">
{pwds.map((p) => {
const selected = p.isDefault ? isDefaultActive : value === p.cwd;
return (
<button
key={p.cwd}
onClick={() => pick(p.isDefault ? null : p.cwd)}
title={p.cwd}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs cursor-pointer hover:bg-muted ${selected ? 'text-duck-teal' : 'text-foreground/80'}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{p.isDefault ? 'Default · home' : shorten(p.cwd)}</span>
{p.sessionCount > 0 && <span className="shrink-0 opacity-40">{p.sessionCount}</span>}
</button>
);
})}
</div>
<button
onClick={() => {
setOpen(false);
setBrowse(true);
}}
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-xs text-foreground/80 hover:bg-muted cursor-pointer"
>
<FolderSearch className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" /> Browse…
</button>
<div className="flex items-center gap-1 border-t border-border p-2">
<input
value={custom}
onChange={(ev) => 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"
/>
<button
onClick={submitCustom}
disabled={!custom.trim()}
className="shrink-0 p-1 rounded text-duck-teal hover:bg-duck-teal/10 cursor-pointer disabled:opacity-30 disabled:cursor-default"
title="Use this path"
>
<CornerDownLeft className="h-3.5 w-3.5" />
</button>
</div>
</div>
</>
)}
<DirPickerModal open={browse} onClose={() => setBrowse(false)} onSelect={(p) => pick(p)} />
</div>
);
};
@@ -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' ? (
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">OpenCode</span>
) : (
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">Claude</span>
);
/*
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/<id> 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<string | null>(null);
const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(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>(VIEW_KEY, 'tree');
const [expanded, setExpanded] = useLocalStorageState<string[]>(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/<path>` 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 (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-3 py-2">
<PwdSelector
value={activeCwd}
onChange={(cwd) => {
setSelected(null); // sessions belong to a cwd — clear the open one when switching
navigate(chatListPath(cwd), { replace: true });
}}
/>
<div className="ml-auto flex items-center gap-1.5">
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
<Tabs value={view} onValueChange={(v) => setView(v as View)}>
<TabsList className="inline-flex h-auto items-center rounded-md bg-muted p-0.5 text-muted-foreground">
{TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="cursor-pointer rounded px-2.5 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:text-foreground focus-visible:ring-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{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 && (
<span className="ml-1 text-success">{liveSessions.length}</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Right-aligned, per the agreed layout. Refresh first: it is the one you reach for without looking. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
onClick={() => refetch()}
className="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
onClick={refresh}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Refresh"
aria-label="Refresh session list"
aria-label="Refresh sessions"
>
<RefreshCw className={`h-3.5 w-3.5 ${isLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-3.5 w-3.5 ${latest.isLoading ? 'animate-spin' : ''}`} />
</button>
{/* 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. */}
<button
onClick={() => {
// A new chat starts in the group the list is showing, and says so in both places: on the
// selection (which is what the composer actually runs in) and in the URL.
setSelected({ id: `new:${Date.now()}`, cwd: activeCwd });
navigate(chatNewPath(activeCwd), { replace: true });
}}
// Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and
// duck-yellow has no dark override at all, so the pair sat around 2:1 contrast either way —
// brand colours used as a fill they were never legible against.
className="flex h-7 cursor-pointer items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={() => setBrowsing(true)}
className="flex cursor-pointer items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
title="New chat in a directory…"
>
<Plus className="h-3.5 w-3.5" />
New Chat
@@ -130,160 +163,151 @@ export const SessionList = () => {
</div>
<div className="min-h-0 flex-1">
{error ? (
<ErrorBlock
title="Could not load sessions"
message={errorText(error)}
action={
<button
onClick={() => refetch()}
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
>
Try again
</button>
}
{view === 'tree' && (
<SessionTree
pwds={pwds}
home={defaultCwd}
liveSessions={liveSessions}
expanded={treeExpanded}
onExpandedChange={setExpanded}
activeId={sessionId}
actions={actions}
/>
) : 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.
<LoadingBlock label="Reading transcripts…" />
) : sessions.length === 0 ? (
<EmptyBlock
icon={MessageSquare}
title="No sessions yet"
hint="Conversations started in this folder show up here. Start one with New Chat."
/>
) : (
<DataList>
{sessions.map((session) => {
const isActive = sessionId === session.id;
const isEditing = editingId === session.id;
const isConfirming = confirmingId === session.id;
if (isEditing) {
return (
<div key={session.id} className="flex min-w-0 items-center gap-2 px-4 py-3">
<input
autoFocus
value={editValue}
onChange={(ev) => 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"
/>
<button
onMouseDown={(ev) => ev.preventDefault()}
onClick={commitRename}
className="cursor-pointer p-1 text-success hover:opacity-80"
title="Save"
aria-label="Save title"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => setEditingId(null)}
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
title="Cancel"
aria-label="Cancel rename"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div
key={session.id}
ref={isActive ? selectedRef : undefined}
className={`group flex items-center ${isActive ? 'bg-muted' : ''}`}
>
{/* The row is the link and the actions are its siblings — a <button> inside an <a> is
not a thing, and nesting them is what breaks cmd-click on half the app's lists. */}
<DataRow
to={linkTo(session.id)}
title={session.title}
selected={isActive}
className="min-w-0 flex-1"
meta={[
<RelativeTime key="t" value={session.updatedAt} />,
<HarnessBadge key="h" harness={session.harness} />,
// Claude's transcripts give a real turn count. OpenCode's session list has no
// count field, so the number on those rows is a hardcoded 0 and costs an HTTP
// call per row to make real — the badge stands in its place rather than beside a
// zero that means "not asked".
session.harness === 'opencode'
? null
: `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`,
// One row, several transcripts: this conversation was `/clear`ed and carried on,
// and the server has spliced the parts back together. Worth saying out loud —
// the message count above covers all of them, and opening the row shows all of
// them, so the row is not standing for the single file its id names.
session.partCount && session.partCount > 1 ? (
<MetaItem key="c" icon={Layers}>
{session.partCount} parts
</MetaItem>
) : null,
]}
/>
{isConfirming ? (
<div className="mr-2 flex shrink-0 items-center gap-1">
{/* Say the size of it. Deleting a merged chain removes every part, and a row
that looks like one conversation giving no warning that it is three files is
exactly the kind of surprise this confirm exists to prevent. */}
<span className="text-xs text-destructive">
{session.partCount && session.partCount > 1 ? `Delete all ${session.partCount}?` : 'Delete?'}
</span>
<button
onClick={() => handleDelete(session.id)}
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
title="Confirm delete"
aria-label={`Confirm deleting ${session.title}`}
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirmingId(null)}
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
title="Cancel"
aria-label="Cancel delete"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
// focus-within, not just hover: keyboard users could never reach these at all.
<div className="mr-2 flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button
onClick={() => startRename(session.id, session.title)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Rename"
aria-label={`Rename ${session.title}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirmingId(session.id)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive"
title="Delete session"
aria-label={`Delete ${session.title}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
);
})}
</DataList>
)}
{view === 'latest' && (
<>
{latest.error ? (
<ErrorBlock
title="Could not load sessions"
message={errorText(latest.error)}
action={
<button
onClick={() => void latest.refetch()}
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
>
Try again
</button>
}
/>
) : 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.
<LoadingBlock label="Reading transcripts…" />
) : latest.sessions.length === 0 ? (
<EmptyBlock
icon={MessageSquare}
title="No sessions yet"
hint="Every conversation you have had shows up here, newest first. Start one with New Chat."
/>
) : (
<DataList>
{latest.sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
activeId={sessionId}
live={liveById.get(session.id) ?? null}
home={defaultCwd}
showPath
actions={actions}
activeRef={activeRef}
/>
))}
</DataList>
)}
</>
)}
{view === 'live' && <LiveView activeId={sessionId} home={defaultCwd} />}
</div>
{browsing && (
<DirPickerModal
open
onClose={() => setBrowsing(false)}
onSelect={(cwd) => {
setBrowsing(false);
startChatIn(cwd);
}}
/>
)}
</div>
);
};
/*
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 (
<ErrorBlock
title="Could not read live sessions"
message={errorText(error)}
action={
<button
onClick={() => void refetch()}
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
>
Try again
</button>
}
/>
);
}
if (isLoading && sessions.length === 0) return <LoadingBlock label="Asking the agent…" />;
if (!sessions.length) {
return (
<EmptyBlock
icon={MessageSquare}
title="Nothing running"
hint="Conversations with an agent behind them right now show up here."
/>
);
}
return (
<DataList>
{sessions.map((session) => (
<LiveRow key={session.sessionKey} session={session} activeId={activeId} home={home} />
))}
</DataList>
);
};
/**
* 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 (
<DataRow
to={transcriptId ? chatSessionPath(transcriptId) : undefined}
title={session.title ?? 'Starting…'}
selected={!!transcriptId && transcriptId === activeId}
meta={[
<LiveMarker key="l" generating={session.isGenerating} />,
session.pendingTasks > 0 ? (
<MetaItem key="t">
{session.pendingTasks} background task{session.pendingTasks === 1 ? '' : 's'}
</MetaItem>
) : null,
session.cwd ? <span key="p">{shortenPath(session.cwd, home)}</span> : null,
]}
/>
);
};
@@ -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' ? (
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">OpenCode</span>
) : (
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">Claude</span>
);
/**
* 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 (
<span className="rounded bg-muted px-1.5 py-0.5 font-medium text-muted-foreground">{model.split('/').pop()}</span>
);
};
/**
* 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.
<span className="flex items-center gap-1.5 text-success">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
generating
</span>
) : (
<span className="inline-flex items-center gap-1 font-medium text-success">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
live
</span>
);
/** 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<unknown>;
deleteSession: (id: string) => Promise<unknown>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(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<typeof useSessionRowActions>;
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 (
<div className="flex min-w-0 items-center gap-2 px-4 py-3">
<input
autoFocus
value={actions.editValue}
onChange={(ev) => 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"
/>
<button
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => void actions.commitRename()}
className="cursor-pointer p-1 text-success hover:opacity-80"
title="Save"
aria-label="Save title"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => actions.setEditingId(null)}
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
title="Cancel"
aria-label="Cancel rename"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div ref={isActive ? activeRef : undefined} className={`group flex items-center ${isActive ? 'bg-muted' : ''}`}>
{/* The row is the link and the actions are its siblings — a <button> inside an <a> is not a thing,
and nesting them is what breaks cmd-click on half the app's lists. No group in the link:
the id already determines the directory. */}
<DataRow
to={chatSessionPath(session.id)}
title={session.title}
selected={isActive}
className="min-w-0 flex-1"
meta={[
// Liveness first, because it is the only field that changes what you would do next.
live ? <LiveMarker key="l" generating={live.isGenerating} /> : null,
<RelativeTime key="t" value={session.updatedAt} />,
<HarnessBadge key="h" harness={session.harness} />,
<ModelBadge key="m" model={session.model} />,
// Claude's transcripts give a real turn count. OpenCode's session list has no count field, so the
// number on those rows is a hardcoded 0 and costs an HTTP call per row to make real — the badge
// stands in its place rather than beside a zero that means "not asked".
session.harness === 'opencode' ? null : `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`,
// One row, several transcripts: this conversation was `/clear`ed and carried on, and the server
// has spliced the parts back together. Worth saying out loud — the message count above covers all
// of them, so the row is not standing for the single file its id names.
session.partCount && session.partCount > 1 ? (
<MetaItem key="c" icon={Layers}>
{session.partCount} parts
</MetaItem>
) : null,
// Only where the view spans directories. Inside a Tree node the path is the heading above.
showPath ? (
<MetaItem key="p" icon={Folder}>
{shortenPath(session.cwd, home)}
</MetaItem>
) : null,
]}
/>
{isConfirming ? (
<div className="mr-2 flex shrink-0 items-center gap-1">
{/* Say the size of it. Deleting a merged chain removes every part, and a row that looks like one
conversation giving no warning that it is three files is exactly the surprise this prevents. */}
<span className="text-xs text-destructive">
{session.partCount && session.partCount > 1 ? `Delete all ${session.partCount}?` : 'Delete?'}
</span>
<button
onClick={() => void actions.handleDelete(session.id)}
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
title="Confirm delete"
aria-label={`Confirm deleting ${session.title}`}
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => actions.setConfirmingId(null)}
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
title="Cancel"
aria-label="Cancel delete"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
// focus-within, not just hover: keyboard users could never reach these at all.
<div className="mr-2 flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button
onClick={() => actions.startRename(session.id, session.title)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Rename"
aria-label={`Rename ${session.title}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => actions.setConfirmingId(session.id)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive"
title="Delete session"
aria-label={`Delete ${session.title}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
);
};
/** 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' });
}
}, []);
}
@@ -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 (
<div className="border-b border-border/60">
<div className="group/dir flex items-center">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<ChevronRight
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
{/* The full path from `~`, not the basename. Several projects share a leaf name — `platform`,
`src`, `web` — and the basename alone made two different directories indistinguishable. `dir`
keeps the tail visible when the path is too long for the panel, which is the useful half. */}
<span dir="rtl" className="min-w-0 flex-1 truncate text-left text-xs font-medium">
{shortenPath(pwd.cwd, home)}
</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">{pwd.sessionCount}</span>
{/* Live count, from the poll — the reason a directory is worth opening right now. Absent rather
than zero: a `0` on every row is noise, and this is meant to catch the eye. */}
{live.length > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-success">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
{live.length}
</span>
)}
</button>
<button
type="button"
onClick={startHere}
title={`New chat in ${shortenPath(pwd.cwd, home)}`}
aria-label={`New chat in ${shortenPath(pwd.cwd, home)}`}
className="mr-2 shrink-0 cursor-pointer rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/dir:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{expanded && (
<div className="bg-background/40">
{isLoading && sessions.length === 0 ? (
<LoadingBlock label="Reading transcripts…" />
) : 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.
<div className="px-9 py-3 text-xs text-muted-foreground">No readable conversations here.</div>
) : (
<DataList className="!h-auto !overflow-visible">
{sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
activeId={activeId}
live={liveById.get(session.id) ?? null}
actions={actions}
activeRef={activeRef}
/>
))}
</DataList>
)}
</div>
)}
</div>
);
};
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 (
<EmptyBlock
icon={MessageSquare}
title="No conversations yet"
hint="Every directory you have run a chat in shows up here. Start one with New Chat."
/>
);
}
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<string, LiveSession[]>();
for (const session of liveSessions) {
if (!session.cwd) continue;
liveByCwd.set(session.cwd, [...(liveByCwd.get(session.cwd) ?? []), session]);
}
return (
<div className="h-full overflow-y-auto">
{pwds.map((pwd) => (
<DirectoryNode
key={pwd.cwd}
pwd={pwd}
home={home}
expanded={expanded.includes(pwd.cwd)}
onToggle={() => toggle(pwd.cwd)}
live={liveByCwd.get(pwd.cwd) ?? []}
activeId={activeId}
actions={actions}
/>
))}
</div>
);
};
@@ -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',
+35 -10
View File
@@ -29,6 +29,14 @@ export type ClaudeSessionSummary = {
* conversation; absent or 1 is the ordinary case.
*/
partCount?: number;
/**
* Which model this conversation is on, in OFFICER's id space (`claude-code/opus[1m]`), or null.
*
* From `chat_session_models`, not from the transcript — the transcript records what the API served
* (`claude-opus-5`), which cannot distinguish `opus` from `opus[1m]`. Null means no turn has recorded one
* yet, and the row shows no model rather than guessing.
*/
model?: string | null;
};
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
@@ -95,21 +103,38 @@ export function useChatPwds() {
return { pwds: data?.pwds ?? [], defaultCwd: data?.default ?? null };
}
/** Sessions for a working directory, read from Claude's own transcript store (source of truth). */
export function useClaudeSessions(cwd?: string | null) {
/**
* Sessions for a working directory, read from Claude's own transcript store (source of truth).
*
* `options.all` widens it to EVERY directory, for the list's Latest tab. It is a separate query key, not a
* filter over the per-directory one: the two answer different questions and caching them together would make
* opening Latest invalidate every directory's list.
*
* `options.enabled` is what keeps the Tree tab cheap. The tree renders one hook per directory and must issue
* ZERO requests while a node is collapsed — twenty directories on screen is otherwise twenty transcript
* scans for rows nobody has asked to see.
*/
export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean; enabled?: boolean }) {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const q = cwdQuery(cwd);
const all = options?.all === true;
// `all` ignores cwd entirely — sending both would imply the server intersects them, and it does not.
const q = all ? '?all=1' : cwdQuery(cwd);
const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
queryKey: [SESSIONS_KEY, cwd ?? 'default'],
enabled: isAuthenticated,
queryKey: [SESSIONS_KEY, all ? '__all__' : (cwd ?? 'default')],
enabled: isAuthenticated && options?.enabled !== false,
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
staleTime: 30 * 1000,
});
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}${q}`);
// Per-session calls take the DIRECTORY query, never `?all=1`: the server uses cwd to locate the
// transcript, and `all` is meaningless to it. In `all` mode there is no single directory, so they send
// nothing and the server falls back to its by-id scan.
const sessionQ = all ? '' : q;
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}${sessionQ}`);
// Invalidate every cwd's list (also refreshes pwd counts) — cheap and avoids stale lists.
const invalidate = useCallback(() => {
@@ -119,18 +144,18 @@ export function useClaudeSessions(cwd?: string | null) {
const deleteSession = useCallback(
async (id: string) => {
await client.delete(`/chat/sessions/${id}${q}`);
await client.delete(`/chat/sessions/${id}${sessionQ}`);
invalidate();
},
[client, q, invalidate],
[client, sessionQ, invalidate],
);
const renameSession = useCallback(
async (id: string, title: string) => {
await client.patch(`/chat/sessions/${id}/title${q}`, { title });
await client.patch(`/chat/sessions/${id}/title${sessionQ}`, { title });
invalidate();
},
[client, q, invalidate],
[client, sessionQ, invalidate],
);
return {