From 9565cd9462e97e1f7bf423ec9cc1023b315b6802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 24 Jul 2026 10:11:58 +0000 Subject: [PATCH] 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 (in-memory session mapping still takes precedence for live turns). Parser verified against real transcripts. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/chat/chat.ts | 10 ++- src/servers/api/chat/claude-sessions.ts | 85 +++++++++++++++++++ src/servers/api/pi/websocket.ts | 3 + src/servers/channels/send-claude-code.ts | 1 + src/servers/sidecar/claude/claude-manager.ts | 8 +- src/servers/sidecar/protocol.ts | 1 + .../src/apps/ChatHistory/ChatDetailPanel.tsx | 7 +- .../src/apps/ChatHistory/SessionList.tsx | 44 +++++++--- .../officerdev/src/hooks/usePiChat.ts | 3 + src/workspaces/state/src/useClaudeSessions.ts | 12 ++- 10 files changed, 158 insertions(+), 16 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 841b19e6..762b0163 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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); +}); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 6e75c240..dc4eb413 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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; toolCallId: string; output?: string; isError?: boolean }; + +type ContentBlock = + | { type: 'text'; text?: string } + | { type: 'thinking' } + | { type: 'tool_use'; id: string; name: string; input?: Record } + | { 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>(); + 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)); diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 1e054991..36eb3ebe 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -253,6 +253,7 @@ async function handleChat( context?: string; contextId?: string; resumeSummary?: string; + resumeSessionId?: string; }, ): Promise { const { userId } = ws.data; @@ -291,6 +292,7 @@ async function handleClaudeCodeChat( cwd?: string; cwdRoot?: string; sandboxed?: boolean; + resumeSessionId?: string; }, effectivePrompt: string, ): Promise { @@ -347,6 +349,7 @@ async function handleClaudeCodeChat( cwd, model, role: ws.data.role, + resumeSessionId: msg.resumeSessionId, onEvent, }); diff --git a/src/servers/channels/send-claude-code.ts b/src/servers/channels/send-claude-code.ts index 9157e3b8..4cec6641 100644 --- a/src/servers/channels/send-claude-code.ts +++ b/src/servers/channels/send-claude-code.ts @@ -39,6 +39,7 @@ type ClaudeCodeStreamingParams = { cwd?: string; model?: string; role?: string; + resumeSessionId?: string; onEvent: (event: PiEvent) => void; }; diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 80828b88..36cb5668 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -170,8 +170,12 @@ export async function spawnClaudeStreaming( const subModel = params.model?.split('/')[1]; if (subModel) claudeArgs.push('--model', subModel); - if (existingSession) { - claudeArgs.push('--resume', existingSession); + // Resume: an in-memory mapping (subsequent turns of a live chat) takes precedence; otherwise a + // 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]; diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 6c6b476f..aabc997b 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -73,6 +73,7 @@ export type ClaudeSpawnStreamingParams = { cwd?: string; model?: string; role?: string; + resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list) }; export type ClaudeCodeResult = { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 7854b7d3..c88ebedb 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -12,6 +12,7 @@ export type SelectedSession = { id: string; model?: string | null; resumeSummary?: string; + resumeSessionId?: string; initialMessages?: ChatMessage[]; } | null; @@ -80,11 +81,12 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved type NewChatProps = { resumeSummary?: string; + resumeSessionId?: string; initialMessages?: ChatMessage[]; savedId?: number; }; -function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: NewChatProps) { +function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: initialSavedId }: NewChatProps) { const location = useLocation(); const locationState = location.state as ChatLocationState; 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 // 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; const isSaved = savedId != null; @@ -178,6 +180,7 @@ export const ChatDetailPanel = () => { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index c46a716e..f92913f4 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -1,25 +1,43 @@ -import { useRef, useCallback } from 'react'; +import { useRef, useState, useCallback } from 'react'; 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 { useClaudeSessions } from 'state/useClaudeSessions'; 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). -// 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 = () => { const navigate = useNavigate(); - const { sessions, isLoading, refetch } = useClaudeSessions(); + const { sessions, isLoading, refetch, loadSession } = useClaudeSessions(); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); + const [openingId, setOpeningId] = useState(null); const scrolledRef = useRef(false); - const selectedRef = useCallback((node: HTMLDivElement | null) => { + const selectedRef = useCallback((node: HTMLButtonElement | null) => { if (node && !scrolledRef.current) { scrolledRef.current = true; 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 (
{/* Header */} @@ -57,16 +75,22 @@ export const SessionList = () => { {sessions.map((session) => { const isActive = selected?.id === session.id; return ( -
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 ? '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 ? ( + + ) : ( + + )}
{session.title}
@@ -82,7 +106,7 @@ export const SessionList = () => { {session.messageCount} msg{session.messageCount === 1 ? '' : 's'}
-
+ ); })}
diff --git a/src/workspaces/officerdev/src/hooks/usePiChat.ts b/src/workspaces/officerdev/src/hooks/usePiChat.ts index 2c60c20c..22671c60 100644 --- a/src/workspaces/officerdev/src/hooks/usePiChat.ts +++ b/src/workspaces/officerdev/src/hooks/usePiChat.ts @@ -20,6 +20,7 @@ type UsePiChatOptions = { context?: string; contextId?: string; resumeSummary?: string; + resumeSessionId?: string; initialMessages?: ChatMessage[]; onTurnComplete?: (hadToolCalls: boolean) => void; }; @@ -34,6 +35,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul context, contextId, resumeSummary: initialResumeSummary, + resumeSessionId, initialMessages: preloadedMessages, onTurnComplete, } = options ?? {}; @@ -338,6 +340,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul ...(context ? { context } : {}), ...(contextId ? { contextId } : {}), ...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}), + ...(resumeSessionId ? { resumeSessionId } : {}), }); } diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 800e3f4c..4111a49c 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -11,6 +11,14 @@ export type ClaudeSessionSummary = { 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; 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). */ export function useClaudeSessions() { const client = useClient(); @@ -23,5 +31,7 @@ export function useClaudeSessions() { staleTime: 30 * 1000, }); - return { sessions: data?.sessions ?? [], isLoading, refetch }; + const loadSession = (id: string) => client.get(`/chat/sessions/${id}`); + + return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession }; }