From 46f612abd50d1967691eb85f1269ad5a415198b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 23 Jun 2026 16:20:11 +0800 Subject: [PATCH] feat: allow deleting trace sessions Adds an idempotent DELETE /api/traces/:sessionId path that removes only the local trace JSONL file and invalidates trace read cache. The Trace list now exposes a trash action behind a confirmation dialog that states chat history is not deleted. Tested: bun test src/server/__tests__/trace-capture.test.ts Tested: cd desktop && bun run test -- src/pages/TraceList.test.tsx --run Tested: bun run check:server Tested: bun run check:desktop Tested: Browser smoke with temporary CLAUDE_CONFIG_DIR verified the Trace list delete button and confirmation dialog on ?traceWindow=1. Constraint: GitHub issue #868 remains open until a release ships and post-release retest passes. Confidence: high Scope-risk: moderate --- desktop/src/api/traces.ts | 6 +- desktop/src/i18n/locales/en.ts | 4 + desktop/src/i18n/locales/jp.ts | 4 + desktop/src/i18n/locales/kr.ts | 4 + desktop/src/i18n/locales/zh-TW.ts | 4 + desktop/src/i18n/locales/zh.ts | 4 + desktop/src/pages/TraceList.test.tsx | 27 +++ desktop/src/pages/TraceList.tsx | 233 ++++++++++++++------- desktop/src/types/trace.ts | 5 + src/server/__tests__/trace-capture.test.ts | 42 ++++ src/server/api/traces.ts | 18 ++ src/server/services/traceCaptureService.ts | 1 + src/services/api/traceCapture.ts | 26 +++ 13 files changed, 300 insertions(+), 78 deletions(-) diff --git a/desktop/src/api/traces.ts b/desktop/src/api/traces.ts index 410df483..4c98ae8b 100644 --- a/desktop/src/api/traces.ts +++ b/desktop/src/api/traces.ts @@ -1,5 +1,5 @@ import { api } from './client' -import type { TraceCaptureSettings, TraceSessionList } from '../types/trace' +import type { TraceCaptureSettings, TraceSessionDeleteResult, TraceSessionList } from '../types/trace' export const tracesApi = { list(options?: { limit?: number; offset?: number; query?: string }) { @@ -18,4 +18,8 @@ export const tracesApi = { updateSettings(settings: Partial>) { return api.put('/api/traces/settings', settings) }, + + deleteSession(sessionId: string) { + return api.delete(`/api/traces/${encodeURIComponent(sessionId)}`) + }, } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 49966061..f4c8644b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1749,6 +1749,7 @@ export const en = { 'trace.copySessionId': 'Copy session ID', 'trace.refresh': 'Refresh trace', 'trace.openWindow': 'Open in separate window', + 'trace.delete': 'Delete trace', 'trace.noResponse': 'No response body captured', 'trace.emptyTitle': 'No trace calls yet', 'trace.emptyBody': 'This session has no captured model calls. New desktop sessions record compact request and response previews automatically.', @@ -1844,6 +1845,9 @@ export const en = { 'trace.list.loadFailed': 'Failed to load trace list', 'trace.list.loadedCount': 'Showing {shown} of {total}', 'trace.list.loadMore': 'Load more', + 'trace.list.deleteConfirmTitle': 'Delete trace session', + 'trace.list.deleteConfirmBody': 'Delete trace data for "{title}"? Chat history is not deleted.', + 'trace.list.deleteFailed': 'Failed to delete trace session', // ─── App Shell ────────────────────────────────────── 'app.serverFailed': 'Local server failed to start', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 777e0d48..87ba9495 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1751,6 +1751,7 @@ export const jp: Record = { 'trace.copySessionId': 'Session ID をコピー', 'trace.refresh': 'Trace を更新', 'trace.openWindow': '別ウィンドウで開く', + 'trace.delete': 'Trace を削除', 'trace.noResponse': 'レスポンス本文はキャプチャされていません', 'trace.emptyTitle': 'Trace 呼び出しはまだありません', 'trace.emptyBody': 'このセッションにはキャプチャ済みのモデル呼び出しがありません。新しいデスクトップセッションでは、軽量なリクエストとレスポンスのプレビューを自動記録します。', @@ -1846,6 +1847,9 @@ export const jp: Record = { 'trace.list.loadFailed': 'Trace 一覧の読み込みに失敗しました', 'trace.list.loadedCount': '{shown} / {total} を表示中', 'trace.list.loadMore': 'さらに読み込む', + 'trace.list.deleteConfirmTitle': 'Trace セッションを削除', + 'trace.list.deleteConfirmBody': '「{title}」の Trace データを削除しますか?チャット履歴は削除されません。', + 'trace.list.deleteFailed': 'Trace セッションの削除に失敗しました', // ─── App Shell ────────────────────────────────────── 'app.serverFailed': 'ローカルサーバーの起動に失敗しました', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 2a9829d2..365dcad6 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1751,6 +1751,7 @@ export const kr: Record = { 'trace.copySessionId': 'Session ID 복사', 'trace.refresh': 'Trace 새로고침', 'trace.openWindow': '별도 창에서 열기', + 'trace.delete': 'Trace 삭제', 'trace.noResponse': '응답 본문이 캡처되지 않았습니다', 'trace.emptyTitle': '아직 Trace 호출이 없습니다', 'trace.emptyBody': '이 세션에는 캡처된 모델 호출이 없습니다. 새 데스크톱 세션은 간단한 요청 및 응답 미리보기를 자동으로 기록합니다.', @@ -1846,6 +1847,9 @@ export const kr: Record = { 'trace.list.loadFailed': 'Trace 목록을 불러오지 못했습니다', 'trace.list.loadedCount': '{shown} / {total} 표시 중', 'trace.list.loadMore': '더 불러오기', + 'trace.list.deleteConfirmTitle': 'Trace 세션 삭제', + 'trace.list.deleteConfirmBody': '"{title}"의 Trace 데이터를 삭제하시겠습니까? 채팅 기록은 삭제되지 않습니다.', + 'trace.list.deleteFailed': 'Trace 세션을 삭제하지 못했습니다', // ─── App Shell ────────────────────────────────────── 'app.serverFailed': '로컬 서버를 시작하지 못했습니다', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 41962d67..5cecd5ed 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1751,6 +1751,7 @@ export const zh: Record = { 'trace.copySessionId': '複製 Session ID', 'trace.refresh': '重新整理 Trace', 'trace.openWindow': '在獨立視窗開啟', + 'trace.delete': '刪除 Trace', 'trace.noResponse': '未捕獲回應正文', 'trace.emptyTitle': '暫無 Trace 呼叫', 'trace.emptyBody': '這個會話還沒有捕獲到模型呼叫。新的桌面會話會自動記錄精簡的請求和回應預覽。', @@ -1846,6 +1847,9 @@ export const zh: Record = { 'trace.list.loadFailed': 'Trace 列表載入失敗', 'trace.list.loadedCount': '已顯示 {shown} / {total}', 'trace.list.loadMore': '載入更多', + 'trace.list.deleteConfirmTitle': '刪除 Trace 會話', + 'trace.list.deleteConfirmBody': '刪除「{title}」的 Trace 資料?聊天記錄不會被刪除。', + 'trace.list.deleteFailed': 'Trace 會話刪除失敗', // ─── 應用外殼 ────────────────────────────────────── 'app.serverFailed': '本地服務啟動失敗', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 22bd97d3..a4ceb905 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1751,6 +1751,7 @@ export const zh: Record = { 'trace.copySessionId': '复制 Session ID', 'trace.refresh': '刷新 Trace', 'trace.openWindow': '在独立窗口打开', + 'trace.delete': '删除 Trace', 'trace.noResponse': '未捕获响应正文', 'trace.emptyTitle': '暂无 Trace 调用', 'trace.emptyBody': '这个会话还没有捕获到模型调用。新的桌面会话会自动记录精简的请求和响应预览。', @@ -1846,6 +1847,9 @@ export const zh: Record = { 'trace.list.loadFailed': 'Trace 列表加载失败', 'trace.list.loadedCount': '已显示 {shown} / {total}', 'trace.list.loadMore': '加载更多', + 'trace.list.deleteConfirmTitle': '删除 Trace 会话', + 'trace.list.deleteConfirmBody': '删除“{title}”的 Trace 数据?聊天记录不会被删除。', + 'trace.list.deleteFailed': 'Trace 会话删除失败', // ─── 应用外壳 ────────────────────────────────────── 'app.serverFailed': '本地服务启动失败', diff --git a/desktop/src/pages/TraceList.test.tsx b/desktop/src/pages/TraceList.test.tsx index 4abc4bc9..3961316c 100644 --- a/desktop/src/pages/TraceList.test.tsx +++ b/desktop/src/pages/TraceList.test.tsx @@ -10,6 +10,7 @@ import type { TraceSessionList } from '../types/trace' vi.mock('../api/traces', () => ({ tracesApi: { list: vi.fn(), + deleteSession: vi.fn(), }, })) @@ -90,6 +91,7 @@ describe('TraceList', () => { useSettingsStore.setState({ locale: 'en' }) useTabStore.setState({ tabs: [], activeTabId: null }) vi.mocked(tracesApi.list).mockResolvedValue(traceList) + vi.mocked(tracesApi.deleteSession).mockResolvedValue({ sessionId: 'session-trace-list', deleted: true }) }) afterEach(() => { @@ -159,6 +161,31 @@ describe('TraceList', () => { expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list') }) + it('requires confirmation before deleting a trace session', async () => { + vi.mocked(tracesApi.list) + .mockResolvedValueOnce(traceList) + .mockResolvedValueOnce({ ...traceList, traces: [], total: 0 }) + + render() + + const row = await findTraceRow(/Debug stuck agent/) + fireEvent.click(within(row).getByRole('button', { name: 'Delete trace' })) + + expect(tracesApi.deleteSession).not.toHaveBeenCalled() + expect(screen.getByText('Delete trace data for "Debug stuck agent"? Chat history is not deleted.')).toBeInTheDocument() + + fireEvent.click(within(screen.getByRole('dialog', { name: 'Delete trace session' })).getByRole('button', { name: 'Delete' })) + + await waitFor(() => { + expect(tracesApi.deleteSession).toHaveBeenCalledWith('session-trace-list') + }) + await waitFor(() => { + expect(screen.queryByText('Debug stuck agent')).not.toBeInTheDocument() + }) + expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' }) + expect(useTabStore.getState().activeTabId).toBeNull() + }) + it('opens General settings from the trace settings button', async () => { render() diff --git a/desktop/src/pages/TraceList.tsx b/desktop/src/pages/TraceList.tsx index a106050b..90a9eedd 100644 --- a/desktop/src/pages/TraceList.tsx +++ b/desktop/src/pages/TraceList.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' -import { ExternalLink, RefreshCw, Search, Workflow } from 'lucide-react' +import { ExternalLink, RefreshCw, Search, Trash2, Workflow } from 'lucide-react' import { tracesApi } from '../api/traces' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' import { useTranslation } from '../i18n' import { Button } from '../components/shared/Button' +import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { getDesktopHost } from '../lib/desktopHost' import type { TraceSessionList, TraceSessionListItem } from '../types/trace' @@ -26,6 +27,8 @@ export function TraceList() { const [queryInput, setQueryInput] = useState('') const [query, setQuery] = useState('') const [isLoadingMore, setIsLoadingMore] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deletingSessionId, setDeletingSessionId] = useState(null) const host = getDesktopHost() useEffect(() => { @@ -101,88 +104,126 @@ export function TraceList() { return { apiCalls, failedCalls, models: modelNames.size } }, [state]) + const confirmDelete = useCallback(async () => { + if (!deleteTarget) return + const currentLimit = state.status === 'ready' + ? Math.max(PAGE_SIZE, state.data.traces.length) + : PAGE_SIZE + setDeletingSessionId(deleteTarget.sessionId) + try { + await tracesApi.deleteSession(deleteTarget.sessionId) + setDeleteTarget(null) + await load({ limit: currentLimit, silent: true }) + } catch (error) { + setState({ + status: 'error', + message: error instanceof Error ? error.message : t('trace.list.deleteFailed'), + }) + } finally { + setDeletingSessionId(null) + } + }, [deleteTarget, load, state, t]) + return ( -
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+ )}
- - {state.status === 'loading' && } - {state.status === 'error' && ( -
- {state.message} -
- )} - {state.status === 'ready' && ( - void load({ - append: true, - offset: state.data.traces.length, - silent: true, - })} - onOpenWindow={(sessionId) => { - if (host.trace) void host.trace.openWindow(sessionId) - }} - /> - )}
-
+ { + if (!deletingSessionId) setDeleteTarget(null) + }} + onConfirm={() => void confirmDelete()} + title={t('trace.list.deleteConfirmTitle')} + body={deleteTarget + ? t('trace.list.deleteConfirmBody', { title: getTraceTitle(deleteTarget, t) }) + : ''} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + loading={deletingSessionId !== null} + /> + ) } @@ -192,12 +233,16 @@ function TraceRows({ traces, total, onOpenWindow, + onDelete, + deletingSessionId, }: { loadingMore: boolean onLoadMore: () => void traces: TraceSessionListItem[] total: number onOpenWindow: (sessionId: string) => void + onDelete: (trace: TraceSessionListItem) => void + deletingSessionId: string | null }) { const t = useTranslation() @@ -217,7 +262,13 @@ function TraceRows({
{traces.map((trace) => ( - + ))}
@@ -235,12 +286,16 @@ function TraceRows({ function TraceRow({ trace, onOpenWindow, + onDelete, + isDeleting, }: { trace: TraceSessionListItem onOpenWindow: (sessionId: string) => void + onDelete: (trace: TraceSessionListItem) => void + isDeleting: boolean }) { const t = useTranslation() - const title = trace.session?.title || t('session.untitled') + const title = getTraceTitle(trace, t) const updatedAt = trace.summary.updatedAt ?? trace.fileUpdatedAt const failedCalls = trace.summary.failedCalls const visibleModels = trace.summary.models.slice(0, MAX_MODEL_CHIPS) @@ -308,7 +363,7 @@ function TraceRow({
-
+
{ @@ -327,6 +382,17 @@ function TraceRow({ > + { + event.stopPropagation() + onDelete(trace) + }} + > +
) @@ -336,18 +402,27 @@ function RowAction({ label, onClick, children, + tone = 'default', + disabled = false, }: { label: string onClick: (event: MouseEvent) => void children: ReactNode + tone?: 'default' | 'danger' + disabled?: boolean }) { return ( @@ -398,6 +473,10 @@ function openTrace(sessionId: string, title: string, t: ReturnType): string { + return trace.session?.title || t('session.untitled') +} + function openTraceSettings(t: ReturnType) { useUIStore.getState().setPendingSettingsTab('general') useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings') diff --git a/desktop/src/types/trace.ts b/desktop/src/types/trace.ts index 8b0a4ac3..d55b7150 100644 --- a/desktop/src/types/trace.ts +++ b/desktop/src/types/trace.ts @@ -117,3 +117,8 @@ export type TraceSessionList = { storageDir: string settings: TraceCaptureSettings } + +export type TraceSessionDeleteResult = { + sessionId: string + deleted: boolean +} diff --git a/src/server/__tests__/trace-capture.test.ts b/src/server/__tests__/trace-capture.test.ts index 5fe9e134..9299f57c 100644 --- a/src/server/__tests__/trace-capture.test.ts +++ b/src/server/__tests__/trace-capture.test.ts @@ -1148,6 +1148,48 @@ describe('session trace API', () => { }) }) + test('deletes a trace session file and invalidates cached reads', async () => { + await traceCaptureService.recordCall({ + sessionId: 'session-delete-trace', + source: 'proxy', + model: 'gpt-5.5', + startedAt: '2026-06-09T08:00:00.000Z', + completedAt: '2026-06-09T08:00:00.015Z', + durationMs: 15, + request: { + method: 'POST', + url: 'https://api.example.test/v1/messages', + body: { model: 'gpt-5.5' }, + }, + response: { + status: 200, + body: { ok: true }, + }, + }) + + const cached = await traceCaptureService.getSessionTrace('session-delete-trace') + expect(cached.calls).toHaveLength(1) + + const req = new Request('http://localhost:3456/api/traces/session-delete-trace', { method: 'DELETE' }) + const res = await handleApiRequest(req, new URL(req.url)) + const body = await res.json() as { sessionId: string; deleted: boolean } + + expect(res.status).toBe(200) + expect(body).toEqual({ sessionId: 'session-delete-trace', deleted: true }) + await expect(fs.stat(path.join(tmpDir, 'cc-haha', 'traces', 'session-delete-trace.jsonl'))).rejects.toThrow() + + const afterDelete = await traceCaptureService.getSessionTrace('session-delete-trace') + expect(afterDelete.calls).toEqual([]) + expect(afterDelete.events).toEqual([]) + + const secondReq = new Request('http://localhost:3456/api/traces/session-delete-trace', { method: 'DELETE' }) + const secondRes = await handleApiRequest(secondReq, new URL(secondReq.url)) + const secondBody = await secondRes.json() as { sessionId: string; deleted: boolean } + + expect(secondRes.status).toBe(200) + expect(secondBody).toEqual({ sessionId: 'session-delete-trace', deleted: false }) + }) + test('searches trace sessions by session title and project path before paginating', async () => { await traceCaptureService.recordCall({ sessionId: 'session-title-alpha', diff --git a/src/server/api/traces.ts b/src/server/api/traces.ts index 7de59d9f..db9badd1 100644 --- a/src/server/api/traces.ts +++ b/src/server/api/traces.ts @@ -60,6 +60,9 @@ export async function handleTracesApi( throw methodNotAllowed(req.method, '/api/traces/settings') default: + if (req.method === 'DELETE' && segments.length === 3) { + return await deleteTraceSession(sub) + } throw ApiError.notFound(`Unknown traces endpoint: ${sub}`) } } catch (error) { @@ -67,6 +70,13 @@ export async function handleTracesApi( } } +async function deleteTraceSession(segment: string): Promise { + const sessionId = decodePathSegment(segment).trim() + if (!sessionId) throw ApiError.badRequest('Trace session id is required') + const result = await traceCaptureService.deleteSessionTrace(sessionId) + return Response.json(result) +} + async function listTraces(url: URL): Promise { const limit = parseTraceListLimit(url.searchParams.get('limit')) const offset = parseTraceListOffset(url.searchParams.get('offset')) @@ -169,6 +179,14 @@ function traceListItemMatchesQuery(item: Pick haystack.includes(term)) } +function decodePathSegment(value: string): string { + try { + return decodeURIComponent(value) + } catch { + throw ApiError.badRequest('Invalid trace session id') + } +} + function parseTraceListLimit(value: string | null): number { const parsed = Number.parseInt(value || '50', 10) if (!Number.isFinite(parsed) || parsed <= 0) return 50 diff --git a/src/server/services/traceCaptureService.ts b/src/server/services/traceCaptureService.ts index 41d18a91..86b8d186 100644 --- a/src/server/services/traceCaptureService.ts +++ b/src/server/services/traceCaptureService.ts @@ -28,6 +28,7 @@ export type { TraceProviderInfo, TraceResponseCapture, TraceSession, + TraceSessionDeleteResult, TraceSessionFileItem, TraceSessionFileList, TraceSessionList, diff --git a/src/services/api/traceCapture.ts b/src/services/api/traceCapture.ts index c8bcaf26..a19fdeef 100644 --- a/src/services/api/traceCapture.ts +++ b/src/services/api/traceCapture.ts @@ -135,6 +135,11 @@ export type TraceSessionList = { settings: TraceCaptureSettings } +export type TraceSessionDeleteResult = { + sessionId: string + deleted: boolean +} + export type RecordTraceCallInput = { id?: string sessionId: string @@ -373,6 +378,27 @@ class TraceCaptureService { return calls.find((call) => call.id === callId) ?? null } + async deleteSessionTrace(sessionId: string): Promise { + const normalizedSessionId = sanitizeTraceFileName(sessionId) + if (!normalizedSessionId) return { sessionId: normalizedSessionId, deleted: false } + + const pendingWrite = traceWriteQueues.get(sessionId) ?? traceWriteQueues.get(normalizedSessionId) + if (pendingWrite) await pendingWrite.catch(() => {}) + + const filePath = getTraceFilePath(normalizedSessionId) + try { + await fs.unlink(filePath) + traceReadCache.delete(filePath) + return { sessionId: normalizedSessionId, deleted: true } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + traceReadCache.delete(filePath) + return { sessionId: normalizedSessionId, deleted: false } + } + throw error + } + } + async listSessionTraces(options?: { limit?: number offset?: number