/** * Session Replay Page (Stories 5.1–5.5) * * Route: /replay/:sessionId * * Features: * - Fetches GET /api/sessions/:id/replay via api client * - Loading spinner, error state (404: "Session not found") * - Header: agent name, session status badge, duration, total cost, event/error/LLM/tool counts * - Back button to /sessions/:id * - URL query param ?step=N sets initial step * - ReplayControls at top * - ReplayScrubber below controls * - ReplayTimeline (left) — step-by-step event view (Story 5.4) * - ContextPanel (right) — cumulative state display (Story 5.5) */ import React, { useCallback, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; import type { SessionStatus } from '@agentkitai/agentlens-core'; import { getSessionReplay, getSessionTrace } from '../api/client'; import type { SessionReplayData, SessionTrace } from '../api/client'; import { useApi } from '../hooks/useApi'; import { ReplayControls } from '../components/replay/ReplayControls'; import { ReplayScrubber } from '../components/replay/ReplayScrubber'; import { ReplayTimeline } from '../components/replay/ReplayTimeline'; import { TraceTree, hasTraceData } from '../components/TraceTree'; import { ContextPanel } from '../components/replay/ContextPanel'; import { BookmarkProvider, useBookmarks } from '../components/replay/BookmarkProvider'; import { SearchBar } from '../components/search/SearchBar'; import { ErrorNav } from '../components/navigation/ErrorNav'; import { useErrorIndices, findNextError, findPrevError } from '../hooks/useErrorIndices'; // ─── Status badge (reuse pattern from SessionDetail) ──────────────── const STATUS_CONFIG: Record = { completed: { label: 'Completed', icon: '✅', className: 'bg-green-100 text-green-800 border-green-300' }, error: { label: 'Error', icon: '❌', className: 'bg-red-100 text-red-800 border-red-300' }, active: { label: 'Active', icon: '🔄', className: 'bg-blue-100 text-blue-800 border-blue-300' }, idle: { label: 'Idle', icon: '💤', className: 'bg-gray-100 text-gray-700 border-gray-300' }, }; function StatusBadge({ status }: { status: SessionStatus }): React.ReactElement { const cfg = STATUS_CONFIG[status]; return ( {status === 'active' && ( )} {cfg.icon} {cfg.label} ); } // ─── Duration formatting ──────────────────────────────────────────── function formatDuration(startedAt: string, endedAt?: string): string { const start = new Date(startedAt).getTime(); const end = endedAt ? new Date(endedAt).getTime() : Date.now(); const diffMs = end - start; if (diffMs < 1000) return `${diffMs}ms`; const secs = Math.floor(diffMs / 1000); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); const remSecs = secs % 60; if (mins < 60) return `${mins}m ${remSecs}s`; const hrs = Math.floor(mins / 60); const remMins = mins % 60; return `${hrs}h ${remMins}m`; } // ─── Stat card ────────────────────────────────────────────────────── function StatCard({ label, value, icon, className = '' }: { label: string; value: string | number; icon: string; className?: string }): React.ReactElement { return (
{icon}
{label}
{value}
); } // ─── [F11-S4] Scrubber wrapper that reads bookmark context ────────── function BookmarkedScrubber(props: React.ComponentProps): React.ReactElement { const { bookmarks } = useBookmarks(); return ; } // ─── Main component ───────────────────────────────────────────────── export function SessionReplay(): React.ReactElement | null { const { sessionId } = useParams<{ sessionId: string }>(); const [searchParams] = useSearchParams(); // Parse initial step from URL query const initialStep = useMemo(() => { const stepParam = searchParams.get('step'); if (stepParam) { const parsed = parseInt(stepParam, 10); return isNaN(parsed) ? 0 : Math.max(0, parsed); } return 0; }, []); // Only on mount — URL is updated by ReplayControls const [currentStep, setCurrentStep] = useState(initialStep); const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState<'list' | 'tree'>('list'); // Fetch replay data const { data: replay, loading, error, } = useApi(() => getSessionReplay(sessionId!), [sessionId]); // Server-assembled execution tree (#119) — fetched only when Tree view is open. const { data: trace, loading: traceLoading } = useApi( () => (viewMode === 'tree' && sessionId ? getSessionTrace(sessionId) : Promise.resolve(null)), [sessionId, viewMode], ); // Clamp step to valid range when data loads const totalSteps = replay?.events.length ?? 0; const clampedStep = Math.min(currentStep, Math.max(0, totalSteps - 1)); const handleStepChange = useCallback( (step: number) => { setCurrentStep(Math.max(0, Math.min(step, totalSteps - 1))); }, [totalSteps], ); // Count event types for stats const stats = useMemo(() => { if (!replay) return null; const events = replay.events; let errorCount = 0; let llmCount = 0; let toolCount = 0; for (const ev of events) { if ( ev.severity === 'error' || ev.severity === 'critical' || ev.eventType === 'tool_error' || ev.eventType === 'alert_triggered' ) { errorCount++; } if (ev.eventType === 'llm_call' || ev.eventType === 'llm_response') { llmCount++; } if ( ev.eventType === 'tool_call' || ev.eventType === 'tool_response' || ev.eventType === 'tool_error' ) { toolCount++; } } return { total: events.length, errorCount, llmCount, toolCount }; }, [replay]); // [F11-S2] Error navigation const errorIndices = useErrorIndices(replay?.events ?? []); const handleErrorNavigate = useCallback( (index: number) => { handleStepChange(index); }, [handleStepChange], ); // [F11-S2] Keyboard shortcuts for error navigation React.useEffect(() => { if (!replay) return; const handleKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.key === 'E' && e.shiftKey) { e.preventDefault(); if (e.ctrlKey || e.metaKey) { const prev = findPrevError(errorIndices, clampedStep); if (prev !== null) handleStepChange(prev); } else { const next = findNextError(errorIndices, clampedStep); if (next !== null) handleStepChange(next); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [replay, errorIndices, clampedStep, handleStepChange]); // [F11-S1] Search-filtered event count. Must run unconditionally (before the // early returns below) or the hook count changes between loading/loaded renders // ("Rendered more hooks than during the previous render"). const searchFilteredCount = useMemo(() => { const evs = replay?.events ?? []; if (!searchQuery) return evs.length; const q = searchQuery.toLowerCase(); return evs.filter( (ev) => JSON.stringify(ev.payload).toLowerCase().includes(q) || ev.eventType.toLowerCase().includes(q), ).length; }, [replay, searchQuery]); // ── 404 ─────────────────────────────────────────────────────── if (error?.includes('404') || error?.toLowerCase().includes('not found')) { return (
🔍

