name the live sessions instead of showing their ids
The titles were looked up client-side against the sessions query already in cache, which only covers the group being browsed — so anything running in another directory rendered as a truncated uuid, which is most of them. Resolved server-side now. The agent reports keys and nothing else, so liveSessionTitle finds the transcript by scanning the project slugs, takes the cwd off its own first entry, and hands that to claudeSessionContext — the same path the list uses, so the two agree on naming, /clear chains merged included, rather than offering a second opinion. A null title means no transcript has been written yet. That row says 'Starting…' and is deliberately not a link: pointing at a session you cannot open yet is worse than plainly not being a link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
renameClaudeSession,
|
||||
loadBackgroundTask,
|
||||
claudeSessionContext,
|
||||
liveSessionTitle,
|
||||
} from './claude-sessions';
|
||||
import {
|
||||
listOpenCodeSessions,
|
||||
@@ -100,8 +101,17 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
//
|
||||
// `pendingTasks` is background work started but not yet notified — with `isGenerating` it is what the
|
||||
// agent's own idle GC consults, so a caller can tell "busy" from "merely open" the same way it does.
|
||||
// Titles are resolved here rather than in the client: the agent reports session keys and nothing else,
|
||||
// and the client can only name the sessions in the group it happens to be browsing — which is how the
|
||||
// list ended up showing raw ids for anything running elsewhere.
|
||||
chatRouter.get('/live', async (ctx) => {
|
||||
return ctx.json({ sessions: await sidecar.listLiveClaudeSessions() });
|
||||
const email = ctx.get('user').email;
|
||||
const live = await sidecar.listLiveClaudeSessions();
|
||||
const sessions = live.map((session) => {
|
||||
const resolved = isOpenCodeSessionId(session.sessionKey) ? null : liveSessionTitle(email, session.sessionKey);
|
||||
return { ...session, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
|
||||
});
|
||||
return ctx.json({ sessions });
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
|
||||
|
||||
@@ -841,6 +841,56 @@ export function claudeSessionContext(
|
||||
return part ? { title: part.title, partCount: 1 } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The display title for a session known only by id — what `/chat/live` needs.
|
||||
*
|
||||
* `claudeSessionContext` is the real answer but has to be told the group, and a live session can be in
|
||||
* any of them; the agent reports session keys and nothing else. So find the transcript by scanning the
|
||||
* slugs, take the cwd off its own first entry, and hand that to the normal path — which means the Live
|
||||
* panel shows exactly the title the list shows, `/clear` chains merged and all, rather than a second
|
||||
* opinion about naming.
|
||||
*
|
||||
* Reads the file to answer. That is a few milliseconds against a poll every ten seconds over a handful
|
||||
* of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
|
||||
* anywhere hotter.
|
||||
*/
|
||||
export function liveSessionTitle(email: string, sessionId: string): { title: string; cwd: string } | null {
|
||||
const projectsDir = claudeProjectsDir(email);
|
||||
let slugs: string[];
|
||||
try {
|
||||
slugs = readdirSync(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const slug of slugs) {
|
||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) continue;
|
||||
|
||||
// The cwd is a property of the transcript's entries, so the first one carrying it settles which
|
||||
// group this session belongs to — no need to reverse the slug, which is lossy.
|
||||
let cwd: string | null = null;
|
||||
try {
|
||||
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const entry = JSON.parse(line) as { cwd?: string };
|
||||
if (entry.cwd) {
|
||||
cwd = entry.cwd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!cwd) return null;
|
||||
|
||||
const context = claudeSessionContext(email, cwd, sessionId);
|
||||
return context ? { title: context.title, cwd } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every transcript that has to go when this conversation is deleted: itself and everything it
|
||||
* continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving
|
||||
|
||||
@@ -2,9 +2,7 @@ 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 { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { useSelectedChatSession } from '../../channels';
|
||||
import { cwdFromSplat, chatSessionPath } from './chat-routes';
|
||||
import { chatSessionPath } from './chat-routes';
|
||||
|
||||
/**
|
||||
* What the agent is actually running, as opposed to what it has ever run.
|
||||
@@ -16,17 +14,10 @@ import { cwdFromSplat, chatSessionPath } from './chat-routes';
|
||||
* record of a live turn and only the agent can say.
|
||||
*/
|
||||
export const LiveSessions = () => {
|
||||
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
||||
const [selected] = useSelectedChatSession();
|
||||
// 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();
|
||||
|
||||
// Titles for free, when we happen to have them. This is the same query key the list below already
|
||||
// holds, so it costs no request — but it only covers the group being browsed, and a live session can
|
||||
// be in any of them. Hence the fallback to a short key rather than pretending we know.
|
||||
const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null;
|
||||
const { sessions } = useClaudeSessions(activeCwd);
|
||||
const titleFor = (key: string) => sessions.find((session) => session.id === key)?.title;
|
||||
|
||||
if (isLoading && live.length === 0) return <LoadingBlock label="Asking the agent…" />;
|
||||
if (error) {
|
||||
return (
|
||||
@@ -49,17 +40,17 @@ export const LiveSessions = () => {
|
||||
return (
|
||||
<DataList>
|
||||
{live.map((session) => {
|
||||
const title = titleFor(session.sessionKey);
|
||||
// A conversation that has not been saved yet has no transcript to open, so it is shown but not
|
||||
// linked — a row that navigates nowhere is worse than one that plainly isn't a link.
|
||||
const isDraft = session.sessionKey.startsWith('new:');
|
||||
// No title means no transcript on disk yet — a conversation whose first turn has not landed. It
|
||||
// is shown but not linked: a row that navigates to a session you cannot open is worse than one
|
||||
// that plainly isn't a link.
|
||||
const isDraft = session.title === null;
|
||||
|
||||
return (
|
||||
<DataRow
|
||||
key={session.sessionKey}
|
||||
to={isDraft ? undefined : chatSessionPath(session.sessionKey)}
|
||||
selected={session.sessionKey === sessionId}
|
||||
title={title ?? (isDraft ? 'Unsaved chat' : `${session.sessionKey.slice(0, 8)}…`)}
|
||||
title={session.title ?? 'Starting…'}
|
||||
meta={[
|
||||
session.isGenerating ? (
|
||||
<MetaItem key="g" icon={Loader2}>
|
||||
|
||||
@@ -13,6 +13,14 @@ export type LiveSession = {
|
||||
isGenerating: boolean;
|
||||
/** Background tasks started but not yet notified — `run_in_background`, Monitor, and friends. */
|
||||
pendingTasks: number;
|
||||
/**
|
||||
* Resolved server-side, and null when no transcript has been written yet.
|
||||
*
|
||||
* It has to come from there: the agent reports keys, and a client can only name the sessions in the
|
||||
* group it is currently browsing — so anything running in another directory showed a raw id.
|
||||
*/
|
||||
title: string | null;
|
||||
cwd: string | null;
|
||||
};
|
||||
|
||||
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';
|
||||
|
||||
Reference in New Issue
Block a user