cc-haha/desktop/src/pages/TraceList.tsx
程序员阿江(Relakkes) 46f612abd5 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
2026-06-23 16:20:11 +08:00

513 lines
20 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from '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'
type TraceListState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; data: TraceSessionList }
const POLL_MS = 5_000
const PAGE_SIZE = 50
const SEARCH_DEBOUNCE_MS = 250
const MAX_MODEL_CHIPS = 2
export function TraceList() {
const t = useTranslation()
const [state, setState] = useState<TraceListState>({ status: 'loading' })
const [queryInput, setQueryInput] = useState('')
const [query, setQuery] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<TraceSessionListItem | null>(null)
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null)
const host = getDesktopHost()
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(queryInput.trim())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [queryInput])
const load = useCallback(async (options?: {
append?: boolean
limit?: number
offset?: number
silent?: boolean
}) => {
const append = options?.append === true
const offset = options?.offset ?? 0
const limit = options?.limit ?? PAGE_SIZE
try {
if (append) {
setIsLoadingMore(true)
} else if (!options?.silent) {
setState({ status: 'loading' })
}
const data = await tracesApi.list({ limit, offset, query })
setState((previous) => {
if (!append || previous.status !== 'ready') {
return { status: 'ready', data }
}
return {
status: 'ready',
data: {
...data,
traces: [...previous.data.traces, ...data.traces],
},
}
})
} catch (error) {
setState({
status: 'error',
message: error instanceof Error ? error.message : t('trace.list.loadFailed'),
})
} finally {
if (append) setIsLoadingMore(false)
}
}, [query, t])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (state.status !== 'ready' || !state.data.settings.enabled) return
const timer = window.setInterval(() => {
void load({
limit: Math.max(PAGE_SIZE, state.data.traces.length),
silent: true,
})
}, POLL_MS)
return () => window.clearInterval(timer)
}, [load, state])
const summary = useMemo(() => {
if (state.status !== 'ready') return { apiCalls: 0, failedCalls: 0, models: 0 }
const modelNames = new Set<string>()
let apiCalls = 0
let failedCalls = 0
for (const item of state.data.traces) {
apiCalls += item.summary.apiCalls
failedCalls += item.summary.failedCalls
for (const model of item.summary.models) modelNames.add(model.model)
}
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 (
<>
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<header className="shrink-0 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
<span>{t('trace.list.eyebrow')}</span>
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
{state.status === 'ready' && (
<span className={`rounded-[var(--radius-sm)] border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
state.data.settings.enabled
? 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
}`}>
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
</span>
)}
{state.status === 'ready' && (
<span className="min-w-0 max-w-full truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={state.data.storageDir}>
{state.data.storageDir}
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => openTraceSettings(t)}>
{t('trace.list.settings')}
</Button>
<Button size="sm" variant="secondary" onClick={() => void load()}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{t('trace.refresh')}
</Button>
</div>
</div>
<div className="mt-3 flex flex-wrap items-baseline gap-x-5 gap-y-1.5">
<MetaChip label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<MetaChip label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<MetaChip label={t('trace.models')} value={String(summary.models)} />
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-[var(--color-border)] px-5 py-3">
<div className="flex h-9 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
<Search className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<input
value={queryInput}
onChange={(event) => setQueryInput(event.currentTarget.value)}
placeholder={t('trace.list.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent px-2 text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
</div>
{state.status === 'loading' && <TraceListSkeleton label={t('common.loading')} />}
{state.status === 'error' && (
<div className="m-5 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 p-4 text-sm text-[var(--color-error)]">
{state.message}
</div>
)}
{state.status === 'ready' && (
<TraceRows
traces={state.data.traces}
total={state.data.total}
loadingMore={isLoadingMore}
deletingSessionId={deletingSessionId}
onLoadMore={() => void load({
append: true,
offset: state.data.traces.length,
silent: true,
})}
onOpenWindow={(sessionId) => {
if (host.trace) void host.trace.openWindow(sessionId)
}}
onDelete={setDeleteTarget}
/>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => {
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}
/>
</>
)
}
function TraceRows({
loadingMore,
onLoadMore,
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()
if (traces.length === 0) {
return (
<div className="flex flex-1 items-start justify-center px-6 py-10">
<div className="w-full max-w-md rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-12 text-center">
<Workflow className="mx-auto h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<h2 className="mt-3 text-sm font-semibold text-[var(--color-text-primary)]">{t('trace.list.emptyTitle')}</h2>
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{t('trace.list.emptyBody')}</p>
</div>
</div>
)
}
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y divide-[var(--color-border)]" role="list">
{traces.map((trace) => (
<TraceRow
key={trace.sessionId}
trace={trace}
onOpenWindow={onOpenWindow}
onDelete={onDelete}
isDeleting={deletingSessionId === trace.sessionId}
/>
))}
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-5 py-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('trace.list.loadedCount', { shown: traces.length, total })}</span>
{traces.length < total && (
<Button size="sm" variant="secondary" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore ? t('common.loading') : t('trace.list.loadMore')}
</Button>
)}
</div>
</div>
)
}
function TraceRow({
trace,
onOpenWindow,
onDelete,
isDeleting,
}: {
trace: TraceSessionListItem
onOpenWindow: (sessionId: string) => void
onDelete: (trace: TraceSessionListItem) => void
isDeleting: boolean
}) {
const t = useTranslation()
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)
const hiddenModels = trace.summary.models.length - visibleModels.length
const totalTokens = trace.summary.totalInputTokens + trace.summary.totalOutputTokens
const open = () => openTrace(trace.sessionId, title, t)
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
open()
}
return (
<div
role="listitem"
aria-label={title}
className="trace-list-row-cv group flex h-14 cursor-pointer items-center gap-4 px-5 transition-colors hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"
onClick={open}
onKeyDown={onKeyDown}
className="flex min-w-0 flex-1 items-center gap-4 self-stretch bg-transparent p-0 text-left"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
{visibleModels.map((model) => (
<span
key={model.model}
title={`${model.model} x${model.calls}`}
className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-brand)]/10 px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-brand)]"
>
{shortModelName(model.model)}
</span>
))}
{hiddenModels > 0 && (
<span className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-text-tertiary)]">
+{hiddenModels}
</span>
)}
{failedCalls > 0 && (
<span title={t('trace.failedCalls')} className="flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-error)]" aria-hidden="true" />
<span className="font-mono text-[10px] text-[var(--color-error)]">{failedCalls}</span>
</span>
)}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-mono">{trace.sessionId.slice(0, 8)}</span>
{trace.session?.projectPath && (
<>
<span aria-hidden="true">·</span>
<span className="truncate" title={trace.session.projectPath}>{trace.session.projectPath}</span>
</>
)}
<span aria-hidden="true">·</span>
<span className="shrink-0 font-mono">{formatUpdatedAt(updatedAt)}</span>
</div>
</div>
<div className="grid shrink-0 grid-cols-[3.5rem_4rem_4rem] items-center gap-3">
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
<MetricCell label={t('trace.modelTime')} value={formatDuration(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
</button>
<div className="flex w-[92px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<RowAction
label={t('trace.open')}
onClick={(event) => {
event.stopPropagation()
open()
}}
>
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
<RowAction
label={t('trace.openWindow')}
onClick={(event) => {
event.stopPropagation()
onOpenWindow(trace.sessionId)
}}
>
<ExternalLink className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
<RowAction
label={t('trace.delete')}
tone="danger"
disabled={isDeleting}
onClick={(event) => {
event.stopPropagation()
onDelete(trace)
}}
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
</div>
</div>
)
}
function RowAction({
label,
onClick,
children,
tone = 'default',
disabled = false,
}: {
label: string
onClick: (event: MouseEvent<HTMLButtonElement>) => void
children: ReactNode
tone?: 'default' | 'danger'
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={`flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-high)] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 ${
tone === 'danger'
? 'hover:text-[var(--color-error)]'
: 'hover:text-[var(--color-text-primary)]'
}`}
>
{children}
</button>
)
}
function MetricCell({ label, value }: { label: string; value: string }) {
return (
<div className="text-right">
<div className="font-mono text-[11px] leading-4 text-[var(--color-text-primary)]">{value}</div>
<div className="truncate text-[10px] uppercase leading-4 tracking-wide text-[var(--color-text-tertiary)]" title={label}>{label}</div>
</div>
)
}
function MetaChip({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
return (
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">{label}</span>
<span className={`font-mono text-[13px] ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>{value}</span>
</div>
)
}
function TraceListSkeleton({ label }: { label: string }) {
return (
<div className="min-h-0 flex-1 overflow-hidden" role="status" aria-label={label}>
<div className="divide-y divide-[var(--color-border)]" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex h-14 items-center gap-4 px-5">
<div className="min-w-0 flex-1">
<div className="h-3 w-48 max-w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-2 h-2.5 w-72 max-w-full animate-pulse rounded bg-[var(--color-surface-container-low)]" />
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="h-3 w-10 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
</div>
))}
</div>
</div>
)
}
function openTrace(sessionId: string, title: string, t: ReturnType<typeof useTranslation>) {
useTabStore.getState().openTraceTab(sessionId, `${t('trace.title')}: ${title}`)
}
function getTraceTitle(trace: TraceSessionListItem, t: ReturnType<typeof useTranslation>): string {
return trace.session?.title || t('session.untitled')
}
function openTraceSettings(t: ReturnType<typeof useTranslation>) {
useUIStore.getState().setPendingSettingsTab('general')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** `claude-sonnet-4-5-20250929` -> `sonnet-4-5`; non-Claude ids pass through. */
function shortModelName(model: string): string {
const short = model.replace(/^claude-/i, '').replace(/-\d{8}$/, '')
return short || model
}
/** Compact count: 847 -> "847", 1234 -> "1.2k", 2345678 -> "2.3m". */
function formatCompact(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0'
if (value < 1000) return String(value)
const scaled = value < 1_000_000 ? value / 1000 : value / 1_000_000
const unit = value < 1_000_000 ? 'k' : 'm'
const text = scaled >= 100 ? String(Math.round(scaled)) : scaled.toFixed(1).replace(/\.0$/, '')
return `${text}${unit}`
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '-'
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function formatUpdatedAt(value: string | null): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}