Session Not Found

The session {sessionId} does not exist.

← Back to Sessions
); } // ── Loading ─────────────────────────────────────────────────── if (loading && !replay) { return (
Loading replay…
); } // ── Error ───────────────────────────────────────────────────── if (error) { return (
Error loading replay: {error}
); } if (!replay) return null; const { session, events } = replay; const hasTrace = hasTraceData(events); // ── Render ──────────────────────────────────────────────────── return (
{/* Back button */} ← Back to Session {/* Header */}

🎬 {session.agentName ?? session.agentId}

Session Replay · {formatDuration(session.startedAt, session.endedAt)}

{/* Stats */}
0 ? 'border-red-200' : ''} />
{/* [F11-S1] Search bar + [F11-S2] Error nav */}
{hasTrace && (
{(['list', 'tree'] as const).map((mode) => ( ))}
)}
{/* Replay controls */} {/* Timeline scrubber */} {/* Timeline + Context Panel */}
{/* Left: ReplayTimeline (list) or TraceTree (tree/waterfall, #119) */}
{viewMode === 'tree' && hasTrace ? (
{traceLoading && !trace ? (
) : trace ? ( { const idx = events.findIndex((e) => e.id === ev.id); if (idx >= 0) handleStepChange(idx); }} /> ) : null}
) : ( )}
{/* Right: ContextPanel */}
); }