@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.
Files changed (78) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +23 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +2 -2
  5. package/corpus/core/src/client/analytics.ts +12 -0
  6. package/corpus/core/src/client/index.ts +2 -0
  7. package/corpus/core/src/client/session-replay.ts +612 -26
  8. package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
  9. package/corpus/core/src/client/settings/SettingsPanel.tsx +31 -211
  10. package/corpus/core/src/client/settings/agent-settings-search.ts +219 -0
  11. package/corpus/core/src/client/settings/index.ts +4 -0
  12. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  13. package/corpus/core/src/demo/config.ts +6 -6
  14. package/corpus/core/src/demo/fetch-interceptor.ts +25 -2
  15. package/corpus/core/src/demo/redact.ts +57 -66
  16. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +65 -6
  17. package/corpus/templates/analytics/AGENTS.md +6 -6
  18. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +429 -244
  19. package/corpus/templates/analytics/app/components/layout/command-palette-search.ts +93 -0
  20. package/corpus/templates/analytics/app/global.css +4 -62
  21. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +2 -1
  22. package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +46 -33
  23. package/corpus/templates/analytics/app/pages/Settings.tsx +8 -45
  24. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +413 -105
  25. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +32 -16
  26. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +126 -0
  27. package/corpus/templates/analytics/changelog/2026-07-12-command-menu-loading-placeholders-now-stay-below-available-r.md +6 -0
  28. package/corpus/templates/analytics/changelog/2026-07-12-command-menu-search-now-prioritizes-the-best-match.md +6 -0
  29. package/corpus/templates/analytics/changelog/2026-07-12-demo-charts-preserve-source-movement.md +6 -0
  30. package/corpus/templates/analytics/changelog/2026-07-12-fixed-session-replays-that-appeared-ultra-wide-hid-recorded-.md +6 -0
  31. package/corpus/templates/analytics/changelog/2026-07-12-replay-storage-settings-stay-readable-at-narrow-widths.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-load-faster-in-demo-mode-while-visitor-email.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-preserve-recorded-styling-and-keep-malformed.md +6 -0
  34. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +11 -2
  35. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +14 -2
  36. package/corpus/templates/calendar/changelog/2026-07-12-calendar-responses-can-now-be-saved-with-command-enter-or-ct.md +6 -0
  37. package/dist/agent/production-agent.js +2 -2
  38. package/dist/agent/production-agent.js.map +1 -1
  39. package/dist/client/analytics.d.ts.map +1 -1
  40. package/dist/client/analytics.js +11 -0
  41. package/dist/client/analytics.js.map +1 -1
  42. package/dist/client/index.d.ts +1 -1
  43. package/dist/client/index.d.ts.map +1 -1
  44. package/dist/client/index.js +1 -1
  45. package/dist/client/index.js.map +1 -1
  46. package/dist/client/session-replay.d.ts +12 -3
  47. package/dist/client/session-replay.d.ts.map +1 -1
  48. package/dist/client/session-replay.js +488 -23
  49. package/dist/client/session-replay.js.map +1 -1
  50. package/dist/client/settings/DemoModeSection.d.ts +3 -3
  51. package/dist/client/settings/DemoModeSection.js +4 -4
  52. package/dist/client/settings/DemoModeSection.js.map +1 -1
  53. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  54. package/dist/client/settings/SettingsPanel.js +41 -191
  55. package/dist/client/settings/SettingsPanel.js.map +1 -1
  56. package/dist/client/settings/agent-settings-search.d.ts +16 -0
  57. package/dist/client/settings/agent-settings-search.d.ts.map +1 -0
  58. package/dist/client/settings/agent-settings-search.js +178 -0
  59. package/dist/client/settings/agent-settings-search.js.map +1 -0
  60. package/dist/client/settings/index.d.ts +1 -0
  61. package/dist/client/settings/index.d.ts.map +1 -1
  62. package/dist/client/settings/index.js +1 -0
  63. package/dist/client/settings/index.js.map +1 -1
  64. package/dist/collab/routes.d.ts +1 -1
  65. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  66. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  67. package/dist/demo/config.js +6 -6
  68. package/dist/demo/config.js.map +1 -1
  69. package/dist/demo/fetch-interceptor.d.ts +1 -0
  70. package/dist/demo/fetch-interceptor.d.ts.map +1 -1
  71. package/dist/demo/fetch-interceptor.js +19 -2
  72. package/dist/demo/fetch-interceptor.js.map +1 -1
  73. package/dist/demo/redact.d.ts +14 -5
  74. package/dist/demo/redact.d.ts.map +1 -1
  75. package/dist/demo/redact.js +30 -56
  76. package/dist/demo/redact.js.map +1 -1
  77. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  78. 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
