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 (