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:
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getClaudeSessionsCwd, listClaudeSessions } from './claude-sessions';
|
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession } from './claude-sessions';
|
||||||
|
|
||||||
export const chatRouter = createRouter();
|
export const chatRouter = createRouter();
|
||||||
|
|
||||||
@@ -10,3 +10,11 @@ chatRouter.get('/sessions', (ctx) => {
|
|||||||
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
|
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
|
||||||
return ctx.json({ sessions });
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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. */
|
/** List sessions Claude has stored for a given working directory, newest first. */
|
||||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
||||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ async function handleChat(
|
|||||||
context?: string;
|
context?: string;
|
||||||
contextId?: string;
|
contextId?: string;
|
||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { userId } = ws.data;
|
const { userId } = ws.data;
|
||||||
@@ -291,6 +292,7 @@ async function handleClaudeCodeChat(
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
|
resumeSessionId?: string;
|
||||||
},
|
},
|
||||||
effectivePrompt: string,
|
effectivePrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -347,6 +349,7 @@ async function handleClaudeCodeChat(
|
|||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
role: ws.data.role,
|
role: ws.data.role,
|
||||||
|
resumeSessionId: msg.resumeSessionId,
|
||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type ClaudeCodeStreamingParams = {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
onEvent: (event: PiEvent) => void;
|
onEvent: (event: PiEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,12 @@ export async function spawnClaudeStreaming(
|
|||||||
const subModel = params.model?.split('/')[1];
|
const subModel = params.model?.split('/')[1];
|
||||||
if (subModel) claudeArgs.push('--model', subModel);
|
if (subModel) claudeArgs.push('--model', subModel);
|
||||||
|
|
||||||
if (existingSession) {
|
// Resume: an in-memory mapping (subsequent turns of a live chat) takes precedence; otherwise a
|
||||||
claudeArgs.push('--resume', existingSession);
|
// caller-supplied session uuid (reopening a session from the /chat list) resumes Claude's transcript.
|
||||||
|
const resumeId = existingSession ?? params.resumeSessionId;
|
||||||
|
if (resumeId) {
|
||||||
|
claudeArgs.push('--resume', resumeId);
|
||||||
|
if (!existingSession) setClaudeSession(sessionKey, resumeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export type ClaudeSpawnStreamingParams = {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClaudeCodeResult = {
|
export type ClaudeCodeResult = {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type SelectedSession = {
|
|||||||
id: string;
|
id: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
initialMessages?: ChatMessage[];
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -80,11 +81,12 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved
|
|||||||
|
|
||||||
type NewChatProps = {
|
type NewChatProps = {
|
||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
initialMessages?: ChatMessage[];
|
||||||
savedId?: number;
|
savedId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: NewChatProps) {
|
function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: initialSavedId }: NewChatProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -108,7 +110,7 @@ function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: Ne
|
|||||||
|
|
||||||
// context 'chat' tells the backend to run this session from the dedicated claude_sessions cwd, so
|
// context 'chat' tells the backend to run this session from the dedicated claude_sessions cwd, so
|
||||||
// its transcript lands in Claude's own store as an isolated project group (source of truth).
|
// its transcript lands in Claude's own store as an isolated project group (source of truth).
|
||||||
const chat = usePiChat(undefined, locationState?.model, { resumeSummary, initialMessages, onTurnComplete, context: 'chat' });
|
const chat = usePiChat(undefined, locationState?.model, { resumeSummary, resumeSessionId, initialMessages, onTurnComplete, context: 'chat' });
|
||||||
chatSessionRef.current = chat.sessionId;
|
chatSessionRef.current = chat.sessionId;
|
||||||
|
|
||||||
const isSaved = savedId != null;
|
const isSaved = savedId != null;
|
||||||
@@ -178,6 +180,7 @@ export const ChatDetailPanel = () => {
|
|||||||
<NewChat
|
<NewChat
|
||||||
key={selected.id}
|
key={selected.id}
|
||||||
resumeSummary={selected.resumeSummary}
|
resumeSummary={selected.resumeSummary}
|
||||||
|
resumeSessionId={selected.resumeSessionId}
|
||||||
initialMessages={selected.initialMessages}
|
initialMessages={selected.initialMessages}
|
||||||
savedId={savedId}
|
savedId={savedId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useState, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Plus, MessageSquare, RefreshCw } from 'lucide-react';
|
import { Plus, MessageSquare, RefreshCw, Loader2 } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import type { SelectedSession } from './ChatDetailPanel';
|
import type { SelectedSession } from './ChatDetailPanel';
|
||||||
|
import type { ChatMessage } from '../Chat/types';
|
||||||
|
|
||||||
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
|
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
|
||||||
// List-only for now: rows display sessions; clicking to resume is the next slice.
|
// Clicking a session loads its transcript and continues the real Claude session via --resume.
|
||||||
export const SessionList = () => {
|
export const SessionList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { sessions, isLoading, refetch } = useClaudeSessions();
|
const { sessions, isLoading, refetch, loadSession } = useClaudeSessions();
|
||||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
|
const [openingId, setOpeningId] = useState<string | null>(null);
|
||||||
|
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
const selectedRef = useCallback((node: HTMLButtonElement | null) => {
|
||||||
if (node && !scrolledRef.current) {
|
if (node && !scrolledRef.current) {
|
||||||
scrolledRef.current = true;
|
scrolledRef.current = true;
|
||||||
node.scrollIntoView({ block: 'center' });
|
node.scrollIntoView({ block: 'center' });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = async (id: string) => {
|
||||||
|
if (openingId) return;
|
||||||
|
setOpeningId(id);
|
||||||
|
try {
|
||||||
|
const detail = await loadSession(id);
|
||||||
|
setSelected({
|
||||||
|
id,
|
||||||
|
model: detail.model,
|
||||||
|
resumeSessionId: id,
|
||||||
|
initialMessages: detail.messages as ChatMessage[],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setOpeningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -57,16 +75,22 @@ export const SessionList = () => {
|
|||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isActive = selected?.id === session.id;
|
const isActive = selected?.id === session.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
ref={isActive ? selectedRef : undefined}
|
ref={isActive ? selectedRef : undefined}
|
||||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 min-w-0 ${
|
onClick={() => handleSelect(session.id)}
|
||||||
|
disabled={!!openingId}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 min-w-0 text-left cursor-pointer transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
|
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
|
||||||
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80'
|
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{openingId === session.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 text-duck-teal/60 animate-spin" />
|
||||||
|
) : (
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
|
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
|
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
@@ -82,7 +106,7 @@ export const SessionList = () => {
|
|||||||
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type UsePiChatOptions = {
|
|||||||
context?: string;
|
context?: string;
|
||||||
contextId?: string;
|
contextId?: string;
|
||||||
resumeSummary?: string;
|
resumeSummary?: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
initialMessages?: ChatMessage[];
|
initialMessages?: ChatMessage[];
|
||||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||||
};
|
};
|
||||||
@@ -34,6 +35,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
|||||||
context,
|
context,
|
||||||
contextId,
|
contextId,
|
||||||
resumeSummary: initialResumeSummary,
|
resumeSummary: initialResumeSummary,
|
||||||
|
resumeSessionId,
|
||||||
initialMessages: preloadedMessages,
|
initialMessages: preloadedMessages,
|
||||||
onTurnComplete,
|
onTurnComplete,
|
||||||
} = options ?? {};
|
} = options ?? {};
|
||||||
@@ -338,6 +340,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
|||||||
...(context ? { context } : {}),
|
...(context ? { context } : {}),
|
||||||
...(contextId ? { contextId } : {}),
|
...(contextId ? { contextId } : {}),
|
||||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||||
|
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ export type ClaudeSessionSummary = {
|
|||||||
messageCount: number;
|
messageCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
|
||||||
|
export type ClaudeSessionMessage =
|
||||||
|
| { role: 'user'; text: string }
|
||||||
|
| { role: 'assistant'; id: string; text: string }
|
||||||
|
| { role: 'tool'; toolName: string; toolInput: Record<string, unknown>; toolCallId: string; output?: string; isError?: boolean };
|
||||||
|
|
||||||
|
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeSessionMessage[] };
|
||||||
|
|
||||||
/** The /chat route's sessions, read straight from Claude's own transcript store (source of truth). */
|
/** The /chat route's sessions, read straight from Claude's own transcript store (source of truth). */
|
||||||
export function useClaudeSessions() {
|
export function useClaudeSessions() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
@@ -23,5 +31,7 @@ export function useClaudeSessions() {
|
|||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { sessions: data?.sessions ?? [], isLoading, refetch };
|
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}`);
|
||||||
|
|
||||||
|
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user