@agent-native/core 0.98.2 → 0.98.3

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 (64) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +10 -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/index.ts +2 -0
  6. package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
  7. package/corpus/core/src/client/settings/SettingsPanel.tsx +31 -211
  8. package/corpus/core/src/client/settings/agent-settings-search.ts +219 -0
  9. package/corpus/core/src/client/settings/index.ts +4 -0
  10. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  11. package/corpus/core/src/demo/config.ts +6 -6
  12. package/corpus/core/src/demo/redact.ts +18 -76
  13. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +21 -4
  14. package/corpus/templates/analytics/AGENTS.md +6 -6
  15. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +429 -244
  16. package/corpus/templates/analytics/app/components/layout/command-palette-search.ts +93 -0
  17. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +2 -1
  18. package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +46 -33
  19. package/corpus/templates/analytics/app/pages/Settings.tsx +8 -45
  20. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +205 -70
  21. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +32 -16
  22. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +126 -0
  23. package/corpus/templates/analytics/changelog/2026-07-12-command-menu-loading-placeholders-now-stay-below-available-r.md +6 -0
  24. package/corpus/templates/analytics/changelog/2026-07-12-command-menu-search-now-prioritizes-the-best-match.md +6 -0
  25. package/corpus/templates/analytics/changelog/2026-07-12-demo-charts-preserve-source-movement.md +6 -0
  26. package/corpus/templates/analytics/changelog/2026-07-12-replay-storage-settings-stay-readable-at-narrow-widths.md +6 -0
  27. package/corpus/templates/analytics/changelog/2026-07-12-session-replays-preserve-recorded-styling-and-keep-malformed.md +6 -0
  28. package/dist/agent/production-agent.js +2 -2
  29. package/dist/agent/production-agent.js.map +1 -1
  30. package/dist/client/index.d.ts +1 -1
  31. package/dist/client/index.d.ts.map +1 -1
  32. package/dist/client/index.js +1 -1
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/settings/DemoModeSection.d.ts +3 -3
  35. package/dist/client/settings/DemoModeSection.js +4 -4
  36. package/dist/client/settings/DemoModeSection.js.map +1 -1
  37. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  38. package/dist/client/settings/SettingsPanel.js +41 -191
  39. package/dist/client/settings/SettingsPanel.js.map +1 -1
  40. package/dist/client/settings/agent-settings-search.d.ts +16 -0
  41. package/dist/client/settings/agent-settings-search.d.ts.map +1 -0
  42. package/dist/client/settings/agent-settings-search.js +178 -0
  43. package/dist/client/settings/agent-settings-search.js.map +1 -0
  44. package/dist/client/settings/index.d.ts +1 -0
  45. package/dist/client/settings/index.d.ts.map +1 -1
  46. package/dist/client/settings/index.js +1 -0
  47. package/dist/client/settings/index.js.map +1 -1
  48. package/dist/collab/routes.d.ts +2 -2
  49. package/dist/collab/struct-routes.d.ts +1 -1
  50. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  51. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  52. package/dist/demo/config.js +6 -6
  53. package/dist/demo/config.js.map +1 -1
  54. package/dist/demo/redact.d.ts +7 -5
  55. package/dist/demo/redact.d.ts.map +1 -1
  56. package/dist/demo/redact.js +19 -54
  57. package/dist/demo/redact.js.map +1 -1
  58. package/dist/notifications/routes.d.ts +1 -1
  59. package/dist/progress/routes.d.ts +1 -1
  60. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  61. package/dist/resources/handlers.d.ts +3 -3
  62. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  63. package/dist/server/transcribe-voice.d.ts +1 -1
  64. package/package.json +2 -2
@@ -26,3 +26,96 @@ export function commandPaletteKeywords(
26
26
 
27
27
  return Array.from(variants);
28
28
  }
