chat: click-to-resume /chat sessions via Claude --resume

Clicking a session in the list loads its transcript (GET /chat/sessions/:id,
parsed from Claude's JSONL into display messages) and continues the actual Claude
session: a resumeSessionId is threaded chat handler -> send-claude-code -> sidecar
-> claude-manager, which passes --resume <uuid> (in-memory session mapping still
takes precedence for live turns). Parser verified against real transcripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:11:58 +00:00
co-authored by Claude Opus 4.8
parent bea3d0a487
commit 9565cd9462
10 changed files with 158 additions and 16 deletions
+9 -1
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { getClaudeSessionsCwd, listClaudeSessions } from './claude-sessions';
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession } from './claude-sessions';
export const chatRouter = createRouter();
@@ -10,3 +10,11 @@ chatRouter.get('/sessions', (ctx) => {
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
return ctx.json({ sessions });
});
// GET /chat/sessions/:id — one conversation's full transcript, parsed into display-ready messages.
chatRouter.get('/sessions/:id', (ctx) => {
const email = ctx.get('user').email;
const detail = loadClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
if (!detail) return ctx.text('Not found', 404);
return ctx.json(detail);
});
+85
View File
@@ -102,6 +102,91 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
};
}
// ── Loading a full transcript for display ──
// App-facing message shape (matches the frontend ChatMessage union), rebuilt from Claude's blocks.
export type ClaudeChatMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; id: string; text: string }
| { role: 'tool'; toolName: string; toolInput: Record<string, unknown>; toolCallId: string; output?: string; isError?: boolean };
type ContentBlock =
| { type: 'text'; text?: string }
| { type: 'thinking' }
| { type: 'tool_use'; id: string; name: string; input?: Record<string, unknown> }
| { type: 'tool_result'; tool_use_id: string; is_error?: boolean | null; content?: unknown };
function blockText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text?: string }).text ?? '' : ''))
.join('');
}
return '';
}
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null {
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return null;
const messages: ClaudeChatMessage[] = [];
const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
let model = '';
let sessionCwd = cwd;
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
if (!line.trim()) continue;
let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } };
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.cwd) sessionCwd = entry.cwd;
if (entry.message?.model && !model) model = entry.message.model;
const content = entry.message?.content;
if (entry.type === 'user' && !entry.isMeta) {
if (typeof content === 'string') {
if (content.trim()) messages.push({ role: 'user', text: content });
continue;
}
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text?.trim()) {
messages.push({ role: 'user', text: block.text });
} else if (block.type === 'tool_result') {
const tool = toolById.get(block.tool_use_id);
if (tool) {
tool.output = blockText(block.content);
tool.isError = block.is_error === true;
}
}
}
}
continue;
}
if (entry.type === 'assistant' && Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text?.trim()) {
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: block.text });
} else if (block.type === 'tool_use') {
const tool = { role: 'tool' as const, toolName: block.name, toolInput: block.input ?? {}, toolCallId: block.id };
messages.push(tool);
toolById.set(block.id, tool);
}
}
}
}
return { id: sessionId, model, cwd: sessionCwd, messages };
}
/** List sessions Claude has stored for a given working directory, newest first. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
+3
View File
@@ -253,6 +253,7 @@ async function handleChat(
context?: string;
contextId?: string;
resumeSummary?: string;
resumeSessionId?: string;
},
): Promise<void> {
const { userId } = ws.data;
@@ -291,6 +292,7 @@ async function handleClaudeCodeChat(
cwd?: string;
cwdRoot?: string;
sandboxed?: boolean;
resumeSessionId?: string;
},
effectivePrompt: string,
): Promise<void> {
@@ -347,6 +349,7 @@ async function handleClaudeCodeChat(
cwd,
model,
role: ws.data.role,
resumeSessionId: msg.resumeSessionId,
onEvent,
});