chat: list /chat sessions from Claude's store; fix the dedicated-cwd routing
The /chat detail (ChatDetailPanel) now passes context 'chat' to usePiChat, so the backend actually runs the session from claude_sessions (the earlier tag was on the wrong component). The left panel (SessionList) now reads GET /chat/sessions — Claude's own transcripts — instead of the old saved-sessions model. List-only: rows display title/time/count; click-to-resume comes next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -94,13 +94,11 @@ export const ChatPanelWrapper = () => {
|
||||
const chatContext =
|
||||
dashboardId === 'email' || dashboardId === 'screens/email'
|
||||
? { context: 'email' as const }
|
||||
: dashboardId === 'screens/chat'
|
||||
? { context: 'chat' as const }
|
||||
: dashboardId?.startsWith('proj-layout-')
|
||||
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
|
||||
: dashboardId && !dashboardId.startsWith('screens/')
|
||||
? { context: 'dashboard' as const, contextId: dashboardId }
|
||||
: {};
|
||||
: dashboardId?.startsWith('proj-layout-')
|
||||
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
|
||||
: dashboardId && !dashboardId.startsWith('screens/')
|
||||
? { context: 'dashboard' as const, contextId: dashboardId }
|
||||
: {};
|
||||
|
||||
const [savedId, setSavedId] = usePanelChannel<number | null>('chat:saved-id', null);
|
||||
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
|
||||
|
||||
@@ -106,7 +106,9 @@ function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: Ne
|
||||
}
|
||||
}, [updateSessionMessages]);
|
||||
|
||||
const chat = usePiChat(undefined, locationState?.model, { resumeSummary, initialMessages, onTurnComplete });
|
||||
// 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' });
|
||||
chatSessionRef.current = chat.sessionId;
|
||||
|
||||
const isSaved = savedId != null;
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { Plus, MessageSquare, Loader2 } from 'lucide-react';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Plus, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useSavedSessions, messagesToTranscript, type RawMessage } from 'state/useSavedSessions';
|
||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import type { SelectedSession } from './ChatDetailPanel';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
import { SessionContextMenu } from './SessionContextMenu';
|
||||
|
||||
// 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.
|
||||
export const SessionList = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isOnChatPage = location.pathname.startsWith('/chat');
|
||||
const { sessions, deleteSavedSession, resumeSession } = useSavedSessions();
|
||||
const { sessions, isLoading, refetch } = useClaudeSessions();
|
||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||
const [isResuming, setIsResuming] = useState<number | null>(null);
|
||||
|
||||
const scrolledRef = useRef(false);
|
||||
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
||||
@@ -23,115 +20,67 @@ export const SessionList = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelect = async (session: (typeof sessions)[number]) => {
|
||||
if (isOnChatPage) {
|
||||
navigate(`/chat/saved/${session.id}`);
|
||||
return;
|
||||
}
|
||||
setIsResuming(session.id);
|
||||
try {
|
||||
const result = await resumeSession(session.id);
|
||||
const rawMessages = result.rawMessages ?? [];
|
||||
const chatMessages: ChatMessage[] = rawMessages.map((m: RawMessage) => {
|
||||
if (m.role === 'user') return { role: 'user' as const, text: m.text || '' };
|
||||
if (m.role === 'assistant') return { role: 'assistant' as const, id: m.id, text: m.text || '' };
|
||||
if (m.role === 'tool') {
|
||||
return {
|
||||
role: 'tool' as const,
|
||||
toolName: m.toolName || '',
|
||||
toolInput: m.toolInput || {},
|
||||
toolCallId: m.toolCallId || '',
|
||||
output: m.output,
|
||||
isError: m.isError,
|
||||
};
|
||||
}
|
||||
return { role: 'assistant' as const, text: '' };
|
||||
});
|
||||
const transcript = messagesToTranscript(rawMessages);
|
||||
setSelected({
|
||||
id: `saved:${session.id}`,
|
||||
model: result.model,
|
||||
resumeSummary: transcript,
|
||||
initialMessages: chatMessages,
|
||||
});
|
||||
} catch {
|
||||
// Failed to resume
|
||||
} finally {
|
||||
setIsResuming(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (selected?.id === `saved:${id}`) {
|
||||
setSelected(null);
|
||||
if (isOnChatPage) navigate('/chat', { replace: true });
|
||||
}
|
||||
await deleteSavedSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Saved Sessions</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: `new:${Date.now()}` });
|
||||
navigate('/chat/new', { replace: true });
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Chat
|
||||
</button>
|
||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 rounded text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal cursor-pointer transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: `new:${Date.now()}` });
|
||||
navigate('/chat/new', { replace: true });
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
No saved sessions yet. Save a chat session to see it here.
|
||||
{isLoading ? 'Loading…' : 'No sessions yet. Start a new chat to see it here.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => {
|
||||
const isActive = selected?.id === `saved:${session.id}`;
|
||||
const isActive = selected?.id === session.id;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={isActive ? selectedRef : undefined}
|
||||
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 min-w-0 ${
|
||||
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 hover:bg-background/90'
|
||||
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(session)}
|
||||
disabled={isResuming === session.id}
|
||||
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
{isResuming === 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="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
<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">
|
||||
<span>
|
||||
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="shrink-0 mr-2">
|
||||
<SessionContextMenu sessionId={session.id} onDelete={handleDelete} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ export { useDashboardState } from './useDashboardState';
|
||||
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
|
||||
export type { ModelOption } from './useModels';
|
||||
export { useAccessPolicy } from './useAccessPolicy';
|
||||
export { useClaudeSessions } from './useClaudeSessions';
|
||||
export type { ClaudeSessionSummary } from './useClaudeSessions';
|
||||
export { useRecentModels } from './useRecentModels';
|
||||
export { usePlans } from './usePlans';
|
||||
export { useLandingPage } from './useLandingPage';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
export type ClaudeSessionSummary = {
|
||||
id: string; // Claude session uuid (= transcript filename)
|
||||
title: string;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
/** The /chat route's sessions, read straight from Claude's own transcript store (source of truth). */
|
||||
export function useClaudeSessions() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
||||
queryKey: ['CLAUDE_SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>('/chat/sessions'),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
return { sessions: data?.sessions ?? [], isLoading, refetch };
|
||||
}
|
||||
Reference in New Issue
Block a user