/** * Timeline — Vertical event timeline with virtual scrolling (Stories 7.3 + 7.6) * * Features: * - Vertical layout, ascending timestamps, time markers on left * - Event type icons + color coding * - tool_call/tool_response paired as expandable nodes with duration * - Chain validity indicator (✓/✗) * - Virtual scrolling via @tanstack/react-virtual (~30 DOM nodes) */ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { AgentLensEvent, EventType, ToolCallPayload, ToolResponsePayload, ToolErrorPayload, ApprovalRequestedPayload, ApprovalDecisionPayload, FormSubmittedPayload, FormCompletedPayload, FormExpiredPayload, LlmCallPayload, LlmResponsePayload, LlmMessage, } from '@agentkitai/agentlens-core'; import { highlightMatches } from '../utils/highlight'; import { otlpTitle, otlpIcon } from '../lib/otlpEvent'; // ─── Types ────────────────────────────────────────────────────────── export interface TimelineProps { events: AgentLensEvent[]; chainValid: boolean; /** false ⇒ OTLP/unchained telemetry; the badge shows record-integrity, not "Chain Valid". (#119) */ chained?: boolean; onEventClick: (event: AgentLensEvent) => void; selectedEventId?: string; /** [F11-S1] Search query for text highlighting */ searchQuery?: string; } interface TimelineNode { kind: 'single' | 'paired' | 'approval_paired' | 'form_paired' | 'llm_paired'; event: AgentLensEvent; /** For paired nodes: the matching response/error/decision event */ responseEvent?: AgentLensEvent; /** Computed duration for paired tool_call → response, approval request → decision, form submission → completed, or llm_call → llm_response */ durationMs?: number; } // ─── Event Styling ────────────────────────────────────────────────── interface EventStyle { icon: string; color: string; bgColor: string; borderColor: string; } const EVENT_STYLES: Record = { // Success / lifecycle session_started: { icon: '▶️', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-300' }, session_ended: { icon: '⏹️', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-300' }, // Tool calls tool_call: { icon: '🔧', color: 'text-blue-700', bgColor: 'bg-blue-50', borderColor: 'border-blue-300' }, tool_response: { icon: '📦', color: 'text-blue-700', bgColor: 'bg-blue-50', borderColor: 'border-blue-300' }, tool_error: { icon: '💥', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-300' }, // Approvals (Story 9.4 — ⏳✅❌⏰ icons) approval_requested: { icon: '⏳', color: 'text-purple-700', bgColor: 'bg-purple-50', borderColor: 'border-purple-300' }, approval_granted: { icon: '✅', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-300' }, approval_denied: { icon: '❌', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-300' }, approval_expired: { icon: '⏰', color: 'text-yellow-700', bgColor: 'bg-yellow-50', borderColor: 'border-yellow-300' }, // Forms (FormBridge — distinct teal/cyan palette) form_submitted: { icon: '📋', color: 'text-teal-700', bgColor: 'bg-teal-50', borderColor: 'border-teal-300' }, form_completed: { icon: '✅', color: 'text-teal-700', bgColor: 'bg-teal-50', borderColor: 'border-teal-300' }, form_expired: { icon: '⏰', color: 'text-orange-700', bgColor: 'bg-orange-50', borderColor: 'border-orange-300' }, // LLM calls (indigo palette) llm_call: { icon: '🧠', color: 'text-indigo-700', bgColor: 'bg-indigo-50', borderColor: 'border-indigo-300' }, llm_response: { icon: '💬', color: 'text-indigo-700', bgColor: 'bg-indigo-50', borderColor: 'border-indigo-300' }, // Cost cost_tracked: { icon: '💰', color: 'text-yellow-700', bgColor: 'bg-yellow-50', borderColor: 'border-yellow-300' }, // Errors / evals (parity with ReplayTimeline) error: { icon: '❌', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-300' }, eval_result: { icon: '⚖️', color: 'text-purple-700', bgColor: 'bg-purple-50', borderColor: 'border-purple-300' }, // Skills skill_activated: { icon: '🧩', color: 'text-fuchsia-700', bgColor: 'bg-fuchsia-50', borderColor: 'border-fuchsia-300' }, // Alerts alert_triggered: { icon: '🚨', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-300' }, alert_resolved: { icon: '✅', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-300' }, // Custom custom: { icon: '🔹', color: 'text-gray-700', bgColor: 'bg-gray-50', borderColor: 'border-gray-300' }, }; export function getEventStyle(eventType: EventType): EventStyle { return EVENT_STYLES[eventType] ?? EVENT_STYLES.custom; } // ─── Severity colors ──────────────────────────────────────────────── function severityDot(severity: string): string { switch (severity) { case 'error': case 'critical': return 'bg-red-500'; case 'warn': return 'bg-yellow-500'; case 'info': return 'bg-blue-500'; default: return 'bg-gray-400'; } } // ─── Time formatting ──────────────────────────────────────────────── function formatTime(ts: string): string { const d = new Date(ts); return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 }); } export function formatMs(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 60_000).toFixed(1)}m`; } function eventName(event: AgentLensEvent): string { const p = event.payload; // OTLP-ingested custom events (Claude Code metrics/logs) — derive a real title // from the metric/event name instead of the bare "otlp_log"/"otlp_metric". const ot = otlpTitle(event); if (ot) return ot; // Approval events — show friendly label + action if (event.eventType === 'approval_requested') { const action = 'action' in p && typeof p.action === 'string' ? p.action : ''; return action ? `Approval Requested: ${action}` : 'Approval Requested'; } if (event.eventType === 'approval_granted') { const decidedBy = 'decidedBy' in p && typeof p.decidedBy === 'string' ? p.decidedBy : ''; return decidedBy ? `Approved by ${decidedBy}` : 'Approved'; } if (event.eventType === 'approval_denied') { const decidedBy = 'decidedBy' in p && typeof p.decidedBy === 'string' ? p.decidedBy : ''; return decidedBy ? `Denied by ${decidedBy}` : 'Denied'; } if (event.eventType === 'approval_expired') { return 'Expired'; } // FormBridge events — friendly labels if (event.eventType === 'form_submitted') { const formName = 'formName' in p && typeof p.formName === 'string' ? p.formName : ''; const fieldCount = 'fieldCount' in p && typeof p.fieldCount === 'number' ? p.fieldCount : 0; const label = formName || 'Form Submitted'; return fieldCount > 0 ? `${label} (${fieldCount} fields)` : label; } if (event.eventType === 'form_completed') { const completedBy = 'completedBy' in p && typeof p.completedBy === 'string' ? p.completedBy : ''; return completedBy ? `Form Completed by ${completedBy}` : 'Form Completed'; } if (event.eventType === 'form_expired') { return 'Form Expired'; } // LLM events — show provider / model if (event.eventType === 'llm_call') { const llmPayload = p as LlmCallPayload; return llmPayload.redacted ? `${llmPayload.provider} / ${llmPayload.model} [redacted]` : `${llmPayload.provider} / ${llmPayload.model}`; } if (event.eventType === 'llm_response') { const llmPayload = p as LlmResponsePayload; return `${llmPayload.provider} / ${llmPayload.model}`; } if (event.eventType === 'skill_activated') { const skill = 'skillName' in p && typeof p.skillName === 'string' ? p.skillName : 'skill'; return `Skill: ${skill}`; } if ('toolName' in p && typeof p.toolName === 'string') return p.toolName; if ('action' in p && typeof p.action === 'string') return p.action; if ('alertName' in p && typeof p.alertName === 'string') return p.alertName; if ('formName' in p && typeof p.formName === 'string') return p.formName; if ('type' in p && typeof p.type === 'string') return p.type; return event.eventType.replace(/_/g, ' '); } // ─── Build timeline nodes (pair tool_call with tool_response) ────── function buildTimelineNodes(events: AgentLensEvent[]): TimelineNode[] { // Build a map of callId → response/error event (tool calls) const responseMap = new Map(); for (const ev of events) { if (ev.eventType === 'tool_response' || ev.eventType === 'tool_error') { const payload = ev.payload as ToolResponsePayload | ToolErrorPayload; if (payload.callId) { responseMap.set(payload.callId, ev); } } } // Build a map of requestId → decision event (approval flow, Story 9.4) const approvalDecisionMap = new Map(); for (const ev of events) { if ( ev.eventType === 'approval_granted' || ev.eventType === 'approval_denied' || ev.eventType === 'approval_expired' ) { const payload = ev.payload as ApprovalDecisionPayload; if (payload.requestId) { approvalDecisionMap.set(payload.requestId, ev); } } } // Build a map of submissionId → form outcome event (Story 10.4) const formOutcomeMap = new Map(); for (const ev of events) { if (ev.eventType === 'form_completed' || ev.eventType === 'form_expired') { const payload = ev.payload as FormCompletedPayload | FormExpiredPayload; if (payload.submissionId) { formOutcomeMap.set(payload.submissionId, ev); } } } // Build a map of callId → llm_response event (LLM call tracking, Story 4.1) const llmResponseMap = new Map(); for (const ev of events) { if (ev.eventType === 'llm_response') { const payload = ev.payload as LlmResponsePayload; if (payload.callId) { llmResponseMap.set(payload.callId, ev); } } } // Track which events we consumed as responses/decisions const consumedIds = new Set(); const nodes: TimelineNode[] = []; for (const ev of events) { // Skip events that are paired with a request if (consumedIds.has(ev.id)) continue; if (ev.eventType === 'tool_call') { const callPayload = ev.payload as ToolCallPayload; const responseEvent = responseMap.get(callPayload.callId); if (responseEvent) { consumedIds.add(responseEvent.id); const respPayload = responseEvent.payload as ToolResponsePayload | ToolErrorPayload; nodes.push({ kind: 'paired', event: ev, responseEvent, durationMs: respPayload.durationMs, }); } else { nodes.push({ kind: 'single', event: ev }); } } else if (ev.eventType === 'approval_requested') { // Pair approval_requested with its decision (Story 9.4) const reqPayload = ev.payload as ApprovalRequestedPayload; const decisionEvent = approvalDecisionMap.get(reqPayload.requestId); if (decisionEvent) { consumedIds.add(decisionEvent.id); // Compute waiting duration from timestamps const requestTime = new Date(ev.timestamp).getTime(); const decisionTime = new Date(decisionEvent.timestamp).getTime(); const waitingMs = Math.max(0, decisionTime - requestTime); nodes.push({ kind: 'approval_paired', event: ev, responseEvent: decisionEvent, durationMs: waitingMs, }); } else { // No decision yet — show as single (still waiting) nodes.push({ kind: 'single', event: ev }); } } else if (ev.eventType === 'form_submitted') { // Pair form_submitted with its outcome (Story 10.4) const subPayload = ev.payload as FormSubmittedPayload; const outcomeEvent = formOutcomeMap.get(subPayload.submissionId); if (outcomeEvent) { consumedIds.add(outcomeEvent.id); // Use durationMs from completed payload if available, else compute from timestamps let waitingMs: number; if (outcomeEvent.eventType === 'form_completed') { const completedPayload = outcomeEvent.payload as FormCompletedPayload; waitingMs = completedPayload.durationMs > 0 ? completedPayload.durationMs : Math.max(0, new Date(outcomeEvent.timestamp).getTime() - new Date(ev.timestamp).getTime()); } else { waitingMs = Math.max(0, new Date(outcomeEvent.timestamp).getTime() - new Date(ev.timestamp).getTime()); } nodes.push({ kind: 'form_paired', event: ev, responseEvent: outcomeEvent, durationMs: waitingMs, }); } else { // No outcome yet — show as single (pending submission) nodes.push({ kind: 'single', event: ev }); } } else if (ev.eventType === 'llm_call') { // Pair llm_call with its llm_response (Story 4.1) const callPayload = ev.payload as LlmCallPayload; const llmResponse = llmResponseMap.get(callPayload.callId); if (llmResponse) { consumedIds.add(llmResponse.id); const respPayload = llmResponse.payload as LlmResponsePayload; nodes.push({ kind: 'llm_paired', event: ev, responseEvent: llmResponse, durationMs: respPayload.latencyMs, }); } else { nodes.push({ kind: 'single', event: ev }); } } else if (ev.eventType === 'llm_response' && !consumedIds.has(ev.id)) { // Orphan llm_response — show as single nodes.push({ kind: 'single', event: ev }); } else if ( (ev.eventType === 'tool_response' || ev.eventType === 'tool_error') && !consumedIds.has(ev.id) ) { // Orphan response — show as single nodes.push({ kind: 'single', event: ev }); } else if ( (ev.eventType === 'approval_granted' || ev.eventType === 'approval_denied' || ev.eventType === 'approval_expired') && !consumedIds.has(ev.id) ) { // Orphan decision — show as single nodes.push({ kind: 'single', event: ev }); } else if ( (ev.eventType === 'form_completed' || ev.eventType === 'form_expired') && !consumedIds.has(ev.id) ) { // Orphan form outcome — show as single nodes.push({ kind: 'single', event: ev }); } else { nodes.push({ kind: 'single', event: ev }); } } return nodes; } // ─── LLM helpers ──────────────────────────────────────────────────── function formatTokenCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } export function formatCost(usd: number): string { if (usd < 0.01) return `$${usd.toFixed(4)}`; return `$${usd.toFixed(2)}`; } function getMessageContentText(content: LlmMessage['content']): string { if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')) .filter(Boolean) .join('\n'); } return ''; } const LLM_CONTENT_TRUNCATE = 300; // ─── Chain validity badge ─────────────────────────────────────────── export function ChainBadge({ valid, chained = true }: { valid: boolean; chained?: boolean }) { // OTLP-ingested traces are unchained by design (every event has prevHash=null): // there is no cross-event hash chain to verify, only per-record integrity. Don't // claim a (misleading) "Chain Valid" for them — say exactly what was verified, // consistent with the server's verifyRecords vs verifyChain split (#119). const label = chained ? valid ? 'Chain Valid' : 'Chain Invalid' : valid ? 'Records Verified' : 'Record Integrity Failed'; const tone = !valid ? 'bg-red-100 text-red-800 border-red-300' : chained ? 'bg-green-100 text-green-800 border-green-300' : 'bg-amber-100 text-amber-800 border-amber-300'; const title = chained ? 'Linear hash chain: each event links to the previous (tamper-evident ordering).' : 'Unchained telemetry (OTLP): per-record integrity only, no cross-event chain.'; return (
{valid ? '✓' : '✗'} {label} {!chained && valid && · unchained}
); } // ─── Single timeline row ──────────────────────────────────────────── interface TimelineRowProps { node: TimelineNode; isSelected: boolean; onClick: (event: AgentLensEvent) => void; searchQuery?: string; } function TimelineRow({ node, isSelected, onClick, searchQuery }: TimelineRowProps) { const [expanded, setExpanded] = useState(false); const [showMore, setShowMore] = useState(false); const baseStyle = getEventStyle(node.event.eventType); // OTLP custom events get a per-subtype icon (token/cost/hook/skill/…). const otIcon = otlpIcon(node.event); const style = otIcon ? { ...baseStyle, icon: otIcon } : baseStyle; const isPaired = node.kind === 'paired'; const isApprovalPaired = node.kind === 'approval_paired'; const isFormPaired = node.kind === 'form_paired'; const isLlmPaired = node.kind === 'llm_paired'; const hasResponse = isPaired || isApprovalPaired || isFormPaired || isLlmPaired; const handleClick = useCallback(() => { onClick(node.event); }, [node.event, onClick]); const toggleExpand = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setExpanded((prev) => !prev); }, []); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }, [handleClick]); // Compute a label for the expand button based on the pair type const expandLabel = isLlmPaired ? 'completion' : isApprovalPaired ? node.responseEvent?.eventType === 'approval_granted' ? 'approved' : node.responseEvent?.eventType === 'approval_denied' ? 'denied' : 'expired' : isFormPaired ? node.responseEvent?.eventType === 'form_completed' ? 'completed' : 'expired' : node.responseEvent?.eventType === 'tool_error' ? 'error' : 'response'; return (
{/* Time marker */}
{formatTime(node.event.timestamp)}
{/* Timeline line + dot */}
{/* Event card — use div with role="button" to avoid nested button a11y violation */}
{style.icon} {searchQuery ? highlightMatches(eventName(node.event), searchQuery) : eventName(node.event)} {node.event.eventType} {node.durationMs !== undefined && ( {isApprovalPaired ? `⏱ waited ${formatMs(node.durationMs)}` : formatMs(node.durationMs)} )} {hasResponse && ( )}
{/* For approval_requested without a decision, show "waiting" indicator */} {node.event.eventType === 'approval_requested' && !isApprovalPaired && (
⏳ Waiting for decision…
)} {/* LLM call summary badges (Story 4.1) */} {(isLlmPaired || node.event.eventType === 'llm_call') && (() => { const callPayload = node.event.payload as LlmCallPayload; const respPayload = isLlmPaired && node.responseEvent ? (node.responseEvent.payload as LlmResponsePayload) : undefined; return (
{callPayload.provider} / {callPayload.model} {callPayload.messages.length} message{callPayload.messages.length !== 1 ? 's' : ''} {respPayload && ( <> → {formatTokenCount(respPayload.usage.inputTokens)} in / {formatTokenCount(respPayload.usage.outputTokens)} out )} {respPayload && respPayload.costUsd > 0 && ( {formatCost(respPayload.costUsd)} )}
); })()}
{/* Expanded paired response / decision */} {hasResponse && expanded && node.responseEvent && !isLlmPaired && (
)} {/* Expanded LLM pair — prompt messages + completion (Story 4.1) */} {isLlmPaired && expanded && (() => { const callPayload = node.event.payload as LlmCallPayload; const respPayload = node.responseEvent ? (node.responseEvent.payload as LlmResponsePayload) : undefined; const isRedacted = callPayload.redacted || respPayload?.redacted; return (
onClick(node.event)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(node.event); } }} > {/* Prompt messages */}
Prompt
{isRedacted ? (
[Content redacted]
) : (
{callPayload.systemPrompt && (
system: {callPayload.systemPrompt.length > LLM_CONTENT_TRUNCATE && !showMore ? callPayload.systemPrompt.slice(0, LLM_CONTENT_TRUNCATE) + '…' : callPayload.systemPrompt}
)} {callPayload.messages.map((msg, i) => { const text = getMessageContentText(msg.content); const truncated = text.length > LLM_CONTENT_TRUNCATE && !showMore; const roleColors: Record = { system: 'bg-gray-100 text-gray-700', user: 'bg-blue-50 text-blue-800', assistant: 'bg-green-50 text-green-800', tool: 'bg-gray-100 text-gray-700 font-mono', }; return (
{msg.role}: {truncated ? text.slice(0, LLM_CONTENT_TRUNCATE) + '…' : text}
); })}
)}
{/* Completion */} {respPayload && (
Completion
{respPayload.redacted ? (
[Content redacted]
) : (
{respPayload.completion ? (respPayload.completion.length > LLM_CONTENT_TRUNCATE && !showMore ? respPayload.completion.slice(0, LLM_CONTENT_TRUNCATE) + '…' : respPayload.completion) : (no text — tool_use)}
)}
)} {/* Show more / less toggle */} {!isRedacted && ( (() => { const hasLong = (callPayload.systemPrompt && callPayload.systemPrompt.length > LLM_CONTENT_TRUNCATE) || callPayload.messages.some((m) => getMessageContentText(m.content).length > LLM_CONTENT_TRUNCATE) || (respPayload?.completion && respPayload.completion.length > LLM_CONTENT_TRUNCATE); return hasLong ? ( ) : null; })() )}
); })()}
); } // ─── Main Component ───────────────────────────────────────────────── const ESTIMATED_ROW_HEIGHT = 64; export function Timeline({ events, chainValid, chained, onEventClick, selectedEventId, searchQuery }: TimelineProps) { const parentRef = useRef(null); const nodes = useMemo(() => buildTimelineNodes(events), [events]); const virtualizer = useVirtualizer({ count: nodes.length, getScrollElement: () => parentRef.current, estimateSize: () => ESTIMATED_ROW_HEIGHT, overscan: 10, }); if (events.length === 0) { return (

No events in this session

); } return (
{/* Chain validity */}
{events.length} event{events.length !== 1 ? 's' : ''} · {nodes.length} timeline node{nodes.length !== 1 ? 's' : ''}
{/* Virtual-scrolled timeline */}
{virtualizer.getVirtualItems().map((virtualRow) => { const node = nodes[virtualRow.index]; if (!node) return null; return (
); })}
); }