/** * @file EventReplay.tsx * @description The session Replay tab: a scrubber over a session's recorded * events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map * whose markers jump the cursor, the state derived up to the cursor * (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of * events, so the live analysis page and a frozen snapshot share it. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react"; import { api } from "../lib/api"; import { chronological, lifecycleMarkers, replayState, LIFECYCLE_TYPES, REPLAY_SPEEDS, REPLAY_STEP_MS, } from "../lib/replay"; import { buildEventTitle } from "../lib/event-grouping"; import type { AgentInfo } from "../lib/event-grouping"; import { formatDateTimeFull, formatMs } from "../lib/format"; import type { DashboardEvent } from "../lib/types"; import { AgentStatusBadge } from "./StatusBadge"; import { EventDetail } from "./EventDetail"; /** The newest this many events are replayed; older ones are left out. */ export const REPLAY_MAX_EVENTS = 5000; const PAGE = 500; /** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */ export async function loadReplayEvents( sessionId: string ): Promise<{ events: DashboardEvent[]; total: number }> { const all: DashboardEvent[] = []; let total = 0; do { const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length }); total = r.total; if (r.events.length === 0) break; all.push(...r.events); } while (all.length < Math.min(total, REPLAY_MAX_EVENTS)); return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total }; } const MARKER_COLOR: Record = { SessionStart: "bg-status-success", UserPromptSubmit: "bg-accent", Compaction: "bg-status-warning", SubagentStop: "bg-violet-500", APIError: "bg-status-danger", Stop: "bg-fg-muted", }; const btn = "inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed"; export function EventReplay({ events: raw, total, agentInfoById, sessionNameById, }: { events: DashboardEvent[]; /** The session's full event count, when more than `events` were recorded. */ total?: number; agentInfoById: Map; sessionNameById: Map; }) { const { t } = useTranslation("sessions"); const events = useMemo(() => chronological(raw), [raw]); const markers = useMemo(() => lifecycleMarkers(events), [events]); const last = events.length - 1; const [cursor, setCursor] = useState(0); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); const at = Math.min(cursor, Math.max(0, last)); const state = useMemo(() => replayState(events, at), [events, at]); const event = events[at]; useEffect(() => { if (!playing) return; if (at >= last) { setPlaying(false); return; } const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed); return () => clearTimeout(timer); }, [playing, at, last, speed]); if (!event) return

{t("detail.noEvents")}

; const go = (i: number) => setCursor(Math.max(0, Math.min(last, i))); const toggle = () => { if (!playing && at >= last) setCursor(0); setPlaying((p) => !p); }; const onKey = (e: KeyboardEvent) => { // The slider steps itself on arrow keys; only the rest is ours there. const onSlider = e.target instanceof HTMLInputElement; if (e.key === " " && !(e.target instanceof HTMLButtonElement)) { e.preventDefault(); toggle(); } else if (onSlider) return; else if (e.key === "ArrowLeft") go(at - 1); else if (e.key === "ArrowRight") go(at + 1); else if (e.key === "Home") go(0); else if (e.key === "End") go(last); else return; e.preventDefault(); }; const markerLabel = (type: string) => t(`replay.marker.${type}`); const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k)); return (
{total != null && total > events.length && (

{t("replay.truncated", { shown: events.length, total })}

)}
{REPLAY_SPEEDS.map((s) => ( ))}
{t("replay.position", { at: at + 1, n: events.length })} ·{" "} {formatDateTimeFull(event.created_at)}
go(Number(e.target.value))} aria-label={t("replay.scrubber")} className="w-full accent-[rgb(var(--accent))]" />
0 ? (at / last) * 100 : 0}%` }} /> {markers.map((m) => (
{present.length > 0 && (
{present.map((k) => ( {markerLabel(k)} ))}
)}
{state.status ? : "-"} {state.currentTool ?? "-"} {state.prompts} {state.errors} {state.compactions} {formatMs(state.elapsedMs)}
{state.toolCounts.length > 0 && (
{state.toolCounts.map((x) => ( {x.tool} ×{x.count} ))}
)}
#{at + 1} {buildEventTitle(event)}
); } function Tile({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); }