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({ status: 'loading' }) 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(() => { 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() 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 ( <>

{t('trace.list.title')}

{state.status === 'ready' && ( {state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')} )} {state.status === 'ready' && ( {state.data.storageDir} )}
0 ? 'danger' : 'default'} />
{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) }} onDelete={setDeleteTarget} /> )}
{ 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 (
) } return (
{traces.map((trace) => ( ))}
{t('trace.list.loadedCount', { shown: traces.length, total })} {traces.length < total && ( )}
) } 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) => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() open() } return (
{ event.stopPropagation() open() }} > { event.stopPropagation() onOpenWindow(trace.sessionId) }} > { event.stopPropagation() onDelete(trace) }} >
) } function RowAction({ label, onClick, children, tone = 'default', disabled = false, }: { label: string onClick: (event: MouseEvent) => void children: ReactNode tone?: 'default' | 'danger' disabled?: boolean }) { return ( ) } function MetricCell({ label, value }: { label: string; value: string }) { return (
{value}
{label}
) } function MetaChip({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) { return (
{label} {value}
) } function TraceListSkeleton({ label }: { label: string }) { return (