mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
fbce0225f2
commit
b0fcbba9f0
@ -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 })
|
||||
},
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '今天',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user