feat(sidebar): delete all sessions for a project (#74)

* feat(sidebar): delete all sessions for a project

Add a "Delete all sessions" option to the project context menu so a
project can be removed from the sidebar entirely (not just hidden).
Hide-from-sidebar is a UI-only soft toggle; this is a destructive
operation that wipes the on-disk artefacts.

Backend
- POST /api/projects/sessions/clear accepts { projectId } or
  { workDir } (the desktop client passes workDir; backend sanitizes).
- Deletes every .jsonl transcript and matching .summary.json sidecar
  under ~/.claude/projects/<sanitized-id>/. Sub-directories and other
  non-session files (memory/, user notes) are preserved.
- If the directory becomes empty, removes it so the project drops off
  the sidebar listing entirely.
- Defence in depth: rejects path-separator/null-byte/`.`/`..` ids and
  re-checks the resolved path stays under projectsDir.
- 11 backend tests covering traversal/null-byte/dot rejection, missing
  dir 200, full-clear+rmdir, non-jsonl preservation, summary sidecar
  deletion, workDir absolute-path acceptance, relative-path rejection,
  and 405 on GET.

Frontend
- Sidebar project context menu: red "Delete all sessions" entry
  (Trash2 icon, danger) below the hide entry.
- ConfirmDialog with project title + session count + irreversibility
  warning. Disconnects open sessions and closes their tabs before the
  call so the websocket layer doesn't keep stale handles.
- After success, fetchSessions() refreshes the list — the project
  disappears if its dir was removed, or shows up empty if memory/
  remained.
- 5 new i18n keys across en/zh/jp/kr/zh-TW.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sidebar): cover projectsApi.clearSessions client behaviour

Wire-level guard for the desktop API helper introduced in this PR — the
sidebar's "Delete all sessions" flow depends on it sending the right
endpoint and body, and surfacing server errors back to the caller for
toast handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
小橙子 2026-06-18 13:12:19 +08:00 committed by GitHub
parent a78a05b52c
commit d2ef9ad330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 485 additions and 6 deletions

View File

@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '@testing-library/jest-dom'
const apiPostMock = vi.fn()
vi.mock('./client', () => ({
api: {
post: (url: string, body?: unknown) => apiPostMock(url, body),
get: vi.fn(),
},
}))
import { projectsApi } from './projects'
beforeEach(() => {
apiPostMock.mockReset()
})
describe('projectsApi.clearSessions', () => {
it('posts to /api/projects/sessions/clear with workDir in body', async () => {
apiPostMock.mockResolvedValue({ ok: true, deletedSessions: 3, projectDirRemoved: true })
const result = await projectsApi.clearSessions('/work/alpha')
expect(apiPostMock).toHaveBeenCalledTimes(1)
expect(apiPostMock).toHaveBeenCalledWith('/api/projects/sessions/clear', { workDir: '/work/alpha' })
expect(result.deletedSessions).toBe(3)
expect(result.projectDirRemoved).toBe(true)
})
it('propagates server errors so the caller can show a toast', async () => {
apiPostMock.mockRejectedValue(new Error('Forbidden'))
await expect(projectsApi.clearSessions('/work/beta')).rejects.toThrow('Forbidden')
})
})

View File

