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:
@@ -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,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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user