Phase 1: Trace System + Observability - tool_traces DB table + insert/update service - tool_trace_start/tool_trace_finish WS frames (contracts + FE types) - Instrumented tool-phase.ts with timing around every tool call - GET /api/chats/:id/traces paginated endpoint - Trace viewer frontend (collapsible panel with timing bars + token breakdown) Phase 2: Session Persistence + Resume - agent_snapshots table (UPSERT per chat, persisted on turn boundaries) - save/load/delete service functions - Agent snapshot sent on WS reconnect - Session timeline view (vertical timeline with scroll-to + restore) Tooling: - run_command tool (execFile, 30s timeout, 32KB cap, path-guarded) - Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
252 lines
8.8 KiB
TypeScript
252 lines
8.8 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||
import { api } from '@/api/client';
|
||
import type { ToolTrace } from '@/api/types';
|
||
|
||
interface Props {
|
||
chatId: string;
|
||
}
|
||
|
||
// Max latency used as the 100% reference for the bar visualization
|
||
const MAX_LATENCY_REF = 30_000; // 30s
|
||
|
||
function latencyBarWidth(latencyMs: number | null): number {
|
||
if (latencyMs == null) return 0;
|
||
return Math.min(latencyMs / MAX_LATENCY_REF, 1);
|
||
}
|
||
|
||
function TraceRow({ trace }: { trace: ToolTrace }) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const isError = trace.outcome !== null && trace.outcome !== 'success';
|
||
const barWidth = latencyBarWidth(trace.latency_ms);
|
||
const latencyLabel =
|
||
trace.latency_ms != null
|
||
? trace.latency_ms >= 1000
|
||
? `${(trace.latency_ms / 1000).toFixed(1)}s`
|
||
: `${trace.latency_ms}ms`
|
||
: null;
|
||
|
||
return (
|
||
<div className="border-b border-border/40 last:border-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
className="flex items-center gap-2 w-full text-left px-2 py-1.5 hover:bg-muted/40 text-[11px]"
|
||
>
|
||
<span className="shrink-0 text-muted-foreground">
|
||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||
</span>
|
||
<span className="font-medium truncate min-w-0">
|
||
{trace.tool_name}
|
||
</span>
|
||
{isError && (
|
||
<span className="shrink-0 text-destructive" title={trace.error ?? 'error'}>
|
||
<AlertCircle size={10} />
|
||
</span>
|
||
)}
|
||
<span className="shrink-0 text-muted-foreground font-mono tabular-nums min-w-[3rem] text-right">
|
||
{latencyLabel ?? '—'}
|
||
</span>
|
||
<span className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[24px] max-w-[60px]">
|
||
<span
|
||
className="block h-full rounded-full bg-primary/30 transition-all"
|
||
style={{ width: `${barWidth * 100}%` }}
|
||
/>
|
||
</span>
|
||
{trace.tokens_used != null && trace.tokens_used > 0 && (
|
||
<span className="shrink-0 text-muted-foreground font-mono tabular-nums">
|
||
{trace.tokens_used}t
|
||
</span>
|
||
)}
|
||
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
|
||
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||
c{trace.cache_tokens}
|
||
</span>
|
||
)}
|
||
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
|
||
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||
r{trace.reasoning_tokens}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{expanded && (
|
||
<div className="px-3 pb-2 space-y-1.5 text-[11px] border-t border-border/40 pt-1.5">
|
||
<div>
|
||
<span className="text-muted-foreground font-medium">Input</span>
|
||
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
|
||
{JSON.stringify(trace.tool_input, null, 1)}
|
||
</pre>
|
||
</div>
|
||
{trace.tool_output != null && (
|
||
<div>
|
||
<span className="text-muted-foreground font-medium">Output</span>
|
||
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
|
||
{trace.tool_output.length > 2000
|
||
? `${trace.tool_output.slice(0, 2000)}…`
|
||
: trace.tool_output}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{trace.error != null && (
|
||
<div className="text-destructive text-[10px] font-mono leading-relaxed bg-destructive/10 rounded p-1.5">
|
||
{trace.error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TraceGroup({ toolName, traces }: { toolName: string; traces: ToolTrace[] }) {
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const totalLatency = traces.reduce((sum, t) => sum + (t.latency_ms ?? 0), 0);
|
||
const totalTokens = traces.reduce((sum, t) => sum + (t.tokens_used ?? 0), 0);
|
||
const errorCount = traces.filter(
|
||
(t) => t.outcome !== null && t.outcome !== 'success',
|
||
).length;
|
||
|
||
return (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCollapsed((v) => !v)}
|
||
className="flex items-center gap-1.5 w-full text-left px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/30 sticky top-0 bg-background"
|
||
>
|
||
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||
<span>{toolName}</span>
|
||
<span className="text-muted-foreground/60 font-mono tabular-nums">
|
||
×{traces.length}
|
||
</span>
|
||
{totalTokens > 0 && (
|
||
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||
{totalTokens}t
|
||
</span>
|
||
)}
|
||
{totalLatency > 0 && (
|
||
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||
{totalLatency >= 1000
|
||
? `${(totalLatency / 1000).toFixed(1)}s`
|
||
: `${totalLatency}ms`}
|
||
</span>
|
||
)}
|
||
{errorCount > 0 && (
|
||
<span className="ml-auto text-destructive text-[10px] font-medium">
|
||
{errorCount} error{errorCount > 1 ? 's' : ''}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{!collapsed && traces.map((trace) => (
|
||
<TraceRow key={trace.id} trace={trace} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TraceViewer({ chatId }: Props) {
|
||
const [open, setOpen] = useState(false);
|
||
const [traces, setTraces] = useState<ToolTrace[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const fetchTraces = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await api.chats.getTraces(chatId);
|
||
setTraces(res.data);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'failed to load traces');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [chatId]);
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
void fetchTraces();
|
||
}
|
||
}, [open, fetchTraces]);
|
||
|
||
const groups = useMemo(() => {
|
||
const map = new Map<string, ToolTrace[]>();
|
||
for (const t of traces) {
|
||
const existing = map.get(t.tool_name);
|
||
if (existing) {
|
||
existing.push(t);
|
||
} else {
|
||
map.set(t.tool_name, [t]);
|
||
}
|
||
}
|
||
return map;
|
||
}, [traces]);
|
||
|
||
const totalCount = traces.length;
|
||
const errorCount = traces.filter(
|
||
(t) => t.outcome !== null && t.outcome !== 'success',
|
||
).length;
|
||
|
||
return (
|
||
<div className="border-t">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/20"
|
||
>
|
||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||
<span>Tool traces</span>
|
||
{totalCount > 0 && (
|
||
<span className="font-mono tabular-nums text-muted-foreground/60">
|
||
{totalCount}
|
||
</span>
|
||
)}
|
||
{errorCount > 0 && (
|
||
<span className="text-destructive ml-auto text-[10px] font-medium">
|
||
{errorCount} error{errorCount > 1 ? 's' : ''}
|
||
</span>
|
||
)}
|
||
{loading && (
|
||
<span className="ml-auto inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||
)}
|
||
</button>
|
||
{open && (
|
||
<div className="max-h-80 overflow-y-auto border-t border-border/40">
|
||
{loading && traces.length === 0 && (
|
||
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
|
||
Loading traces…
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<div className="px-3 py-2 text-[11px] text-destructive">
|
||
{error}
|
||
<button
|
||
type="button"
|
||
onClick={() => void fetchTraces()}
|
||
className="ml-2 underline hover:no-underline"
|
||
>
|
||
retry
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!loading && !error && traces.length === 0 && (
|
||
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
|
||
No tool traces yet.
|
||
</div>
|
||
)}
|
||
{traces.length > 0 && (
|
||
<div className="divide-y divide-border/40">
|
||
{Array.from(groups.entries()).map(([toolName, groupTraces]) => (
|
||
<TraceGroup
|
||
key={toolName}
|
||
toolName={toolName}
|
||
traces={groupTraces}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|