@ -121,4 +121,18 @@ export const projectsApi = {
return null
}
},
/**
* Permanently delete every .jsonl session file under the given project.
* Pass an absolute workDir (the desktop sidebar's project key) the
* backend sanitizes it. If the project directory becomes empty, it is
* removed so the project drops off the sidebar entirely. Non-.jsonl files
* (memory/, user notes) are preserved.
*/
clearSessions(workDir: string): Promise<{ ok: true; deletedSessions: number; projectDirRemoved: boolean }> {
return api.post<{ ok: true; deletedSessions: number; projectDirRemoved: boolean }>(
'/api/projects/sessions/clear',
{ workDir },
)
},
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react'
import { Folder, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, Trash2, X } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
@ -39,6 +39,7 @@ import {
positionProjectMenu,
isDocumentVisible,
} from './sidebarUtils'
import { projectsApi } from '../../api/projects'
import {
GitHubIcon,
PlusIcon,
@ -98,6 +99,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
const [pendingClearProjectKey, setPendingClearProjectKey] = useState<string | null>(null)
const [isClearingProject, setIsClearingProject] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(null)
@ -448,6 +451,61 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
const requestClearProjectSessions = useCallback((project: ProjectGroup) => {
setProjectContextMenu(null)
setPendingClearProjectKey(project.key)
}, [])
const confirmClearProjectSessions = useCallback(async () => {
if (!pendingClearProjectKey) return
const project = orderedProjectGroups.find((g) => g.key === pendingClearProjectKey)
if (!project) {
setPendingClearProjectKey(null)
return
}
const workDir = project.workDir
if (!workDir) {
addToast({
type: 'error',
message: t('sidebar.clearProjectSessionsNoWorkDir', { project: project.title }),
})
setPendingClearProjectKey(null)
return
}
setIsClearingProject(true)
try {
// Disconnect any open sessions for this project so the websocket
// server-side handles don't keep referencing files we're about to
// delete. closeTab handles missing tabs gracefully.
for (const session of project.sessions) {
try { closeTab(session.id) } catch { /* ignore */ }
try { disconnectSession(session.id) } catch { /* ignore */ }
}
const result = await projectsApi.clearSessions(workDir)
// Re-fetch sessions so the project either disappears (if dir removed)
// or shows up empty (preserved memory/ etc.).
await fetchSessions()
addToast({
type: 'success',
message: t('sidebar.clearProjectSessionsSuccess', {
project: project.title,
count: result.deletedSessions,
}),
})
} catch (error) {
addToast({
type: 'error',
message: t('sidebar.clearProjectSessionsFailure', {
project: project.title,
error: error instanceof Error ? error.message : String(error),
}),
})
} finally {
setIsClearingProject(false)
setPendingClearProjectKey(null)
}
}, [addToast, closeTab, disconnectSession, fetchSessions, orderedProjectGroups, pendingClearProjectKey, t])
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
setProjectContextMenu(null)
try {
@ -1149,6 +1207,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
>
{t(hidden ? 'sidebar.restoreProjectToSidebar' : 'sidebar.hideProjectFromSidebar')}
</ProjectMenuItem>
<ProjectMenuItem
icon={<Trash2 size={18} aria-hidden="true" />}
onClick={() => requestClearProjectSessions(project)}
danger
>
{t('sidebar.clearProjectSessions')}
</ProjectMenuItem>
</div>
)
})()}
@ -1235,6 +1300,32 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
confirmVariant="danger"
loading={isBatchDeleting}
/>
<ConfirmDialog
open={pendingClearProjectKey !== null}
onClose={() => {
if (!isClearingProject) setPendingClearProjectKey(null)
}}
onConfirm={confirmClearProjectSessions}
title={t('sidebar.clearProjectSessions')}
body={(() => {
const project = pendingClearProjectKey
? orderedProjectGroups.find((g) => g.key === pendingClearProjectKey)
: null
if (!project) return ''
return (
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{t('sidebar.clearProjectSessionsConfirm', {
project: project.title,
count: project.sessions.length,
})}
</p>
)
})()}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isClearingProject}
/>
</aside>
)
}

View File

