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
@@ -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 = () => {
<NewChat
key={selected.id}
resumeSummary={selected.resumeSummary}
resumeSessionId={selected.resumeSessionId}
initialMessages={selected.initialMessages}
savedId={savedId}
/>
@@ -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<SelectedSession>('chat:selected-session', null);
const [openingId, setOpeningId] = useState<string | null>(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 (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
@@ -57,16 +75,22 @@ export const SessionList = () => {
{sessions.map((session) => {
const isActive = selected?.id === session.id;
return (
<div
<button
key={session.id}
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
? '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'
}`}
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
{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" />
)}
<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="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>
</div>
</div>
</div>
</button>
);
})}
</div>
@@ -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 } : {}),
});
}
+11 -1
View File
@@ -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<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). */
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<ClaudeSessionDetail>(`/chat/sessions/${id}`);
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession };
}