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:
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { defaultLayout, hasAppType } from './defaultLayout';
|
||||
|
||||
/**
|
||||
* The chat screen's layout migration, and the two properties that make it safe.
|
||||
*
|
||||
* The effect in index.tsx reads:
|
||||
*
|
||||
* if (!hasAppType(workspace.value, 'chat-live')) return;
|
||||
* workspace.setValue(defaultLayout);
|
||||
*
|
||||
* Both halves can fail catastrophically and neither fails loudly:
|
||||
*
|
||||
* - if the replacement still CONTAINED `chat-live`, the effect would fire on its own output, forever
|
||||
* - if the test were not inverted, layouts naming the retired panel would never be migrated, and
|
||||
* `normalizeLayout` would collapse it onto `appTypes.fallback` and persist that — which is what made
|
||||
* /offscale render three copies of its server picker on 2026-08-17
|
||||
*
|
||||
* So these assert the shape rather than the effect: cheap, and they hold whoever edits the layout next.
|
||||
*/
|
||||
describe('chat defaultLayout', () => {
|
||||
test('does not contain the appType the migration tests for, so the guard terminates', () => {
|
||||
expect(hasAppType(defaultLayout, 'chat-live')).toBe(false);
|
||||
});
|
||||
|
||||
test('holds exactly the two panels the screen allows', () => {
|
||||
// `appTypes.allowed` in index.tsx must stay in step with this, or normalizeLayout rewrites the default
|
||||
// itself on first render.
|
||||
expect(hasAppType(defaultLayout, 'chat-session-list')).toBe(true);
|
||||
expect(hasAppType(defaultLayout, 'chat-detail')).toBe(true);
|
||||
});
|
||||
|
||||
test('the sidebar is one panel, not a split', () => {
|
||||
// The merge is the point of the change: a vertical split here means the Live panel came back.
|
||||
expect(defaultLayout.type).toBe('group');
|
||||
if (defaultLayout.type !== 'group') throw new Error('unreachable');
|
||||
expect(defaultLayout.children).toHaveLength(2);
|
||||
expect(defaultLayout.children.every((child) => child.node.type === 'panel')).toBe(true);
|
||||
});
|
||||
|
||||
test('hasAppType finds a panel at any depth', () => {
|
||||
// It is the migration's only instrument, so a nested layout must not read as clean.
|
||||
const nested = {
|
||||
type: 'group' as const,
|
||||
id: 'root',
|
||||
direction: 'horizontal' as const,
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group' as const,
|
||||
id: 'side',
|
||||
direction: 'vertical' as const,
|
||||
children: [{ node: { type: 'panel' as const, id: 'l', appType: 'chat-live' }, size: 50 }],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hasAppType(nested, 'chat-live')).toBe(true);
|
||||
expect(hasAppType(nested, 'chat-detail')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,26 @@ export function hasAppType(node: LayoutNode, appType: string): boolean {
|
||||
return node.children.some((child) => hasAppType(child.node, appType));
|
||||
}
|
||||
|
||||
// The left column is itself a split: what is running now on top, the whole history below. They answer
|
||||
// different questions from different sources — the agent's in-memory map versus transcripts on disk —
|
||||
// and the live one is small and usually empty, hence the lopsided 25/75.
|
||||
/*
|
||||
One sidebar panel, not two.
|
||||
|
||||
It was a vertical 25/75 split — `chat-live` above `chat-session-list` — on the reasoning that the two
|
||||
answered different questions from different sources (the agent's in-memory map versus transcripts on
|
||||
disk). True, and still true; what made it wrong was the arithmetic. Live sessions are rare, so the top
|
||||
quarter of the sidebar was empty almost always, and the list that is used constantly got three quarters
|
||||
of the space instead of all of it.
|
||||
|
||||
Both questions now live in one panel as tabs, which is the same information in the same place without
|
||||
paying for the rarer one when it has nothing to say. `chat-live` no longer exists as a panel type — see
|
||||
the migration guard in index.tsx, which exists because deleting a panel type from the code does not
|
||||
delete it from anyone's saved layout.
|
||||
*/
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'chat-history-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'chat-sidebar',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'chat-live', appType: 'chat-live' }, size: 25 },
|
||||
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 75 },
|
||||
],
|
||||
},
|
||||
size: 35,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 35 },
|
||||
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -32,19 +32,31 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
||||
|
||||
// Adopt a structural change to this screen's layout.
|
||||
//
|
||||
// `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened
|
||||
// /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs
|
||||
// which app a panel runs, never the tree, so adding the Live panel above the list would have been
|
||||
// invisible to every existing user and visible only on a fresh account.
|
||||
//
|
||||
// Replacing outright is safe *here* specifically because the screen is `locked`: its structure is
|
||||
// dictated by code and the only thing a user can have contributed is the column sizes, which is a
|
||||
// cheap thing to lose once. Terminates because the replacement contains the panel it tests for.
|
||||
/*
|
||||
Adopt a structural change to this screen's layout.
|
||||
|
||||
`useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened /chat
|
||||
keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs which app
|
||||
a panel RUNS, never the tree.
|
||||
|
||||
── The test is INVERTED, and getting that wrong is destructive ──
|
||||
|
||||
This used to read `if (hasAppType(value, 'chat-live')) return;` — adopt the layout that HAS the Live
|
||||
panel. Now that the two sidebar panels are merged, `chat-live` is the appType that no longer exists, so
|
||||
the same line would refuse to migrate exactly the layouts that need it, and `normalizeLayout` would
|
||||
collapse the stranded panel onto `appTypes.fallback` and WRITE THAT BACK. That is not hypothetical: it is
|
||||
what made /offscale render three copies of its server picker on 2026-08-17 (`ed3806f9`), and a locked
|
||||
screen has no app picker to recover with.
|
||||
|
||||
So: replace any layout still naming `chat-live`. Terminates because the replacement does not contain it.
|
||||
|
||||
Replacing outright is safe *here* specifically because the screen is `locked` — its structure is dictated
|
||||
by code, and the only thing a user can have contributed is the column sizes, which is a cheap thing to
|
||||
lose once.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!workspace.isLoaded) return;
|
||||
if (hasAppType(workspace.value, 'chat-live')) return;
|
||||
if (!hasAppType(workspace.value, 'chat-live')) return;
|
||||
workspace.setValue(defaultLayout);
|
||||
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
||||
|
||||
@@ -124,7 +136,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['chat-session-list', 'chat-live', 'chat-detail'], fallback: 'chat-detail' }}
|
||||
appTypes={{ allowed: ['chat-session-list', 'chat-detail'], fallback: 'chat-detail' }}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onMobilePanelChange={(id) => {
|
||||
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
||||
|
||||
@@ -4,10 +4,11 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getUserSettings, getChatSessionModel } from 'officerdb';
|
||||
import { getUserSettings, getChatSessionModel, getChatSessionModels } from 'officerdb';
|
||||
import {
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
listAllClaudeSessions,
|
||||
loadClaudeSession,
|
||||
loadClaudeSessionById,
|
||||
deleteClaudeSession,
|
||||
@@ -108,12 +109,38 @@ chatRouter.get('/pwds', async (ctx) => {
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
|
||||
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
||||
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
||||
// the row renderer) is indifferent to which directories the rows came from.
|
||||
const all = ctx.req.query('all') === '1';
|
||||
|
||||
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({
|
||||
...s,
|
||||
harness: 'claude' as const,
|
||||
}));
|
||||
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
|
||||
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
|
||||
const opencode = who.isOwner ? await listOpenCodeSessions(cwd) : [];
|
||||
//
|
||||
// `all` does not widen this: OpenCode's listing is already per-cwd with no all-directories form, and
|
||||
// inventing one by looping would need a directory list it does not publish. So Latest is Claude-only
|
||||
// for now, which is honest — every OpenCode session is still reachable from its own directory in Tree.
|
||||
const opencode = who.isOwner && !all ? await listOpenCodeSessions(cwd) : [];
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return ctx.json({ sessions });
|
||||
|
||||
/*
|
||||
The model each session is on, batched.
|
||||
|
||||
One query for the whole page rather than one per row — that is what `getChatSessionModels` exists for.
|
||||
A session with no row yet resolves to null and the row shows no model, which is the same "unknown rather
|
||||
than guessed" rule the picker follows. Never fatal: a database that will not answer this must not take
|
||||
the conversation list down with it.
|
||||
*/
|
||||
const models = await getChatSessionModels(sessions.map((s) => s.id)).catch(() => new Map<string, string>());
|
||||
|
||||
return ctx.json({
|
||||
sessions: sessions.map((s) => ({ ...s, model: models.get(s.id) ?? null })),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /chat/sessions/:id[?cwd=][&limit=&before=] — one conversation's transcript. Routes by harness
|
||||
|
||||
@@ -966,6 +966,30 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every conversation in every directory, newest first.
|
||||
*
|
||||
* For the sessions list's **Latest** tab, which asks a question the per-directory listing cannot: what have
|
||||
* I been working on lately, wherever it was. The alternative was one request per directory from the client,
|
||||
* which turns one screen into twenty round trips and still cannot sort across them.
|
||||
*
|
||||
* ── Why it loops directories rather than scanning flat ──
|
||||
*
|
||||
* `/clear` chains are merged into one row, and a chain lives inside a single project group. Scanning every
|
||||
* transcript flat and merging afterwards would let two unrelated chains in different directories collapse
|
||||
* into each other, because the lineage heuristics match on time and title rather than on path. Looping
|
||||
* `listClaudeSessions` per directory keeps merging scoped exactly the way the per-directory list does, so
|
||||
* the same conversation is one row in both views.
|
||||
*
|
||||
* The cost is one group scan per directory — the same total work as expanding every node of the tree, and
|
||||
* bounded by how many directories you have ever run `claude` in.
|
||||
*/
|
||||
export function listAllClaudeSessions(who: ChatIdentity): ClaudeSessionSummary[] {
|
||||
return listClaudePwds(who)
|
||||
.flatMap((pwd) => listClaudeSessions(who, pwd.cwd))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-level facts: the title it is shown under, and how many parts it spans.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user