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:
2026-07-24 09:50:38 +00:00
co-authored by Claude Opus 4.8
parent eb4ce4301b
commit add5351dc7
5 changed files with 77 additions and 99 deletions
@@ -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>
);
+2
View File
@@ -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 };
}