29
+
30
+ function normalizeSearchText(value: string): string {
31
+ return value
32
+ .normalize("NFKD")
33
+ .replace(/[\u0300-\u036f]/g, "")
34
+ .toLowerCase()
35
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
36
+ .trim();
37
+ }
38
+
39
+ function fuzzySubsequenceScore(candidate: string, query: string): number {
40
+ const compactCandidate = candidate.replace(/\s/g, "");
41
+ const compactQuery = query.replace(/\s/g, "");
42
+ let queryIndex = 0;
43
+
44
+ for (const character of compactCandidate) {
45
+ if (character === compactQuery[queryIndex]) queryIndex += 1;
46
+ if (queryIndex === compactQuery.length) {
47
+ return 0.2 * (compactQuery.length / compactCandidate.length);
48
+ }
49
+ }
50
+
51
+ return 0;
52
+ }
53
+
54
+ export function commandPaletteFilter(
55
+ value: string,
56
+ search: string,
57
+ keywords: string[] = [],
58
+ ): number {
59
+ const query = normalizeSearchText(search);
60
+ if (!query) return 1;
61
+
62
+ const queryWords = query.split(" ");
63
+ let bestScore = 0;
64
+
65
+ for (const rawCandidate of [...keywords, value]) {
66
+ const candidate = normalizeSearchText(rawCandidate);
67
+ if (!candidate) continue;
68
+
69
+ const words = candidate.split(" ");
70
+ let score = 0;
71
+
72
+ if (candidate === query) score = 1;
73
+ else if (candidate.startsWith(query)) score = 0.96;
74
+ else if (words.includes(query)) score = 0.94;
75
+ else if (words.some((word) => word.startsWith(query))) score = 0.9;
76
+ else if (candidate.includes(query)) score = 0.8;
77
+ else if (
78
+ queryWords.every((queryWord) =>
79
+ words.some((word) => word.startsWith(queryWord)),
80
+ )
81
+ ) {
82
+ score = 0.72;
83
+ } else {
84
+ score = fuzzySubsequenceScore(candidate, query);
85
+ }
86
+
87
+ bestScore = Math.max(bestScore, score);
88
+ }
89
+
90
+ return bestScore;
91
+ }
92
+
93
+ export function rankCommandPaletteEntries<T>(
94
+ entries: T[],
95
+ search: string,
96
+ getSearchData: (entry: T) => { value: string; keywords?: string[] },
97
+ ): Array<{ entry: T; score: number }> {
98
+ return entries
99
+ .map((entry, index) => {
100
+ const { value, keywords } = getSearchData(entry);
101
+ return {
102
+ entry,
103
+ index,
104
+ score: commandPaletteFilter(value, search, keywords),
105
+ };
106
+ })
107
+ .filter(({ score }) => score > 0)
108
+ .sort((a, b) => b.score - a.score || a.index - b.index)
109
+ .map(({ entry, score }) => ({ entry, score }));
110
+ }
111
+
112
+ export function uniqueCommandItems<T extends { id: string; name: string }>(
113
+ items: T[],
114
+ ): T[] {
115
+ const seenIds = new Set<string>();
116
+ return items.filter((item) => {
117
+ if (seenIds.has(item.id)) return false;
118
+ seenIds.add(item.id);
119
+ return true;
120
+ });
121
+ }
@@ -46,11 +46,12 @@ export async function fetchReplayStorageStatus(): Promise<ReplayStorageStatus> {
46
46
  };
47
47
  }
48
48
 
