@reactra/replay-devtools 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/panel.js ADDED
@@ -0,0 +1,725 @@
1
+ // @reactra/replay-devtools — the <ReplayDevtools> session-recorder panel.
2
+ //
3
+ // Owner: reactra-runtime-spec.md §2 (UI-companion tier) + §3; the consumed
4
+ // APIs are Replay spec §5 (`finalizeReplay`, `ReplayPlayer.statesAt`, the
5
+ // live re-drive trio). Written with `createElement` (no JSX — Node
6
+ // type-stripping doesn't transform JSX; §2 tier rule) and an injected
7
+ // stylesheet (no `.css` import). Browser globals appear only inside
8
+ // handlers/effects.
9
+ //
10
+ // The server READ surface (`GET {endpoint}/sessions`, `…/:id/bundle`,
11
+ // `DELETE …/:id`, `?meta.<k>=<v>`) matches examples/replay-server's HTTP
12
+ // API and is documented in the README — it is NOT a Reactra spec contract;
13
+ // the streaming WS transport stays user code in examples/ (§2 tier rule).
14
+ //
15
+ // Chrome mechanics (drag-resize, pop-out, theme toggle, Shift+Alt+R hotkey)
16
+ // use shared helpers from `@reactra/replay-devtools/chrome` (C1 — one impl
17
+ // shared with @reactra/devtools). createPortal stays here (C2 — no react-dom
18
+ // dep in chrome.ts).
19
+ //
20
+ // C3/C4 pop-out lifecycle:
21
+ // Pop-out is a createPortal re-attach only — the panel fiber is NEVER
22
+ // unmounted on pop-out or popup-close. The [bundle]-keyed drive effect and
23
+ // the []-keyed unmount cleanup are both anchored to the panel's lifecycle,
24
+ // not to the popup's. On popup-close (C4): setPopup(null) → panel returns
25
+ // to the drawer; driveOn + liveRoute.current survive intact, drive continues
26
+ // from the drawer. The panel never calls exitReplayMode on popup-close.
27
+ import { createElement as h, useEffect, useMemo, useRef, useState } from "react";
28
+ import { createPortal } from "react-dom";
29
+ import { applyReplayState, enterReplayMode, exitReplayMode, finalizeReplay, } from "@reactra/behaviours/replayable";
30
+ import { ReplayPlayer, diffBundles } from "@reactra/replay";
31
+ import { navigate } from "@reactra/router";
32
+ import { openPanelWindow, usePanelHeight, usePanelTheme, useToggleHotkey } from "./chrome.js";
33
+ import { authHeaders, bundleFilename, bundleToBlobText, describeEvent, diffKeys, eventStops, eventStyle, formatOffset, instanceIds, nextChangeStop, playbackDelay, routeDriveTarget, sessionsQuery, stopIndexAt, } from "./helpers.js";
34
+ import { ensureStyles } from "./styles.js";
35
+ import { Timeline } from "./timeline.js";
36
+ import { CompareBody } from "./compare.js";
37
+ const ALL_TYPES = ["action", "state_snapshot", "resource", "mount", "gap"];
38
+ /**
39
+ * The Reactra session-recorder panel: finalize-and-scrub, a color-coded
40
+ * visual timeline, transport controls with auto-playback, keyboard stepping
41
+ * (←/→/space), per-instance state cards with change highlighting, event-type
42
+ * and instance filters, live re-drive, and server-session browsing.
43
+ *
44
+ * Window chrome: Shift+Alt+R hotkey, drag-to-resize, minimize, pop-out,
45
+ * System→Light→Dark theme — all powered by the shared `/chrome` helpers.
46
+ */
47
+ export const ReplayDevtools = (props) => {
48
+ const endpoint = props.endpoint ?? "/replay";
49
+ const showDrive = props.drive ?? true;
50
+ const [open, setOpen] = useState(props.defaultOpen ?? false);
51
+ const [minimized, setMinimized] = useState(false);
52
+ // popup: the Window we portaled into. null = docked in the page.
53
+ const [popup, setPopup] = useState(null);
54
+ const [bundle, setBundle] = useState(null);
55
+ const [bundleSource, setBundleSource] = useState("local");
56
+ const [offset, setOffset] = useState(0);
57
+ const [playing, setPlaying] = useState(false);
58
+ const [speed, setSpeed] = useState(1);
59
+ const [driveOn, setDriveOn] = useState(false);
60
+ const [sessions, setSessions] = useState(null);
61
+ const [serverError, setServerError] = useState(null);
62
+ // Non-null while a server fetch (list refresh or bundle load) is in flight —
63
+ // drives the indeterminate loading bar so async loads aren't silent.
64
+ const [busy, setBusy] = useState(null);
65
+ const [metaFilter, setMetaFilter] = useState("");
66
+ const [typesOff, setTypesOff] = useState(new Set());
67
+ const [instsOff, setInstsOff] = useState(new Set());
68
+ // The from/to timeline window (Chrome-Network-style brush); null = full session.
69
+ const [win, setWin] = useState(null);
70
+ // Compare mode state. null = normal single-bundle mode.
71
+ // compareSlotA: the bundle waiting to be paired (after the user picks A from
72
+ // the rail but before they pick B). null in normal mode and once both are set.
73
+ const [compareSession, setCompareSession] = useState(null);
74
+ const [compareSlotA, setCompareSlotA] = useState(null);
75
+ // Theme: System→Light→Dark, persisted under a namespace distinct from
76
+ // @reactra/devtools so co-mounted panels don't fight (plan constraint).
77
+ const [theme, cycleTheme] = usePanelTheme("@reactra/replay-devtools:theme");
78
+ // Panel height: drag-to-resize + localStorage persistence.
79
+ const [panelHeight, resizeHandleProps] = usePanelHeight("@reactra/replay-devtools:panel-height", { defaultHeight: 380, min: 100, max: 1200 });
80
+ // Latest-value ref for `open`, read by the Shift+Alt+R handler — avoids a
81
+ // nested setState inside a setOpen updater (StrictMode double-invoke bug,
82
+ // per 2026-06-10 devtools fix).
83
+ const openRef = useRef(open);
84
+ openRef.current = open;
85
+ // Shift+Alt+R: toggle open / minimized (devtools owns Shift+Alt+D).
86
+ useToggleHotkey("R", { shift: true, alt: true }, () => {
87
+ if (!openRef.current)
88
+ setOpen(true);
89
+ else
90
+ setMinimized((m) => !m);
91
+ });
92
+ // Hidden file-input for the ⭱ import button (A3). A ref avoids a stateful
93
+ // DOM node — React renders it once; we trigger it programmatically on click.
94
+ const importInputRef = useRef(null);
95
+ ensureStyles();
96
+ const player = useMemo(() => (bundle ? new ReplayPlayer(bundle) : null), [bundle]);
97
+ const instances = useMemo(() => (bundle ? instanceIds(bundle) : []), [bundle]);
98
+ const visible = (e) => !typesOff.has(e.type) && !instsOff.has(e.componentId);
99
+ const inWindow = (e) => {
100
+ if (!win || !bundle)
101
+ return true;
102
+ const t = e.timestamp - bundle.startTime;
103
+ return t >= win.from && t <= win.to;
104
+ };
105
+ const stops = useMemo(() => (bundle ? eventStops(bundle, (e) => visible(e) && inWindow(e)) : []), [bundle, typesOff, instsOff, win]);
106
+ const stepIndex = stopIndexAt(stops, offset);
107
+ const states = useMemo(() => (player && bundle ? player.statesAt(bundle.startTime + offset) : null), [player, bundle, offset]);
108
+ const prevStates = useMemo(() => {
109
+ if (!player || !bundle || stepIndex < 1)
110
+ return null;
111
+ return player.statesAt(bundle.startTime + stops[stepIndex - 1]);
112
+ }, [player, bundle, stepIndex, stops]);
113
+ // The live URL captured when drive turned ON, so `⏎ live` / drive-off can
114
+ // restore it instead of stranding the browser at a scrubbed route (§5 C2).
115
+ const liveRoute = useRef(null);
116
+ const currentLocation = () => typeof location === "undefined" ? "" : location.pathname + location.search;
117
+ const drive = (atOffset) => {
118
+ if (!player || !bundle)
119
+ return;
120
+ const s = player.statesAt(bundle.startTime + atOffset);
121
+ // Route-first (§5 C2): navigate BEFORE applying component state, only when
122
+ // the recorded route differs from the current URL.
123
+ const target = routeDriveTarget(player.routeAt(bundle.startTime + atOffset), currentLocation());
124
+ if (target !== undefined)
125
+ navigate(target);
126
+ for (const [componentId, state] of s) {
127
+ if (componentId === "Route#1")
128
+ continue;
129
+ applyReplayState(state, componentId.split("#")[0] ?? componentId);
130
+ }
131
+ };
132
+ /** Restore the live URL captured at drive-on (§5 C2). */
133
+ const restoreLiveRoute = () => {
134
+ const live = liveRoute.current;
135
+ if (live !== null && live !== currentLocation())
136
+ navigate(live);
137
+ liveRoute.current = null;
138
+ };
139
+ const scrub = (atOffset) => {
140
+ setOffset(atOffset);
141
+ if (driveOn)
142
+ drive(atOffset);
143
+ };
144
+ const stepTo = (i) => {
145
+ const t = stops[Math.max(0, Math.min(stops.length - 1, i))];
146
+ if (t !== undefined)
147
+ scrub(t);
148
+ };
149
+ // Auto-playback: walk the stops at recorded pacing / speed.
150
+ useEffect(() => {
151
+ if (!playing || !bundle)
152
+ return;
153
+ if (stepIndex >= stops.length - 1) {
154
+ setPlaying(false);
155
+ return;
156
+ }
157
+ const cur = stops[Math.max(0, stepIndex)] ?? 0;
158
+ const next = stops[stepIndex + 1];
159
+ const id = setTimeout(() => scrub(next), playbackDelay(cur, next, speed));
160
+ return () => clearTimeout(id);
161
+ });
162
+ // Replay mode must not outlive the panel; restore the live URL too so
163
+ // unmounting mid-scrub doesn't strand the browser (§5 C2).
164
+ // C3: this effect has [] deps — it fires ONLY on true unmount, not on
165
+ // pop-out (which keeps the panel fiber alive via createPortal).
166
+ useEffect(() => () => {
167
+ exitReplayMode();
168
+ restoreLiveRoute();
169
+ }, []);
170
+ // Drive ON by default: loading or finishing a session immediately re-drives
171
+ // the live app to its first event (parity with devtools Time Travel). The
172
+ // toggle switches to inspect-only. Keyed on the bundle so each loaded
173
+ // session re-enters drive; `showDrive===false` opts out entirely.
174
+ // C3: keyed on [bundle], not on popup/minimized — pop-out doesn't re-fire.
175
+ // COND-3: compare mode suppresses drive entirely — entering compare already
176
+ // called exitDrive(), and the panel must not re-enter while comparing.
177
+ useEffect(() => {
178
+ if (!bundle || !showDrive || compareSession !== null)
179
+ return;
180
+ enterDrive(eventStops(bundle)[0] ?? 0);
181
+ // Release the live app before the next session re-enters.
182
+ // Unmount is covered by the [] effect above.
183
+ return () => {
184
+ exitReplayMode();
185
+ restoreLiveRoute();
186
+ };
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, [bundle]);
189
+ // Popup-close detection (MAJOR-3 fix).
190
+ // Primary: a synchronous `pagehide` listener on the popup fires immediately
191
+ // when the user closes the window via the OS chrome (X button), avoiding the
192
+ // race where createPortal targets a tearing-down document during the interval
193
+ // lag. The interval is kept as a backstop for environments that suppress
194
+ // pagehide (some embedded webviews).
195
+ // C4: both paths call setPopup(null) only — driveOn + liveRoute.current are
196
+ // unchanged; exitReplayMode is NOT called; drive resumes from the drawer.
197
+ useEffect(() => {
198
+ if (!popup)
199
+ return;
200
+ const onClose = () => setPopup(null);
201
+ popup.addEventListener("pagehide", onClose);
202
+ const id = setInterval(() => {
203
+ if (popup.closed)
204
+ setPopup(null);
205
+ }, 500);
206
+ return () => {
207
+ popup.removeEventListener("pagehide", onClose);
208
+ clearInterval(id);
209
+ };
210
+ }, [popup]);
211
+ const enterDrive = (atOffset) => {
212
+ if (liveRoute.current === null)
213
+ liveRoute.current = currentLocation();
214
+ enterReplayMode();
215
+ setDriveOn(true);
216
+ drive(atOffset);
217
+ };
218
+ /** Stop driving: release the live app and restore the route it was on. */
219
+ const exitDrive = () => {
220
+ exitReplayMode();
221
+ restoreLiveRoute();
222
+ setDriveOn(false);
223
+ };
224
+ // COND-3: in compare mode the drive toggle is disabled. toggleDrive is
225
+ // left callable for keyboard shortcuts but exits early.
226
+ const toggleDrive = () => {
227
+ if (compareSession !== null)
228
+ return;
229
+ if (driveOn)
230
+ exitDrive();
231
+ else
232
+ enterDrive(offset);
233
+ };
234
+ // COND-3: entering compare mode FIRST exits any active drive (exitDrive calls
235
+ // exitReplayMode + restoreLiveRoute). Then compute the diff and enter compare.
236
+ // Drive never re-enters while compareSession !== null (the [bundle] effect
237
+ // is gated above).
238
+ const enterCompareMode = (bA, bB) => {
239
+ if (driveOn)
240
+ exitDrive();
241
+ const diff = diffBundles(bA, bB);
242
+ const firstShared = diff.instances.find((i) => i.presence === "both");
243
+ const componentId = firstShared?.componentId ?? diff.instances[0]?.componentId ?? "";
244
+ // Pick the first ordinal that has state divergence. When there are none
245
+ // (only event-level divergences), fall back to ordinal 0 so the state
246
+ // panel shows the actual state rather than "select a divergence row".
247
+ const stateDivOrdinal = firstShared?.stops.findIndex((s) => s.changedKeys.length > 0) ?? -1;
248
+ const firstDivergent = stateDivOrdinal !== -1
249
+ ? stateDivOrdinal
250
+ : (firstShared?.stops.length ?? 0) > 0 ? 0 : -1;
251
+ setCompareSession({
252
+ bundleA: bA,
253
+ bundleB: bB,
254
+ playerA: new ReplayPlayer(bA),
255
+ playerB: new ReplayPlayer(bB),
256
+ diff,
257
+ componentId,
258
+ selectedOrdinal: firstDivergent,
259
+ });
260
+ setCompareSlotA(null);
261
+ setServerError(null);
262
+ };
263
+ /** Exit compare mode — returns to normal single-bundle inspect.
264
+ * COND-3: if a bundle is loaded and drive is enabled, re-enter drive so
265
+ * the user doesn't have to manually click "driving app" after exiting compare.
266
+ */
267
+ const exitCompareMode = () => {
268
+ setCompareSession(null);
269
+ setCompareSlotA(null);
270
+ // Re-enter drive only when a bundle is present and drive is not suppressed
271
+ // (showDrive). driveOn may be false here because enterCompareMode called
272
+ // exitDrive first (COND-3). We re-enter at the current offset so the app
273
+ // snaps back to the scrubbed position the user was inspecting.
274
+ if (bundle !== null && showDrive) {
275
+ enterDrive(offset);
276
+ }
277
+ };
278
+ /**
279
+ * Handle a "compare" click on a rail session row. If no slot A is set yet,
280
+ * and a bundle is already loaded, use it as A and load the clicked session
281
+ * as B immediately. If no bundle is loaded, set slot A to the clicked
282
+ * session (waiting for B). If slot A is already set, enter compare.
283
+ */
284
+ const handleCompareClick = async (s) => {
285
+ // If a bundle is already loaded, treat it as A and the clicked row as B.
286
+ if (bundle && compareSlotA === null) {
287
+ try {
288
+ const res = await fetch(`${endpoint}/sessions/${s.sessionId}/bundle`, {
289
+ headers: fetchHeaders(),
290
+ });
291
+ if (!res.ok)
292
+ throw new Error(`HTTP ${res.status}`);
293
+ const bB = (await res.json());
294
+ enterCompareMode(bundle, bB);
295
+ }
296
+ catch (err) {
297
+ setServerError(`compare load failed: ${String(err)}`);
298
+ }
299
+ return;
300
+ }
301
+ // No bundle loaded yet: first click sets slot A.
302
+ if (compareSlotA === null) {
303
+ try {
304
+ const res = await fetch(`${endpoint}/sessions/${s.sessionId}/bundle`, {
305
+ headers: fetchHeaders(),
306
+ });
307
+ if (!res.ok)
308
+ throw new Error(`HTTP ${res.status}`);
309
+ const bA = (await res.json());
310
+ setCompareSlotA(bA);
311
+ setServerError(`compare: slot A = ${s.sessionId.slice(0, 8)} — now pick session B`);
312
+ }
313
+ catch (err) {
314
+ setServerError(`compare load failed: ${String(err)}`);
315
+ }
316
+ return;
317
+ }
318
+ // Slot A is set — load B and enter compare.
319
+ try {
320
+ const res = await fetch(`${endpoint}/sessions/${s.sessionId}/bundle`, {
321
+ headers: fetchHeaders(),
322
+ });
323
+ if (!res.ok)
324
+ throw new Error(`HTTP ${res.status}`);
325
+ const bB = (await res.json());
326
+ enterCompareMode(compareSlotA, bB);
327
+ }
328
+ catch (err) {
329
+ setServerError(`compare load failed: ${String(err)}`);
330
+ }
331
+ };
332
+ const togglePlay = () => {
333
+ if (!bundle || stops.length === 0)
334
+ return;
335
+ if (playing) {
336
+ setPlaying(false);
337
+ return;
338
+ }
339
+ if (stepIndex >= stops.length - 1)
340
+ scrub(stops[0]);
341
+ setPlaying(true);
342
+ };
343
+ const finish = async () => {
344
+ const b = await finalizeReplay();
345
+ setBundle(b);
346
+ setBundleSource("local");
347
+ setPlaying(false);
348
+ setWin(null);
349
+ setOffset(b ? (eventStops(b)[0] ?? 0) : 0);
350
+ // The session streamed to the server during recording; finalizeReplay just
351
+ // sent the finalize frame. Refresh the server list so the just-finished
352
+ // session appears (flipping live → final) without a manual ⟳. Best-effort.
353
+ await refreshSessions();
354
+ };
355
+ // A2: download the current bundle as a JSON file. All browser globals
356
+ // (Blob, URL, document.createElement) are inside the handler — never at
357
+ // module or hook top level (UI-companion tier rule; test:node gate).
358
+ const exportBundle = () => {
359
+ if (!bundle)
360
+ return;
361
+ const blob = new Blob([bundleToBlobText(bundle)], { type: "application/json" });
362
+ const url = URL.createObjectURL(blob);
363
+ const a = document.createElement("a");
364
+ a.href = url;
365
+ a.download = bundleFilename(bundle);
366
+ a.click();
367
+ URL.revokeObjectURL(url);
368
+ };
369
+ // A3: load a bundle from a local file. Validates the minimal shape
370
+ // (version + events array + numeric startTime) before accepting it; on
371
+ // failure sets serverError so the user sees the problem inline.
372
+ const importBundle = (file) => {
373
+ file.text().then((text) => {
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(text);
377
+ }
378
+ catch {
379
+ setServerError("invalid bundle file: not valid JSON");
380
+ return;
381
+ }
382
+ if (typeof parsed !== "object" || parsed === null) {
383
+ setServerError("invalid bundle file: missing required fields (version, events, startTime)");
384
+ return;
385
+ }
386
+ const rec = parsed;
387
+ if (!("version" in rec) ||
388
+ !Array.isArray(rec["events"]) ||
389
+ typeof rec["startTime"] !== "number") {
390
+ setServerError("invalid bundle file: missing required fields (version, events, startTime)");
391
+ return;
392
+ }
393
+ const b = rec;
394
+ setBundle(b);
395
+ setBundleSource("local");
396
+ setPlaying(false);
397
+ setWin(null);
398
+ setOffset(eventStops(b)[0] ?? 0);
399
+ setServerError(null);
400
+ }).catch((err) => {
401
+ setServerError(`invalid bundle file: ${String(err)}`);
402
+ });
403
+ };
404
+ // A4: copy the server bundle URL for a session to the clipboard. On
405
+ // clipboard failure (permission denied, non-HTTPS, etc.) shows the URL in
406
+ // serverError so the user can still copy it manually.
407
+ const copySessionLink = (sessionId) => {
408
+ const url = `${endpoint}/sessions/${sessionId}/bundle`;
409
+ navigator.clipboard.writeText(url).catch(() => {
410
+ setServerError(`copy failed — link: ${url}`);
411
+ });
412
+ };
413
+ const fetchHeaders = () => authHeaders(props.token, props.headers);
414
+ const refreshSessions = async () => {
415
+ setBusy("loading sessions…");
416
+ try {
417
+ const res = await fetch(`${endpoint}/sessions${sessionsQuery(metaFilter)}`, {
418
+ headers: fetchHeaders(),
419
+ });
420
+ if (!res.ok)
421
+ throw new Error(`HTTP ${res.status}`);
422
+ setSessions((await res.json()));
423
+ setServerError(null);
424
+ }
425
+ catch (err) {
426
+ setServerError(`replay-server unreachable: ${String(err)}`);
427
+ }
428
+ finally {
429
+ setBusy(null);
430
+ }
431
+ };
432
+ const loadSession = async (sessionId) => {
433
+ setBusy("loading session…");
434
+ try {
435
+ const res = await fetch(`${endpoint}/sessions/${sessionId}/bundle`, {
436
+ headers: fetchHeaders(),
437
+ });
438
+ if (!res.ok)
439
+ throw new Error(`HTTP ${res.status}`);
440
+ const b = (await res.json());
441
+ setBundle(b);
442
+ setBundleSource("server");
443
+ setPlaying(false);
444
+ setWin(null);
445
+ setOffset(eventStops(b)[0] ?? 0);
446
+ setServerError(null);
447
+ }
448
+ catch (err) {
449
+ setServerError(`bundle fetch failed: ${String(err)}`);
450
+ }
451
+ finally {
452
+ setBusy(null);
453
+ }
454
+ };
455
+ const deleteSession = async (sessionId) => {
456
+ try {
457
+ await fetch(`${endpoint}/sessions/${sessionId}`, {
458
+ method: "DELETE",
459
+ headers: fetchHeaders(),
460
+ });
461
+ await refreshSessions();
462
+ }
463
+ catch (err) {
464
+ setServerError(`delete failed: ${String(err)}`);
465
+ }
466
+ };
467
+ const popOut = () => {
468
+ const w = openPanelWindow({
469
+ name: "reactra-replay-devtools",
470
+ title: "Reactra Session Replay",
471
+ width: 1200,
472
+ height: 560,
473
+ injectStyles: [ensureStyles],
474
+ });
475
+ // C3: setPopup triggers a createPortal re-attach only — the panel fiber
476
+ // stays alive. The [bundle] drive effect does NOT re-fire because its deps
477
+ // haven't changed. driveOn + liveRoute.current are unchanged.
478
+ if (w)
479
+ setPopup(w);
480
+ };
481
+ // Keyboard transport on the focused panel: ←/→ step, space play/pause.
482
+ const onKeyDown = (e) => {
483
+ if (e.target?.tagName === "INPUT")
484
+ return;
485
+ if (e.key === "ArrowLeft") {
486
+ e.preventDefault();
487
+ stepTo(stepIndex - 1);
488
+ }
489
+ else if (e.key === "ArrowRight") {
490
+ e.preventDefault();
491
+ stepTo(stepIndex + 1);
492
+ }
493
+ else if (e.key === " ") {
494
+ e.preventDefault();
495
+ togglePlay();
496
+ }
497
+ };
498
+ if (!open) {
499
+ return h("button", {
500
+ className: "rdt-root rrd-pill",
501
+ onClick: () => setOpen(true),
502
+ title: "Open the Reactra session recorder (Shift+Alt+R)",
503
+ }, h("span", { className: "rrd-dot" }), "replay");
504
+ }
505
+ const timeline = bundle &&
506
+ h(Timeline, {
507
+ bundle,
508
+ offset,
509
+ window: win,
510
+ visible,
511
+ onScrub: scrub,
512
+ onWindowChange: setWin,
513
+ onGrab: () => setPlaying(false),
514
+ });
515
+ const transport = bundle &&
516
+ h("div", { className: "rrd-transport" }, h("button", { className: "rrd-btn", disabled: stops.length === 0 || stepIndex <= 0, onClick: () => stepTo(0), title: "Rewind to the first event" }, "⏮"), h("button", { className: "rrd-btn", disabled: stepIndex <= 0, onClick: () => stepTo(stepIndex - 1), title: "Previous event (←)" }, "⏴"), h("button", { className: "rrd-btn rrd-btn--accent", onClick: togglePlay, title: "Play / pause (space) — restarts from the first event at the end" }, playing ? "⏸" : "▶"), h("button", { className: "rrd-btn", disabled: stepIndex >= stops.length - 1, onClick: () => stepTo(stepIndex + 1), title: "Next event (→)" }, "⏵"), h("select", {
517
+ className: "rrd-select",
518
+ value: String(speed),
519
+ onChange: (e) => setSpeed(Number(e.target.value)),
520
+ title: "Playback speed",
521
+ }, h("option", { value: "0.5" }, "0.5×"), h("option", { value: "1" }, "1×"), h("option", { value: "2" }, "2×"), h("option", { value: "4" }, "4×")), h("span", { className: "rrd-pos" }, `event ${Math.max(0, stepIndex) + 1}/${stops.length} · ${formatOffset(offset)}`), win &&
522
+ h("button", {
523
+ className: "rrd-btn rrd-mini",
524
+ onClick: () => setWin(null),
525
+ title: "Clear the from/to window",
526
+ }, `✕ ${formatOffset(win.from)}–${formatOffset(win.to)}`), showDrive &&
527
+ h("button", {
528
+ // COND-3: disabled in compare mode — can't drive to two states at once.
529
+ className: driveOn ? "rrd-btn rrd-btn--live" : "rrd-btn",
530
+ disabled: compareSession !== null,
531
+ onClick: toggleDrive,
532
+ title: compareSession !== null
533
+ ? "Drive disabled in compare mode"
534
+ : driveOn
535
+ ? "Driving the live app — scrub/play re-drives it (Replay §5). Click for inspect-only."
536
+ : "Inspect-only — the panel shows recorded state but doesn't touch the live app. Click to drive it.",
537
+ }, driveOn ? "● driving app" : "○ inspect only"));
538
+ const filters = bundle &&
539
+ h("div", { className: "rrd-filters" }, ...ALL_TYPES.map((t) => h("button", {
540
+ key: t,
541
+ className: typesOff.has(t) ? "rrd-chip" : "rrd-chip rrd-chip--on",
542
+ onClick: () => setTypesOff((cur) => {
543
+ const next = new Set(cur);
544
+ if (next.has(t))
545
+ next.delete(t);
546
+ else
547
+ next.add(t);
548
+ return next;
549
+ }),
550
+ }, t)), ...instances.map((id) => h("button", {
551
+ key: id,
552
+ className: instsOff.has(id) ? "rrd-chip" : "rrd-chip rrd-chip--on",
553
+ onClick: () => setInstsOff((cur) => {
554
+ const next = new Set(cur);
555
+ if (next.has(id))
556
+ next.delete(id);
557
+ else
558
+ next.add(id);
559
+ return next;
560
+ }),
561
+ }, id)));
562
+ const stateCards = states &&
563
+ h("div", { className: "rrd-states" }, ...[...states.entries()]
564
+ .filter(([id]) => !instsOff.has(id))
565
+ .map(([id, state]) => {
566
+ const changed = new Set(diffKeys(prevStates?.get(id), state));
567
+ return h("div", { key: id, className: "rrd-card" }, h("div", { className: "rrd-card-id" }, id), ...Object.entries(state).map(([k, v]) => h("div", { key: k, className: "rrd-kv" },
568
+ // B2: clicking a key jumps to the next/previous stop where that
569
+ // key changes. Shift-click walks backward. No-op when there is
570
+ // no further change in that direction. `player` and `bundle` are
571
+ // non-null here (stateCards is only built when `states` is set,
572
+ // which requires both). `stops` is the already-filtered array —
573
+ // so the jump respects active type/instance chips + window brush.
574
+ h("span", {
575
+ className: "rrd-k rrd-k--jumpable",
576
+ title: "Jump to next change (Shift-click: previous)",
577
+ onClick: (e) => {
578
+ if (!player || !bundle)
579
+ return;
580
+ const dir = e.shiftKey ? -1 : 1;
581
+ const target = nextChangeStop(player, bundle, stops, stepIndex, id, k, dir);
582
+ if (target !== null)
583
+ stepTo(target);
584
+ },
585
+ }, `${k}:`), h("span", { className: changed.has(k) ? "rrd-v rrd-v--changed" : "rrd-v" }, JSON.stringify(v)))));
586
+ }));
587
+ const eventTable = bundle &&
588
+ h("div", { className: "rrd-events" }, h("table", null, h("thead", null, h("tr", null, h("th", null, "t"), h("th", null, "instance"), h("th", null, "event"))), h("tbody", null, ...bundle.events.map((e, i) => {
589
+ if (!visible(e) || !inWindow(e))
590
+ return null;
591
+ const t = e.timestamp - bundle.startTime;
592
+ const s = eventStyle(e);
593
+ const cls = t === offset ? "rrd-row--current" : t > offset ? "rrd-row--future" : undefined;
594
+ return h("tr", { key: i, className: cls, onClick: () => scrub(t) }, h("td", null, formatOffset(t)), h("td", null, e.componentId), h("td", { className: "rrd-detail" }, h("span", { className: "rrd-glyph", style: { color: s.color } }, s.glyph), `${e.type === "state_snapshot" ? "" : e.type + " "}${describeEvent(e)}`));
595
+ }))));
596
+ const sessionList = h("div", { className: "rrd-sessions" }, h("h4", null, "Server sessions"), h("div", { style: { display: "flex", gap: "6px", marginBottom: "6px" } }, h("button", { className: "rrd-btn rrd-icon-btn", onClick: refreshSessions, disabled: busy !== null, title: "Refresh session list" }, "⟳"), h("input", {
597
+ className: "rrd-input",
598
+ style: { minWidth: 0, flex: 1 },
599
+ placeholder: "meta: userId=…",
600
+ value: metaFilter,
601
+ onChange: (e) => setMetaFilter(e.target.value),
602
+ title: "Filter: GET /sessions?meta.<key>=<value>",
603
+ })),
604
+ // Progress indicator while a server fetch is in flight (issue: async loads
605
+ // were silent — the user couldn't tell a load from the server was running).
606
+ busy && h("div", { className: "rrd-loading-label" }, busy), busy && h("div", { className: "rrd-loading", role: "progressbar", "aria-label": busy }), serverError && h("div", { className: "rrd-err" }, serverError), sessions !== null && sessions.length === 0 && h("div", { className: "rrd-hint" }, "no sessions"), ...(sessions ?? []).map((s) => h("div", { key: s.sessionId, className: "rrd-session" }, h("code", null, s.sessionId),
607
+ // Metadata row: event count + status + meta tags (info only, wraps).
608
+ h("div", { className: "rrd-session-meta" }, h("span", { className: "rrd-hint" }, `${s.eventCount}ev · ${s.finalized ? "final" : "live"}`), ...Object.entries(s.meta ?? {}).map(([k, v]) => h("span", { key: k, className: "rrd-meta-tag" }, `${k}=${v}`))),
609
+ // Action row: all buttons on one line; the destructive delete is pushed
610
+ // to the far right (separated from load/compare/copy).
611
+ h("div", { className: "rrd-session-actions" },
612
+ // `load` is the primary action per row → accent-highlighted.
613
+ h("button", { className: "rrd-btn rrd-mini rrd-btn--accent", onClick: () => loadSession(s.sessionId) }, "load"),
614
+ // Compare: pick this session as one side of a diff.
615
+ h("button", {
616
+ className: compareSlotA?.sessionId === s.sessionId
617
+ ? "rrd-btn rrd-icon-btn rrd-btn--compare-active"
618
+ : "rrd-btn rrd-icon-btn rrd-btn--compare",
619
+ title: compareSlotA
620
+ ? `Compare with slot A (${compareSlotA.sessionId.slice(0, 8)})`
621
+ : bundle
622
+ ? "Compare against the loaded session"
623
+ : "Pick as compare slot A",
624
+ onClick: () => handleCompareClick(s),
625
+ }, "⟷"),
626
+ // A4: copy the direct bundle URL to the clipboard.
627
+ h("button", {
628
+ className: "rrd-btn rrd-icon-btn",
629
+ title: "Copy bundle URL to clipboard",
630
+ onClick: () => copySessionLink(s.sessionId),
631
+ }, "⧉"), h("button", { className: "rrd-btn rrd-icon-btn rrd-btn--danger rrd-session-del", title: "Erase on the server (PII removal)", onClick: () => deleteSession(s.sessionId) }, "🗑")))));
632
+ // `rdt-root` is required on the panel root so the `--rdt-*` token block
633
+ // (injected by ensureTokens via ensureStyles) is in scope for child elements.
634
+ const panelClass = popup
635
+ ? "rdt-root rrd-panel rrd-panel--popup"
636
+ : minimized
637
+ ? "rdt-root rrd-panel rrd-panel--min"
638
+ : "rdt-root rrd-panel";
639
+ // Apply the persisted height only for the docked, non-minimized panel.
640
+ const panelStyle = popup || minimized ? undefined : { height: panelHeight };
641
+ const panel = h("section", {
642
+ className: panelClass,
643
+ tabIndex: 0,
644
+ style: panelStyle,
645
+ // Explicit theme override; "system" defers to prefers-color-scheme via CSS.
646
+ "data-rdt-theme": theme,
647
+ onKeyDown,
648
+ },
649
+ // Resize handle: only docked + not minimized.
650
+ !popup && !minimized &&
651
+ h("div", {
652
+ className: "rrd-resize-handle",
653
+ onMouseDown: resizeHandleProps.onMouseDown,
654
+ title: "Drag to resize panel",
655
+ role: "separator",
656
+ "aria-orientation": "horizontal",
657
+ }), h("header", { className: "rrd-head" }, h("span", { className: "rrd-title" }, "Reactra Session Replay"), h("span", { className: "rrd-kbd-hint" }, "←/→ step · space play"), h("span", { className: "rrd-spacer" }), h("button", { className: "rrd-btn rrd-btn--accent", onClick: finish }, "⏹ finish & inspect"),
658
+ // A2: download the current bundle. Only visible when a bundle is loaded.
659
+ bundle &&
660
+ h("button", {
661
+ className: "rrd-btn",
662
+ onClick: exportBundle,
663
+ title: "Download session as JSON",
664
+ }, "⭳ export"),
665
+ // A3: import a bundle from a local file — triggers the hidden file input.
666
+ h("button", {
667
+ className: "rrd-btn",
668
+ onClick: () => importInputRef.current?.click(),
669
+ title: "Load a session bundle from a local file",
670
+ }, "⭱ import"),
671
+ // Hidden file input — the actual file picker, triggered by the import button.
672
+ h("input", {
673
+ ref: importInputRef,
674
+ type: "file",
675
+ accept: ".json",
676
+ style: { display: "none" },
677
+ onChange: (e) => {
678
+ const file = e.target.files?.[0];
679
+ if (file)
680
+ importBundle(file);
681
+ // Reset the input so the same file can be re-imported after a reload.
682
+ if (importInputRef.current)
683
+ importInputRef.current.value = "";
684
+ },
685
+ }),
686
+ // Theme toggle: System→Light→Dark→System cycle, persisted.
687
+ h("button", {
688
+ className: "rrd-btn",
689
+ onClick: cycleTheme,
690
+ title: `Theme: ${theme} (click to cycle System → Light → Dark)`,
691
+ "aria-label": `Theme: ${theme}`,
692
+ }, theme === "light" ? "☀" : theme === "dark" ? "🌙" : "◐"),
693
+ // Minimize / restore — only shown when docked (popup is already resizable).
694
+ !popup &&
695
+ h("button", {
696
+ className: "rrd-btn",
697
+ onClick: () => setMinimized((m) => !m),
698
+ title: minimized ? "Restore" : "Minimize to the header bar",
699
+ }, minimized ? "▴" : "▾"),
700
+ // Pop-out to a separate window (C3/C4: portal-only, fiber stays alive).
701
+ !popup &&
702
+ h("button", { className: "rrd-btn", onClick: popOut, title: "Pop out into its own window" }, "⧉"), !popup &&
703
+ h("button", { className: "rrd-btn", onClick: () => setOpen(false), title: "Collapse" }, "✕")),
704
+ // Panel body: hidden while minimized (body not rendered, effects/subscriptions
705
+ // pause) — unless we're in the popup window (it's already a separate window).
706
+ (!minimized || popup !== null) &&
707
+ h("div", { className: "rrd-body" },
708
+ // In compare mode: the full body is replaced by CompareBody. The session
709
+ // rail is still shown on the left so the user can navigate or exit compare.
710
+ compareSession !== null
711
+ ? h(CompareBody, {
712
+ session: compareSession,
713
+ onSelectComponent: (id) => setCompareSession((cs) => cs ? { ...cs, componentId: id, selectedOrdinal: -1 } : cs),
714
+ onSelectOrdinal: (ordinal) => setCompareSession((cs) => cs ? { ...cs, selectedOrdinal: ordinal } : cs),
715
+ onExit: exitCompareMode,
716
+ })
717
+ : h("div", { className: "rrd-body-inner" }, sessionList, h("div", { className: "rrd-main" }, bundle === null &&
718
+ h("div", { className: "rrd-hint" }, "Recording is live (configured at bootstrap). Interact with the app, then finish the session — or load one back from the server."), bundle &&
719
+ h("div", { className: "rrd-info" }, h("span", { className: bundleSource === "server" ? "rrd-src rrd-src--server" : "rrd-src rrd-src--local" }, bundleSource), h("code", null, bundle.sessionId), `${bundle.events.length} events · ${(bundle.duration / 1000).toFixed(1)}s · ${instances.join(", ")}`), timeline, transport, filters, bundle && h("div", { className: "rrd-content" }, stateCards, eventTable)))));
720
+ // createPortal re-attaches the existing panel fiber into the popup window's
721
+ // body without unmounting it. The [] cleanup effect and [bundle] drive effect
722
+ // are anchored to the panel's lifecycle, so neither fires on portal changes.
723
+ return popup ? createPortal(panel, popup.document.body) : panel;
724
+ };
725
+ //# sourceMappingURL=panel.js.map