chat: delete sessions + auto-refresh the /chat list after each turn
DELETE /chat/sessions/:id removes Claude's transcript file; the list gets a per-row delete button. The list also invalidates on turn-complete so new and continued sessions surface without a manual refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession } from './claude-sessions';
|
||||
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession } from './claude-sessions';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
|
||||
@@ -18,3 +18,11 @@ chatRouter.get('/sessions/:id', (ctx) => {
|
||||
if (!detail) return ctx.text('Not found', 404);
|
||||
return ctx.json(detail);
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id — remove a conversation (deletes Claude's transcript file).
|
||||
chatRouter.delete('/sessions/:id', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const ok = deleteClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
@@ -187,6 +187,14 @@ export function loadClaudeSession(email: string, cwd: string, sessionId: string)
|
||||
return { id: sessionId, model, cwd: sessionCwd, messages };
|
||||
}
|
||||
|
||||
/** Delete a session by removing its transcript file. Returns false if it didn't exist. */
|
||||
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
|
||||
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) return false;
|
||||
rmSync(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** List sessions Claude has stored for a given working directory, newest first. */
|
||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { getHostHome } from 'state/useModels';
|
||||
import { useSavedSessions } from 'state/useSavedSessions';
|
||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { usePiChat, EmbeddableChat } from '../Chat';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
|
||||
@@ -92,6 +93,7 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const { saveSession, updateSessionMessages } = useSavedSessions();
|
||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedId, setSavedId] = usePanelChannel<number | null>(SAVED_ID_CHANNEL, initialSavedId ?? null);
|
||||
const savedIdRef = useRef(savedId);
|
||||
@@ -100,13 +102,15 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
|
||||
const resumedMessageCountRef = useRef(initialMessages?.length ?? 0);
|
||||
|
||||
const onTurnComplete = useCallback(() => {
|
||||
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
||||
invalidateClaudeSessions();
|
||||
const id = savedIdRef.current;
|
||||
const sid = chatSessionRef.current;
|
||||
if (id != null && sid) {
|
||||
const count = resumedMessageCountRef.current;
|
||||
updateSessionMessages(id, sid, count > 0 ? count : undefined).catch(() => {});
|
||||
}
|
||||
}, [updateSessionMessages]);
|
||||
}, [updateSessionMessages, invalidateClaudeSessions]);
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Plus, MessageSquare, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { Plus, MessageSquare, RefreshCw, Loader2, Trash2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import type { SelectedSession } from './ChatDetailPanel';
|
||||
@@ -10,12 +10,12 @@ import type { ChatMessage } from '../Chat/types';
|
||||
// Clicking a session loads its transcript and continues the real Claude session via --resume.
|
||||
export const SessionList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { sessions, isLoading, refetch, loadSession } = useClaudeSessions();
|
||||
const { sessions, isLoading, refetch, loadSession, deleteSession } = 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: HTMLButtonElement | null) => {
|
||||
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
||||
if (node && !scrolledRef.current) {
|
||||
scrolledRef.current = true;
|
||||
node.scrollIntoView({ block: 'center' });
|
||||
@@ -38,6 +38,11 @@ export const SessionList = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (selected?.id === id) setSelected(null);
|
||||
await deleteSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
@@ -75,38 +80,49 @@ export const SessionList = () => {
|
||||
{sessions.map((session) => {
|
||||
const isActive = selected?.id === session.id;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
ref={isActive ? selectedRef : undefined}
|
||||
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 ${
|
||||
className={`group flex items-center rounded-lg border 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 hover:bg-background/90'
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
<span>
|
||||
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
||||
<button
|
||||
onClick={() => handleSelect(session.id)}
|
||||
disabled={!!openingId}
|
||||
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
{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">
|
||||
<span>
|
||||
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(session.id)}
|
||||
className="shrink-0 mr-2 p-1.5 rounded text-duck-dark/30 dark:text-foreground/30 opacity-0 group-hover:opacity-100 hover:text-red-500 cursor-pointer transition-colors"
|
||||
title="Delete session"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
const QUERY_KEY = ['CLAUDE_SESSIONS'];
|
||||
|
||||
export type ClaudeSessionSummary = {
|
||||
id: string; // Claude session uuid (= transcript filename)
|
||||
title: string;
|
||||
@@ -22,10 +25,11 @@ export type ClaudeSessionDetail = { id: string; model: string; cwd: string; mess
|
||||
/** The /chat route's sessions, read straight from Claude's own transcript store (source of truth). */
|
||||
export function useClaudeSessions() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
||||
queryKey: ['CLAUDE_SESSIONS'],
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>('/chat/sessions'),
|
||||
staleTime: 30 * 1000,
|
||||
@@ -33,5 +37,15 @@ export function useClaudeSessions() {
|
||||
|
||||
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}`);
|
||||
|
||||
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession };
|
||||
const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: QUERY_KEY }), [queryClient]);
|
||||
|
||||
const deleteSession = useCallback(
|
||||
async (id: string) => {
|
||||
await client.delete(`/chat/sessions/${id}`);
|
||||
invalidate();
|
||||
},
|
||||
[client, invalidate],
|
||||
);
|
||||
|
||||
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, invalidate };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user