feat: add live H1-H7 trace explorer

This commit is contained in:
thanhnv
2026-07-10 23:17:23 +09:00
parent 7cb592a32f
commit 1345d4c930
11 changed files with 496 additions and 28 deletions
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api, type HarnessGateNode, type HarnessGateStatus, type HarnessTraceGraph } from '../../lib/api';
import { Card, StatusBadge } from '../ui/Card';
const NODE_TONE: Record<HarnessGateStatus, string> = {
queued: 'border-slate-200 bg-slate-50 text-slate-500',
running: 'border-blue-300 bg-blue-50 text-blue-800 shadow-[0_0_0_4px_rgba(59,130,246,0.1)]',
pass: 'border-emerald-300 bg-emerald-50 text-emerald-800',
warning: 'border-amber-300 bg-amber-50 text-amber-800',
blocked: 'border-rose-400 bg-rose-50 text-rose-800 shadow-[0_0_0_4px_rgba(244,63,94,0.09)]',
error: 'border-rose-400 bg-rose-50 text-rose-800 shadow-[0_0_0_4px_rgba(244,63,94,0.09)]',
skipped: 'border-slate-200 bg-slate-100 text-slate-400',
};
const STATUS_MARK: Record<HarnessGateStatus, string> = {
queued: '○',
running: '●',
pass: '✓',
warning: '!',
blocked: '×',
error: '×',
skipped: '–',
};
function Evidence({ node }: { node: HarnessGateNode }) {
const entries = Object.entries(node.evidence);
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.8fr)]">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold text-slate-900">{node.title}</h3>
<StatusBadge value={node.status} />
</div>
<p className="mt-1 text-sm text-slate-500">{node.description}</p>
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Latest decision</div>
<p className="mt-2 text-sm leading-6 text-slate-700">{node.reason}</p>
{node.updated_at && <p className="mt-2 text-xs text-slate-400">{node.updated_at.replace('T', ' ').replace('Z', '')}</p>}
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-950 p-4 text-slate-100">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Safe evidence</div>
{entries.length > 0 ? (
<dl className="mt-3 space-y-2 text-xs">
{entries.map(([key, value]) => (
<div key={key} className="grid grid-cols-[8rem_minmax(0,1fr)] gap-3 border-b border-slate-800 pb-2 last:border-0">
<dt className="font-medium text-slate-400">{key}</dt>
<dd className="break-all font-mono text-slate-200">{typeof value === 'string' ? value : JSON.stringify(value)}</dd>
</div>
))}
</dl>
) : <p className="mt-3 text-sm text-slate-500">No evidence has been emitted for this gate yet.</p>}
</div>
</div>
);
}
export function TraceExplorer({ traceId, onClose }: { traceId: string; onClose?: () => void }) {
const query = useQuery({
queryKey: ['trace-graph', traceId],
queryFn: () => api.traceGraph(traceId),
enabled: Boolean(traceId),
});
const [graph, setGraph] = useState<HarnessTraceGraph | null>(null);
const [selectedId, setSelectedId] = useState('H1-context');
useEffect(() => {
if (query.data) setGraph(query.data);
}, [query.data]);
useEffect(() => {
if (!traceId) return undefined;
return api.watchTrace(traceId, setGraph);
}, [traceId]);
const selected = useMemo(
() => graph?.nodes.find((node) => node.id === selectedId) ?? graph?.nodes[0] ?? null,
[graph, selectedId],
);
return (
<Card
title="Harness trace explorer"
right={onClose && <button type="button" onClick={onClose} className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50">Close</button>}
className="overflow-hidden"
>
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.13em] text-slate-400">Trace ID</p>
<p className="mt-1 break-all font-mono text-sm text-slate-700">{traceId}</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className={`h-2 w-2 rounded-full ${graph?.terminal ? 'bg-emerald-500' : 'animate-pulse bg-blue-500'}`} />
{graph?.terminal ? 'Completed' : 'Live · waiting for events'}
<span className="text-slate-300">·</span>
{graph?.progress ?? 0}/7 gates
</div>
</div>
{query.isLoading && !graph ? <div className="py-10 text-center text-sm text-slate-500">Loading trace…</div> : (
<>
<div className="overflow-x-auto pb-5">
<div className="flex min-w-max items-center px-1 py-3">
{graph?.nodes.map((node, index) => {
const next = graph.nodes[index + 1];
const edgeActive = node.status === 'pass' || node.status === 'running' || node.status === 'warning' || next?.status === 'running';
return (
<div key={node.id} className="flex items-center">
<button
type="button"
aria-pressed={selectedId === node.id}
onClick={() => setSelectedId(node.id)}
className={`w-40 rounded-xl border p-3 text-left transition duration-200 hover:-translate-y-0.5 hover:shadow-md ${NODE_TONE[node.status]} ${selectedId === node.id ? 'ring-2 ring-slate-900/10' : ''}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold tracking-wide">{node.title.split(' · ')[0]}</span>
<span className={`flex h-6 w-6 items-center justify-center rounded-full border border-current text-xs ${node.status === 'running' ? 'animate-pulse' : ''}`}>{STATUS_MARK[node.status]}</span>
</div>
<div className="mt-3 text-sm font-semibold">{node.title.split(' · ')[1]}</div>
<div className="mt-1 truncate text-[11px] opacity-70">{node.reason}</div>
</button>
{next && <div className={`trace-edge mx-2 ${edgeActive ? 'trace-edge-active' : ''}`} aria-hidden="true" />}
</div>
);
})}
</div>
</div>
{selected && <Evidence node={selected} />}
{!graph?.found && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">This is a legacy or not-yet-started trace. New chat turns emit detailed H1–H7 events automatically.</div>}
</>
)}
</Card>
);
}