From b0fcbba9f01ef0cd4753ea89d77a169a38f1a03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 12 May 2026 13:42:45 +0800 Subject: [PATCH] Make desktop session cleanup practical at list scale The sidebar had only single-session deletion, which made stale or noisy session lists expensive to maintain. This adds a batch-management lane in the desktop UI and a server endpoint that deletes multiple sessions while preserving per-session failure reporting and cleanup behavior. Constraint: Session deletion must preserve existing transcript and adapter cleanup semantics Constraint: Desktop UI should reuse the shared confirmation dialog instead of introducing a second modal surface Rejected: Delete all selected sessions through repeated client DELETE calls | weaker partial-failure handling and duplicated cleanup orchestration Rejected: Use a generic checkmark entry icon | it did not communicate batch deletion clearly enough Confidence: high Scope-risk: moderate Directive: Keep batch deletion routed through the server batch endpoint so rollback and adapter cleanup stay centralized Tested: bun test src/server/__tests__/sessions.test.ts -t batch-delete Tested: bun run check:server Tested: cd desktop && bun run check:desktop Tested: browser UI smoke for group select, shift range select, delete confirmation, 7/30 day cleanup, Cmd+A filtered selection, and Escape exit Not-tested: Live provider-backed session generation; this change covers session list management after sessions already exist --- desktop/src/api/sessions.ts | 13 + .../src/components/layout/Sidebar.test.tsx | 96 ++++- desktop/src/components/layout/Sidebar.tsx | 338 ++++++++++++++++-- .../src/components/shared/ConfirmDialog.tsx | 11 +- desktop/src/i18n/locales/en.ts | 14 + desktop/src/i18n/locales/zh.ts | 14 + desktop/src/stores/sessionStore.ts | 58 ++- src/server/__tests__/sessions.test.ts | 91 +++++ src/server/api/sessions.ts | 60 ++++ src/server/services/conversationService.ts | 12 + src/server/services/sessionService.ts | 44 +++ 11 files changed, 717 insertions(+), 34 deletions(-) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index bb7ad9f9..9c76649b 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -8,6 +8,15 @@ type MessagesResponse = { taskNotifications?: AgentTaskNotification[] } type CreateSessionResponse = { sessionId: string; workDir?: string } +export type BatchDeleteSessionsResponse = { + ok: boolean + successes: string[] + failures: Array<{ + sessionId: string + message: string + code?: string + }> +} export type SessionGitWorktreeInfo = { enabled: boolean path: string | null @@ -309,6 +318,10 @@ export const sessionsApi = { return api.delete<{ ok: true }>(`/api/sessions/${sessionId}`) }, + batchDelete(sessionIds: string[]) { + return api.post('/api/sessions/batch-delete', { sessionIds }) + }, + rename(sessionId: string, title: string) { return api.patch<{ ok: true }>(`/api/sessions/${sessionId}`, { title }) }, diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 8961068f..9e831ba0 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -7,7 +7,7 @@ vi.mock('./ProjectFilter', () => ({ })) vi.mock('../../i18n', () => ({ - useTranslation: () => (key: string) => { + useTranslation: () => (key: string, params?: Record) => { const translations: Record = { 'sidebar.newSession': 'New Session', 'sidebar.scheduled': 'Scheduled', @@ -28,11 +28,29 @@ vi.mock('../../i18n', () => ({ 'sidebar.timeGroup.older': 'Older', 'sidebar.missingDir': 'Missing', 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', + 'sidebar.batchManage': 'Batch manage', + 'sidebar.batchSelectedCount': '{count} selected', + 'sidebar.batchSelectAll': 'Select all', + 'sidebar.batchDeselectAll': 'Deselect all', + 'sidebar.batchSelectGroup': 'Select {group}', + 'sidebar.batchDeleteSelected': 'Delete selected ({count})', + 'sidebar.batchDeleteConfirm': 'Delete {count} sessions? This cannot be undone.', + 'sidebar.batchDeleteConfirmBody': 'The following sessions will be deleted:', + 'sidebar.batchDeleteMore': '...and {count} more', + 'sidebar.batchClearOlderThan30': 'Clear >30d', + 'sidebar.batchClearOlderThan7': 'Clear >7d', + 'sidebar.batchExit': 'Cancel batch mode', + 'sidebar.batchDeleteSucceeded': 'Deleted {count} sessions.', + 'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', } - return translations[key] ?? key + let text = translations[key] ?? key + for (const [name, value] of Object.entries(params ?? {})) { + text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)) + } + return text }, })) @@ -48,6 +66,7 @@ describe('Sidebar', () => { const fetchSessions = vi.fn() const createSession = vi.fn() const deleteSession = vi.fn() + const deleteSessions = vi.fn() const addToast = vi.fn() beforeEach(() => { @@ -56,6 +75,7 @@ describe('Sidebar', () => { fetchSessions.mockReset() createSession.mockReset() deleteSession.mockReset() + deleteSessions.mockReset() addToast.mockReset() useTabStore.setState({ tabs: [], activeTabId: null }) @@ -66,9 +86,12 @@ describe('Sidebar', () => { error: null, selectedProjects: [], availableProjects: [], + isBatchMode: false, + selectedSessionIds: new Set(), fetchSessions, createSession, deleteSession, + deleteSessions, }) useChatStore.setState({ connectToSession, @@ -170,6 +193,75 @@ describe('Sidebar', () => { expect(useTabStore.getState().activeTabId).toBeNull() }) + it('selects and deletes multiple sessions from batch mode', async () => { + deleteSessions.mockResolvedValue({ + ok: true, + successes: ['session-1', 'session-2'], + failures: [], + }) + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'First Session', + createdAt: now, + modifiedAt: now, + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + { + id: 'session-2', + title: 'Second Session', + createdAt: now, + modifiedAt: now, + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + }) + useTabStore.setState({ + tabs: [ + { sessionId: 'session-1', title: 'First Session', type: 'session', status: 'idle' }, + { sessionId: 'session-2', title: 'Second Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'session-1', + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Batch manage' })) + fireEvent.click(screen.getByRole('button', { name: /First Session/ })) + fireEvent.click(screen.getByRole('button', { name: /Second Session/ })) + + expect(screen.getByText('2 selected')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Delete selected (2)' })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('Delete 2 sessions? This cannot be undone.')).toBeInTheDocument() + expect(within(dialog).getByText('First Session')).toBeInTheDocument() + expect(within(dialog).getByText('Second Session')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + }) + + await waitFor(() => { + expect(deleteSessions).toHaveBeenCalledWith(['session-1', 'session-2']) + expect(disconnectSession).toHaveBeenCalledWith('session-1') + expect(disconnectSession).toHaveBeenCalledWith('session-2') + }) + expect(useTabStore.getState().tabs).toEqual([]) + expect(addToast).toHaveBeenCalledWith({ + type: 'success', + message: 'Deleted 2 sessions.', + }) + }) + it('collapses into an icon rail and expands back', async () => { render() diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 0211c4ca..b370a785 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -21,12 +21,21 @@ type SidebarProps = { } export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { + const t = useTranslation() const sessions = useSessionStore((s) => s.sessions) const selectedProjects = useSessionStore((s) => s.selectedProjects) const isLoading = useSessionStore((s) => s.isLoading) const error = useSessionStore((s) => s.error) const fetchSessions = useSessionStore((s) => s.fetchSessions) const deleteSession = useSessionStore((s) => s.deleteSession) + const deleteSessions = useSessionStore((s) => s.deleteSessions) + const isBatchMode = useSessionStore((s) => s.isBatchMode) + const selectedSessionIds = useSessionStore((s) => s.selectedSessionIds) + const enterBatchMode = useSessionStore((s) => s.enterBatchMode) + const exitBatchMode = useSessionStore((s) => s.exitBatchMode) + const toggleSessionSelected = useSessionStore((s) => s.toggleSessionSelected) + const selectSessions = useSessionStore((s) => s.selectSessions) + const deselectSessions = useSessionStore((s) => s.deselectSessions) const renameSession = useSessionStore((s) => s.renameSession) const addToast = useUIStore((s) => s.addToast) const sidebarOpen = useUIStore((s) => s.sidebarOpen) @@ -37,8 +46,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const [searchQuery, setSearchQuery] = useState('') const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null) + const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState(null) + const [isBatchDeleting, setIsBatchDeleting] = useState(false) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') + const [lastSelectedSessionId, setLastSelectedSessionId] = useState(null) useEffect(() => { fetchSessions() @@ -71,11 +83,24 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions]) const showInitialLoading = isLoading && sessions.length === 0 + const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions]) + const selectedCount = selectedSessionIds.size + const sessionsById = useMemo( + () => new Map(sessions.map((session) => [session.id, session])), + [sessions], + ) + const pendingBatchDeleteSessions = useMemo( + () => (pendingBatchDeleteSessionIds ?? []) + .map((sessionId) => sessionsById.get(sessionId)) + .filter((session): session is SessionListItem => Boolean(session)), + [pendingBatchDeleteSessionIds, sessionsById], + ) const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { e.preventDefault() + if (isBatchMode) return setContextMenu({ id, x: e.clientX, y: e.clientY }) - }, []) + }, [isBatchMode]) const handleDelete = useCallback((id: string) => { setContextMenu(null) @@ -90,6 +115,83 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { setPendingDeleteSessionId(null) }, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId]) + const handleBatchSessionClick = useCallback((event: React.MouseEvent, id: string) => { + if (event.shiftKey && lastSelectedSessionId) { + const start = filteredSessionIds.indexOf(lastSelectedSessionId) + const end = filteredSessionIds.indexOf(id) + if (start >= 0 && end >= 0) { + const [from, to] = start < end ? [start, end] : [end, start] + selectSessions(filteredSessionIds.slice(from, to + 1)) + setLastSelectedSessionId(id) + return + } + } + + toggleSessionSelected(id) + setLastSelectedSessionId(id) + }, [filteredSessionIds, lastSelectedSessionId, selectSessions, toggleSessionSelected]) + + const handleExitBatchMode = useCallback(() => { + exitBatchMode() + setLastSelectedSessionId(null) + setPendingBatchDeleteSessionIds(null) + }, [exitBatchMode]) + + const requestBatchDelete = useCallback((ids: string[]) => { + if (ids.length === 0) return + setPendingBatchDeleteSessionIds([...new Set(ids)]) + }, []) + + const confirmBatchDelete = useCallback(async () => { + const ids = pendingBatchDeleteSessionIds ?? [] + if (ids.length === 0) return + + setIsBatchDeleting(true) + try { + const result = await deleteSessions(ids) + for (const sessionId of result.successes) { + disconnectSession(sessionId) + closeTab(sessionId) + } + + if (result.failures.length > 0) { + addToast({ + type: 'error', + message: t('sidebar.batchDeleteFailed', { count: result.failures.length }), + }) + } else { + addToast({ + type: 'success', + message: t('sidebar.batchDeleteSucceeded', { count: result.successes.length }), + }) + handleExitBatchMode() + } + setPendingBatchDeleteSessionIds(null) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('sidebar.batchDeleteFailed', { count: ids.length }), + }) + } finally { + setIsBatchDeleting(false) + } + }, [addToast, closeTab, deleteSessions, disconnectSession, handleExitBatchMode, pendingBatchDeleteSessionIds, t]) + + const toggleGroupSelection = useCallback((ids: string[]) => { + const allSelected = ids.every((id) => selectedSessionIds.has(id)) + if (allSelected) { + deselectSessions(ids) + } else { + selectSessions(ids) + } + }, [deselectSessions, selectSessions, selectedSessionIds]) + + const selectOlderThan = useCallback((days: number) => { + const ids = getSessionsOlderThan(filteredSessions, days) + selectSessions(ids) + requestBatchDelete(ids) + }, [filteredSessions, requestBatchDelete, selectSessions]) + const handleStartRename = useCallback((id: string, currentTitle: string) => { setContextMenu(null) setRenamingId(id) @@ -122,7 +224,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { startDraggingRef.current?.() }, []) - const t = useTranslation() const expanded = isMobile ? true : sidebarOpen const closeMobileDrawer = useCallback(() => { if (isMobile) onRequestClose?.() @@ -136,6 +237,27 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { older: t('sidebar.timeGroup.older'), } + useEffect(() => { + if (!isBatchMode) return + + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + if (target?.closest('input, textarea, [contenteditable="true"]')) return + + if (event.key === 'Escape') { + handleExitBatchMode() + } + + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + selectSessions(filteredSessionIds) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [filteredSessionIds, handleExitBatchMode, isBatchMode, selectSessions]) + return ( ) } @@ -442,6 +719,13 @@ function groupByTime(sessions: SessionListItem[]): Map new Date(session.modifiedAt).getTime() < threshold) + .map((session) => session.id) +} + function NavItem({ active, collapsed, diff --git a/desktop/src/components/shared/ConfirmDialog.tsx b/desktop/src/components/shared/ConfirmDialog.tsx index 6284547d..82bc4043 100644 --- a/desktop/src/components/shared/ConfirmDialog.tsx +++ b/desktop/src/components/shared/ConfirmDialog.tsx @@ -1,12 +1,13 @@ import { Modal } from './Modal' import { Button } from './Button' +import type { ReactNode } from 'react' type ConfirmDialogProps = { open: boolean onClose: () => void onConfirm: () => void | Promise title: string - body: string + body: ReactNode confirmLabel: string cancelLabel: string confirmVariant?: 'primary' | 'danger' @@ -41,9 +42,11 @@ export function ConfirmDialog({ )} > -

- {body} -

+ {typeof body === 'string' ? ( +

+ {body} +

+ ) : body} ) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 9bbc982b..8d4acabd 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -26,6 +26,20 @@ export const en = { 'sidebar.sessionListFailed': 'Session list failed to load', 'sidebar.missingDir': 'missing dir', 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', + 'sidebar.batchManage': 'Batch manage', + 'sidebar.batchSelectedCount': '{count} selected', + 'sidebar.batchSelectAll': 'Select all', + 'sidebar.batchDeselectAll': 'Deselect all', + 'sidebar.batchSelectGroup': 'Select {group}', + 'sidebar.batchDeleteSelected': 'Delete selected ({count})', + 'sidebar.batchDeleteConfirm': 'Delete {count} sessions? This cannot be undone.', + 'sidebar.batchDeleteConfirmBody': 'The following sessions will be deleted:', + 'sidebar.batchDeleteMore': '...and {count} more', + 'sidebar.batchClearOlderThan30': 'Clear >30d', + 'sidebar.batchClearOlderThan7': 'Clear >7d', + 'sidebar.batchExit': 'Cancel batch mode', + 'sidebar.batchDeleteSucceeded': 'Deleted {count} sessions.', + 'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.', 'sidebar.allProjects': 'All projects', 'sidebar.other': 'Other', 'sidebar.timeGroup.today': 'Today', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 86ba03f1..e7001a27 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -28,6 +28,20 @@ export const zh: Record = { 'sidebar.sessionListFailed': '会话列表加载失败', 'sidebar.missingDir': '目录缺失', 'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。', + 'sidebar.batchManage': '批量管理', + 'sidebar.batchSelectedCount': '已选 {count} 个', + 'sidebar.batchSelectAll': '全选', + 'sidebar.batchDeselectAll': '取消全选', + 'sidebar.batchSelectGroup': '选择{group}', + 'sidebar.batchDeleteSelected': '删除已选 ({count})', + 'sidebar.batchDeleteConfirm': '确定要删除 {count} 个会话吗?此操作不可撤销。', + 'sidebar.batchDeleteConfirmBody': '以下会话将被删除:', + 'sidebar.batchDeleteMore': '...还有 {count} 条', + 'sidebar.batchClearOlderThan30': '清空 30 天前', + 'sidebar.batchClearOlderThan7': '清空 7 天前', + 'sidebar.batchExit': '退出批量管理', + 'sidebar.batchDeleteSucceeded': '已删除 {count} 个会话。', + 'sidebar.batchDeleteFailed': '有 {count} 个会话删除失败。', 'sidebar.allProjects': '所有项目', 'sidebar.other': '其他', 'sidebar.timeGroup.today': '今天', diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index 223aaa1e..ed7aa214 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions' +import { sessionsApi, type BatchDeleteSessionsResponse, type CreateSessionRepositoryOptions } from '../api/sessions' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import type { SessionListItem } from '../types/session' @@ -16,10 +16,19 @@ type SessionStore = { error: string | null selectedProjects: string[] availableProjects: string[] + isBatchMode: boolean + selectedSessionIds: Set fetchSessions: (project?: string) => Promise createSession: (workDir?: string, options?: CreateSessionOptions) => Promise deleteSession: (id: string) => Promise + deleteSessions: (ids: string[]) => Promise + enterBatchMode: () => void + exitBatchMode: () => void + toggleSessionSelected: (id: string) => void + selectSessions: (ids: string[]) => void + deselectSessions: (ids: string[]) => void + clearSessionSelection: () => void renameSession: (id: string, title: string) => Promise updateSessionTitle: (id: string, title: string) => void setActiveSession: (id: string | null) => void @@ -33,6 +42,8 @@ export const useSessionStore = create((set, get) => ({ error: null, selectedProjects: [], availableProjects: [], + isBatchMode: false, + selectedSessionIds: new Set(), fetchSessions: async (project?: string) => { set({ isLoading: true, error: null }) @@ -96,9 +107,47 @@ export const useSessionStore = create((set, get) => ({ set((s) => ({ sessions: s.sessions.filter((session) => session.id !== id), activeSessionId: s.activeSessionId === id ? null : s.activeSessionId, + selectedSessionIds: removeIdsFromSet(s.selectedSessionIds, [id]), })) }, + deleteSessions: async (ids: string[]) => { + const sessionIds = [...new Set(ids)].filter(Boolean) + const result = await sessionsApi.batchDelete(sessionIds) + for (const id of result.successes) { + useSessionRuntimeStore.getState().clearSelection(id) + } + set((s) => ({ + sessions: s.sessions.filter((session) => !result.successes.includes(session.id)), + activeSessionId: s.activeSessionId && result.successes.includes(s.activeSessionId) + ? null + : s.activeSessionId, + selectedSessionIds: removeIdsFromSet(s.selectedSessionIds, result.successes), + })) + return result + }, + + enterBatchMode: () => set({ isBatchMode: true }), + exitBatchMode: () => set({ isBatchMode: false, selectedSessionIds: new Set() }), + toggleSessionSelected: (id) => set((s) => { + const selectedSessionIds = new Set(s.selectedSessionIds) + if (selectedSessionIds.has(id)) { + selectedSessionIds.delete(id) + } else { + selectedSessionIds.add(id) + } + return { selectedSessionIds } + }), + selectSessions: (ids) => set((s) => { + const selectedSessionIds = new Set(s.selectedSessionIds) + for (const id of ids) selectedSessionIds.add(id) + return { selectedSessionIds } + }), + deselectSessions: (ids) => set((s) => ({ + selectedSessionIds: removeIdsFromSet(s.selectedSessionIds, ids), + })), + clearSessionSelection: () => set({ selectedSessionIds: new Set() }), + renameSession: async (id: string, title: string) => { await sessionsApi.rename(id, title) set((s) => ({ @@ -120,6 +169,13 @@ export const useSessionStore = create((set, get) => ({ setSelectedProjects: (projects) => set({ selectedProjects: projects }), })) +function removeIdsFromSet(selected: Set, ids: string[]): Set { + if (ids.length === 0) return selected + const next = new Set(selected) + for (const id of ids) next.delete(id) + return next +} + function preserveLocalTitle( current: SessionListItem | undefined, incoming: SessionListItem, diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index febcba39..788f8985 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1858,6 +1858,97 @@ describe('Sessions API', () => { } }) + it('POST /api/sessions/batch-delete should delete sessions and clean adapter mappings', async () => { + const sessionIdA = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const sessionIdB = 'ffffffff-1111-2222-3333-ffffffffffff' + const otherSessionId = '99999999-1111-2222-3333-999999999999' + await writeSessionFile('-tmp-api-test', sessionIdA, [makeSnapshotEntry()]) + await writeSessionFile('-tmp-api-test', sessionIdB, [makeSnapshotEntry()]) + await fs.writeFile( + path.join(tmpDir, 'adapter-sessions.json'), + JSON.stringify({ + 'wechat-chat-a': { + sessionId: sessionIdA, + workDir: '/tmp/project-a', + updatedAt: 1, + }, + 'wechat-chat-b': { + sessionId: sessionIdB, + workDir: '/tmp/project-b', + updatedAt: 2, + }, + 'other-chat': { + sessionId: otherSessionId, + workDir: '/tmp/project-c', + updatedAt: 3, + }, + }, null, 2), + 'utf-8', + ) + + const res = await fetch(`${baseUrl}/api/sessions/batch-delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionIds: [sessionIdA, sessionIdB] }), + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + ok: true, + successes: [sessionIdA, sessionIdB], + failures: [], + }) + + expect((await fetch(`${baseUrl}/api/sessions/${sessionIdA}`)).status).toBe(404) + expect((await fetch(`${baseUrl}/api/sessions/${sessionIdB}`)).status).toBe(404) + const persisted = JSON.parse( + await fs.readFile(path.join(tmpDir, 'adapter-sessions.json'), 'utf-8'), + ) + expect(persisted['wechat-chat-a']).toBeUndefined() + expect(persisted['wechat-chat-b']).toBeUndefined() + expect(persisted['other-chat'].sessionId).toBe(otherSessionId) + }) + + it('POST /api/sessions/batch-delete should report partial failures and roll back failed delete markers', async () => { + const successSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const failedSessionId = 'ffffffff-1111-2222-3333-ffffffffffff' + await writeSessionFile('-tmp-api-test', successSessionId, [makeSnapshotEntry()]) + await writeSessionFile('-tmp-api-test', failedSessionId, [makeSnapshotEntry()]) + + const originalDeleteSession = sessionService.deleteSession.bind(sessionService) + sessionService.deleteSession = (async (targetSessionId: string) => { + if (targetSessionId === failedSessionId) { + throw new Error('simulated batch unlink failure') + } + return originalDeleteSession(targetSessionId) + }) as typeof sessionService.deleteSession + + try { + const res = await fetch(`${baseUrl}/api/sessions/batch-delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionIds: [successSessionId, failedSessionId] }), + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + ok: false, + successes: [successSessionId], + failures: [{ + sessionId: failedSessionId, + message: 'simulated batch unlink failure', + }], + }) + expect((conversationService as any).deletedSessions.has(failedSessionId)).toBe(false) + expect((await fetch(`${baseUrl}/api/sessions/${successSessionId}`)).status).toBe(404) + expect((await fetch(`${baseUrl}/api/sessions/${failedSessionId}`)).status).toBe(200) + } finally { + sessionService.deleteSession = originalDeleteSession as typeof sessionService.deleteSession + conversationService.unmarkSessionDeleted(successSessionId) + conversationService.unmarkSessionDeleted(failedSessionId) + } + }) + it('PATCH /api/sessions/:id should rename the session', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-api-test', sessionId, [ diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index fdb1f169..50ac2a4d 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -10,6 +10,7 @@ * GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览 * GET /api/sessions/:id/turn-checkpoints/diff — 获取绑定到指定 checkpoint 的 diff * POST /api/sessions — 创建新会话 + * POST /api/sessions/batch-delete — 批量删除会话 * DELETE /api/sessions/:id — 删除会话 * PATCH /api/sessions/:id — 重命名会话 */ @@ -70,6 +71,17 @@ export async function handleSessionsApi( } } + // Special collection route: /api/sessions/batch-delete + if (sessionId === 'batch-delete') { + if (req.method !== 'POST') { + return Response.json( + { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, + { status: 405 } + ) + } + return await batchDeleteSessions(req) + } + // Special collection route: /api/sessions/recent-projects if (sessionId === 'recent-projects' && req.method === 'GET') { return await getRecentProjects(url) @@ -359,6 +371,54 @@ async function deleteSession(sessionId: string): Promise { return Response.json({ ok: true }) } +async function batchDeleteSessions(req: Request): Promise { + let body: { sessionIds?: unknown } + try { + body = (await req.json()) as { sessionIds?: unknown } + } catch { + throw ApiError.badRequest('Invalid JSON body') + } + + const sessionIds = normalizeSessionIds(body.sessionIds) + conversationService.markSessionsDeleted(sessionIds) + const result = await sessionService.deleteSessions(sessionIds) + + if (result.failures.length > 0) { + conversationService.unmarkSessionsDeleted(result.failures.map((failure) => failure.sessionId)) + } + + for (const sessionId of result.successes) { + closeSessionConnection(sessionId, 'session deleted') + cleanupAdapterSessionMappings(sessionId) + } + + return Response.json({ + ok: result.failures.length === 0, + successes: result.successes, + failures: result.failures, + }) +} + +function normalizeSessionIds(value: unknown): string[] { + if (!Array.isArray(value)) { + throw ApiError.badRequest('sessionIds must be an array') + } + + const sessionIds: string[] = [] + for (const sessionId of value) { + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + throw ApiError.badRequest('sessionIds must contain only non-empty strings') + } + sessionIds.push(sessionId.trim()) + } + + if (sessionIds.length === 0) { + throw ApiError.badRequest('sessionIds must include at least one session id') + } + + return [...new Set(sessionIds)] +} + function cleanupAdapterSessionMappings(sessionId: string): void { const removedChatIds = new SessionStore().deleteBySessionId(sessionId) if (removedChatIds.length > 0) { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 2de58704..62a7e4cb 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -662,10 +662,22 @@ export class ConversationService { this.stopSession(sessionId) } + markSessionsDeleted(sessionIds: string[]): void { + for (const sessionId of sessionIds) { + this.markSessionDeleted(sessionId) + } + } + unmarkSessionDeleted(sessionId: string): void { this.deletedSessions.delete(sessionId) } + unmarkSessionsDeleted(sessionIds: string[]): void { + for (const sessionId of sessionIds) { + this.unmarkSessionDeleted(sessionId) + } + } + getActiveSessions(): string[] { return Array.from(this.sessions.keys()) } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 6f7bfc60..98b0f23e 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -41,6 +41,17 @@ export type SessionListItem = { workDirExists: boolean } +export type DeleteSessionFailure = { + sessionId: string + message: string + code?: string +} + +export type DeleteSessionsResult = { + successes: string[] + failures: DeleteSessionFailure[] +} + export type SessionDetail = SessionListItem & { messages: MessageEntry[] } @@ -1339,6 +1350,39 @@ export class SessionService { await fs.unlink(found.filePath) } + async deleteSessions(sessionIds: string[]): Promise { + const successes: string[] = [] + const failures: DeleteSessionFailure[] = [] + + const results = await Promise.all(sessionIds.map(async (sessionId) => { + try { + await this.deleteSession(sessionId) + return { type: 'success' as const, sessionId } + } catch (error) { + return { + type: 'failure' as const, + sessionId, + message: error instanceof Error ? error.message : 'Unknown delete failure', + code: error instanceof ApiError ? error.code : undefined, + } + } + })) + + for (const result of results) { + if (result.type === 'success') { + successes.push(result.sessionId) + } else { + failures.push({ + sessionId: result.sessionId, + message: result.message, + code: result.code, + }) + } + } + + return { successes, failures } + } + /** * Rename a session by appending a custom-title entry to its JSONL file. */