import { useEffect, useMemo, useState } from 'react'; import { useSession } from '../../hooks/useSession'; import { useWire } from '../../hooks/useWire'; import { analyzeWire, type Analysis, type StepNode, type ToolCallNode, type TurnNode, } from '../../lib/analysis'; import type { WireEntry } from '../../types'; import { formatBytes } from '../shared/SizePreview'; import { formatDuration, formatTokens } from '../../util/time'; import { Pill, type PillTone } from '../shared/Pill'; interface TimelineTabProps { sessionId: string; } /** Timeline tab — the agent's execution folded into turns → steps → tool * calls, with the derived metrics the flat record list does not surface: * durations, per-turn token cost, context-window growth, cache-hit rate, * tool latency, truncation, and idle gaps. All computed client-side from * the same wire the Wire tab fetches. */ export function TimelineTab({ sessionId }: TimelineTabProps) { const { data: detail } = useSession(sessionId); const [agentId, setAgentId] = useState('main'); // Reset the selected agent when navigating to another session while this tab // stays mounted; otherwise a previously-selected subagent would 404 against // the new session (mirrors WireTab/ContextTab). useEffect(() => { setAgentId('main'); }, [sessionId]); const { data: wire, isLoading, error } = useWire(sessionId, agentId); const analysis = useMemo(() => { if (!wire) return null; return analyzeWire(wire.records as WireEntry[]); }, [wire]); const agents = detail?.agents ?? []; return (
{isLoading ? (
analyzing…
) : error ? (
{error.message}
) : analysis === null || analysis.summary.turnCount === 0 ? (
no turns to analyze in this agent's wire
) : (
turns · {analysis.turns.length}
{analysis.turns.map((turn) => ( ))}
)}
); } function SectionTitle({ children }: { children: import('react').ReactNode }) { return

{children}

; } function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { return (
{label}
{value}
); } function SummaryGrid({ analysis }: { analysis: Analysis }) { const s = analysis.summary; const hit = analysis.cache.hitRate; return (
0 ? 'var(--color-sev-error)' : undefined} />
); } function ContextSparkline({ analysis }: { analysis: Analysis }) { const pts = analysis.contextSeries; if (pts.length < 2) return null; const peak = analysis.summary.peakContextTokens || 1; const W = 600; const H = 44; const dx = W / (pts.length - 1); const path = pts .map((p, i) => `${i === 0 ? 'M' : 'L'} ${(i * dx).toFixed(1)} ${(H - (p.contextTokens / peak) * H).toFixed(1)}`) .join(' '); return (
context-window fill over steps · peak {formatTokens(peak)}
step 1 step {pts.length}
); } function ToolStatsTable({ analysis }: { analysis: Analysis }) { if (analysis.toolStats.length === 0) return null; return (
tool usage · {analysis.toolStats.length} distinct
{analysis.toolStats.map((t) => ( ))}
toolcallserrorstruncated avgmaxoutput
{t.name} {t.count} 0 ? 'var(--color-sev-error)' : undefined}>{t.errorCount} 0 ? 'var(--color-sev-warning)' : undefined}>{t.truncatedCount} {formatDuration(t.avgMs)} {formatDuration(t.maxMs)} {formatBytes(t.totalOutputBytes)}
); } function Th({ children, align = 'right' }: { children: import('react').ReactNode; align?: 'left' | 'right' }) { return {children}; } function Td({ children, tone }: { children: import('react').ReactNode; tone?: string }) { return {children}; } function ConfigChanges({ analysis }: { analysis: Analysis }) { if (analysis.configChanges.length === 0) return null; return (
config changes · {analysis.configChanges.length}
{analysis.configChanges.map((c) => (
line {c.lineNo} {c.changed.map((ch) => ( {ch.field}={ch.value} ))}
))}
); } function IdleGaps({ analysis }: { analysis: Analysis }) { const gaps = analysis.idleGaps.slice(0, 5); if (gaps.length === 0) return null; return (
longest idle gaps
{gaps.map((g, i) => (
{g.kind === 'between_turns' ? 'waiting' : 'in-turn'} {formatDuration(g.gapMs)} line {g.afterLineNo} → {g.beforeLineNo}
))}
); } function TurnCard({ turn }: { turn: TurnNode }) { const [open, setOpen] = useState(turn.index === 0); const totalTokens = turn.tokens.inputOther + turn.tokens.output + turn.tokens.inputCacheRead + turn.tokens.inputCacheCreation; return (
{open ? (
{turn.waitBeforeMs !== undefined && turn.waitBeforeMs >= 1000 ? (
⏱ waited {formatDuration(turn.waitBeforeMs)} before this turn
) : null}
{turn.steps.map((step) => ( ))}
) : null}
); } function outcomeTone(outcome: NonNullable): PillTone { if (outcome === 'completed') return 'success'; if (outcome === 'failed') return 'error'; return 'warning'; } function StepRow({ step, turnDurationMs }: { step: StepNode; turnDurationMs?: number }) { const widthPct = turnDurationMs && step.durationMs ? Math.max(2, (step.durationMs / turnDurationMs) * 100) : 0; return (
step {step.step} {step.finishReason ? ( {step.finishReason} ) : null} {formatDuration(step.durationMs)} {step.llmFirstTokenLatencyMs !== undefined ? ( ttft {step.llmFirstTokenLatencyMs}ms {step.llmServerFirstTokenMs !== undefined && step.llmRequestBuildMs !== undefined ? ` (api ${step.llmServerFirstTokenMs} + client ${step.llmRequestBuildMs})` : ''} ) : null} {step.llmServerDecodeMs !== undefined && step.llmClientConsumeMs !== undefined ? ( decode {step.llmServerDecodeMs}+{step.llmClientConsumeMs}ms {step.llmClientBlockedMs !== undefined ? ` (busy ${step.llmClientBlockedMs}ms)` : ''} ) : null} {step.contextTokens !== undefined ? ( ctx {formatTokens(step.contextTokens)} ) : null} {step.content.thinkChars > 0 ? 💭 {step.content.thinkChars} : null}
{widthPct > 0 ? (
) : null} {step.toolCalls.length > 0 ? (
{step.toolCalls.map((tc) => ( ))}
) : null}
); } function ToolRow({ tc, stepDurationMs }: { tc: ToolCallNode; stepDurationMs?: number }) { const widthPct = stepDurationMs && tc.durationMs ? Math.max(2, (tc.durationMs / stepDurationMs) * 100) : 0; return (
{tc.name} {formatDuration(tc.durationMs)} {tc.outputBytes !== undefined ? ( {formatBytes(tc.outputBytes)} ) : null} {tc.isError ? error : null} {tc.truncated ? truncated : null} {tc.resultLineNo === undefined ? no result : null} {widthPct > 0 ? (
) : null}
); }