feat: session replay

* wip

* wip

* wip

* wip

* final fixes

* comments

* fix
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-26 14:09:53 +01:00
committed by GitHub
parent 38d9b65ec8
commit aa81bbfe77
67 changed files with 3059 additions and 556 deletions

View File

@@ -89,7 +89,7 @@ export function useColumns() {
projectId: row.original.projectId,
});
}}
className="font-medium"
className="font-medium hover:underline"
>
{renderName()}
</button>
@@ -144,10 +144,21 @@ export function useColumns() {
{
accessorKey: 'sessionId',
header: 'Session ID',
size: 320,
size: 100,
meta: {
hidden: true,
},
cell({ row }) {
const { sessionId } = row.original;
return (
<ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{sessionId.slice(0,6)}
</ProjectLink>
);
},
},
{
accessorKey: 'deviceId',

View File

@@ -0,0 +1,42 @@
import { cn } from '@/utils/cn';
import type { ReactNode } from 'react';
export function BrowserChrome({
url,
children,
right,
controls = (
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
),
className,
}: {
url?: ReactNode;
children: ReactNode;
right?: ReactNode;
controls?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background',
className,
)}
>
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background h-10">
{controls}
{url !== false && (
<div className="flex-1 mx-4 px-3 h-8 py-1 text-sm bg-def-100 rounded-md border border-border flex items-center truncate">
{url}
</div>
)}
{right}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,242 @@
import type { IServiceEvent } from '@openpanel/db';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Maximize2, Minimize2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BrowserChrome } from './browser-chrome';
import { ReplayTime } from './replay-controls';
import { ReplayTimeline } from './replay-timeline';
import { getEventOffsetMs } from './replay-utils';
import {
ReplayProvider,
useCurrentTime,
useReplayContext,
} from '@/components/sessions/replay/replay-context';
import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed';
import { ReplayPlayer } from '@/components/sessions/replay/replay-player';
import { useTRPC } from '@/integrations/trpc/react';
function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
const { startTime } = useReplayContext();
const currentTime = useCurrentTime(250);
const currentUrl = useMemo(() => {
if (startTime == null || !events.length) {
return '';
}
const withOffset = events
.map((ev) => ({
event: ev,
offsetMs: getEventOffsetMs(ev, startTime),
}))
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
.sort((a, b) => a.offsetMs - b.offsetMs);
const latest = withOffset.at(-1);
if (!latest) {
return '';
}
const { origin = '', path = '/' } = latest.event;
return `${origin}${path}`;
}, [events, currentTime, startTime]);
return <span className="truncate text-muted-foreground">{currentUrl}</span>;
}
/**
* Feeds remaining chunks into the player after it's ready.
* Receives already-fetched chunks from the initial batch, then pages
* through the rest using replayChunksFrom.
*/
function ReplayChunkLoader({
sessionId,
projectId,
fromIndex,
}: {
sessionId: string;
projectId: string;
fromIndex: number;
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { addEvent, refreshDuration } = useReplayContext();
useEffect(() => {
function recursive(fromIndex: number) {
queryClient
.fetchQuery(
trpc.session.replayChunksFrom.queryOptions({
sessionId,
projectId,
fromIndex,
})
)
.then((res) => {
res.data.forEach((row) => {
row?.events?.forEach((event) => {
addEvent(event);
});
});
refreshDuration();
if (res.hasMore) {
recursive(fromIndex + res.data.length);
}
})
.catch(() => {
// chunk loading failed — replay may be incomplete
});
}
recursive(fromIndex);
}, []);
return null;
}
function FullscreenButton({
containerRef,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggle = useCallback(() => {
if (!containerRef.current) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
containerRef.current.requestFullscreen();
}
}, [containerRef]);
return (
<button
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground"
onClick={toggle}
type="button"
>
{isFullscreen ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
);
}
function ReplayContent({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
const trpc = useTRPC();
const containerRef = useRef<HTMLDivElement>(null);
const { data: eventsData } = useQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
})
);
// Fetch first batch of chunks (includes chunk 0 for player init + more)
const { data: firstBatch, isLoading: replayLoading } = useQuery(
trpc.session.replayChunksFrom.queryOptions({
sessionId,
projectId,
fromIndex: 0,
})
);
const events = eventsData?.data ?? [];
const playerEvents =
firstBatch?.data.flatMap((row) => row?.events ?? []) ?? [];
const hasMore = firstBatch?.hasMore ?? false;
const hasReplay = playerEvents.length !== 0;
function renderReplay() {
if (replayLoading) {
return (
<div className="col h-[320px] items-center justify-center gap-4 bg-background">
<div className="h-8 w-8 animate-pulse rounded-full bg-muted" />
<div>Loading session replay</div>
</div>
);
}
if (hasReplay) {
return <ReplayPlayer events={playerEvents} />;
}
return (
<div className="flex h-[320px] items-center justify-center bg-background text-muted-foreground text-sm">
No replay data available for this session.
</div>
);
}
return (
<ReplayProvider>
<div
className="grid gap-4 lg:grid-cols-[1fr_380px] [&:fullscreen]:flex [&:fullscreen]:flex-col [&:fullscreen]:bg-background [&:fullscreen]:p-4"
id="replay"
ref={containerRef}
>
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
right={
<div className="flex items-center gap-2">
{hasReplay && <ReplayTime />}
<FullscreenButton containerRef={containerRef} />
</div>
}
url={
hasReplay ? (
<BrowserUrlBar events={events} />
) : (
<span className="text-muted-foreground">about:blank</span>
)
}
>
{renderReplay()}
{hasReplay && <ReplayTimeline events={events} />}
</BrowserChrome>
</div>
<div className="relative hidden lg:block">
<div className="absolute inset-0">
<ReplayEventFeed events={events} replayLoading={replayLoading} />
</div>
</div>
</div>
{hasReplay && hasMore && (
<ReplayChunkLoader
fromIndex={firstBatch?.data?.length ?? 0}
projectId={projectId}
sessionId={sessionId}
/>
)}
</ReplayProvider>
);
}
export function ReplayShell({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
return <ReplayContent projectId={projectId} sessionId={sessionId} />;
}

View File

@@ -0,0 +1,205 @@
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
export interface ReplayPlayerInstance {
play: () => void;
pause: () => void;
toggle: () => void;
goto: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
getMetaData: () => { startTime: number; endTime: number; totalTime: number };
getReplayer: () => { getCurrentTime: () => number };
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
addEventListener: (event: string, handler: (e: { payload: unknown }) => void) => void;
$set?: (props: Record<string, unknown>) => void;
$destroy?: () => void;
}
type CurrentTimeListener = (t: number) => void;
interface ReplayContextValue {
// High-frequency value — read via ref, not state. Use subscribeToCurrentTime
// or useCurrentTime() to get updates without causing 60fps re-renders.
currentTimeRef: React.MutableRefObject<number>;
subscribeToCurrentTime: (fn: CurrentTimeListener) => () => void;
// Low-frequency state (safe to consume directly)
isPlaying: boolean;
duration: number;
startTime: number | null;
isReady: boolean;
// Playback controls
play: () => void;
pause: () => void;
toggle: () => void;
seek: (timeMs: number) => void;
setSpeed: (speed: number) => void;
// Lazy chunk loading
addEvent: (event: { type: number; data: unknown; timestamp: number }) => void;
refreshDuration: () => void;
// Called by ReplayPlayer to register/unregister the rrweb instance
onPlayerReady: (player: ReplayPlayerInstance, playerStartTime: number) => void;
onPlayerDestroy: () => void;
// State setters exposed so ReplayPlayer can wire rrweb event listeners
setCurrentTime: (t: number) => void;
setIsPlaying: (p: boolean) => void;
setDuration: (d: number) => void;
}
const ReplayContext = createContext<ReplayContextValue | null>(null);
const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const;
export function useReplayContext() {
const ctx = useContext(ReplayContext);
if (!ctx) {
throw new Error('useReplayContext must be used within ReplayProvider');
}
return ctx;
}
/**
* Subscribe to currentTime updates at a throttled rate.
* intervalMs=0 means every tick (use for the progress bar DOM writes).
* intervalMs=250 means 4 updates/second (use for text displays).
*/
export function useCurrentTime(intervalMs = 0): number {
const { currentTimeRef, subscribeToCurrentTime } = useReplayContext();
const [time, setTime] = useState(currentTimeRef.current);
const lastUpdateRef = useRef(0);
useEffect(() => {
return subscribeToCurrentTime((t) => {
if (intervalMs === 0) {
setTime(t);
return;
}
const now = performance.now();
if (now - lastUpdateRef.current >= intervalMs) {
lastUpdateRef.current = now;
setTime(t);
}
});
}, [subscribeToCurrentTime, intervalMs]);
return time;
}
export function ReplayProvider({ children }: { children: ReactNode }) {
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const isPlayingRef = useRef(false);
const currentTimeRef = useRef(0);
const listenersRef = useRef<Set<CurrentTimeListener>>(new Set());
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [startTime, setStartTime] = useState<number | null>(null);
const [isReady, setIsReady] = useState(false);
const setIsPlayingWithRef = useCallback((playing: boolean) => {
isPlayingRef.current = playing;
setIsPlaying(playing);
}, []);
const subscribeToCurrentTime = useCallback((fn: CurrentTimeListener) => {
listenersRef.current.add(fn);
return () => {
listenersRef.current.delete(fn);
};
}, []);
// Called by ReplayPlayer on every ui-update-current-time tick.
// Updates the ref and notifies subscribers — no React state update here.
const setCurrentTime = useCallback((t: number) => {
currentTimeRef.current = t;
for (const fn of listenersRef.current) {
fn(t);
}
}, []);
const onPlayerReady = useCallback(
(player: ReplayPlayerInstance, playerStartTime: number) => {
playerRef.current = player;
setStartTime(playerStartTime);
currentTimeRef.current = 0;
setIsPlayingWithRef(false);
setIsReady(true);
},
[setIsPlayingWithRef],
);
const onPlayerDestroy = useCallback(() => {
playerRef.current = null;
setIsReady(false);
currentTimeRef.current = 0;
setDuration(0);
setStartTime(null);
setIsPlayingWithRef(false);
}, [setIsPlayingWithRef]);
const play = useCallback(() => {
playerRef.current?.play();
}, []);
const pause = useCallback(() => {
playerRef.current?.pause();
}, []);
const toggle = useCallback(() => {
playerRef.current?.toggle();
}, []);
const seek = useCallback((timeMs: number) => {
playerRef.current?.goto(timeMs, isPlayingRef.current);
}, []);
const setSpeed = useCallback((s: number) => {
if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return;
playerRef.current?.setSpeed(s);
}, []);
const addEvent = useCallback(
(event: { type: number; data: unknown; timestamp: number }) => {
playerRef.current?.addEvent(event);
},
[],
);
const refreshDuration = useCallback(() => {
const total = playerRef.current?.getMetaData().totalTime ?? 0;
if (total > 0) setDuration(total);
}, []);
const value: ReplayContextValue = {
currentTimeRef,
subscribeToCurrentTime,
isPlaying,
duration,
startTime,
isReady,
play,
pause,
toggle,
seek,
setSpeed,
addEvent,
refreshDuration,
onPlayerReady,
onPlayerDestroy,
setCurrentTime,
setIsPlaying: setIsPlayingWithRef,
setDuration,
};
return (
<ReplayContext.Provider value={value}>{children}</ReplayContext.Provider>
);
}
export { SPEED_OPTIONS };

View File

@@ -0,0 +1,33 @@
import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context';
import { Button } from '@/components/ui/button';
import { Pause, Play } from 'lucide-react';
import { formatDuration } from './replay-utils';
export function ReplayTime() {
const { duration } = useReplayContext();
const currentTime = useCurrentTime(250);
return (
<span className="text-sm tabular-nums text-muted-foreground font-mono">
{formatDuration(currentTime)} / {formatDuration(duration)}
</span>
);
}
export function ReplayPlayPauseButton() {
const { isPlaying, isReady, toggle } = useReplayContext();
if (!isReady) return null;
return (
<Button
type="button"
variant={isPlaying ? 'outline' : 'default'}
size="icon"
onClick={toggle}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
);
}

View File

@@ -0,0 +1,107 @@
import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context';
import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IServiceEvent } from '@openpanel/db';
import { useEffect, useMemo, useRef } from 'react';
import { BrowserChrome } from './browser-chrome';
import { getEventOffsetMs } from './replay-utils';
type EventWithOffset = { event: IServiceEvent; offsetMs: number };
export function ReplayEventFeed({ events, replayLoading }: { events: IServiceEvent[]; replayLoading: boolean }) {
const { startTime, isReady, seek } = useReplayContext();
const currentTime = useCurrentTime(100);
const viewportRef = useRef<HTMLDivElement | null>(null);
const prevCountRef = useRef(0);
// Pre-sort events by offset once when events/startTime changes.
// This is the expensive part — done once, not on every tick.
const sortedEvents = useMemo<EventWithOffset[]>(() => {
if (startTime == null || !isReady) return [];
return events
.map((ev) => ({ event: ev, offsetMs: getEventOffsetMs(ev, startTime) }))
.filter(({ offsetMs }) => offsetMs >= -10_000)
.sort((a, b) => a.offsetMs - b.offsetMs);
}, [events, startTime, isReady]);
// Binary search to find how many events are visible at currentTime.
// O(log n) instead of O(n) filter on every tick.
const visibleCount = useMemo(() => {
let lo = 0;
let hi = sortedEvents.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if ((sortedEvents[mid]?.offsetMs ?? 0) <= currentTime) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}, [sortedEvents, currentTime]);
const visibleEvents = sortedEvents.slice(0, visibleCount);
const currentEventId = visibleEvents[visibleCount - 1]?.event.id ?? null;
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || visibleEvents.length === 0) return;
const isNewItem = visibleEvents.length > prevCountRef.current;
prevCountRef.current = visibleEvents.length;
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: isNewItem ? 'smooth' : 'instant',
});
});
}, [visibleEvents.length]);
return (
<BrowserChrome
url={false}
controls={<span className="text-lg font-medium">Timeline</span>}
className="h-full"
>
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
<div className="flex w-full flex-col">
{visibleEvents.map(({ event, offsetMs }) => (
<div
key={event.id}
className="animate-in fade-in-0 slide-in-from-bottom-3 min-w-0 duration-300 fill-mode-both"
>
<ReplayEventItem
event={event}
isCurrent={event.id === currentEventId}
onClick={() => seek(Math.max(0, offsetMs))}
/>
</div>
))}
{!replayLoading && visibleEvents.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
Events will appear as the replay plays.
</div>
)}
{replayLoading &&
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 border-b px-3 py-2"
>
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div
className="h-3 animate-pulse rounded bg-muted"
style={{ width: `${50 + (i % 4) * 12}%` }}
/>
</div>
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</ScrollArea>
</BrowserChrome>
);
}

