diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 78cd7aaa..77b81878 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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 diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 6e52cf22..431f35c3 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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 diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx index acb6a349..d01b5ec7 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx @@ -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 ; if (error) { return ( @@ -49,17 +40,17 @@ export const LiveSessions = () => { return ( {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 ( diff --git a/src/workspaces/state/src/useLiveSessions.ts b/src/workspaces/state/src/useLiveSessions.ts index 14cb6dc8..99675dec 100644 --- a/src/workspaces/state/src/useLiveSessions.ts +++ b/src/workspaces/state/src/useLiveSessions.ts @@ -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';