@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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/chrome.d.ts +81 -0
- package/dist/chrome.d.ts.map +1 -0
- package/dist/chrome.js +156 -0
- package/dist/chrome.js.map +1 -0
- package/dist/compare.d.ts +31 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +165 -0
- package/dist/compare.js.map +1 -0
- package/dist/helpers.d.ts +101 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +236 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/panel.d.ts +25 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +725 -0
- package/dist/panel.js.map +1 -0
- package/dist/styles.d.ts +10 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +388 -0
- package/dist/styles.js.map +1 -0
- package/dist/timeline.d.ts +34 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +144 -0
- package/dist/timeline.js.map +1 -0
- package/dist/tokens.d.ts +10 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +317 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +53 -0
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
|