/** * ReplayTimeline — Step-by-step vertical event list (Story 5.4) * * Features: * - Vertical list of ReplayEventCard components * - Current step highlighted with distinct background + border * - Auto-scrolls to keep current step centered * - Each card: step number, timestamp (relative), event type icon + label, * summary (tool name, LLM model, error message), expandable payload detail * - Paired events: response shown inline below call with duration badge * - Event type filter toolbar with checkboxes * - Click any card → sets as current step * - Performance: simple slice-based windowing (~50 visible cards) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AgentLensEvent, EventType } from '@agentkitai/agentlens-core'; import { otlpTitle } from '../../lib/otlpEvent'; import { highlightMatches } from '../../utils/highlight'; import { useBookmarks } from './BookmarkProvider'; // ─── Types ────────────────────────────────────────────────────────── export interface ReplayTimelineProps { events: AgentLensEvent[]; currentStep: number; onStepChange: (step: number) => void; sessionStartTime: string; /** [F11-S1] Search query for filtering and highlighting */ searchQuery?: string; } // ─── Event type styles ────────────────────────────────────────────── interface EventStyle { icon: string; label: string; color: string; bgColor: string; borderColor: string; } const EVENT_STYLES: Record = { session_started: { icon: '🚀', label: 'Session Started', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-200' }, session_ended: { icon: '🏁', label: 'Session Ended', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-200' }, tool_call: { icon: '🔧', label: 'Tool Call', color: 'text-blue-700', bgColor: 'bg-blue-50', borderColor: 'border-blue-200' }, tool_response: { icon: '📦', label: 'Tool Response', color: 'text-blue-700', bgColor: 'bg-blue-50', borderColor: 'border-blue-200' }, tool_error: { icon: '💥', label: 'Tool Error', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-200' }, approval_requested: { icon: '🔐', label: 'Approval Requested', color: 'text-purple-700', bgColor: 'bg-purple-50', borderColor: 'border-purple-200' }, approval_granted: { icon: '✅', label: 'Approval Granted', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-200' }, approval_denied: { icon: '🚫', label: 'Approval Denied', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-200' }, approval_expired: { icon: '⏰', label: 'Approval Expired', color: 'text-yellow-700', bgColor: 'bg-yellow-50', borderColor: 'border-yellow-200' }, form_submitted: { icon: '📝', label: 'Form Submitted', color: 'text-teal-700', bgColor: 'bg-teal-50', borderColor: 'border-teal-200' }, form_completed: { icon: '✏️', label: 'Form Completed', color: 'text-teal-700', bgColor: 'bg-teal-50', borderColor: 'border-teal-200' }, form_expired: { icon: '⏳', label: 'Form Expired', color: 'text-orange-700', bgColor: 'bg-orange-50', borderColor: 'border-orange-200' }, cost_tracked: { icon: '💰', label: 'Cost Tracked', color: 'text-yellow-700', bgColor: 'bg-yellow-50', borderColor: 'border-yellow-200' }, llm_call: { icon: '🧠', label: 'LLM Call', color: 'text-indigo-700', bgColor: 'bg-indigo-50', borderColor: 'border-indigo-200' }, llm_response: { icon: '💬', label: 'LLM Response', color: 'text-indigo-700', bgColor: 'bg-indigo-50', borderColor: 'border-indigo-200' }, alert_triggered: { icon: '🚨', label: 'Alert Triggered', color: 'text-red-700', bgColor: 'bg-red-50', borderColor: 'border-red-200' }, alert_resolved: { icon: '🔔', label: 'Alert Resolved', color: 'text-green-700', bgColor: 'bg-green-50', borderColor: 'border-green-200' }, eval_result: { icon: '⚖️', label: 'Eval Result', color: 'text-cyan-700', bgColor: 'bg-cyan-50', borderColor: 'border-cyan-200' }, skill_activated: { icon: '🧩', label: 'Skill', color: 'text-fuchsia-700',bgColor: 'bg-fuchsia-50', borderColor: 'border-fuchsia-200' }, custom: { icon: '📎', label: 'Custom', color: 'text-gray-700', bgColor: 'bg-gray-50', borderColor: 'border-gray-200' }, }; function getEventStyle(eventType: string): EventStyle { return EVENT_STYLES[eventType] ?? EVENT_STYLES.custom; } // ─── Filter categories ────────────────────────────────────────────── interface FilterCategory { key: string; label: string; icon: string; eventTypes: Set; } const FILTER_CATEGORIES: FilterCategory[] = [ { key: 'llm', label: 'LLM', icon: '🧠', eventTypes: new Set(['llm_call', 'llm_response']) }, { key: 'tools', label: 'Tools', icon: '🔧', eventTypes: new Set(['tool_call', 'tool_response', 'tool_error']) }, { key: 'errors', label: 'Errors', icon: '❌', eventTypes: new Set(['tool_error', 'alert_triggered']) }, { key: 'approvals', label: 'Approvals', icon: '🔐', eventTypes: new Set(['approval_requested', 'approval_granted', 'approval_denied', 'approval_expired']) }, { key: 'forms', label: 'Forms', icon: '📝', eventTypes: new Set(['form_submitted', 'form_completed', 'form_expired']) }, { key: 'lifecycle', label: 'Lifecycle', icon: '🔄', eventTypes: new Set(['session_started', 'session_ended']) }, { key: 'cost', label: 'Cost', icon: '💰', eventTypes: new Set(['cost_tracked']) }, { key: 'evals', label: 'Evals', icon: '⚖️', eventTypes: new Set(['eval_result']) }, // Generic OTLP-ingested telemetry (Claude Code metrics/logs: prompts, hooks, // skills, token/cost/active-time metrics, …) — all ingested as eventType=custom. { key: 'telemetry', label: 'Telemetry', icon: '📡', eventTypes: new Set(['custom']) }, ]; // ─── Pairing helpers ──────────────────────────────────────────────── const CALL_RESPONSE_MAP: Record = { tool_call: 'tool_response', llm_call: 'llm_response', approval_requested: 'approval_granted', form_submitted: 'form_completed', }; const RESPONSE_TYPES = new Set([ 'tool_response', 'tool_error', 'llm_response', 'approval_granted', 'approval_denied', 'approval_expired', 'form_completed', 'form_expired', ]); function getCallId(ev: AgentLensEvent): string | null { const p = ev.payload as Record; if (typeof p.callId === 'string') return p.callId; if (typeof p.requestId === 'string') return p.requestId; if (typeof p.submissionId === 'string') return p.submissionId; return null; } interface PairedEntry { /** Index in the original events array */ index: number; event: AgentLensEvent; pairedEvent?: AgentLensEvent; pairDurationMs?: number; } function buildPairedEntries(events: AgentLensEvent[]): PairedEntry[] { // Build a map of callId→response for pairing const responseMap = new Map(); for (const ev of events) { if (RESPONSE_TYPES.has(ev.eventType)) { const cid = getCallId(ev); if (cid) responseMap.set(`${ev.eventType}:${cid}`, ev); } } const usedResponses = new Set(); const entries: PairedEntry[] = []; for (let i = 0; i < events.length; i++) { const ev = events[i]; // Skip response events that will be shown inline under their call if (RESPONSE_TYPES.has(ev.eventType)) { const cid = getCallId(ev); if (cid && usedResponses.has(`${ev.eventType}:${cid}`)) continue; } const entry: PairedEntry = { index: i, event: ev }; // Try to find paired response if (CALL_RESPONSE_MAP[ev.eventType]) { const cid = getCallId(ev); if (cid) { // Look for matching response or error const responseType = CALL_RESPONSE_MAP[ev.eventType]; const errorTypes = ev.eventType === 'tool_call' ? ['tool_error'] : ev.eventType === 'approval_requested' ? ['approval_denied', 'approval_expired'] : ev.eventType === 'form_submitted' ? ['form_expired'] : []; // Check primary response type let paired = responseMap.get(`${responseType}:${cid}`); if (paired) { usedResponses.add(`${responseType}:${cid}`); } else { // Check error variants for (const errType of errorTypes) { paired = responseMap.get(`${errType}:${cid}`); if (paired) { usedResponses.add(`${errType}:${cid}`); break; } } } if (paired) { entry.pairedEvent = paired; const callTime = new Date(ev.timestamp).getTime(); const respTime = new Date(paired.timestamp).getTime(); entry.pairDurationMs = respTime - callTime; } } } entries.push(entry); } return entries; } // ─── Event summary extraction ─────────────────────────────────────── function getEventSummary(ev: AgentLensEvent): string { const p = ev.payload as Record; switch (ev.eventType) { case 'tool_call': return `${p.toolName}(${summarizeArgs(p.arguments as Record)})`; case 'tool_response': return `${p.toolName} → ${formatDurationMs(p.durationMs as number)}`; case 'tool_error': return `${p.toolName}: ${p.error}`; case 'llm_call': return `${p.provider}/${p.model} · ${(p.messages as unknown[])?.length ?? 0} messages`; case 'llm_response': { const usage = p.usage as { inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined; return `${p.model} · ${usage?.totalTokens ?? 0} tokens · $${((p.costUsd as number) ?? 0).toFixed(4)}`; } case 'cost_tracked': return `${p.provider}/${p.model} · $${((p.costUsd as number) ?? 0).toFixed(4)}`; case 'session_started': return p.agentName ? `Agent: ${p.agentName}` : 'Session initialized'; case 'session_ended': return `Reason: ${p.reason}${p.totalDurationMs ? ` · ${formatDurationMs(p.totalDurationMs as number)}` : ''}`; case 'approval_requested': return `${p.action} (${p.urgency})`; case 'approval_granted': case 'approval_denied': case 'approval_expired': return `${p.action} by ${p.decidedBy ?? 'system'}`; case 'form_submitted': return `${p.formName ?? p.formId} · ${p.fieldCount} fields`; case 'form_completed': return `${p.formId} by ${p.completedBy}`; case 'form_expired': return `${p.formId} after ${formatDurationMs(p.expiredAfterMs as number)}`; case 'alert_triggered': return `${p.alertName}: ${p.message}`; case 'alert_resolved': return `${p.alertName} resolved`; case 'skill_activated': return `${p.skillName ?? 'skill'}${p.source ? ` (${p.source})` : ''}`; case 'eval_result': { const score = typeof p.score === 'number' ? p.score : 0; const violations = (p.violations as unknown[] | undefined)?.length ?? 0; const kind = p.method === 'llm_judge' ? 'AI judgment' : (p.scorerType ?? 'compliance'); return `${kind} · ${(score * 100).toFixed(0)}%${violations ? ` · ${violations} violation${violations === 1 ? '' : 's'}` : ''}`; } default: { const ot = otlpTitle(ev); if (ot) return ot; const customP = p as { type?: string }; return customP.type ?? ev.eventType; } } } function summarizeArgs(args: Record | undefined): string { if (!args) return ''; const keys = Object.keys(args); if (keys.length === 0) return ''; if (keys.length <= 2) { return keys.map(k => { const v = args[k]; const s = typeof v === 'string' ? (v.length > 30 ? v.slice(0, 30) + '…' : v) : JSON.stringify(v); return `${k}: ${s}`; }).join(', '); } return `${keys.length} args`; } function formatDurationMs(ms: number | undefined): string { if (ms === undefined || ms === null) return '—'; if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 60000).toFixed(1)}m`; } function formatRelativeTime(eventTime: string, sessionStart: string): string { const diff = new Date(eventTime).getTime() - new Date(sessionStart).getTime(); if (diff < 0) return '-' + formatDurationMs(-diff); return '+' + formatDurationMs(diff); } // ─── Windowing constants ──────────────────────────────────────────── const WINDOW_SIZE = 50; const WINDOW_BUFFER = 10; // ─── ReplayEventCard ──────────────────────────────────────────────── interface ReplayEventCardProps { entry: PairedEntry; stepNumber: number; isCurrent: boolean; sessionStartTime: string; onClick: () => void; searchQuery?: string; } function ReplayEventCard({ entry, stepNumber, isCurrent, sessionStartTime, onClick, searchQuery, }: ReplayEventCardProps): React.ReactElement { const [expanded, setExpanded] = useState(false); const style = getEventStyle(entry.event.eventType); const { isBookmarked, toggle: toggleBookmark } = useBookmarks(); const handleToggleExpand = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setExpanded(prev => !prev); }, []); return (
{/* Header row */}
{/* Step number */} {stepNumber} {/* Event icon + type */} {style.icon} {style.label} {/* Timestamp */} {formatRelativeTime(entry.event.timestamp, sessionStartTime)} {/* Severity badge for errors */} {(entry.event.severity === 'error' || entry.event.severity === 'critical') && ( {entry.event.severity} )} {/* Eval-result verdict: PASS/FAIL, plus an "AI judgment" tag so a non-deterministic LLM judgment reads as a judgment, not a proof. */} {entry.event.eventType === 'eval_result' && (() => { const ev = entry.event.payload as { passed: boolean; method?: string }; return ( <> {ev.passed ? 'PASS' : 'FAIL'} {ev.method === 'llm_judge' && ( AI judgment )} ); })()} {/* [F11-S4] Bookmark toggle */}
{/* Summary */}
{searchQuery ? highlightMatches(getEventSummary(entry.event), searchQuery) : getEventSummary(entry.event)}
{/* Paired response inline */} {entry.pairedEvent && (
{getEventStyle(entry.pairedEvent.eventType).icon} {getEventStyle(entry.pairedEvent.eventType).label} {entry.pairDurationMs !== undefined && ( {formatDurationMs(entry.pairDurationMs)} )}
{getEventSummary(entry.pairedEvent)}
)} {/* Expand toggle */} {/* Expanded payload */} {expanded && (
{Object.keys(entry.event.metadata).length > 0 && ( )} {entry.pairedEvent && ( )}
)}
); } // ─── Payload detail display ───────────────────────────────────────── function PayloadDetail({ label, data }: { label: string; data: unknown }): React.ReactElement { return (
{label}
        {JSON.stringify(data, null, 2)}
      
); } // ─── Main ReplayTimeline Component ────────────────────────────────── export function ReplayTimeline({ events, currentStep, onStepChange, sessionStartTime, searchQuery, }: ReplayTimelineProps): React.ReactElement { const scrollContainerRef = useRef(null); const currentCardRef = useRef(null); const [filters, setFilters] = useState>(() => { const initial: Record = {}; for (const cat of FILTER_CATEGORIES) { initial[cat.key] = true; } return initial; }); // Build paired entries const pairedEntries = useMemo(() => buildPairedEntries(events), [events]); // Compute which event types are active based on filters const activeEventTypes = useMemo(() => { const active = new Set(); for (const cat of FILTER_CATEGORIES) { if (filters[cat.key]) { for (const t of cat.eventTypes) { active.add(t); } } } return active; }, [filters]); // Filter entries based on active types const typeFilteredEntries = useMemo(() => { return pairedEntries.filter(entry => activeEventTypes.has(entry.event.eventType)); }, [pairedEntries, activeEventTypes]); // [F11-S1] Search filter const filteredEntries = useMemo(() => { if (!searchQuery) return typeFilteredEntries; const q = searchQuery.toLowerCase(); return typeFilteredEntries.filter(entry => JSON.stringify(entry.event.payload).toLowerCase().includes(q) || entry.event.eventType.toLowerCase().includes(q), ); }, [typeFilteredEntries, searchQuery]); // Find the current step index within the filtered list const currentFilteredIndex = useMemo(() => { return filteredEntries.findIndex(e => e.index === currentStep); }, [filteredEntries, currentStep]); // Slice-based windowing: show ~WINDOW_SIZE entries around the current step const { windowStart, windowEnd, windowedEntries } = useMemo(() => { const center = Math.max(0, currentFilteredIndex); const halfWindow = Math.floor(WINDOW_SIZE / 2); let start = Math.max(0, center - halfWindow); let end = Math.min(filteredEntries.length, start + WINDOW_SIZE); // Adjust start if we hit the end if (end - start < WINDOW_SIZE) { start = Math.max(0, end - WINDOW_SIZE); } return { windowStart: start, windowEnd: end, windowedEntries: filteredEntries.slice(start, end), }; }, [filteredEntries, currentFilteredIndex]); // Auto-scroll to current card useEffect(() => { if (currentCardRef.current) { currentCardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', }); } }, [currentStep, currentFilteredIndex]); // Filter toggle handler const toggleFilter = useCallback((key: string) => { setFilters(prev => ({ ...prev, [key]: !prev[key] })); }, []); // All/None toggle const allFiltersActive = Object.values(filters).every(Boolean); const toggleAll = useCallback(() => { const newVal = !allFiltersActive; setFilters(() => { const next: Record = {}; for (const cat of FILTER_CATEGORIES) { next[cat.key] = newVal; } return next; }); }, [allFiltersActive]); return (
{/* Filter toolbar */}
{FILTER_CATEGORIES.map(cat => ( ))} {filteredEntries.length}/{pairedEntries.length} events
{/* Event list */}
{/* Indicator for items above window */} {windowStart > 0 && (
↑ {windowStart} earlier events
)} {windowedEntries.map((entry) => { const isCurrentCard = entry.index === currentStep; const stepNum = entry.index + 1; return (
onStepChange(entry.index)} searchQuery={searchQuery} />
); })} {/* Indicator for items below window */} {windowEnd < filteredEntries.length && (
↓ {filteredEntries.length - windowEnd} more events
)} {filteredEntries.length === 0 && (
🔍

No events match the current filters

)}
); }