- /** Toast/snackbar noise only — keep insertStyleRules minimal like builder-internal. */
181
- const SUPPRESS_OVERLAYS_CSS = [
182
- "[data-radix-popper-content-wrapper], .Toastify, [class*='toast'], [class*='Toast'], [class*='Snackbar'] { display: none !important; }",
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(() => replayViewportDimensions(events), [events]);
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
- // Keep the stage in rrweb's coordinate system. Clamping only this outer
505
- // wrapper while rrweb keeps its raw iframe dimensions clips and stretches
506
- // wide recordings because the two layers no longer share a viewport.
507
- const playerWidth =
508
- streamedDims?.width ?? initialDims?.width ?? DEFAULT_PLAYER_WIDTH;
509
- const playerHeight =
510
- streamedDims?.height ?? initialDims?.height ?? DEFAULT_PLAYER_HEIGHT;
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
- [playingRef, status, totalTime, updateTime],
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. Only use dimensions for CSS
697
- // fit-to-stage scaling of the outer wrapper never rewrite Meta or force
698
- // iframe width/height (that mismatches the FullSnapshot DOM and blanks
699
- // the stage).
700
- setStreamedDims(replayViewportDimensions(replayEvents));
701
- localReplayer = new Replayer(replayEvents as any[], {
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
- insertStyleRules: SUPPRESS_OVERLAYS_CSS,
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
- setStreamedDims({
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 (Number.isFinite(nextTime)) updateTime(nextTime);
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
- const REPLAY_NETWORK_ATTRIBUTES = new Set([
1859
- "src",
1860
- "srcset",
1861
- "poster",
1862
- "background",
1863
- "action",
1864
- "formaction",
1865
- "data",
1866
- "ping",
1867
- "cite",
1868
- "xlink:href",
1869
- ]);
1870
-
1871
- function stripReplayCssUrls(value: string): string {
1872
- return value
1873
- .replace(/@import\s+(?:url\()?[^;)]+(?:\))?\s*;?/gi, "")
1874
- .replace(/url\(\s*[^)]*\)/gi, "none");
1875
- }
1876
-
1877
- function sanitizeReplayValue(value: unknown, key?: string): unknown {
1878
- if (Array.isArray(value)) {
1879
- return value.map((item) => sanitizeReplayValue(item));
1880
- }
1881
- if (!isRecord(value)) {
1882
- if (typeof value !== "string") return value;
1883
- const normalizedKey = key?.toLowerCase();
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
- normalizedKey === "href" ||
1886
- REPLAY_NETWORK_ATTRIBUTES.has(normalizedKey ?? "")
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 "about:blank";
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
- normalizedKey === "style" ||
1892
- normalizedKey === "_csstext" ||
1893
- normalizedKey === "csstext"
2035
+ source !== INCREMENTAL_SOURCE.MouseMove &&
2036
+ source !== INCREMENTAL_SOURCE.TouchMove &&
2037
+ source !== INCREMENTAL_SOURCE.Drag
1894
2038
  ) {
1895
- return stripReplayCssUrls(value);
2039
+ return event;
1896
2040
  }
1897
- return value;
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
- return Object.fromEntries(
1901
- Object.entries(value).map(([entryKey, entryValue]) => [
1902
- entryKey,
1903
- sanitizeReplayValue(entryValue, entryKey),
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
- * rrweb's iframe sandbox prevents scripts but permits passive resource loads.
1910
- * Preserve the captured DOM structure and inline styling while neutralizing
1911
- * URLs that could contact a recorded page or third-party origin.
1912
- */
1913
- export function normalizeReplayEvents(events: unknown[]): AnyReplayEvent[] {
1914
- return events
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
- if (isReplayActivityEvent(event)) {
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 isReplayActivityEvent(event: AnyReplayEvent): boolean {
2161
- if (event.type === RRWEB_EVENT_TYPE.Meta) return true;
2162
- if (event.type !== RRWEB_EVENT_TYPE.IncrementalSnapshot) return false;
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 true;
2361
+ return [timestamp];
2171
2362
  }
2172
- if (source !== INCREMENTAL_SOURCE.MouseInteraction) return false;
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
  }