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,8 +94,6 @@ export const ChatPanelWrapper = () => {
|
|||||||
const chatContext =
|
const chatContext =
|
||||||
dashboardId === 'email' || dashboardId === 'screens/email'
|
dashboardId === 'email' || dashboardId === 'screens/email'
|
||||||
? { context: 'email' as const }
|
? { context: 'email' as const }
|
||||||
: dashboardId === 'screens/chat'
|
|
||||||
? { context: 'chat' as const }
|
|
||||||
: dashboardId?.startsWith('proj-layout-')
|
: dashboardId?.startsWith('proj-layout-')
|
||||||
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
|
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
|
||||||
: dashboardId && !dashboardId.startsWith('screens/')
|
: dashboardId && !dashboardId.startsWith('screens/')
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ function NewChat({ resumeSummary, initialMessages, savedId: initialSavedId }: Ne
|
|||||||
}
|
}
|
||||||
}, [updateSessionMessages]);
|
}, [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;
|
chatSessionRef.current = chat.sessionId;
|
||||||
|
|
||||||
const isSaved = savedId != null;
|
const isSaved = savedId != null;
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Plus, MessageSquare, Loader2 } from 'lucide-react';
|
import { Plus, MessageSquare, RefreshCw } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
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 { 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 = () => {
|
export const SessionList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const { sessions, isLoading, refetch } = useClaudeSessions();
|
||||||
const isOnChatPage = location.pathname.startsWith('/chat');
|
|
||||||
const { sessions, deleteSavedSession, resumeSession } = useSavedSessions();
|
|
||||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
const [isResuming, setIsResuming] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
@@ -23,57 +20,19 @@ 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 (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* Header */}
|
{/* 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">
|
<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>
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected({ id: `new:${Date.now()}` });
|
setSelected({ id: `new:${Date.now()}` });
|
||||||
@@ -85,54 +44,44 @@ export const SessionList = () => {
|
|||||||
New Chat
|
New Chat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Session list */}
|
{/* Session list */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
|
||||||
{sessions.length === 0 && (
|
{sessions.length === 0 && (
|
||||||
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isActive = selected?.id === `saved:${session.id}`;
|
const isActive = selected?.id === session.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
ref={isActive ? selectedRef : undefined}
|
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
|
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 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" />
|
<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">
|
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
|
||||||
{session.title}
|
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
</div>
|
<span>
|
||||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
||||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
<div className="shrink-0 mr-2">
|
|
||||||
<SessionContextMenu sessionId={session.id} onDelete={handleDelete} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export { useDashboardState } from './useDashboardState';
|
|||||||
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
|
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
|
||||||
export type { ModelOption } from './useModels';
|
export type { ModelOption } from './useModels';
|
||||||
export { useAccessPolicy } from './useAccessPolicy';
|
export { useAccessPolicy } from './useAccessPolicy';
|
||||||
|
export { useClaudeSessions } from './useClaudeSessions';
|
||||||
|
export type { ClaudeSessionSummary } from './useClaudeSessions';
|
||||||
export { useRecentModels } from './useRecentModels';
|
export { useRecentModels } from './useRecentModels';
|
||||||
export { usePlans } from './usePlans';
|
export { usePlans } from './usePlans';
|
||||||
export { useLandingPage } from './useLandingPage';
|
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