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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 13:42:45 +08:00
parent fbce0225f2
commit b0fcbba9f0
11 changed files with 717 additions and 34 deletions

View File

@ -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<BatchDeleteSessionsResponse>('/api/sessions/batch-delete', { sessionIds })
},
rename(sessionId: string, title: string) {
return api.patch<{ ok: true }>(`/api/sessions/${sessionId}`, { title })
},

View File

@ -7,7 +7,7 @@ vi.mock('./ProjectFilter', () => ({
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => {
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'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(<Sidebar />)
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(<Sidebar />)

View File

@ -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<string | null>(null)
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(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 (
<aside
onMouseDown={handleSidebarDrag}
@ -244,20 +366,37 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
style={{ overflow: 'visible' }}
>
<div className="flex h-9 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-1.5 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
<ProjectFilter variant="embedded" />
<span className="mx-2 h-4 w-px bg-[var(--color-border)]/80" aria-hidden="true" />
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
<SearchIcon />
</span>
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
<div className="flex items-center gap-1.5">
<div className="flex h-9 min-w-0 flex-1 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-1.5 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
<ProjectFilter variant="embedded" />
<span className="mx-2 h-4 w-px bg-[var(--color-border)]/80" aria-hidden="true" />
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
<SearchIcon />
</span>
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
</div>
<button
type="button"
onClick={isBatchMode ? handleExitBatchMode : enterBatchMode}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[12px] border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isBatchMode
? 'border-[var(--color-brand)] bg-[var(--color-sidebar-item-active)] text-[var(--color-brand)]'
: 'border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}`}
aria-label={isBatchMode ? t('sidebar.batchExit') : t('sidebar.batchManage')}
title={isBatchMode ? t('sidebar.batchExit') : t('sidebar.batchManage')}
>
<span className="material-symbols-outlined text-[18px]">
{isBatchMode ? 'close' : 'delete_sweep'}
</span>
</button>
</div>
</div>
@ -265,6 +404,66 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
data-testid="sidebar-session-list-section"
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
>
{isBatchMode && (
<div className="mx-3 mb-2 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2 shadow-sm">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 text-xs font-medium text-[var(--color-text-primary)]">
{t('sidebar.batchSelectedCount', { count: selectedCount })}
</span>
<button
type="button"
onClick={handleExitBatchMode}
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
aria-label={t('sidebar.batchExit')}
title={t('sidebar.batchExit')}
>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => {
if (filteredSessionIds.every((id) => selectedSessionIds.has(id))) {
deselectSessions(filteredSessionIds)
} else {
selectSessions(filteredSessionIds)
}
}}
disabled={filteredSessionIds.length === 0}
className="rounded-md border border-[var(--color-border)] px-2 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
{filteredSessionIds.length > 0 && filteredSessionIds.every((id) => selectedSessionIds.has(id))
? t('sidebar.batchDeselectAll')
: t('sidebar.batchSelectAll')}
</button>
<button
type="button"
onClick={() => requestBatchDelete([...selectedSessionIds])}
disabled={selectedCount === 0}
className="rounded-md bg-[var(--color-error)] px-2 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{t('sidebar.batchDeleteSelected', { count: selectedCount })}
</button>
<button
type="button"
onClick={() => selectOlderThan(7)}
disabled={getSessionsOlderThan(filteredSessions, 7).length === 0}
className="rounded-md border border-[var(--color-border)] px-2 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
{t('sidebar.batchClearOlderThan7')}
</button>
<button
type="button"
onClick={() => selectOlderThan(30)}
disabled={getSessionsOlderThan(filteredSessions, 30).length === 0}
className="rounded-md border border-[var(--color-border)] px-2 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
{t('sidebar.batchClearOlderThan30')}
</button>
</div>
</div>
)}
<div className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
@ -290,10 +489,30 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{TIME_GROUP_ORDER.map((group) => {
const items = timeGroups.get(group)
if (!items || items.length === 0) return null
const groupIds = items.map((session) => session.id)
const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length
return (
<div key={group} className="mb-1">
<div className="px-2 pb-1 pt-4 text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
{timeGroupLabels[group]}
<div className="flex items-center justify-between px-2 pb-1 pt-4">
<div className="text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
{timeGroupLabels[group]}
</div>
{isBatchMode && (
<button
type="button"
onClick={() => toggleGroupSelection(groupIds)}
className={`rounded-md px-1.5 py-0.5 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
groupSelectedCount > 0
? 'text-[var(--color-brand)] hover:bg-[var(--color-brand)]/10'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)]'
}`}
aria-label={t('sidebar.batchSelectGroup', { group: timeGroupLabels[group] })}
>
{groupSelectedCount === groupIds.length
? t('sidebar.batchDeselectAll')
: t('sidebar.batchSelectAll')}
</button>
)}
</div>
{items.map((session) => (
<div key={session.id} className="relative">
@ -314,7 +533,11 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
/>
) : (
<button
onClick={() => {
onClick={(event) => {
if (isBatchMode) {
handleBatchSessionClick(event, session.id)
return
}
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
closeMobileDrawer()
@ -322,20 +545,38 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group w-full rounded-[12px] px-3 ${isMobile ? 'py-3' : 'py-2'} text-left text-sm transition-colors duration-200
${session.id === activeTabId
${selectedSessionIds.has(session.id)
? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] ring-1 ring-[var(--color-brand)]/15'
: session.id === activeTabId
? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]'
}
`}
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
>
<span className="flex items-center gap-2.5">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}}
/>
{isBatchMode ? (
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
selectedSessionIds.has(session.id)
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
aria-hidden="true"
>
{selectedSessionIds.has(session.id) && (
<span className="material-symbols-outlined text-[12px]">check</span>
)}
</span>
) : (
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}}
/>
)}
<span className="flex-1 truncate font-medium tracking-[-0.01em]">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
@ -414,6 +655,42 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
cancelLabel={t('common.cancel')}
confirmVariant="danger"
/>
<ConfirmDialog
open={pendingBatchDeleteSessionIds !== null}
onClose={() => {
if (!isBatchDeleting) setPendingBatchDeleteSessionIds(null)
}}
onConfirm={confirmBatchDelete}
title={t('common.delete')}
body={(
<div className="space-y-3">
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{t('sidebar.batchDeleteConfirm', { count: pendingBatchDeleteSessionIds?.length ?? 0 })}
</p>
<div>
<div className="mb-1.5 text-xs font-medium text-[var(--color-text-primary)]">
{t('sidebar.batchDeleteConfirmBody')}
</div>
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
{pendingBatchDeleteSessions.slice(0, 5).map((session) => (
<li key={session.id} className="truncate text-xs text-[var(--color-text-secondary)]">
{session.title || 'Untitled'}
</li>
))}
{(pendingBatchDeleteSessionIds?.length ?? 0) > 5 && (
<li className="text-xs text-[var(--color-text-tertiary)]">
{t('sidebar.batchDeleteMore', { count: (pendingBatchDeleteSessionIds?.length ?? 0) - 5 })}
</li>
)}
</ul>
</div>
</div>
)}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isBatchDeleting}
/>
</aside>
)
}
@ -442,6 +719,13 @@ function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListIte
return groups
}
function getSessionsOlderThan(sessions: SessionListItem[], days: number): string[] {
const threshold = Date.now() - days * 86400000
return sessions
.filter((session) => new Date(session.modifiedAt).getTime() < threshold)
.map((session) => session.id)
}
function NavItem({
active,
collapsed,

View File

@ -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<void>
title: string
body: string
body: ReactNode
confirmLabel: string
cancelLabel: string
confirmVariant?: 'primary' | 'danger'
@ -41,9 +42,11 @@ export function ConfirmDialog({
</>
)}
>
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{body}
</p>
{typeof body === 'string' ? (
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{body}
</p>
) : body}
</Modal>
)
}

View File

@ -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',

View File

@ -28,6 +28,20 @@ export const zh: Record<TranslationKey, string> = {
'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': '今天',

View File

@ -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<string>
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
deleteSession: (id: string) => Promise<void>
deleteSessions: (ids: string[]) => Promise<BatchDeleteSessionsResponse>
enterBatchMode: () => void
exitBatchMode: () => void
toggleSessionSelected: (id: string) => void
selectSessions: (ids: string[]) => void
deselectSessions: (ids: string[]) => void
clearSessionSelection: () => void
renameSession: (id: string, title: string) => Promise<void>
updateSessionTitle: (id: string, title: string) => void
setActiveSession: (id: string | null) => void
@ -33,6 +42,8 @@ export const useSessionStore = create<SessionStore>((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<SessionStore>((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<SessionStore>((set, get) => ({
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
}))
function removeIdsFromSet(selected: Set<string>, ids: string[]): Set<string> {
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,

View File

@ -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, [

View File

@ -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<Response> {
return Response.json({ ok: true })
}
async function batchDeleteSessions(req: Request): Promise<Response> {
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) {

View File

@ -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())
}

View File

@ -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<DeleteSessionsResult> {
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.
*/