@agent-native/core 0.98.2 → 0.98.4
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +23 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +2 -2
- package/corpus/core/src/client/analytics.ts +12 -0
- package/corpus/core/src/client/index.ts +2 -0
- package/corpus/core/src/client/session-replay.ts +612 -26
- package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
- package/corpus/core/src/client/settings/SettingsPanel.tsx +31 -211
- package/corpus/core/src/client/settings/agent-settings-search.ts +219 -0
- package/corpus/core/src/client/settings/index.ts +4 -0
- package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
- package/corpus/core/src/demo/config.ts +6 -6
- package/corpus/core/src/demo/fetch-interceptor.ts +25 -2
- package/corpus/core/src/demo/redact.ts +57 -66
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +65 -6
- package/corpus/templates/analytics/AGENTS.md +6 -6
- package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +429 -244
- package/corpus/templates/analytics/app/components/layout/command-palette-search.ts +93 -0
- package/corpus/templates/analytics/app/global.css +4 -62
- package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +2 -1
- package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +46 -33
- package/corpus/templates/analytics/app/pages/Settings.tsx +8 -45
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +413 -105
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +32 -16
- package/corpus/templates/analytics/app/pages/settings/settings-search.ts +126 -0
- package/corpus/templates/analytics/changelog/2026-07-12-command-menu-loading-placeholders-now-stay-below-available-r.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-command-menu-search-now-prioritizes-the-best-match.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-demo-charts-preserve-source-movement.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-fixed-session-replays-that-appeared-ultra-wide-hid-recorded-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-replay-storage-settings-stay-readable-at-narrow-widths.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-session-replays-load-faster-in-demo-mode-while-visitor-email.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-session-replays-preserve-recorded-styling-and-keep-malformed.md +6 -0
- package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +11 -2
- package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +14 -2
- package/corpus/templates/calendar/changelog/2026-07-12-calendar-responses-can-now-be-saved-with-command-enter-or-ct.md +6 -0
- package/dist/agent/production-agent.js +2 -2
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +11 -0
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/session-replay.d.ts +12 -3
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +488 -23
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/settings/DemoModeSection.d.ts +3 -3
- package/dist/client/settings/DemoModeSection.js +4 -4
- package/dist/client/settings/DemoModeSection.js.map +1 -1
- package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
- package/dist/client/settings/SettingsPanel.js +41 -191
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/settings/agent-settings-search.d.ts +16 -0
- package/dist/client/settings/agent-settings-search.d.ts.map +1 -0
- package/dist/client/settings/agent-settings-search.js +178 -0
- package/dist/client/settings/agent-settings-search.js.map +1 -0
- package/dist/client/settings/index.d.ts +1 -0
- package/dist/client/settings/index.d.ts.map +1 -1
- package/dist/client/settings/index.js +1 -0
- package/dist/client/settings/index.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/demo/actions/toggle-demo-mode.js +2 -2
- package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
- package/dist/demo/config.js +6 -6
- package/dist/demo/config.js.map +1 -1
- package/dist/demo/fetch-interceptor.d.ts +1 -0
- package/dist/demo/fetch-interceptor.d.ts.map +1 -1
- package/dist/demo/fetch-interceptor.js +19 -2
- package/dist/demo/fetch-interceptor.js.map +1 -1
- package/dist/demo/redact.d.ts +14 -5
- package/dist/demo/redact.d.ts.map +1 -1
- package/dist/demo/redact.js +30 -56
- package/dist/demo/redact.js.map +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/package.json +2 -2
|
@@ -160,8 +160,22 @@ type ReplayViewportDimensions = {
|
|
|
160
160
|
height: number;
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
+
type ReplayViewportChange = ReplayViewportDimensions & {
|
|
164
|
+
offsetMs: number;
|
|
165
|
+
};
|
|
166
|
+
|
|
163
167
|
const DEFAULT_PLAYER_WIDTH = 1024;
|
|
164
168
|
const DEFAULT_PLAYER_HEIGHT = 640;
|
|
169
|
+
const MALFORMED_REPLAY_MIN_WIDTH = 4000;
|
|
170
|
+
const MALFORMED_REPLAY_MIN_ASPECT_RATIO = 4;
|
|
171
|
+
// Evidence-backed exception for the one pre-fix viewport shape observed in a
|
|
172
|
+
// stored recording. Do not broaden this to an aspect-ratio range: valid
|
|
173
|
+
// 3440x900 ultrawide browser viewports occupy the same range.
|
|
174
|
+
const LEGACY_MALFORMED_REPLAY_WIDTH = 3189;
|
|
175
|
+
const LEGACY_MALFORMED_REPLAY_HEIGHT = 885;
|
|
176
|
+
const MIN_REPLAY_DISPLAY_DIMENSION = 240;
|
|
177
|
+
const RECOVERED_REPLAY_DISPLAY_ASPECT_RATIO =
|
|
178
|
+
DEFAULT_PLAYER_WIDTH / DEFAULT_PLAYER_HEIGHT;
|
|
165
179
|
const DEFAULT_SPEED = 2;
|
|
166
180
|
const SPEED_OPTIONS = [0.5, 1, 2, 4, 8];
|
|
167
181
|
const SKIP_STEP_MS = 5000;
|
|
@@ -177,10 +191,14 @@ const MIN_STAGE_HEIGHT_PX = 240;
|
|
|
177
191
|
const SCRUBBER_MARKER_LIMIT = 500;
|
|
178
192
|
const TIMELINE_MARKER_LIMIT = 300;
|
|
179
193
|
const TIMELINE_FOLLOW_PAUSE_MS = 4000;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
const REPLAY_CLOCK_UPDATE_INTERVAL_MS = 100;
|
|
195
|
+
/**
|
|
196
|
+
* Keep captured overlays intact. Toasts and snackbars are product feedback,
|
|
197
|
+
* not recorder chrome, and can be essential to understanding the session.
|
|
198
|
+
* The recorder does not inject notification UI into the recorded document, so
|
|
199
|
+
* blanket selectors (including Sonner's data attributes) are not justified.
|
|
200
|
+
*/
|
|
201
|
+
export const REPLAY_OVERLAY_STYLE_RULES: string[] = [];
|
|
184
202
|
type ReplayConsoleDiagnostics = ReturnType<
|
|
185
203
|
typeof extractReplayDiagnostics
|
|
186
204
|
>["console"];
|
|
@@ -226,11 +244,13 @@ const RRWEB_EVENT_TYPE = {
|
|
|
226
244
|
|
|
227
245
|
const INCREMENTAL_SOURCE = {
|
|
228
246
|
Mutation: 0,
|
|
247
|
+
MouseMove: 1,
|
|
229
248
|
MouseInteraction: 2,
|
|
230
249
|
Scroll: 3,
|
|
231
250
|
ViewportResize: 4,
|
|
232
251
|
Input: 5,
|
|
233
252
|
TouchMove: 6,
|
|
253
|
+
Drag: 12,
|
|
234
254
|
} as const;
|
|
235
255
|
|
|
236
256
|
const MOUSE_INTERACTION = {
|
|
@@ -471,6 +491,7 @@ function ReplayPlayer({
|
|
|
471
491
|
const stageRootRef = useRef<HTMLDivElement>(null);
|
|
472
492
|
const replayerRef = useRef<any>(null);
|
|
473
493
|
const rafRef = useRef<number | null>(null);
|
|
494
|
+
const lastClockUpdateAtRef = useRef<number | null>(null);
|
|
474
495
|
const [status, setStatus] = useState<ReplayPlayerStatus>("idle");
|
|
475
496
|
const [error, setError] = useState<string | null>(null);
|
|
476
497
|
const [playing, setPlaying] = useState(false);
|
|
@@ -487,8 +508,17 @@ function ReplayPlayer({
|
|
|
487
508
|
height: number;
|
|
488
509
|
} | null>(null);
|
|
489
510
|
const [fitScale, setFitScale] = useState(1);
|
|
490
|
-
const initialDims = useMemo(
|
|
511
|
+
const initialDims = useMemo(
|
|
512
|
+
() => replayInitialDisplayDimensions(events),
|
|
513
|
+
[events],
|
|
514
|
+
);
|
|
515
|
+
const viewportTimeline = useMemo(
|
|
516
|
+
() => buildReplayViewportTimeline(events),
|
|
517
|
+
[events],
|
|
518
|
+
);
|
|
491
519
|
const eventsRef = useLiveRef(events);
|
|
520
|
+
const viewportTimelineRef = useLiveRef(viewportTimeline);
|
|
521
|
+
const streamedDimsRef = useLiveRef(streamedDims);
|
|
492
522
|
// Stable identity for the loaded event set so progressive chunk publishes
|
|
493
523
|
// that only grow the array do not tear down a healthy Replayer mid-playback.
|
|
494
524
|
const eventsIdentity = useMemo(
|
|
@@ -501,13 +531,17 @@ function ReplayPlayer({
|
|
|
501
531
|
const scrubbingRef = useRef(false);
|
|
502
532
|
const scrubResumePlayingRef = useRef(false);
|
|
503
533
|
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
534
|
+
// IMPORTANT — DO NOT REMOVE THIS LEGACY VIEWPORT CORRECTION.
|
|
535
|
+
// Some stored sessions contain impossible 3,000–7,500px-wide Meta/resize
|
|
536
|
+
// values even though the browser was a normal desktop viewport. Rendering
|
|
537
|
+
// those raw values collapses the replay into a tiny horizontal ribbon. Keep
|
|
538
|
+
// normal recordings exact—including 32:9 and mobile portrait. Recover only
|
|
539
|
+
// widths >=4,000px with aspect >4:1, plus the exact known 3,189x885 legacy
|
|
540
|
+
// shape, to the standard 16:10 viewport. The same dimensions are applied to
|
|
541
|
+
// rrweb's iframe so CSS breakpoints/camera agree.
|
|
542
|
+
const displayDims = clampReplayDisplayDimensions(streamedDims ?? initialDims);
|
|
543
|
+
const playerWidth = displayDims?.width ?? DEFAULT_PLAYER_WIDTH;
|
|
544
|
+
const playerHeight = displayDims?.height ?? DEFAULT_PLAYER_HEIGHT;
|
|
511
545
|
const skipRanges = useMemo(() => buildIdleSkipRanges(events), [events]);
|
|
512
546
|
const skipRangesRef = useLiveRef(skipRanges);
|
|
513
547
|
const skipInactiveRef = useLiveRef(skipInactive);
|
|
@@ -603,10 +637,16 @@ function ReplayPlayer({
|
|
|
603
637
|
|
|
604
638
|
const updateTime = useCallback(
|
|
605
639
|
(next: number) => {
|
|
640
|
+
if (!Number.isFinite(next) || next === currentTimeRef.current) return;
|
|
641
|
+
// Keep the live value in sync before React commits. The animation clock
|
|
642
|
+
// can run again while React is still processing the previous render;
|
|
643
|
+
// relying on useLiveRef's effect here republishes the same value and can
|
|
644
|
+
// create a nested update loop in development.
|
|
645
|
+
currentTimeRef.current = next;
|
|
606
646
|
setCurrentTime(next);
|
|
607
647
|
onTimeUpdate(next);
|
|
608
648
|
},
|
|
609
|
-
[onTimeUpdate],
|
|
649
|
+
[currentTimeRef, onTimeUpdate],
|
|
610
650
|
);
|
|
611
651
|
|
|
612
652
|
const seek = useCallback(
|
|
@@ -626,9 +666,35 @@ function ReplayPlayer({
|
|
|
626
666
|
console.warn("[session-replay] seek failed", seekError);
|
|
627
667
|
return;
|
|
628
668
|
}
|
|
669
|
+
const seekDims = replayViewportDimensionsAtTime(
|
|
670
|
+
viewportTimelineRef.current,
|
|
671
|
+
clamped,
|
|
672
|
+
);
|
|
673
|
+
if (seekDims) {
|
|
674
|
+
// rrweb 2.1 does not reliably re-emit Resize when seeking backwards.
|
|
675
|
+
// IMPORTANT: correct both layers together. Correcting only the outer
|
|
676
|
+
// stage or only rrweb's iframe recreates the ultra-wide/clipped bug.
|
|
677
|
+
const correctedSeekDims = clampReplayDisplayDimensions(seekDims);
|
|
678
|
+
const currentDims = streamedDimsRef.current;
|
|
679
|
+
if (
|
|
680
|
+
correctedSeekDims &&
|
|
681
|
+
(currentDims?.width !== correctedSeekDims.width ||
|
|
682
|
+
currentDims?.height !== correctedSeekDims.height)
|
|
683
|
+
) {
|
|
684
|
+
replayer.handleResize?.(correctedSeekDims);
|
|
685
|
+
setStreamedDims(correctedSeekDims);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
629
688
|
updateTime(clamped);
|
|
630
689
|
},
|
|
631
|
-
[
|
|
690
|
+
[
|
|
691
|
+
playingRef,
|
|
692
|
+
status,
|
|
693
|
+
streamedDimsRef,
|
|
694
|
+
totalTime,
|
|
695
|
+
updateTime,
|
|
696
|
+
viewportTimelineRef,
|
|
697
|
+
],
|
|
632
698
|
);
|
|
633
699
|
|
|
634
700
|
const beginScrub = useCallback(
|
|
@@ -662,27 +728,17 @@ function ReplayPlayer({
|
|
|
662
728
|
let localReplayer: any = null;
|
|
663
729
|
|
|
664
730
|
async function loadReplay() {
|
|
665
|
-
// Wait for the full recording before creating the Replayer. Progressive
|
|
666
|
-
// chunk publishes used to rebuild the player on every append, which
|
|
667
|
-
// reset the clock, disabled the scrubber, and desynced the playhead.
|
|
668
731
|
const replayEvents = eventsRef.current;
|
|
732
|
+
// Replayer construction remains all-or-nothing. Progressive response
|
|
733
|
+
// publishes update loading progress only; rebuilding during download
|
|
734
|
+
// rewinds the player and can desync the scrubber/playhead.
|
|
669
735
|
if (!response.isComplete) {
|
|
670
736
|
setStatus("loading");
|
|
671
737
|
setError(null);
|
|
672
738
|
setPlaying(false);
|
|
673
739
|
return;
|
|
674
740
|
}
|
|
675
|
-
if (replayEvents.length < 2) {
|
|
676
|
-
throw new Error(t("sessions.noReplayEvents"));
|
|
677
|
-
}
|
|
678
|
-
if (
|
|
679
|
-
!replayEvents.some(
|
|
680
|
-
(event) => event.type === RRWEB_EVENT_TYPE.FullSnapshot,
|
|
681
|
-
)
|
|
682
|
-
) {
|
|
683
|
-
throw new Error(t("sessions.noReplayEvents"));
|
|
684
|
-
}
|
|
685
|
-
if (!hasPlayableReplayEvents(replayEvents)) {
|
|
741
|
+
if (replayEvents.length < 2 || !hasPlayableReplayEvents(replayEvents)) {
|
|
686
742
|
throw new Error(t("sessions.noReplayEvents"));
|
|
687
743
|
}
|
|
688
744
|
setStatus("loading");
|
|
@@ -693,21 +749,50 @@ function ReplayPlayer({
|
|
|
693
749
|
|
|
694
750
|
stageRootRef.current.innerHTML = "";
|
|
695
751
|
// Match builder-internal: pass events through untouched and let rrweb own
|
|
696
|
-
// iframe sizing via Meta / ViewportResize.
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
// the stage
|
|
700
|
-
|
|
701
|
-
|
|
752
|
+
// normal iframe sizing via Meta / ViewportResize. Never rewrite Meta or
|
|
753
|
+
// force arbitrary iframe dimensions (that mismatches the FullSnapshot DOM
|
|
754
|
+
// and blanks the stage). The one exception below mirrors the narrowly
|
|
755
|
+
// detected legacy malformed viewport onto both the iframe and outer stage.
|
|
756
|
+
const rawInitialDims = replayInitialViewportDimensions(replayEvents);
|
|
757
|
+
const correctedInitialDims = replayInitialDisplayDimensions(replayEvents);
|
|
758
|
+
setStreamedDims(correctedInitialDims);
|
|
759
|
+
// The legacy malformed-width recordings need one paired exception to
|
|
760
|
+
// raw-event pass-through: pointer coordinates were captured in the same
|
|
761
|
+
// impossible viewport coordinate space. Correcting only the iframe makes
|
|
762
|
+
// rrweb cast clicks thousands of pixels off-stage. Project pointer data
|
|
763
|
+
// with each malformed Meta/resize while retaining every other raw field.
|
|
764
|
+
const playerEvents = projectReplayPointerCoordinates(replayEvents);
|
|
765
|
+
// Keep this loosely typed because the legacy viewport recovery below
|
|
766
|
+
// intentionally calls rrweb's runtime handleResize hook, which its
|
|
767
|
+
// TypeScript declaration marks private.
|
|
768
|
+
localReplayer = new Replayer(playerEvents as any[], {
|
|
702
769
|
root: stageRootRef.current,
|
|
703
770
|
speed: speedRef.current,
|
|
704
771
|
skipInactive: false,
|
|
705
772
|
showWarning: false,
|
|
706
773
|
showDebug: false,
|
|
707
|
-
triggerFocus: false,
|
|
708
774
|
mouseTail: false,
|
|
709
|
-
|
|
775
|
+
// Match stock rrweb/builder-internal focus replay. Disabling focus
|
|
776
|
+
// drops recorded focus-visible state and can leave menus/forms looking
|
|
777
|
+
// unlike the source page even when the snapshot CSS is correct.
|
|
778
|
+
triggerFocus: true,
|
|
779
|
+
insertStyleRules: REPLAY_OVERLAY_STYLE_RULES,
|
|
710
780
|
});
|
|
781
|
+
// IMPORTANT: rrweb constructs its iframe from the raw initial Meta event.
|
|
782
|
+
// When that legacy Meta width is malformed, correcting only streamedDims
|
|
783
|
+
// fixes the outer stage but leaves CSS breakpoints and clicks ultra-wide.
|
|
784
|
+
// Apply the same correction to rrweb immediately, before first playback.
|
|
785
|
+
if (
|
|
786
|
+
correctedInitialDims &&
|
|
787
|
+
(rawInitialDims?.width !== correctedInitialDims.width ||
|
|
788
|
+
rawInitialDims?.height !== correctedInitialDims.height)
|
|
789
|
+
) {
|
|
790
|
+
(localReplayer as any).handleResize?.(correctedInitialDims);
|
|
791
|
+
}
|
|
792
|
+
// rrweb already sandboxes the replay document without script execution.
|
|
793
|
+
// Do not mutate recorded URLs/CSS; suppress viewer-page referrer leakage
|
|
794
|
+
// at the iframe boundary while retaining historical visual resources.
|
|
795
|
+
localReplayer.iframe?.setAttribute?.("referrerpolicy", "no-referrer");
|
|
711
796
|
replayerRef.current = localReplayer;
|
|
712
797
|
const meta = localReplayer.getMetaData?.();
|
|
713
798
|
const total = Number(meta?.totalTime ?? replayDuration(replayEvents));
|
|
@@ -732,10 +817,34 @@ function ReplayPlayer({
|
|
|
732
817
|
dims.width > 0 &&
|
|
733
818
|
dims.height > 0
|
|
734
819
|
) {
|
|
735
|
-
|
|
820
|
+
const rawDims = {
|
|
736
821
|
width: Math.round(dims.width),
|
|
737
822
|
height: Math.round(dims.height),
|
|
738
|
-
}
|
|
823
|
+
};
|
|
824
|
+
const correctedDims = clampReplayDisplayDimensions(rawDims);
|
|
825
|
+
if (!correctedDims) return;
|
|
826
|
+
// rrweb handles the raw resize before notifying us. Override only
|
|
827
|
+
// impossible legacy geometry so its iframe and our stage stay equal.
|
|
828
|
+
// DO NOT clamp just one layer; that breaks responsive CSS and clicks.
|
|
829
|
+
if (
|
|
830
|
+
correctedDims.width !== rawDims.width ||
|
|
831
|
+
correctedDims.height !== rawDims.height
|
|
832
|
+
) {
|
|
833
|
+
// rrweb applies the raw Meta/ViewportResize to its iframe before
|
|
834
|
+
// emitting this callback. Re-apply the correction every time the
|
|
835
|
+
// raw event is malformed, even when React already stores the same
|
|
836
|
+
// corrected dimensions. Returning early here recreates the exact
|
|
837
|
+
// bug where the outer stage is normal but the iframe is ultra-wide.
|
|
838
|
+
(localReplayer as any).handleResize?.(correctedDims);
|
|
839
|
+
}
|
|
840
|
+
const currentDims = streamedDimsRef.current;
|
|
841
|
+
if (
|
|
842
|
+
currentDims?.width === correctedDims.width &&
|
|
843
|
+
currentDims?.height === correctedDims.height
|
|
844
|
+
) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
setStreamedDims(correctedDims);
|
|
739
848
|
}
|
|
740
849
|
});
|
|
741
850
|
updateTime(startAt);
|
|
@@ -768,6 +877,7 @@ function ReplayPlayer({
|
|
|
768
877
|
try {
|
|
769
878
|
localReplayer?.pause?.();
|
|
770
879
|
localReplayer?.destroy?.();
|
|
880
|
+
replayerRef.current?.pause?.();
|
|
771
881
|
replayerRef.current?.destroy?.();
|
|
772
882
|
} catch {
|
|
773
883
|
// rrweb cleanup is best-effort across versions.
|
|
@@ -775,9 +885,6 @@ function ReplayPlayer({
|
|
|
775
885
|
replayerRef.current = null;
|
|
776
886
|
if (stageRootRef.current) stageRootRef.current.innerHTML = "";
|
|
777
887
|
};
|
|
778
|
-
// Key only on isComplete + a stable events identity. Including the events
|
|
779
|
-
// array reference would rebuild the Replayer when the final publish()
|
|
780
|
-
// replaces the chunks object even though the event set is unchanged.
|
|
781
888
|
}, [
|
|
782
889
|
currentTimeRef,
|
|
783
890
|
eventsIdentity,
|
|
@@ -792,10 +899,11 @@ function ReplayPlayer({
|
|
|
792
899
|
if (!playing || status !== "ready") {
|
|
793
900
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
794
901
|
rafRef.current = null;
|
|
902
|
+
lastClockUpdateAtRef.current = null;
|
|
795
903
|
return;
|
|
796
904
|
}
|
|
797
905
|
|
|
798
|
-
const tick = () => {
|
|
906
|
+
const tick = (frameTime: number) => {
|
|
799
907
|
const replayer = replayerRef.current;
|
|
800
908
|
if (replayer && !scrubbingRef.current) {
|
|
801
909
|
let nextTime = Number(
|
|
@@ -818,7 +926,17 @@ function ReplayPlayer({
|
|
|
818
926
|
}
|
|
819
927
|
}
|
|
820
928
|
}
|
|
821
|
-
if (
|
|
929
|
+
if (
|
|
930
|
+
shouldPublishReplayClockUpdate(
|
|
931
|
+
lastClockUpdateAtRef.current,
|
|
932
|
+
frameTime,
|
|
933
|
+
currentTimeRef.current,
|
|
934
|
+
nextTime,
|
|
935
|
+
)
|
|
936
|
+
) {
|
|
937
|
+
lastClockUpdateAtRef.current = frameTime;
|
|
938
|
+
updateTime(nextTime);
|
|
939
|
+
}
|
|
822
940
|
}
|
|
823
941
|
rafRef.current = requestAnimationFrame(tick);
|
|
824
942
|
};
|
|
@@ -827,6 +945,7 @@ function ReplayPlayer({
|
|
|
827
945
|
return () => {
|
|
828
946
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
829
947
|
rafRef.current = null;
|
|
948
|
+
lastClockUpdateAtRef.current = null;
|
|
830
949
|
};
|
|
831
950
|
}, [
|
|
832
951
|
currentTimeRef,
|
|
@@ -912,6 +1031,10 @@ function ReplayPlayer({
|
|
|
912
1031
|
/>
|
|
913
1032
|
<button
|
|
914
1033
|
type="button"
|
|
1034
|
+
// IMPORTANT: Keep the viewer's real OS pointer visible while
|
|
1035
|
+
// the synthetic rrweb pointer replays underneath it. Hiding
|
|
1036
|
+
// this during playback makes the page feel broken whenever
|
|
1037
|
+
// the viewer moves their mouse over the full-stage control.
|
|
915
1038
|
className="absolute inset-0 z-20 cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
|
|
916
1039
|
disabled={disabled}
|
|
917
1040
|
aria-label={
|
|
@@ -1855,66 +1978,116 @@ function useReplayEvents(
|
|
|
1855
1978
|
);
|
|
1856
1979
|
}
|
|
1857
1980
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1981
|
+
/**
|
|
1982
|
+
* Stock rrweb consumes the recorded event objects directly. Playback-time URL
|
|
1983
|
+
* or CSS rewriting breaks snapshots, responsive rules, fonts, and navigation
|
|
1984
|
+
* metadata, so filtering invalid container entries is the only normalization.
|
|
1985
|
+
*
|
|
1986
|
+
* IMPORTANT — DO NOT add URL/CSS sanitization here. A previous security review
|
|
1987
|
+
* changed href/src/_cssText to about:blank and immediately regressed every
|
|
1988
|
+
* historical recording that depended on captured styles. Network/privacy
|
|
1989
|
+
* controls belong at capture or the sandbox boundary, never in rrweb events.
|
|
1990
|
+
*/
|
|
1991
|
+
export function normalizeReplayEvents(events: unknown[]): AnyReplayEvent[] {
|
|
1992
|
+
return events
|
|
1993
|
+
.filter((event): event is AnyReplayEvent => isRecord(event))
|
|
1994
|
+
.sort((a, b) => Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0));
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Pair pointer coordinates with the narrowly gated legacy viewport recovery.
|
|
1999
|
+
*
|
|
2000
|
+
* IMPORTANT: do not apply this to normal recordings and do not rewrite the
|
|
2001
|
+
* Meta/resize events themselves. rrweb must still rebuild the captured DOM
|
|
2002
|
+
* from raw events; only pointer coordinates need projection into the corrected
|
|
2003
|
+
* iframe camera. Returning original references for untouched events keeps this
|
|
2004
|
+
* compatible with stock rrweb and avoids cloning large snapshots.
|
|
2005
|
+
*/
|
|
2006
|
+
export function projectReplayPointerCoordinates(
|
|
2007
|
+
events: AnyReplayEvent[],
|
|
2008
|
+
): AnyReplayEvent[] {
|
|
2009
|
+
let rawDims: ReplayViewportDimensions | null = null;
|
|
2010
|
+
let displayDims: ReplayViewportDimensions | null = null;
|
|
2011
|
+
|
|
2012
|
+
return events.map((event) => {
|
|
2013
|
+
const nextDims = dimensionsFromReplayEvent(event);
|
|
2014
|
+
if (nextDims) {
|
|
2015
|
+
rawDims = nextDims;
|
|
2016
|
+
displayDims = clampReplayDisplayDimensions(nextDims);
|
|
2017
|
+
return event;
|
|
2018
|
+
}
|
|
1884
2019
|
if (
|
|
1885
|
-
|
|
1886
|
-
|
|
2020
|
+
!rawDims ||
|
|
2021
|
+
!displayDims ||
|
|
2022
|
+
(rawDims.width === displayDims.width &&
|
|
2023
|
+
rawDims.height === displayDims.height) ||
|
|
2024
|
+
event.type !== RRWEB_EVENT_TYPE.IncrementalSnapshot
|
|
1887
2025
|
) {
|
|
1888
|
-
return
|
|
2026
|
+
return event;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const source = event.data?.source;
|
|
2030
|
+
if (source === INCREMENTAL_SOURCE.MouseInteraction) {
|
|
2031
|
+
const data = projectReplayPointerData(event.data, rawDims, displayDims);
|
|
2032
|
+
return data === event.data ? event : { ...event, data };
|
|
1889
2033
|
}
|
|
1890
2034
|
if (
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
2035
|
+
source !== INCREMENTAL_SOURCE.MouseMove &&
|
|
2036
|
+
source !== INCREMENTAL_SOURCE.TouchMove &&
|
|
2037
|
+
source !== INCREMENTAL_SOURCE.Drag
|
|
1894
2038
|
) {
|
|
1895
|
-
return
|
|
2039
|
+
return event;
|
|
1896
2040
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
2041
|
+
const positions = Array.isArray(event.data?.positions)
|
|
2042
|
+
? event.data.positions
|
|
2043
|
+
: [];
|
|
2044
|
+
let changed = false;
|
|
2045
|
+
const projectedPositions = positions.map((position: unknown) => {
|
|
2046
|
+
if (!isRecord(position)) return position;
|
|
2047
|
+
const projected = projectReplayPointerData(
|
|
2048
|
+
position,
|
|
2049
|
+
rawDims!,
|
|
2050
|
+
displayDims!,
|
|
2051
|
+
);
|
|
2052
|
+
if (projected !== position) changed = true;
|
|
2053
|
+
return projected;
|
|
2054
|
+
});
|
|
2055
|
+
return changed
|
|
2056
|
+
? { ...event, data: { ...event.data, positions: projectedPositions } }
|
|
2057
|
+
: event;
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
1899
2060
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2061
|
+
function projectReplayPointerData(
|
|
2062
|
+
data: AnyReplayEvent,
|
|
2063
|
+
rawDims: ReplayViewportDimensions,
|
|
2064
|
+
displayDims: ReplayViewportDimensions,
|
|
2065
|
+
): AnyReplayEvent {
|
|
2066
|
+
const projectedX = projectReplayCoordinate(
|
|
2067
|
+
data.x,
|
|
2068
|
+
displayDims.width / rawDims.width,
|
|
2069
|
+
displayDims.width,
|
|
1905
2070
|
);
|
|
2071
|
+
const projectedY = projectReplayCoordinate(
|
|
2072
|
+
data.y,
|
|
2073
|
+
displayDims.height / rawDims.height,
|
|
2074
|
+
displayDims.height,
|
|
2075
|
+
);
|
|
2076
|
+
if (projectedX === data.x && projectedY === data.y) return data;
|
|
2077
|
+
return {
|
|
2078
|
+
...data,
|
|
2079
|
+
...(projectedX !== undefined ? { x: projectedX } : {}),
|
|
2080
|
+
...(projectedY !== undefined ? { y: projectedY } : {}),
|
|
2081
|
+
};
|
|
1906
2082
|
}
|
|
1907
2083
|
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
return
|
|
1915
|
-
.filter((event): event is AnyReplayEvent => isRecord(event))
|
|
1916
|
-
.map((event) => sanitizeReplayValue(event) as AnyReplayEvent)
|
|
1917
|
-
.sort((a, b) => Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0));
|
|
2084
|
+
function projectReplayCoordinate(
|
|
2085
|
+
value: unknown,
|
|
2086
|
+
scale: number,
|
|
2087
|
+
maximum: number,
|
|
2088
|
+
): number | undefined {
|
|
2089
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
2090
|
+
return clamp(value * scale, 0, Math.max(0, maximum - 1));
|
|
1918
2091
|
}
|
|
1919
2092
|
|
|
1920
2093
|
export function buildReplayMarkers(events: AnyReplayEvent[]): ReplayMarker[] {
|
|
@@ -2132,9 +2305,7 @@ export function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
|
|
|
2132
2305
|
const timestamp = Number(event.timestamp ?? 0);
|
|
2133
2306
|
if (!Number.isFinite(timestamp) || timestamp <= 0) continue;
|
|
2134
2307
|
lastTimestamp = Math.max(lastTimestamp, timestamp);
|
|
2135
|
-
|
|
2136
|
-
interactions.push(timestamp);
|
|
2137
|
-
}
|
|
2308
|
+
interactions.push(...replayActivityTimestamps(event, timestamp));
|
|
2138
2309
|
}
|
|
2139
2310
|
interactions.sort((a, b) => a - b);
|
|
2140
2311
|
const ranges: SkipRange[] = [];
|
|
@@ -2157,22 +2328,42 @@ export function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
|
|
|
2157
2328
|
return ranges;
|
|
2158
2329
|
}
|
|
2159
2330
|
|
|
2160
|
-
function
|
|
2161
|
-
|
|
2162
|
-
|
|
2331
|
+
function replayActivityTimestamps(
|
|
2332
|
+
event: AnyReplayEvent,
|
|
2333
|
+
timestamp: number,
|
|
2334
|
+
): number[] {
|
|
2335
|
+
if (event.type === RRWEB_EVENT_TYPE.Meta) return [timestamp];
|
|
2336
|
+
if (event.type !== RRWEB_EVENT_TYPE.IncrementalSnapshot) return [];
|
|
2163
2337
|
|
|
2164
2338
|
const source = event.data?.source;
|
|
2339
|
+
if (
|
|
2340
|
+
source === INCREMENTAL_SOURCE.MouseMove ||
|
|
2341
|
+
source === INCREMENTAL_SOURCE.TouchMove ||
|
|
2342
|
+
source === INCREMENTAL_SOURCE.Drag
|
|
2343
|
+
) {
|
|
2344
|
+
const positions = Array.isArray(event.data?.positions)
|
|
2345
|
+
? event.data.positions
|
|
2346
|
+
: [];
|
|
2347
|
+
// IMPORTANT: rrweb batches pointer positions and schedules each one at
|
|
2348
|
+
// event.timestamp + timeOffset. Treat those exact moments as activity.
|
|
2349
|
+
// Ignoring the batch made Skip inactivity jump across visible movement,
|
|
2350
|
+
// which looked like a frozen cursor even though the recording was intact.
|
|
2351
|
+
return positions.flatMap((position: unknown) => {
|
|
2352
|
+
if (!isRecord(position)) return [];
|
|
2353
|
+
const timeOffset = Number(position.timeOffset ?? 0);
|
|
2354
|
+
return [timestamp + (Number.isFinite(timeOffset) ? timeOffset : 0)];
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2165
2357
|
if (
|
|
2166
2358
|
source === INCREMENTAL_SOURCE.Input ||
|
|
2167
|
-
source === INCREMENTAL_SOURCE.Scroll
|
|
2168
|
-
source === INCREMENTAL_SOURCE.TouchMove
|
|
2359
|
+
source === INCREMENTAL_SOURCE.Scroll
|
|
2169
2360
|
) {
|
|
2170
|
-
return
|
|
2361
|
+
return [timestamp];
|
|
2171
2362
|
}
|
|
2172
|
-
if (source !== INCREMENTAL_SOURCE.MouseInteraction) return
|
|
2363
|
+
if (source !== INCREMENTAL_SOURCE.MouseInteraction) return [];
|
|
2173
2364
|
|
|
2174
2365
|
const interactionType = event.data?.type;
|
|
2175
|
-
return
|
|
2366
|
+
return [
|
|
2176
2367
|
interactionType === MOUSE_INTERACTION.MouseUp ||
|
|
2177
2368
|
interactionType === MOUSE_INTERACTION.MouseDown ||
|
|
2178
2369
|
interactionType === MOUSE_INTERACTION.Click ||
|
|
@@ -2181,7 +2372,9 @@ function isReplayActivityEvent(event: AnyReplayEvent): boolean {
|
|
|
2181
2372
|
interactionType === MOUSE_INTERACTION.Focus ||
|
|
2182
2373
|
interactionType === MOUSE_INTERACTION.TouchStart ||
|
|
2183
2374
|
interactionType === MOUSE_INTERACTION.TouchEnd
|
|
2184
|
-
|
|
2375
|
+
? timestamp
|
|
2376
|
+
: null,
|
|
2377
|
+
].filter((value): value is number => value !== null);
|
|
2185
2378
|
}
|
|
2186
2379
|
|
|
2187
2380
|
function pushIdleRange(
|
|
@@ -2236,6 +2429,81 @@ export function replayViewportDimensions(
|
|
|
2236
2429
|
return best;
|
|
2237
2430
|
}
|
|
2238
2431
|
|
|
2432
|
+
export function replayInitialViewportDimensions(
|
|
2433
|
+
events: AnyReplayEvent[],
|
|
2434
|
+
): ReplayViewportDimensions | null {
|
|
2435
|
+
// rrweb itself initializes from the first Meta event. A resize can appear
|
|
2436
|
+
// earlier in chunk order after reconnect/flush boundaries, but it must not
|
|
2437
|
+
// replace the snapshot's native starting viewport.
|
|
2438
|
+
for (const event of events) {
|
|
2439
|
+
if (event.type !== RRWEB_EVENT_TYPE.Meta) continue;
|
|
2440
|
+
const dims = dimensionsFromReplayEvent(event);
|
|
2441
|
+
if (dims) return dims;
|
|
2442
|
+
}
|
|
2443
|
+
for (const event of events) {
|
|
2444
|
+
const dims = dimensionsFromReplayEvent(event);
|
|
2445
|
+
if (dims) return dims;
|
|
2446
|
+
}
|
|
2447
|
+
return null;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
export function replayInitialDisplayDimensions(
|
|
2451
|
+
events: AnyReplayEvent[],
|
|
2452
|
+
): ReplayViewportDimensions | null {
|
|
2453
|
+
// Keep the initial outer stage and rrweb iframe on one corrected camera.
|
|
2454
|
+
// DO NOT use the raw initial Meta dimensions for only one of those layers.
|
|
2455
|
+
return clampReplayDisplayDimensions(replayInitialViewportDimensions(events));
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
export function buildReplayViewportTimeline(
|
|
2459
|
+
events: AnyReplayEvent[],
|
|
2460
|
+
): ReplayViewportChange[] {
|
|
2461
|
+
const initial = replayInitialViewportDimensions(events);
|
|
2462
|
+
if (!initial) return [];
|
|
2463
|
+
const startedAt = replayStartedAt(events);
|
|
2464
|
+
const firstMetaTimestamp = events.reduce((best, event) => {
|
|
2465
|
+
if (event.type !== RRWEB_EVENT_TYPE.Meta) return best;
|
|
2466
|
+
const timestamp = Number(event.timestamp ?? 0);
|
|
2467
|
+
return Number.isFinite(timestamp) && timestamp > 0
|
|
2468
|
+
? Math.min(best, timestamp)
|
|
2469
|
+
: best;
|
|
2470
|
+
}, Number.POSITIVE_INFINITY);
|
|
2471
|
+
const changes: ReplayViewportChange[] = [{ ...initial, offsetMs: 0 }];
|
|
2472
|
+
for (const event of events) {
|
|
2473
|
+
const timestamp = Number(event.timestamp ?? 0);
|
|
2474
|
+
if (!Number.isFinite(timestamp) || timestamp <= firstMetaTimestamp)
|
|
2475
|
+
continue;
|
|
2476
|
+
const dims = dimensionsFromReplayEvent(event);
|
|
2477
|
+
if (!dims) continue;
|
|
2478
|
+
const previous = changes[changes.length - 1];
|
|
2479
|
+
if (previous?.width === dims.width && previous.height === dims.height) {
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
changes.push({
|
|
2483
|
+
...dims,
|
|
2484
|
+
offsetMs: Math.max(0, timestamp - startedAt),
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
return changes;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
export function replayViewportDimensionsAtTime(
|
|
2491
|
+
changes: ReplayViewportChange[],
|
|
2492
|
+
elapsedMs: number,
|
|
2493
|
+
): ReplayViewportDimensions | null {
|
|
2494
|
+
if (changes.length === 0) return null;
|
|
2495
|
+
const target = Math.max(0, elapsedMs);
|
|
2496
|
+
let low = 0;
|
|
2497
|
+
let high = changes.length - 1;
|
|
2498
|
+
while (low < high) {
|
|
2499
|
+
const middle = Math.ceil((low + high) / 2);
|
|
2500
|
+
if ((changes[middle]?.offsetMs ?? 0) <= target) low = middle;
|
|
2501
|
+
else high = middle - 1;
|
|
2502
|
+
}
|
|
2503
|
+
const match = changes[low];
|
|
2504
|
+
return match ? { width: match.width, height: match.height } : null;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2239
2507
|
function dimensionsFromReplayEvent(
|
|
2240
2508
|
event: AnyReplayEvent,
|
|
2241
2509
|
): ReplayViewportDimensions | null {
|
|
@@ -2272,6 +2540,32 @@ export function normalizeReplayDimensions(
|
|
|
2272
2540
|
};
|
|
2273
2541
|
}
|
|
2274
2542
|
|
|
2543
|
+
export function clampReplayDisplayDimensions(
|
|
2544
|
+
dims: ReplayViewportDimensions | null,
|
|
2545
|
+
): ReplayViewportDimensions | null {
|
|
2546
|
+
if (!dims) return null;
|
|
2547
|
+
if (
|
|
2548
|
+
dims.width < MIN_REPLAY_DISPLAY_DIMENSION ||
|
|
2549
|
+
dims.height < MIN_REPLAY_DISPLAY_DIMENSION
|
|
2550
|
+
) {
|
|
2551
|
+
return { width: DEFAULT_PLAYER_WIDTH, height: DEFAULT_PLAYER_HEIGHT };
|
|
2552
|
+
}
|
|
2553
|
+
const aspect = dims.width / dims.height;
|
|
2554
|
+
const hasMalformedWideGeometry =
|
|
2555
|
+
dims.width >= MALFORMED_REPLAY_MIN_WIDTH &&
|
|
2556
|
+
aspect > MALFORMED_REPLAY_MIN_ASPECT_RATIO;
|
|
2557
|
+
const hasLegacyMalformedWideGeometry =
|
|
2558
|
+
dims.width === LEGACY_MALFORMED_REPLAY_WIDTH &&
|
|
2559
|
+
dims.height === LEGACY_MALFORMED_REPLAY_HEIGHT;
|
|
2560
|
+
if (hasMalformedWideGeometry || hasLegacyMalformedWideGeometry) {
|
|
2561
|
+
return {
|
|
2562
|
+
width: Math.round(dims.height * RECOVERED_REPLAY_DISPLAY_ASPECT_RATIO),
|
|
2563
|
+
height: dims.height,
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
return dims;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2275
2569
|
export function filterReplayMarkers(
|
|
2276
2570
|
markers: ReplayMarker[],
|
|
2277
2571
|
query: string,
|
|
@@ -2428,6 +2722,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2428
2722
|
return Math.min(max, Math.max(min, value));
|
|
2429
2723
|
}
|
|
2430
2724
|
|
|
2725
|
+
export function shouldPublishReplayClockUpdate(
|
|
2726
|
+
lastUpdateAt: number | null,
|
|
2727
|
+
frameTime: number,
|
|
2728
|
+
currentTime: number,
|
|
2729
|
+
nextTime: number,
|
|
2730
|
+
): boolean {
|
|
2731
|
+
if (!Number.isFinite(frameTime) || !Number.isFinite(nextTime)) return false;
|
|
2732
|
+
if (nextTime === currentTime) return false;
|
|
2733
|
+
return (
|
|
2734
|
+
lastUpdateAt == null ||
|
|
2735
|
+
frameTime - lastUpdateAt >= REPLAY_CLOCK_UPDATE_INTERVAL_MS
|
|
2736
|
+
);
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2431
2739
|
function isRecord(value: unknown): value is AnyRecord {
|
|
2432
2740
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
2433
2741
|
}
|