@ -53,6 +53,11 @@ export const en = {
'sidebar.restoreProjectToSidebar': 'Restore to Sidebar',
'sidebar.restoreHiddenProjects': 'Restore hidden projects ({count})',
'sidebar.projectHidden': '{project} was hidden from the sidebar. Existing sessions were not deleted.',
'sidebar.clearProjectSessions': 'Delete all sessions',
'sidebar.clearProjectSessionsConfirm': 'Permanently delete all {count} sessions in {project}? The project will be removed from the sidebar. This cannot be undone.',
'sidebar.clearProjectSessionsSuccess': 'Deleted {count} sessions from {project}.',
'sidebar.clearProjectSessionsFailure': 'Failed to delete sessions from {project}: {error}',
'sidebar.clearProjectSessionsNoWorkDir': 'Cannot delete sessions for {project}: working directory unknown.',
'sidebar.newSessionInProject': 'New session in {project}',
'sidebar.showMoreSessions': 'Expand display',
'sidebar.showFewerSessions': 'Collapse display',

View File

@ -55,6 +55,11 @@ export const jp: Record<TranslationKey, string> = {
'sidebar.restoreProjectToSidebar': 'サイドバーに復元',
'sidebar.restoreHiddenProjects': '非表示のプロジェクトを復元 ({count})',
'sidebar.projectHidden': '{project} をサイドバーから非表示にしました。既存のセッションは削除されていません。',
'sidebar.clearProjectSessions': 'すべてのセッションを削除',
'sidebar.clearProjectSessionsConfirm': '{project} のすべての {count} 個のセッションを完全に削除しますか?プロジェクトはサイドバーから消えます。この操作は取り消せません。',
'sidebar.clearProjectSessionsSuccess': '{project} から {count} 個のセッションを削除しました。',
'sidebar.clearProjectSessionsFailure': '{project} のセッション削除に失敗しました:{error}',
'sidebar.clearProjectSessionsNoWorkDir': '{project} のセッションを削除できません:作業ディレクトリが不明です。',
'sidebar.newSessionInProject': '{project} で新しいセッション',
'sidebar.showMoreSessions': '表示を展開',
'sidebar.showFewerSessions': '表示を折りたたむ',

View File

@ -55,6 +55,11 @@ export const kr: Record<TranslationKey, string> = {
'sidebar.restoreProjectToSidebar': '사이드바로 복원',
'sidebar.restoreHiddenProjects': '숨긴 프로젝트 복원 ({count})',
'sidebar.projectHidden': '{project}을(를) 사이드바에서 숨겼습니다. 기존 세션은 삭제되지 않았습니다.',
'sidebar.clearProjectSessions': '모든 세션 삭제',
'sidebar.clearProjectSessionsConfirm': '{project}의 모든 {count}개 세션을 영구적으로 삭제하시겠습니까? 프로젝트가 사이드바에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.',
'sidebar.clearProjectSessionsSuccess': '{project}에서 {count}개 세션을 삭제했습니다.',
'sidebar.clearProjectSessionsFailure': '{project}의 세션 삭제 실패: {error}',
'sidebar.clearProjectSessionsNoWorkDir': '{project}의 세션을 삭제할 수 없습니다: 작업 디렉토리를 알 수 없습니다.',
'sidebar.newSessionInProject': '{project}에서 새 세션',
'sidebar.showMoreSessions': '펼쳐서 표시',
'sidebar.showFewerSessions': '접어서 표시',

View File

@ -55,6 +55,11 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.restoreProjectToSidebar': '恢復到側邊欄',
'sidebar.restoreHiddenProjects': '恢復隱藏專案({count}',
'sidebar.projectHidden': '已從側邊欄隱藏 {project},已有會話不會被刪除。',
'sidebar.clearProjectSessions': '刪除全部會話',
'sidebar.clearProjectSessionsConfirm': '確定永久刪除 {project} 中的全部 {count} 個會話?專案將從側邊欄消失。此操作無法撤銷。',
'sidebar.clearProjectSessionsSuccess': '已從 {project} 刪除 {count} 個會話。',
'sidebar.clearProjectSessionsFailure': '刪除 {project} 的會話失敗:{error}',
'sidebar.clearProjectSessionsNoWorkDir': '無法刪除 {project} 的會話:工作目錄未知。',
'sidebar.newSessionInProject': '在 {project} 中新建會話',
'sidebar.showMoreSessions': '展開顯示',
'sidebar.showFewerSessions': '摺疊顯示',

View File

@ -55,6 +55,11 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.restoreProjectToSidebar': '恢复到侧边栏',
'sidebar.restoreHiddenProjects': '恢复隐藏项目({count}',
'sidebar.projectHidden': '已从侧边栏隐藏 {project},已有会话不会被删除。',
'sidebar.clearProjectSessions': '删除全部会话',
'sidebar.clearProjectSessionsConfirm': '确定永久删除 {project} 中的全部 {count} 个会话?项目将从侧边栏消失。此操作无法撤销。',
'sidebar.clearProjectSessionsSuccess': '已从 {project} 删除 {count} 个会话。',
'sidebar.clearProjectSessionsFailure': '删除 {project} 的会话失败:{error}',
'sidebar.clearProjectSessionsNoWorkDir': '无法删除 {project} 的会话:工作目录未知。',
'sidebar.newSessionInProject': '在 {project} 中新建会话',
'sidebar.showMoreSessions': '展开显示',
'sidebar.showFewerSessions': '折叠显示',

View File

@ -0,0 +1,184 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test'
import * as path from 'path'
const MOCK_CLAUDE_HOME = path.join('/mock', 'home', '.claude')
const MOCK_PROJECTS_DIR = path.join(MOCK_CLAUDE_HOME, 'projects')
mock.module('../../utils/envUtils.js', () => ({
getClaudeConfigHomeDir: () => MOCK_CLAUDE_HOME,
}))
mock.module('../../utils/path.js', () => ({
// Match the real sanitizePath behaviour for our test inputs: replace
// path separators and colons with '-'.
sanitizePath: (p: string) => p.replace(/[\\/:]/g, '-').replace(/^-+/, ''),
}))
// projectActivityService is unrelated to clear-sessions; stub it so the import
// chain doesn't pull in real fs work.
mock.module('../services/projectActivityService.js', () => ({
getRecentActivity: async () => ({ items: [] }),
}))
type FakeEntry = { name: string; isFile: () => boolean; isDirectory: () => boolean }
const dirs = new Map<string, FakeEntry[]>()
const unlinked: string[] = []
const rmdired: string[] = []
let nextRmdirError: NodeJS.ErrnoException | null = null
mock.module('fs/promises', () => ({
readdir: async (dirPath: string, _opts?: unknown) => {
const entries = dirs.get(path.resolve(dirPath))
if (!entries) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
return entries
},
unlink: async (filePath: string) => {
unlinked.push(filePath)
// Remove the entry from its directory so a follow-up rmdir sees it as
// empty when only .jsonl files were present.
const dir = path.dirname(filePath)
const base = path.basename(filePath)
const list = dirs.get(path.resolve(dir))
if (list) dirs.set(path.resolve(dir), list.filter(e => e.name !== base))
},
rmdir: async (dirPath: string) => {
if (nextRmdirError) {
const err = nextRmdirError
nextRmdirError = null
throw err
}
rmdired.push(path.resolve(dirPath))
dirs.delete(path.resolve(dirPath))
},
}))
import { handleProjectsApi } from '../api/projects'
function projectDir(id: string) {
return path.join(MOCK_PROJECTS_DIR, id)
}
function file(name: string): FakeEntry {
return { name, isFile: () => true, isDirectory: () => false }
}
function dir(name: string): FakeEntry {
return { name, isFile: () => false, isDirectory: () => true }
}
beforeEach(() => {
dirs.clear()
unlinked.length = 0
rmdired.length = 0
nextRmdirError = null
})
describe('POST /api/projects/sessions/clear', () => {
async function call(body: unknown): Promise<Response> {
const url = new URL('http://localhost/api/projects/sessions/clear')
const req = new Request(url, { method: 'POST', body: JSON.stringify(body) })
return handleProjectsApi(req, url, ['api', 'projects', 'sessions', 'clear'])
}
it('rejects invalid projectId (path separators)', async () => {
const res = await call({ projectId: '../escaped' })
expect(res.status).toBe(400)
})
it('rejects missing projectId', async () => {
const res = await call({})
expect(res.status).toBe(400)
})
it('rejects projectId with null byte', async () => {
const res = await call({ projectId: 'foo\0bar' })
expect(res.status).toBe(400)
})
it('rejects . and ..', async () => {
expect((await call({ projectId: '.' })).status).toBe(400)
expect((await call({ projectId: '..' })).status).toBe(400)
})
it('returns ok with 0 deletes when project dir does not exist', async () => {
const res = await call({ projectId: 'C--Users-70641-vanished' })
expect(res.status).toBe(200)
const data = await res.json() as { ok: boolean; deletedSessions: number; projectDirRemoved: boolean }
expect(data.ok).toBe(true)
expect(data.deletedSessions).toBe(0)
expect(data.projectDirRemoved).toBe(false)
})
it('deletes all .jsonl files and removes the now-empty project dir', async () => {
const id = 'C--Users-70641-myproj'
dirs.set(path.resolve(projectDir(id)), [
file('a.jsonl'),
file('b.jsonl'),
file('c.jsonl'),
])
const res = await call({ projectId: id })
expect(res.status).toBe(200)
const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean }
expect(data.deletedSessions).toBe(3)
expect(data.projectDirRemoved).toBe(true)
expect(unlinked).toHaveLength(3)
expect(rmdired).toContain(path.resolve(projectDir(id)))
})
it('keeps non-.jsonl files and does not remove the directory', async () => {
const id = 'C--Users-70641-keep-state'
dirs.set(path.resolve(projectDir(id)), [
file('session.jsonl'),
file('user-notes.txt'),
dir('memory'),
])
const res = await call({ projectId: id })
const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean }
expect(data.deletedSessions).toBe(1)
expect(data.projectDirRemoved).toBe(false)
expect(unlinked).toHaveLength(1)
expect(rmdired).toHaveLength(0)
})
it('GET on /sessions/clear returns 405', async () => {
const url = new URL('http://localhost/api/projects/sessions/clear')
const req = new Request(url, { method: 'GET' })
const res = await handleProjectsApi(req, url, ['api', 'projects', 'sessions', 'clear'])
expect(res.status).toBe(405)
})
it('accepts an absolute workDir and sanitizes it server-side', async () => {
// Stub sanitizes "/Users/me/proj" -> "Users-me-proj".
const sanitizedId = 'Users-me-proj'
dirs.set(path.resolve(projectDir(sanitizedId)), [file('a.jsonl')])
const res = await call({ workDir: '/Users/me/proj' })
expect(res.status).toBe(200)
const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean }
expect(data.deletedSessions).toBe(1)
expect(data.projectDirRemoved).toBe(true)
})
it('rejects a relative workDir', async () => {
const res = await call({ workDir: 'relative/path' })
expect(res.status).toBe(400)
})
it('also deletes per-session summary sidecars (.summary.json) and removes the empty dir', async () => {
const id = 'C--Users-70641-with-summaries'
dirs.set(path.resolve(projectDir(id)), [
file('a.jsonl'),
file('a.summary.json'),
file('b.jsonl'),
file('b.summary.json'),
])
const res = await call({ projectId: id })
expect(res.status).toBe(200)
const data = await res.json() as { deletedSessions: number; projectDirRemoved: boolean }
expect(data.deletedSessions).toBe(2) // counts .jsonl files
expect(data.projectDirRemoved).toBe(true) // both .jsonl + .summary.json gone
expect(unlinked).toHaveLength(4)
})
})