View File

@@ -0,0 +1,51 @@
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import type { IServiceEvent } from '@openpanel/db';
function formatTime(date: Date | string): string {
const d = date instanceof Date ? date : new Date(date);
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
const s = d.getSeconds().toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
export function ReplayEventItem({
event,
isCurrent,
onClick,
}: {
event: IServiceEvent;
isCurrent: boolean;
onClick: () => void;
}) {
const displayName =
event.name === 'screen_view' && event.path
? event.path
: event.name.replace(/_/g, ' ');
return (
<button
type="button"
onClick={onClick}
className={cn(
'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent',
isCurrent ? 'bg-accent/10' : 'bg-card',
)}
>
<div className="row items-center gap-2">
<div className="flex-shrink-0">
<EventIcon name={event.name} meta={event.meta} size="sm" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{displayName}
</div>
</div>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatTime(event.createdAt)}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,184 @@
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context';
import { useEffect, useMemo, useRef, useState } from 'react';
import 'rrweb-player/dist/style.css';
/** rrweb meta event (type 4) carries the recorded viewport size */
function getRecordedDimensions(
events: Array<{ type: number; data: unknown }>,
): { width: number; height: number } | null {
const meta = events.find((e) => e.type === 4);
if (
meta &&
typeof meta.data === 'object' &&
meta.data !== null &&
'width' in meta.data &&
'height' in meta.data
) {
const { width, height } = meta.data as { width: number; height: number };
if (width > 0 && height > 0) return { width, height };
}
return null;
}
function calcDimensions(
containerWidth: number,
aspectRatio: number,
): { width: number; height: number } {
const maxHeight = window.innerHeight * 0.7;
const height = Math.min(Math.round(containerWidth / aspectRatio), maxHeight);
const width = Math.min(containerWidth, Math.round(height * aspectRatio));
return { width, height };
}
export function ReplayPlayer({
events,
}: {
events: Array<{ type: number; data: unknown; timestamp: number }>;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const {
onPlayerReady,
onPlayerDestroy,
setCurrentTime,
setIsPlaying,
setDuration,
} = useReplayContext();
const [importError, setImportError] = useState(false);
const recordedDimensions = useMemo(
() => getRecordedDimensions(events),
[events],
);
useEffect(() => {
if (!events.length || !containerRef.current) return;
// Clear any previous player DOM
containerRef.current.innerHTML = '';
let mounted = true;
let player: ReplayPlayerInstance | null = null;
let handleVisibilityChange: (() => void) | null = null;
const aspectRatio = recordedDimensions
? recordedDimensions.width / recordedDimensions.height
: 16 / 9;
const { width, height } = calcDimensions(
containerRef.current.offsetWidth,
aspectRatio,
);
import('rrweb-player')
.then((module) => {
if (!containerRef.current || !mounted) return;
const PlayerConstructor = module.default;
player = new PlayerConstructor({
target: containerRef.current,
props: {
events,
width,
height,
autoPlay: false,
showController: false,
speedOption: [0.5, 1, 2, 4, 8],
UNSAFE_replayCanvas: true,
skipInactive: false,
},
}) as ReplayPlayerInstance;
playerRef.current = player;
// Track play state from replayer (getMetaData() does not expose isPlaying)
let playingState = false;
// Wire rrweb's built-in event emitter — no RAF loop needed.
// Note: rrweb-player does NOT emit ui-update-duration; duration is
// read from getMetaData() on init and after each addEvent batch.
player.addEventListener('ui-update-current-time', (e) => {
const t = e.payload as number;
setCurrentTime(t);
});
player.addEventListener('ui-update-player-state', (e) => {
const playing = e.payload === 'playing';
playingState = playing;
setIsPlaying(playing);
});
// Pause on tab hide; resume on show (prevents timer drift).
// getMetaData() does not expose isPlaying, so we use playingState
// kept in sync by ui-update-player-state above.
let wasPlaying = false;
handleVisibilityChange = () => {
if (!player) return;
if (document.hidden) {
wasPlaying = playingState;
if (wasPlaying) player.pause();
} else {
if (wasPlaying) {
player.play();
wasPlaying = false;
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Notify context — marks isReady = true and sets initial duration
const meta = player.getMetaData();
if (meta.totalTime > 0) setDuration(meta.totalTime);
onPlayerReady(player, meta.startTime);
})
.catch(() => {
if (mounted) setImportError(true);
});
const onWindowResize = () => {
if (!containerRef.current || !mounted || !playerRef.current?.$set) return;
const { width: w, height: h } = calcDimensions(
containerRef.current.offsetWidth,
aspectRatio,
);
playerRef.current.$set({ width: w, height: h });
};
window.addEventListener('resize', onWindowResize);
return () => {
mounted = false;
window.removeEventListener('resize', onWindowResize);
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (player) {
player.pause();
}
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
playerRef.current = null;
onPlayerDestroy();
};
}, [events, recordedDimensions, onPlayerReady, onPlayerDestroy, setCurrentTime, setIsPlaying, setDuration]);
if (importError) {
return (
<div className="flex h-[320px] items-center justify-center bg-black text-sm text-muted-foreground">
Failed to load replay player.
</div>
);
}
return (
<div className="relative flex w-full justify-center overflow-hidden">
<div
ref={containerRef}
className="w-full"
style={{ maxHeight: '70vh' }}
/>
</div>
);
}

View File

@@ -0,0 +1,261 @@
import { useCurrentTime, useReplayContext } from '@/components/sessions/replay/replay-context';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { IServiceEvent } from '@openpanel/db';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import { ReplayPlayPauseButton } from './replay-controls';
import { formatDuration, getEventOffsetMs } from './replay-utils';
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
const { currentTimeRef, duration, startTime, isReady, seek, subscribeToCurrentTime } =
useReplayContext();
// currentTime as React state is only needed for keyboard seeks (low frequency).
// The progress bar and thumb are updated directly via DOM refs to avoid re-renders.
const currentTime = useCurrentTime(250);
const trackRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverInfo, setHoverInfo] = useState<{
pct: number;
timeMs: number;
} | null>(null);
const dragCleanupRef = useRef<(() => void) | null>(null);
const rafDragRef = useRef<number | null>(null);
// Clean up any in-progress drag listeners when the component unmounts
useEffect(() => {
return () => {
dragCleanupRef.current?.();
};
}, []);
// Update progress bar and thumb directly via DOM on every tick — no React re-render.
useEffect(() => {
if (duration <= 0) return;
return subscribeToCurrentTime((t) => {
const pct = Math.max(0, Math.min(100, (t / duration) * 100));
if (progressBarRef.current) {
progressBarRef.current.style.width = `${pct}%`;
}
if (thumbRef.current) {
thumbRef.current.style.left = `calc(${pct}% - 8px)`;
}
});
}, [subscribeToCurrentTime, duration]);
const getTimeFromClientX = useCallback(
(clientX: number) => {
if (!trackRef.current || duration <= 0) return null;
const rect = trackRef.current.getBoundingClientRect();
if (rect.width <= 0 || !Number.isFinite(rect.width)) {
return null;
}
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
return { pct, timeMs: pct * duration };
},
[duration],
);
const handleTrackMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('[data-timeline-event]')) {
setHoverInfo(null);
return;
}
const info = getTimeFromClientX(e.clientX);
if (info) setHoverInfo(info);
},
[getTimeFromClientX],
);
const handleTrackMouseLeave = useCallback(() => {
if (!isDragging) setHoverInfo(null);
}, [isDragging]);
const handleTrackMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only handle direct clicks on the track, not on child elements like the thumb
if (
e.target !== trackRef.current &&
!(e.target as HTMLElement).closest('.replay-track-bg')
)
return;
const info = getTimeFromClientX(e.clientX);
if (info) seek(info.timeMs);
},
[getTimeFromClientX, seek],
);
const eventsWithOffset = useMemo(
() =>
events
.map((ev) => ({
event: ev,
offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0,
}))
.filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration),
[events, startTime, duration],
);
// Group events that are within 24px of each other on the track.
// We need the track width for pixel math — use a stable ref-based calculation.
const groupedEvents = useMemo(() => {
if (!eventsWithOffset.length || duration <= 0) return [];
// Sort by offsetMs so we sweep left-to-right
const sorted = [...eventsWithOffset].sort((a, b) => a.offsetMs - b.offsetMs);
// 24px in ms — recalculated from container width; fall back to 2% of duration
const trackWidth = trackRef.current?.offsetWidth ?? 600;
const thresholdMs = (24 / trackWidth) * duration;
const groups: { items: typeof sorted; pct: number }[] = [];
for (const item of sorted) {
const last = groups[groups.length - 1];
const lastPct = last ? (last.items[last.items.length - 1]!.offsetMs / duration) * 100 : -Infinity;
const thisPct = (item.offsetMs / duration) * 100;
if (last && item.offsetMs - last.items[last.items.length - 1]!.offsetMs <= thresholdMs) {
last.items.push(item);
// Anchor the group at its first item's position
} else {
groups.push({ items: [item], pct: thisPct });
}
// keep pct pointing at the first item (already set on push)
void lastPct;
}
return groups;
}, [eventsWithOffset, duration]);
if (!isReady || duration <= 0) return null;
const progressPct = Math.max(0, Math.min(100, (currentTimeRef.current / duration) * 100));
return (
<TooltipProvider delayDuration={300}>
<div className="row items-center gap-4 p-4">
<ReplayPlayPauseButton />
<div className="col gap-4 flex-1 px-2">
<div
ref={trackRef}
role="slider"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={currentTime}
tabIndex={0}
className="relative flex h-8 cursor-pointer items-center outline-0"
onMouseDown={handleTrackMouseDown}
onMouseMove={handleTrackMouseMove}
onMouseLeave={handleTrackMouseLeave}
onKeyDown={(e) => {
const step = 5000;
if (e.key === 'ArrowLeft') {
e.preventDefault();
seek(Math.max(0, currentTime - step));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
seek(Math.min(duration, currentTime + step));
}
}}
>
<div className="replay-track-bg bg-muted h-1.5 w-full overflow-hidden rounded-full">
<div
ref={progressBarRef}
className="bg-primary h-full rounded-full"
style={{ width: `${progressPct}%` }}
/>
</div>
<div
ref={thumbRef}
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm"
style={{ left: `calc(${progressPct}% - 8px)` }}
aria-hidden
/>
{/* Hover timestamp tooltip */}
<AnimatePresence>
{hoverInfo && (
<motion.div
className="pointer-events-none absolute z-20"
style={{
left: `${hoverInfo.pct * 100}%`,
top: 0,
bottom: 0,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Vertical line */}
<div className="absolute left-0 top-1/2 h-4 w-px -translate-x-1/2 -translate-y-1/2 bg-foreground/30" />
{/* Timestamp badge */}
<motion.div
className="absolute bottom-6 left-1/2 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background shadow"
initial={{ opacity: 0, y: 16, scale: 0.5 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.5 }}
transition={{ duration: 0.2 }}
>
{formatDuration(hoverInfo.timeMs)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{groupedEvents.map((group) => {
const first = group.items[0]!;
const isGroup = group.items.length > 1;
return (
<Tooltip key={first.event.id}>
<TooltipTrigger asChild>
<button
type="button"
data-timeline-event
className="absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-105"
style={{ left: `${group.pct}%`, marginLeft: -12 }}
onClick={(e) => {
e.stopPropagation();
seek(first.offsetMs);
}}
aria-label={isGroup ? `${group.items.length} events at ${formatDuration(first.offsetMs)}` : `${first.event.name} at ${formatDuration(first.offsetMs)}`}
>
<EventIcon name={first.event.name} meta={first.event.meta} size="sm" />
{isGroup && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[9px] font-bold leading-none text-background">
{group.items.length}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="col gap-1.5">
{group.items.map(({ event: ev, offsetMs }) => (
<div key={ev.id} className="row items-center gap-2">
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
<span className="font-medium">
{ev.name === 'screen_view' ? ev.path : ev.name}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDuration(offsetMs)}
</span>
</div>
))}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,20 @@
import type { IServiceEvent } from '@openpanel/db';
export function getEventOffsetMs(
event: IServiceEvent,
startTime: number,
): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
/** Format a duration in milliseconds as M:SS */
export function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}

View File

@@ -1,13 +1,12 @@
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { getProfileName } from '@/utils/getters';
import { round } from '@openpanel/common';
import type { IServiceSession } from '@openpanel/db';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { getProfileName } from '@/utils/getters';
function formatDuration(milliseconds: number): string {
const seconds = milliseconds / 1000;
@@ -44,13 +43,25 @@ export function useColumns() {
cell: ({ row }) => {
const session = row.original;
return (
<ProjectLink
href={`/sessions/${session.id}`}
className="font-medium"
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
<div className="row items-center gap-2">
<ProjectLink
className="font-medium"
href={`/sessions/${session.id}`}
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
{session.hasReplay && (
<ProjectLink
aria-label="View replay"
className="text-muted-foreground hover:text-foreground"
href={`/sessions/${session.id}#replay`}
title="View replay"
>
<Video className="size-4" />
</ProjectLink>
)}
</div>
);
},
},
@@ -63,8 +74,8 @@ export function useColumns() {
if (session.profile) {
return (
<ProjectLink
className="row items-center gap-2 font-medium"
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
className="font-medium row gap-2 items-center"
>
<ProfileAvatar size="sm" {...session.profile} />
{getProfileName(session.profile)}
@@ -73,8 +84,8 @@ export function useColumns() {
}
return (
<ProjectLink
className="font-medium font-mono"
href={`/profiles/${encodeURIComponent(session.profileId)}`}
className="font-mono font-medium"
>
{session.profileId}
</ProjectLink>

View File

@@ -48,7 +48,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
ref={ref}
>
{children}