510 lines
23 KiB
TypeScript
510 lines
23 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { fetchLocalFileContent } from '../../../api';
|
||
|
||
// Mirror of `src/progress/event-log.ts` EventBase. Kept as a duplicate
|
||
// here because the Vite UI build is a separate project from the engine.
|
||
interface TraceEvent {
|
||
v: 1;
|
||
ts: string;
|
||
seq: number;
|
||
eventId: string;
|
||
runId: string;
|
||
parentEventId?: string;
|
||
correlationId?: string;
|
||
llmToolCallId?: string;
|
||
movement?: string;
|
||
iteration?: number;
|
||
kind: string;
|
||
payload: unknown;
|
||
}
|
||
|
||
interface ParseSummary {
|
||
events: TraceEvent[];
|
||
skipped: number;
|
||
unknownVersion: number;
|
||
}
|
||
|
||
function parseEventsJsonl(raw: string): ParseSummary {
|
||
const out: TraceEvent[] = [];
|
||
let skipped = 0;
|
||
let unknownVersion = 0;
|
||
for (const line of raw.split('\n')) {
|
||
if (!line.trim()) continue;
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(line);
|
||
} catch {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
const obj = parsed as Record<string, unknown>;
|
||
if (obj.v !== 1) {
|
||
unknownVersion++;
|
||
continue;
|
||
}
|
||
if (typeof obj.kind !== 'string' || typeof obj.seq !== 'number' || typeof obj.eventId !== 'string') {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
out.push(obj as unknown as TraceEvent);
|
||
}
|
||
return { events: out, skipped, unknownVersion };
|
||
}
|
||
|
||
// Categorize for filter chips and color coding.
|
||
const CATEGORIES: Array<{ id: string; label: string; kinds: string[]; tone: string }> = [
|
||
{ id: 'run', label: 'Run', kinds: ['run_start', 'run_complete'], tone: 'bg-surface-2 text-slate-700 border-hairline' },
|
||
{ id: 'movement', label: 'Movement', kinds: ['movement_start', 'movement_complete', 'transition', 'complete'], tone: 'bg-blue-50 text-blue-800 border-blue-100 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30' },
|
||
{ id: 'tool', label: 'Tool', kinds: ['tool_call', 'tool_result'], tone: 'bg-canvas text-slate-700 border-hairline' },
|
||
{ id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' },
|
||
{ id: 'cache', label: 'Cache', kinds: ['cache_set', 'cache_hit', 'cache_invalidate'], tone: 'bg-amber-50 text-amber-800 border-amber-100 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30' },
|
||
{ id: 'memory', label: 'Memory', kinds: ['memory_invalidate', 'memory_update_call', 'memory_handoff_write', 'memory_handoff_read', 'memory_delta_write', 'memory_delta_absorb', 'memory_snapshot_written', 'memory_snapshot_failed'], tone: 'bg-emerald-50 text-emerald-800 border-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30' },
|
||
{ id: 'watchdog', label: 'Watchdog', kinds: ['watchdog_fire', 'followup_detected'], tone: 'bg-red-50 text-red-800 border-red-100 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30' },
|
||
{ id: 'context', label: 'Context', kinds: ['context_action'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' },
|
||
];
|
||
|
||
/**
|
||
* Color a duration bar by magnitude. Bars are inline next to tool_result /
|
||
* llm_call_end rows so users can scan a timeline and spot the long tail at
|
||
* a glance — XPostDetail taking 3 min stands out as red, a 200ms Read fades
|
||
* to almost nothing.
|
||
*/
|
||
function durationBarStyle(ms: number): { widthPct: number; tone: string } {
|
||
if (!Number.isFinite(ms) || ms <= 0) return { widthPct: 0, tone: 'bg-slate-200' };
|
||
// Log scale: 100ms = 10%, 1s = 30%, 10s = 60%, 100s = 90%, >180s = 100%.
|
||
const widthPct = Math.min(100, Math.max(4, Math.log10(ms) * 22 - 22));
|
||
const tone = ms >= 60_000 ? 'bg-red-400'
|
||
: ms >= 10_000 ? 'bg-orange-400'
|
||
: ms >= 2_000 ? 'bg-amber-400'
|
||
: ms >= 500 ? 'bg-emerald-400'
|
||
: 'bg-slate-300';
|
||
return { widthPct, tone };
|
||
}
|
||
|
||
function formatDurationLabel(ms: number): string {
|
||
if (!Number.isFinite(ms) || ms < 0) return '?';
|
||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||
const sec = Math.round(ms / 1000);
|
||
return `${Math.floor(sec / 60)}m${(sec % 60).toString().padStart(2, '0')}s`;
|
||
}
|
||
|
||
function toneFor(kind: string): string {
|
||
for (const c of CATEGORIES) if (c.kinds.includes(kind)) return c.tone;
|
||
return 'bg-canvas text-slate-600 border-slate-200';
|
||
}
|
||
|
||
function categoryFor(kind: string): string {
|
||
for (const c of CATEGORIES) if (c.kinds.includes(kind)) return c.id;
|
||
return 'other';
|
||
}
|
||
|
||
function summarizePayload(event: TraceEvent): string {
|
||
const p = event.payload as Record<string, unknown> | null;
|
||
if (!p) return '';
|
||
switch (event.kind) {
|
||
case 'tool_call': {
|
||
const args = p.args as Record<string, unknown> | undefined;
|
||
const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['url'] ?? args?.['pattern'];
|
||
return `${String(p.tool ?? '?')}${filePath ? ` ${filePath}` : ''}`;
|
||
}
|
||
case 'tool_result':
|
||
return `${String(p.tool ?? '?')} ${p.isError ? '⚠ error' : 'ok'}${p.cacheHit ? ' (cached)' : ''} ${formatDurationLabel(Number(p.durationMs ?? 0))}`;
|
||
case 'llm_call_start':
|
||
return `iter=${p.iteration ?? '?'} msgs=${p.messageCount ?? '?'}`;
|
||
case 'llm_call_end': {
|
||
const tokens = (typeof p.promptTokens === 'number' && typeof p.completionTokens === 'number')
|
||
? ` in=${p.promptTokens} out=${p.completionTokens}`
|
||
: '';
|
||
const shape = (p.toolCalls as number) > 0 ? ` tools=${p.toolCalls}`
|
||
: (p.textChars as number) > 0 ? ` text=${p.textChars}c`
|
||
: '';
|
||
return `${formatDurationLabel(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`;
|
||
}
|
||
case 'cache_set':
|
||
return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`;
|
||
case 'cache_hit':
|
||
return `${String(p.tool ?? '?')} from ${String(p.sourceMovement ?? '?')} (${p.ageMs ?? '?'}ms ago)`;
|
||
case 'cache_invalidate':
|
||
case 'memory_invalidate':
|
||
return `${String(p.trigger ?? '')} → ${p.entriesEvicted ?? 0} entries`;
|
||
case 'memory_update_call': {
|
||
const counts = p.counts as Record<string, number> | null;
|
||
if (!counts) return p.empty ? 'empty payload' : '';
|
||
const parts: string[] = [];
|
||
if (counts.factsAdded) parts.push(`facts +${counts.factsAdded}`);
|
||
if (counts.factsMerged) parts.push(`facts merged ${counts.factsMerged}`);
|
||
if (counts.decisionsAdded) parts.push(`decisions +${counts.decisionsAdded}`);
|
||
if (counts.openQuestionsAdded) parts.push(`open_questions +${counts.openQuestionsAdded}`);
|
||
if (counts.doNotRepeatAdded) parts.push(`do_not_repeat +${counts.doNotRepeatAdded}`);
|
||
return parts.join(', ') || 'no changes';
|
||
}
|
||
case 'memory_handoff_write':
|
||
return p.skipped ? `skipped: ${p.reason}` : `→ child #${p.subtaskIndex ?? '?'} (${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
|
||
case 'memory_handoff_read':
|
||
return `from parent ${String(p.parentJobId ?? '?')}`;
|
||
case 'memory_delta_write':
|
||
return p.skipped ? `skipped: ${p.reason}` : `${p.childStatus} ${p.partial ? '(partial) ' : ''}(${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
|
||
case 'memory_delta_absorb':
|
||
return `${String(p.outcome ?? '?')}${p.childJobId ? ` ← ${p.childJobId}` : ''}`;
|
||
case 'memory_snapshot_written': {
|
||
const parts: string[] = [];
|
||
if (typeof p.facts === 'number') parts.push(`${p.facts}f`);
|
||
if (typeof p.decisions === 'number') parts.push(`${p.decisions}d`);
|
||
if (typeof p.openQuestions === 'number') parts.push(`${p.openQuestions}q`);
|
||
const counts = parts.length ? ` (${parts.join('/')})` : '';
|
||
const sizeKb = typeof p.bytes === 'number' ? ` ${(p.bytes / 1024).toFixed(1)}KB` : '';
|
||
return `${String(p.status ?? '?')} → ${String(p.path ?? '?')}${counts}${sizeKb}`;
|
||
}
|
||
case 'memory_snapshot_failed':
|
||
return `${String(p.status ?? '?')} write failed: ${String(p.error ?? '?')}`;
|
||
case 'watchdog_fire':
|
||
return `${String(p.kind2 ?? '')} at iter=${p.iteration ?? '?'}`;
|
||
case 'followup_detected':
|
||
return `movement=${String(p.movementName ?? '?')}`;
|
||
case 'context_action':
|
||
return `${String(p.type ?? '?')} ratio=${typeof p.ratio === 'number' ? (p.ratio * 100).toFixed(0) + '%' : '?'}`;
|
||
case 'transition':
|
||
return `→ ${String(p.nextStep ?? '?')}`;
|
||
case 'complete':
|
||
return `${String(p.status ?? '?')}`;
|
||
case 'movement_start':
|
||
return `visit ${p.visitCount ?? '?'}/${p.maxVisits ?? '?'}`;
|
||
case 'movement_complete':
|
||
return `→ ${String(p.next ?? '?')}`;
|
||
case 'run_start':
|
||
return `piece=${String(p.pieceName ?? '?')}`;
|
||
case 'run_complete': {
|
||
const cancel = p.cancel as { phase?: string; movement?: string } | undefined;
|
||
const cancelInfo = cancel?.phase ? ` cancel:${cancel.phase}@${cancel.movement ?? '?'}` : '';
|
||
const snapshot = p.memorySnapshotPath ? ` snapshot:${String(p.memorySnapshotPath).replace(/^logs\//, '')}` : '';
|
||
return `${String(p.status ?? '?')}${p.abortReason ? ` (${p.abortReason})` : ''}${cancelInfo}${snapshot}`;
|
||
}
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
interface TraceTabProps {
|
||
taskId: number;
|
||
}
|
||
|
||
export function TraceTab({ taskId }: TraceTabProps) {
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||
const [enabledCategories, setEnabledCategories] = useState<Set<string>>(
|
||
new Set(CATEGORIES.map((c) => c.id).concat(['other'])),
|
||
);
|
||
const [movementFilter, setMovementFilter] = useState<string>('all');
|
||
const [search, setSearch] = useState<string>('');
|
||
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ['trace-events', taskId, refreshKey],
|
||
queryFn: async () => {
|
||
try {
|
||
return await fetchLocalFileContent(taskId, 'logs', 'events.jsonl');
|
||
} catch (err) {
|
||
// events.jsonl が存在しない初回タスクなどは空扱い
|
||
if (err instanceof Error && /not.*found|404/i.test(err.message)) return '';
|
||
throw err;
|
||
}
|
||
},
|
||
refetchInterval: 5000, // 自動 5 秒ポーリング
|
||
staleTime: 0,
|
||
});
|
||
|
||
const summary = useMemo(() => {
|
||
if (!data) return { events: [], skipped: 0, unknownVersion: 0 };
|
||
return parseEventsJsonl(data);
|
||
}, [data]);
|
||
|
||
const movements = useMemo(() => {
|
||
const set = new Set<string>();
|
||
for (const e of summary.events) if (e.movement) set.add(e.movement);
|
||
return ['all', ...Array.from(set).sort()];
|
||
}, [summary.events]);
|
||
|
||
/**
|
||
* Per-tool aggregation: total wall-clock time spent + call count, sorted
|
||
* by total time descending. Surfaces "XPostDetail consumed 12 min over
|
||
* 4 calls" at a glance — the kind of thing buried in 500-line event
|
||
* lists otherwise. Only counts non-cache hits so cached results don't
|
||
* dilute the signal.
|
||
*/
|
||
const toolTimings = useMemo(() => {
|
||
const stats = new Map<string, { totalMs: number; count: number; errors: number; maxMs: number }>();
|
||
let llmTotalMs = 0;
|
||
let llmCount = 0;
|
||
for (const e of summary.events) {
|
||
const p = e.payload as Record<string, unknown> | null;
|
||
if (!p) continue;
|
||
if (e.kind === 'tool_result' && !p.cacheHit) {
|
||
const tool = String(p.tool ?? '?');
|
||
const ms = Number(p.durationMs ?? 0);
|
||
const cur = stats.get(tool) ?? { totalMs: 0, count: 0, errors: 0, maxMs: 0 };
|
||
cur.totalMs += ms;
|
||
cur.count += 1;
|
||
if (p.isError) cur.errors += 1;
|
||
if (ms > cur.maxMs) cur.maxMs = ms;
|
||
stats.set(tool, cur);
|
||
}
|
||
if (e.kind === 'llm_call_end') {
|
||
llmTotalMs += Number(p.durationMs ?? 0);
|
||
llmCount += 1;
|
||
}
|
||
}
|
||
const tools = Array.from(stats.entries())
|
||
.map(([tool, s]) => ({ tool, ...s }))
|
||
.sort((a, b) => b.totalMs - a.totalMs);
|
||
return { tools, llm: { totalMs: llmTotalMs, count: llmCount } };
|
||
}, [summary.events]);
|
||
|
||
const filtered = useMemo(() => {
|
||
const term = search.trim().toLowerCase();
|
||
return summary.events.filter((e) => {
|
||
const cat = categoryFor(e.kind);
|
||
if (!enabledCategories.has(cat)) return false;
|
||
if (movementFilter !== 'all' && e.movement !== movementFilter) return false;
|
||
if (term) {
|
||
const haystack = `${e.kind} ${e.movement ?? ''} ${summarizePayload(e)} ${JSON.stringify(e.payload)}`.toLowerCase();
|
||
if (!haystack.includes(term)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}, [summary.events, enabledCategories, movementFilter, search]);
|
||
|
||
// Group consecutive events by correlationId so tool_call ↔ tool_result are
|
||
// visually paired without manual interaction.
|
||
const grouped = useMemo(() => {
|
||
const groups: Array<{ correlationId?: string; events: TraceEvent[] }> = [];
|
||
for (const e of filtered) {
|
||
const last = groups[groups.length - 1];
|
||
if (e.correlationId && last && last.correlationId === e.correlationId) {
|
||
last.events.push(e);
|
||
} else {
|
||
groups.push({ correlationId: e.correlationId, events: [e] });
|
||
}
|
||
}
|
||
return groups;
|
||
}, [filtered]);
|
||
|
||
// Auto-collapse expansion state when raw data is reloaded from polling.
|
||
useEffect(() => {
|
||
setExpanded((prev) => {
|
||
const next = new Set<string>();
|
||
for (const id of prev) {
|
||
if (summary.events.some((e) => e.eventId === id)) next.add(id);
|
||
}
|
||
return next;
|
||
});
|
||
}, [summary.events]);
|
||
|
||
function toggleCategory(id: string): void {
|
||
setEnabledCategories((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function toggleExpanded(eventId: string): void {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(eventId)) next.delete(eventId);
|
||
else next.add(eventId);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
if (isLoading) {
|
||
return <div className="text-[13px] text-slate-500 p-4">読み込み中...</div>;
|
||
}
|
||
|
||
if (error) {
|
||
return <div className="text-[13px] text-red-600 p-4">エラー: {String(error)}</div>;
|
||
}
|
||
|
||
if (summary.events.length === 0) {
|
||
return (
|
||
<div className="text-xs text-slate-500 p-4 leading-relaxed">
|
||
<div className="section-label mb-1.5">no trace yet</div>
|
||
events.jsonl がまだ存在しません。タスクが少なくとも一度実行されると、engine 内部動作のトレースがここに表示されます。
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
{/* Filter bar — sticky so it stays visible while scrolling. */}
|
||
<div className="sticky top-0 z-10 -mx-3 px-3 -mt-3 pt-3 pb-2 bg-surface/95 backdrop-blur border-b border-hairline">
|
||
<div className="flex flex-wrap gap-1 mb-2">
|
||
{CATEGORIES.map((c) => {
|
||
const on = enabledCategories.has(c.id);
|
||
return (
|
||
<button
|
||
key={c.id}
|
||
onClick={() => toggleCategory(c.id)}
|
||
className={`h-6 px-2 text-[10px] font-medium border rounded transition-colors ${
|
||
on ? c.tone : 'bg-canvas text-slate-400 border-hairline hover:text-slate-600'
|
||
}`}
|
||
>
|
||
{c.label}
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
onClick={() => toggleCategory('other')}
|
||
className={`h-6 px-2 text-[10px] font-medium border rounded transition-colors ${
|
||
enabledCategories.has('other')
|
||
? 'bg-canvas text-slate-700 border-hairline'
|
||
: 'bg-canvas text-slate-400 border-hairline-soft hover:text-slate-600'
|
||
}`}
|
||
>
|
||
Other
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-1.5 items-center">
|
||
<select
|
||
value={movementFilter}
|
||
onChange={(e) => setMovementFilter(e.target.value)}
|
||
className="h-7 text-2xs border border-hairline rounded-md px-2 bg-canvas text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||
>
|
||
{movements.map((m) => (
|
||
<option key={m} value={m}>{m === 'all' ? 'all movements' : m}</option>
|
||
))}
|
||
</select>
|
||
<div className="flex-1 min-w-0 flex items-center gap-1.5 bg-canvas border border-hairline rounded-md h-7 px-2 focus-within:ring-2 focus-within:ring-accent-ring">
|
||
<svg aria-hidden="true" className="w-3 h-3 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
<input
|
||
type="text"
|
||
placeholder="search kind, movement, payload..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="text-2xs flex-1 min-w-0 bg-transparent outline-none text-slate-900 placeholder:text-slate-400"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => setRefreshKey((k) => k + 1)}
|
||
className="h-7 w-7 flex items-center justify-center text-xs border border-hairline rounded-md text-slate-500 bg-canvas hover:bg-surface transition-colors"
|
||
title="手動更新(自動 5 秒ごとにも更新されます)"
|
||
>
|
||
↻
|
||
</button>
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 font-mono tabular-nums mt-1.5">
|
||
{filtered.length} / {summary.events.length} events
|
||
{summary.skipped > 0 && <span className="ml-2 text-amber-600">⚠ {summary.skipped} skipped</span>}
|
||
{summary.unknownVersion > 0 && <span className="ml-2 text-amber-600">⚠ {summary.unknownVersion} unknown version</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tool / LLM time aggregation — surfaces "what ate the wall-clock". */}
|
||
{(toolTimings.tools.length > 0 || toolTimings.llm.count > 0) && (
|
||
<div className="border border-hairline rounded-md p-2 bg-canvas">
|
||
<div className="section-label mb-1.5">time by source</div>
|
||
<div className="flex flex-col gap-0.5">
|
||
{toolTimings.llm.count > 0 && (() => {
|
||
const bar = durationBarStyle(toolTimings.llm.totalMs);
|
||
return (
|
||
<div className="flex items-center gap-2 text-2xs font-mono">
|
||
<span className="min-w-[14ch] text-indigo-700 dark:text-indigo-300 shrink-0">llm × {toolTimings.llm.count}</span>
|
||
<div className="flex-1 min-w-0 h-2 bg-slate-100 rounded relative overflow-hidden">
|
||
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
|
||
</div>
|
||
<span className="min-w-[6ch] text-right tabular-nums text-slate-700 shrink-0">
|
||
{formatDurationLabel(toolTimings.llm.totalMs)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
{toolTimings.tools.map(({ tool, totalMs, count, errors, maxMs }) => {
|
||
const bar = durationBarStyle(totalMs);
|
||
return (
|
||
<div key={tool} className="flex items-center gap-2 text-2xs font-mono">
|
||
<span className={`min-w-[14ch] shrink-0 truncate ${errors > 0 ? 'text-red-700 dark:text-red-300' : 'text-slate-700'}`} title={tool}>
|
||
{tool} × {count}
|
||
{errors > 0 ? <span className="text-red-600"> ⚠{errors}</span> : null}
|
||
</span>
|
||
<div className="flex-1 min-w-0 h-2 bg-slate-100 rounded relative overflow-hidden">
|
||
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
|
||
</div>
|
||
<span className="min-w-[6ch] text-right tabular-nums text-slate-700 shrink-0" title={`max ${formatDurationLabel(maxMs)}`}>
|
||
{formatDurationLabel(totalMs)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1.5">
|
||
総時間 (cache hit 除外、max は個別呼び出しの最大値)
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Event list */}
|
||
<div className="flex flex-col">
|
||
{grouped.map((group, gi) => {
|
||
const grouped_ = group.correlationId && group.events.length > 1;
|
||
return (
|
||
<div key={gi} className={grouped_ ? 'border-l-2 border-hairline pl-2 my-0.5' : ''}>
|
||
{group.events.map((e) => {
|
||
const open = expanded.has(e.eventId);
|
||
const tone = toneFor(e.kind);
|
||
return (
|
||
<div key={e.eventId} className={`border rounded-md text-xs mb-1 ${tone}`}>
|
||
<button
|
||
onClick={() => toggleExpanded(e.eventId)}
|
||
className="w-full text-left px-2 py-1.5 flex items-center gap-2 cursor-pointer"
|
||
>
|
||
<span className="font-mono text-[10px] text-slate-500 shrink-0 w-[12ch] tabular-nums">
|
||
{new Date(e.ts).toLocaleTimeString(undefined, { hour12: false })}
|
||
</span>
|
||
<span className="font-mono font-semibold text-2xs shrink-0 min-w-[14ch]">{e.kind}</span>
|
||
{e.movement && (
|
||
<span className="font-mono text-[10px] text-slate-500 shrink-0">
|
||
{e.movement}{typeof e.iteration === 'number' ? `:${e.iteration}` : ''}
|
||
</span>
|
||
)}
|
||
<span className="flex-1 truncate font-mono text-2xs">{summarizePayload(e)}</span>
|
||
{(() => {
|
||
// Inline magnitude bar for any event carrying a durationMs.
|
||
// Visual sort: a 3-minute fetch jumps off the screen even
|
||
// when scrolled past, so you don't need to read every row.
|
||
const p = e.payload as Record<string, unknown> | null;
|
||
const ms = (e.kind === 'tool_result' || e.kind === 'llm_call_end') && p
|
||
? Number(p.durationMs ?? 0)
|
||
: 0;
|
||
if (ms <= 0) return null;
|
||
const bar = durationBarStyle(ms);
|
||
return (
|
||
<div className="w-[60px] h-1.5 bg-slate-100 rounded shrink-0 overflow-hidden" title={`${ms}ms`}>
|
||
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
|
||
</div>
|
||
);
|
||
})()}
|
||
<span className="text-slate-400 shrink-0 text-[10px]">{open ? '▾' : '▸'}</span>
|
||
</button>
|
||
{open && (
|
||
<div className="border-t border-current/15 px-2 py-1.5 bg-white/60 font-mono text-2xs whitespace-pre overflow-x-auto leading-relaxed">
|
||
{JSON.stringify(e, null, 2)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|