49
- export function useReplayStorageStatus() {
49
+ export function useReplayStorageStatus(options?: { enabled?: boolean }) {
50
50
  return useQuery({
51
51
  queryKey: REPLAY_STORAGE_STATUS_KEY,
52
52
  queryFn: fetchReplayStorageStatus,
53
53
  staleTime: 60_000,
54
+ enabled: options?.enabled,
54
55
  });
55
56
  }
56
57
 
@@ -52,49 +52,56 @@ function normalizedVolatility(
52
52
  }
53
53
 
54
54
  function normalizedTrend(
55
- length: number,
55
+ series: number[],
56
+ minimum: number,
57
+ range: number,
56
58
  volatilityScore: number,
57
59
  random: () => number,
58
60
  ): number[] {
61
+ const length = series.length;
59
62
  if (length <= 1) return [0];
60
63
  if (length === 2) return [0, 1];
61
64
 
62
- const volatility = 0.015 + volatilityScore * (0.22 + random() * 0.12);
63
- const noiseMemory = 0.78 - volatilityScore * 0.58;
64
- let noise = 0;
65
- const values = Array.from({ length }, (_, index) => {
66
- if (index === 0) return 0;
67
- if (index === length - 1) return 1;
65
+ const normalized = series.map((value) => (value - minimum) / range);
66
+ const randomization = 0.012 + volatilityScore * 0.08;
67
+ const shape = [normalized[0]];
68
+
69
+ // Preserve the source's actual step pattern (including when and how sharply
70
+ // it spikes), with just enough seeded variation that otherwise-similar demo
71
+ // series do not become identical. Smooth sources receive almost no jitter;
72
+ // volatile sources can vary a little more without moving their events.
73
+ for (let index = 1; index < length; index += 1) {
74
+ const sourceStep = normalized[index] - normalized[index - 1];
75
+ const factor = 1 + (random() * 2 - 1) * randomization;
76
+ shape.push(shape[index - 1] + sourceStep * factor);
77
+ }
68
78
 
79
+ // Add only the linear drift required for the first point to be the global
80
+ // minimum and the last to be the global maximum. A linear term has zero
81
+ // second difference, so the source's local acceleration, spikes, and dips
82
+ // survive instead of being replaced by a synthetic random walk.
83
+ let requiredDrift = 0;
84
+ for (let index = 1; index < length; index += 1) {
85
+ const progress = index / (length - 1);
86
+ requiredDrift = Math.max(
87
+ requiredDrift,
88
+ (shape[0] - shape[index]) / progress,
89
+ );
90
+ }
91
+ for (let index = 0; index < length - 1; index += 1) {
69
92
  const progress = index / (length - 1);
70
- noise = noise * noiseMemory + (random() * 2 - 1) * (1 - noiseMemory);
71
- const taperedNoise = noise * volatility * Math.sin(Math.PI * progress);
72
- return clamp(progress + taperedNoise, 0.025, 0.975);
73
- });
74
-
75
- // Pullback count and depth follow the source's normalized volatility. A
76
- // smooth source remains a gentle rise; a jagged source gets several seeded
77
- // reversals. Spacing the reversals across the series prevents them from
78
- // collapsing into one noisy patch.
79
- if (length >= 5 && volatilityScore >= 0.12) {
80
- const maximumPullbacks = Math.min(3, Math.floor((length - 2) / 2));
81
- const pullbackCount = Math.max(
82
- 1,
83
- Math.round(volatilityScore * maximumPullbacks),
93
+ requiredDrift = Math.max(
94
+ requiredDrift,
95
+ (shape[index] - shape[length - 1]) / (1 - progress),
84
96
  );
85
- for (let pullback = 0; pullback < pullbackCount; pullback += 1) {
86
- const segmentCenter =
87
- ((pullback + 1) * (length - 3)) / (pullbackCount + 1) + 1;
88
- const jitter = (random() - 0.5) * Math.max(1, length / 10);
89
- const dipIndex = Math.round(clamp(segmentCenter + jitter, 2, length - 2));
90
- const center = dipIndex / (length - 1);
91
- const amplitude = 0.025 + volatilityScore * (0.075 + random() * 0.12);
92
- values[dipIndex - 1] = clamp(center + amplitude, 0.075, 0.925);
93
- values[dipIndex] = clamp(center - amplitude, 0.05, 0.9);
94
- }
95
97
  }
96
98
 
97
- return values;
99
+ const drift = requiredDrift + 0.012 + random() * 0.018;
100
+ const candidate = shape.map(
101
+ (value, index) => value + drift * (index / (length - 1)),
102
+ );
103
+ const candidateRange = candidate[length - 1] - candidate[0];
104
+ return candidate.map((value) => (value - candidate[0]) / candidateRange);
98
105
  }
99
106
 
100
107
  /**
@@ -133,7 +140,13 @@ export function createDemoChartTrendRows(
133
140
  minimum,
134
141
  range,
135
142
  );
136
- const trend = normalizedTrend(points.length, volatilityScore, random);
143
+ const trend = normalizedTrend(
144
+ points.map((point) => point.numeric),
145
+ minimum,
146
+ range,
147
+ volatilityScore,
148
+ random,
149
+ );
137
150
  const useStringValues = points.every(
138
151
  (point) => typeof point.original === "string",
139
152
  );
@@ -5,7 +5,6 @@ import {
5
5
  useAgentSettingsTabs,
6
6
  useSession,
7
7
  useT,
8
- type SettingsSearchEntry,
9
8
  type SettingsTabItem,
10
9
  } from "@agent-native/core/client";
11
10
  import { TeamPage } from "@agent-native/core/client/org";
@@ -27,6 +26,7 @@ import changelog from "../../CHANGELOG.md?raw";
27
26
  import { useReplayStorageStatus } from "../hooks/use-replay-storage-status";
28
27
  import { ReplayStorageHint } from "./sessions/SessionsPage";
29
28
  import { AlertRulesSettingsCard } from "./settings/AlertRulesSettingsCard";
29
+ import { buildAnalyticsGeneralSettingsSearchEntries } from "./settings/settings-search";
30
30
 
31
31
  export default function Settings() {
32
32
  // Settings is also reachable directly from the full-page agent surface.
@@ -56,49 +56,12 @@ export default function Settings() {
56
56
  [agentSettingsTabs, t],
57
57
  );
58
58
 
59
- const generalSearchEntries = useMemo<SettingsSearchEntry[]>(
60
- () => [
61
- {
62
- id: "analytics-account",
63
- label: t("settings.account"),
64
- keywords: "profile email signed in identity",
65
- hash: "account",
66
- },
67
- {
68
- id: "analytics-credentials",
69
- label: t("settings.credentials"),
70
- keywords: "data sources api keys manage credentials",
71
- hash: "credentials",
72
- },
73
- {
74
- id: "analytics-dashboard-templates",
75
- label: t("settings.dashboardTemplates"),
76
- keywords: "templates catalog dashboards",
77
- hash: "dashboard-templates",
78
- },
79
- ...(replayStorageStatus.data?.configured
80
- ? [
81
- {
82
- id: "analytics-replay-storage",
83
- label: t("sessions.storageSetupTitle"),
84
- keywords: "session replay recording storage s3 bucket builder",
85
- hash: "replay-storage",
86
- },
87
- ]
88
- : []),
89
- {
90
- id: "analytics-language",
91
- label: t("settings.languageTitle"),
92
- keywords: "language locale translation i18n",
93
- hash: "language",
94
- },
95
- {
96
- id: "analytics-about",
97
- label: t("settings.about"),
98
- keywords: "about version info usage",
99
- hash: "about",
100
- },
101
- ],
59
+ const generalSearchEntries = useMemo(
60
+ () =>
61
+ buildAnalyticsGeneralSettingsSearchEntries(
62
+ t,
63
+ !!replayStorageStatus.data?.configured,
64
+ ),
102
65
  [replayStorageStatus.data?.configured, t],
103
66
  );
104
67
 
@@ -184,7 +147,7 @@ export default function Settings() {
184
147
  </CardDescription>
185
148
  </CardHeader>
186
149
  <CardContent>
187
- <ReplayStorageHint />
150
+ <ReplayStorageHint embedded />
188
151
  </CardContent>
189
152
  </Card>
190
153
  ) : null}
@@ -160,8 +160,17 @@ 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
+ const MIN_REPLAY_DISPLAY_DIMENSION = 240;
172
+ const RECOVERED_REPLAY_DISPLAY_ASPECT_RATIO =
173
+ DEFAULT_PLAYER_WIDTH / DEFAULT_PLAYER_HEIGHT;
165
174
  const DEFAULT_SPEED = 2;
166
175
  const SPEED_OPTIONS = [0.5, 1, 2, 4, 8];
167
176
  const SKIP_STEP_MS = 5000;
@@ -487,8 +496,17 @@ function ReplayPlayer({
487
496
  height: number;
488
497
  } | null>(null);
489
498
  const [fitScale, setFitScale] = useState(1);
490
- const initialDims = useMemo(() => replayViewportDimensions(events), [events]);
499
+ const initialDims = useMemo(
500
+ () => replayInitialDisplayDimensions(events),
501
+ [events],
502
+ );
503
+ const viewportTimeline = useMemo(
504
+ () => buildReplayViewportTimeline(events),
505
+ [events],
506
+ );
491
507
  const eventsRef = useLiveRef(events);
508
+ const viewportTimelineRef = useLiveRef(viewportTimeline);
509
+ const streamedDimsRef = useLiveRef(streamedDims);
492
510
  // Stable identity for the loaded event set so progressive chunk publishes
493
511
  // that only grow the array do not tear down a healthy Replayer mid-playback.
494
512
  const eventsIdentity = useMemo(
@@ -501,13 +519,16 @@ function ReplayPlayer({
501
519
  const scrubbingRef = useRef(false);
502
520
  const scrubResumePlayingRef = useRef(false);
503
521
 
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;
522
+ // IMPORTANT DO NOT REMOVE THIS LEGACY VIEWPORT CORRECTION.
523
+ // Some stored sessions contain impossible 4,000–7,500px-wide Meta/resize
524
+ // values even though the browser was a normal desktop viewport. Rendering
525
+ // those raw values collapses the replay into a tiny horizontal ribbon. Keep
526
+ // normal recordings exact—including 32:9 and mobile portrait. Recover only
527
+ // widths >=4,000px with aspect >4:1 to the standard 16:10 viewport. The same
528
+ // dimensions are applied to rrweb's iframe so CSS breakpoints/camera agree.
529
+ const displayDims = clampReplayDisplayDimensions(streamedDims ?? initialDims);
530
+ const playerWidth = displayDims?.width ?? DEFAULT_PLAYER_WIDTH;
531
+ const playerHeight = displayDims?.height ?? DEFAULT_PLAYER_HEIGHT;
511
532
  const skipRanges = useMemo(() => buildIdleSkipRanges(events), [events]);
512
533
  const skipRangesRef = useLiveRef(skipRanges);
513
534
  const skipInactiveRef = useLiveRef(skipInactive);
@@ -626,9 +647,35 @@ function ReplayPlayer({
626
647
  console.warn("[session-replay] seek failed", seekError);
627
648
  return;
628
649
  }
650
+ const seekDims = replayViewportDimensionsAtTime(
651
+ viewportTimelineRef.current,
652
+ clamped,
653
+ );
654
+ if (seekDims) {
655
+ // rrweb 2.1 does not reliably re-emit Resize when seeking backwards.
656
+ // IMPORTANT: correct both layers together. Correcting only the outer
657
+ // stage or only rrweb's iframe recreates the ultra-wide/clipped bug.
658
+ const correctedSeekDims = clampReplayDisplayDimensions(seekDims);
659
+ const currentDims = streamedDimsRef.current;
660
+ if (
661
+ correctedSeekDims &&
662
+ (currentDims?.width !== correctedSeekDims.width ||
663
+ currentDims?.height !== correctedSeekDims.height)
664
+ ) {
665
+ replayer.handleResize?.(correctedSeekDims);
666
+ setStreamedDims(correctedSeekDims);
667
+ }
668
+ }
629
669
  updateTime(clamped);
630
670
  },
631
- [playingRef, status, totalTime, updateTime],
671
+ [
672
+ playingRef,
673
+ status,
674
+ streamedDimsRef,
675
+ totalTime,
676
+ updateTime,
677
+ viewportTimelineRef,
678
+ ],
632
679
  );
633
680
 
634
681
  const beginScrub = useCallback(
@@ -693,11 +740,13 @@ function ReplayPlayer({
693
740
 
694
741
  stageRootRef.current.innerHTML = "";
695
742
  // 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));
743
+ // normal iframe sizing via Meta / ViewportResize. Never rewrite Meta or
744
+ // force arbitrary iframe dimensions (that mismatches the FullSnapshot DOM
745
+ // and blanks the stage). The one exception below mirrors the narrowly
746
+ // detected legacy malformed viewport onto both the iframe and outer stage.
747
+ const rawInitialDims = replayInitialViewportDimensions(replayEvents);
748
+ const correctedInitialDims = replayInitialDisplayDimensions(replayEvents);
749
+ setStreamedDims(correctedInitialDims);
701
750
  localReplayer = new Replayer(replayEvents as any[], {
702
751
  root: stageRootRef.current,
703
752
  speed: speedRef.current,
@@ -708,6 +757,21 @@ function ReplayPlayer({
708
757
  mouseTail: false,
709
758
  insertStyleRules: SUPPRESS_OVERLAYS_CSS,
710
759
  });
760
+ // IMPORTANT: rrweb constructs its iframe from the raw initial Meta event.
761
+ // When that legacy Meta width is malformed, correcting only streamedDims
762
+ // fixes the outer stage but leaves CSS breakpoints and clicks ultra-wide.
763
+ // Apply the same correction to rrweb immediately, before first playback.
764
+ if (
765
+ correctedInitialDims &&
766
+ (rawInitialDims?.width !== correctedInitialDims.width ||
767
+ rawInitialDims?.height !== correctedInitialDims.height)
768
+ ) {
769
+ localReplayer.handleResize?.(correctedInitialDims);
770
+ }
771
+ // rrweb already sandboxes the replay document without script execution.
772
+ // Do not mutate recorded URLs/CSS; suppress viewer-page referrer leakage
773
+ // at the iframe boundary while retaining historical visual resources.
774
+ localReplayer.iframe?.setAttribute?.("referrerpolicy", "no-referrer");
711
775
  replayerRef.current = localReplayer;
712
776
  const meta = localReplayer.getMetaData?.();
713
777
  const total = Number(meta?.totalTime ?? replayDuration(replayEvents));
@@ -732,10 +796,29 @@ function ReplayPlayer({
732
796
  dims.width > 0 &&
733
797
  dims.height > 0
734
798
  ) {
735
- setStreamedDims({
799
+ const rawDims = {
736
800
  width: Math.round(dims.width),
737
801
  height: Math.round(dims.height),
738
- });
802
+ };
803
+ const correctedDims = clampReplayDisplayDimensions(rawDims);
804
+ if (!correctedDims) return;
805
+ // rrweb handles the raw resize before notifying us. Override only
806
+ // impossible legacy geometry so its iframe and our stage stay equal.
807
+ // DO NOT clamp just one layer; that breaks responsive CSS and clicks.
808
+ const currentDims = streamedDimsRef.current;
809
+ if (
810
+ currentDims?.width === correctedDims.width &&
811
+ currentDims?.height === correctedDims.height
812
+ ) {
813
+ return;
814
+ }
815
+ if (
816
+ correctedDims.width !== rawDims.width ||
817
+ correctedDims.height !== rawDims.height
818
+ ) {
819
+ localReplayer.handleResize?.(correctedDims);
820
+ }
821
+ setStreamedDims(correctedDims);
739
822
  }
740
823
  });
741
824
  updateTime(startAt);
@@ -1855,65 +1938,19 @@ function useReplayEvents(
1855
1938
  );
1856
1939
  }
1857
1940
 
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();
1884
- if (
1885
- normalizedKey === "href" ||
1886
- REPLAY_NETWORK_ATTRIBUTES.has(normalizedKey ?? "")
1887
- ) {
1888
- return "about:blank";
1889
- }
1890
- if (
1891
- normalizedKey === "style" ||
1892
- normalizedKey === "_csstext" ||
1893
- normalizedKey === "csstext"
1894
- ) {
1895
- return stripReplayCssUrls(value);
1896
- }
1897
- return value;
1898
- }
1899
-
1900
- return Object.fromEntries(
1901
- Object.entries(value).map(([entryKey, entryValue]) => [
1902
- entryKey,
1903
- sanitizeReplayValue(entryValue, entryKey),
1904
- ]),
1905
- );
1906
- }
1907
-
1908
1941
  /**
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.
1942
+ * Stock rrweb consumes the recorded event objects directly. Playback-time URL
1943
+ * or CSS rewriting breaks snapshots, responsive rules, fonts, and navigation
1944
+ * metadata, so filtering invalid container entries is the only normalization.
1945
+ *
1946
+ * IMPORTANT — DO NOT add URL/CSS sanitization here. A previous security review
1947
+ * changed href/src/_cssText to about:blank and immediately regressed every
1948
+ * historical recording that depended on captured styles. Network/privacy
1949
+ * controls belong at capture or the sandbox boundary, never in rrweb events.
1912
1950
  */
1913
1951
  export function normalizeReplayEvents(events: unknown[]): AnyReplayEvent[] {
1914
1952
  return events
1915
1953
  .filter((event): event is AnyReplayEvent => isRecord(event))
1916
- .map((event) => sanitizeReplayValue(event) as AnyReplayEvent)
1917
1954
  .sort((a, b) => Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0));
1918
1955
  }
1919
1956
 
@@ -2236,6 +2273,81 @@ export function replayViewportDimensions(
2236
2273
  return best;
2237
2274
  }
2238
2275
 
2276
+ export function replayInitialViewportDimensions(
2277
+ events: AnyReplayEvent[],
2278
+ ): ReplayViewportDimensions | null {
2279
+ // rrweb itself initializes from the first Meta event. A resize can appear
2280
+ // earlier in chunk order after reconnect/flush boundaries, but it must not
2281
+ // replace the snapshot's native starting viewport.
2282
+ for (const event of events) {
2283
+ if (event.type !== RRWEB_EVENT_TYPE.Meta) continue;
2284
+ const dims = dimensionsFromReplayEvent(event);
2285
+ if (dims) return dims;
2286
+ }
2287
+ for (const event of events) {
2288
+ const dims = dimensionsFromReplayEvent(event);
2289
+ if (dims) return dims;
2290
+ }
2291
+ return null;
2292
+ }
2293
+
2294
+ export function replayInitialDisplayDimensions(
2295
+ events: AnyReplayEvent[],
2296
+ ): ReplayViewportDimensions | null {
2297
+ // Keep the initial outer stage and rrweb iframe on one corrected camera.
2298
+ // DO NOT use the raw initial Meta dimensions for only one of those layers.
2299
+ return clampReplayDisplayDimensions(replayInitialViewportDimensions(events));
2300
+ }
2301
+
2302
+ export function buildReplayViewportTimeline(
2303
+ events: AnyReplayEvent[],
2304
+ ): ReplayViewportChange[] {
2305
+ const initial = replayInitialViewportDimensions(events);
2306
+ if (!initial) return [];
2307
+ const startedAt = replayStartedAt(events);
2308
+ const firstMetaTimestamp = events.reduce((best, event) => {
2309
+ if (event.type !== RRWEB_EVENT_TYPE.Meta) return best;
2310
+ const timestamp = Number(event.timestamp ?? 0);
2311
+ return Number.isFinite(timestamp) && timestamp > 0
2312
+ ? Math.min(best, timestamp)
2313
+ : best;
2314
+ }, Number.POSITIVE_INFINITY);
2315
+ const changes: ReplayViewportChange[] = [{ ...initial, offsetMs: 0 }];
2316
+ for (const event of events) {
2317
+ const timestamp = Number(event.timestamp ?? 0);
2318
+ if (!Number.isFinite(timestamp) || timestamp <= firstMetaTimestamp)
2319
+ continue;
2320
+ const dims = dimensionsFromReplayEvent(event);
2321
+ if (!dims) continue;
2322
+ const previous = changes[changes.length - 1];
2323
+ if (previous?.width === dims.width && previous.height === dims.height) {
2324
+ continue;
2325
+ }
2326
+ changes.push({
2327
+ ...dims,
2328
+ offsetMs: Math.max(0, timestamp - startedAt),
2329
+ });
2330
+ }
2331
+ return changes;
2332
+ }
2333
+
2334
+ export function replayViewportDimensionsAtTime(
2335
+ changes: ReplayViewportChange[],
2336
+ elapsedMs: number,
2337
+ ): ReplayViewportDimensions | null {
2338
+ if (changes.length === 0) return null;
2339
+ const target = Math.max(0, elapsedMs);
2340
+ let low = 0;
2341
+ let high = changes.length - 1;
2342
+ while (low < high) {
2343
+ const middle = Math.ceil((low + high) / 2);
2344
+ if ((changes[middle]?.offsetMs ?? 0) <= target) low = middle;
2345
+ else high = middle - 1;
2346
+ }
2347
+ const match = changes[low];
2348
+ return match ? { width: match.width, height: match.height } : null;
2349
+ }
2350
+
2239
2351
  function dimensionsFromReplayEvent(
2240
2352
  event: AnyReplayEvent,
2241
2353
  ): ReplayViewportDimensions | null {
@@ -2272,6 +2384,29 @@ export function normalizeReplayDimensions(
2272
2384
  };
2273
2385
  }
2274
2386
 
2387
+ export function clampReplayDisplayDimensions(
2388
+ dims: ReplayViewportDimensions | null,
2389
+ ): ReplayViewportDimensions | null {
2390
+ if (!dims) return null;
2391
+ if (
2392
+ dims.width < MIN_REPLAY_DISPLAY_DIMENSION ||
2393
+ dims.height < MIN_REPLAY_DISPLAY_DIMENSION
2394
+ ) {
2395
+ return { width: DEFAULT_PLAYER_WIDTH, height: DEFAULT_PLAYER_HEIGHT };
2396
+ }
2397
+ const aspect = dims.width / dims.height;
2398
+ if (
2399
+ dims.width >= MALFORMED_REPLAY_MIN_WIDTH &&
2400
+ aspect > MALFORMED_REPLAY_MIN_ASPECT_RATIO
2401
+ ) {
2402
+ return {
2403
+ width: Math.round(dims.height * RECOVERED_REPLAY_DISPLAY_ASPECT_RATIO),
2404
+ height: dims.height,
2405
+ };
2406
+ }
2407
+ return dims;
2408
+ }
2409
+
2275
2410
  export function filterReplayMarkers(
2276
2411
  markers: ReplayMarker[],
2277
2412
  query: string,