View File

@ -1,16 +1,135 @@
/**
* /api/projects/* read-only endpoints derived from the on-disk projects
* directory (`~/.claude/projects/<sanitized-workDir>/...`) plus git state.
* /api/projects/* endpoints derived from the on-disk projects directory
* (`~/.claude/projects/<sanitized-workDir>/...`) plus git state.
*
* Currently exposes:
* GET /api/projects/recent-activity?workDir=<absolute-path>
* GET /api/projects/recent-activity?workDir=<absolute-path>
* POST /api/projects/sessions/clear body: { projectId }
*
* which returns a "what was the user just doing in this project" snapshot
* for the desktop welcome screen. Pure derivation never invokes a model.
* The first returns a "what was the user just doing in this project" snapshot
* for the desktop welcome screen. The second permanently deletes every .jsonl
* session file under the named project id (and removes the now-empty project
* directory) so the project disappears from the sidebar/listings.
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { getRecentActivity } from '../services/projectActivityService.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { sanitizePath } from '../../utils/path.js'
function getProjectsDir(): string {
return path.join(getClaudeConfigHomeDir(), 'projects')
}
function isValidProjectId(projectId: string): boolean {
return (
typeof projectId === 'string' &&
projectId.length > 0 &&
!projectId.includes('\0') &&
!projectId.includes('/') &&
!projectId.includes('\\') &&
projectId !== '.' &&
projectId !== '..'
)
}
async function clearProjectSessions(req: Request): Promise<Response> {
let body: unknown
try {
body = await req.json()
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
const { projectId, workDir } = body as { projectId?: unknown; workDir?: unknown }
// Accept either projectId (already sanitized) or workDir (an absolute path
// that we sanitize ourselves). The desktop client passes workDir; backend
// tooling can pass projectId directly.
let resolvedId: string
if (typeof projectId === 'string' && projectId.length > 0) {
if (!isValidProjectId(projectId)) {
throw ApiError.badRequest('Invalid projectId')
}
resolvedId = projectId
} else if (typeof workDir === 'string' && workDir.length > 0 && path.isAbsolute(workDir)) {
resolvedId = sanitizePath(workDir)
if (!isValidProjectId(resolvedId)) {
throw ApiError.badRequest('workDir sanitized to an invalid id')
}
} else {
throw ApiError.badRequest('Provide projectId or absolute workDir')
}
const projectsDir = path.resolve(getProjectsDir())
const projectDir = path.join(projectsDir, resolvedId)
// Defence in depth: even though isValidProjectId rejects path separators,
// verify the resolved directory still sits under the projects dir.
const resolvedProjectDir = path.resolve(projectDir)
if (
resolvedProjectDir !== path.join(projectsDir, resolvedId) ||
!resolvedProjectDir.startsWith(projectsDir + path.sep)
) {
throw ApiError.badRequest('projectId resolves outside projects directory')
}
let entries: import('node:fs').Dirent[]
try {
entries = await fs.readdir(resolvedProjectDir, { withFileTypes: true })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return Response.json({ ok: true, deletedSessions: 0, projectDirRemoved: false })
}
throw error
}
let deletedSessions = 0
let nonSessionEntries = 0
for (const entry of entries) {
if (!entry.isFile()) {
// Sub-directories (e.g. workspace snapshots tied to a session) are
// intentionally preserved — they may hold user state we shouldn't drop.
nonSessionEntries += 1
continue
}
// Per-session artefacts: the JSONL transcript, plus its sidecar summary.
if (entry.name.endsWith('.jsonl')) {
try {
await fs.unlink(path.join(resolvedProjectDir, entry.name))
deletedSessions += 1
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
continue
}
if (entry.name.endsWith('.summary.json')) {
try {
await fs.unlink(path.join(resolvedProjectDir, entry.name))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
continue
}
nonSessionEntries += 1
}
// If the directory is now empty (no leftover memory/, etc.), remove it so
// the project drops off the sidebar entirely.
let projectDirRemoved = false
if (nonSessionEntries === 0) {
try {
await fs.rmdir(resolvedProjectDir)
projectDirRemoved = true
} catch {
// Race with another writer or non-empty after all — leave directory.
}
}
return Response.json({ ok: true, deletedSessions, projectDirRemoved })
}
export async function handleProjectsApi(
req: Request,
@ -38,6 +157,17 @@ export async function handleProjectsApi(
return Response.json(result)
}
if (sub === 'sessions' && segments[3] === 'clear') {
if (req.method !== 'POST') {
throw new ApiError(
405,
`Method ${req.method} not allowed on /api/projects/sessions/clear`,
'METHOD_NOT_ALLOWED',
)
}
return await clearProjectSessions(req)
}
throw ApiError.notFound(`Unknown projects endpoint: ${sub ?? '(root)'}`)
} catch (error) {
return errorResponse(error)