@agent-native/core 0.98.0 → 0.98.1

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 (243) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +2 -2
  4. package/corpus/core/src/action-change-marker.ts +8 -1
  5. package/corpus/core/src/changelog/parse.ts +8 -3
  6. package/corpus/core/src/client/AgentPanel.tsx +2 -3
  7. package/corpus/core/src/client/AssistantChat.tsx +83 -39
  8. package/corpus/core/src/client/analytics.ts +29 -0
  9. package/corpus/core/src/client/blocks/library/wireframe-kit.tsx +16 -2
  10. package/corpus/core/src/client/chat-model-groups.ts +28 -0
  11. package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +442 -85
  12. package/corpus/core/src/client/composer/TiptapComposer.tsx +46 -29
  13. package/corpus/core/src/client/composer/VoiceButton.tsx +2 -1
  14. package/corpus/core/src/client/composer/index.ts +5 -0
  15. package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +1206 -67
  16. package/corpus/core/src/client/index.ts +5 -0
  17. package/corpus/core/src/client/progress/RunsTray.tsx +13 -1
  18. package/corpus/core/src/client/settings/SecretsSection.tsx +387 -266
  19. package/corpus/core/src/client/settings/SettingsPanel.tsx +117 -38
  20. package/corpus/core/src/client/use-action.ts +12 -0
  21. package/corpus/core/src/client/use-agent-chat-home-handoff.ts +8 -1
  22. package/corpus/core/src/client/use-agent-engine-configured.ts +17 -3
  23. package/corpus/core/src/client/use-db-sync.ts +84 -34
  24. package/corpus/core/src/client/use-pausing-interval.ts +14 -4
  25. package/corpus/core/src/integrations/scope-store.ts +1 -1
  26. package/corpus/core/src/integrations/usage-budget-store.ts +1 -1
  27. package/corpus/core/src/localization/default-messages.ts +44 -1
  28. package/corpus/core/src/mcp-client/workspace-servers.ts +4 -4
  29. package/corpus/core/src/secrets/crypto.ts +28 -8
  30. package/corpus/core/src/server/action-change.ts +5 -1
  31. package/corpus/core/src/server/action-routes.ts +8 -0
  32. package/corpus/core/src/server/poll.ts +1 -0
  33. package/corpus/core/src/server/realtime-voice.ts +299 -13
  34. package/corpus/core/src/styles/agent-native.css +2 -2
  35. package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  36. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  37. package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  38. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
  39. package/corpus/core/src/vite/client.ts +9 -1
  40. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +2 -2
  41. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +10 -5
  42. package/corpus/templates/analytics/AGENTS.md +9 -13
  43. package/corpus/templates/analytics/actions/navigate.ts +59 -5
  44. package/corpus/templates/analytics/actions/view-screen.ts +3 -6
  45. package/corpus/templates/analytics/app/components/ProviderCorpusJobNotifier.tsx +20 -3
  46. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +2 -1
  47. package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -1
  48. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -6
  49. package/corpus/templates/analytics/app/global.css +19 -20
  50. package/corpus/templates/analytics/app/lib/provider-corpus-job-sync.ts +26 -0
  51. package/corpus/templates/analytics/app/pages/Settings.tsx +35 -341
  52. package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +38 -7
  53. package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +1 -1
  54. package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +3 -0
  55. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +333 -272
  56. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +72 -17
  57. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +297 -108
  58. package/corpus/templates/analytics/app/root.tsx +28 -8
  59. package/corpus/templates/analytics/changelog/2026-06-24-first-party-signup-and-pageview-panels-now-honor-dashboard-filters.md +2 -1
  60. package/corpus/templates/analytics/changelog/2026-06-25-referrer-dashboard-panels-show-external-domains.md +2 -1
  61. package/corpus/templates/analytics/changelog/2026-07-06-mobile-chat-header-is-more-compact-and-the-menu-button-opens-navigation.md +1 -0
  62. package/corpus/templates/analytics/changelog/2026-07-08-dashboard-history-and-chat-context.md +1 -0
  63. package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +1 -0
  64. package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +1 -0
  65. package/corpus/templates/analytics/changelog/2026-07-11-analytics-no-longer-repeatedly-restarts-background-requests-.md +6 -0
  66. package/corpus/templates/analytics/changelog/2026-07-11-analytics-only-asks-for-replay-storage-when-you-enable-sessi.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-07-11-charts-now-show-their-loading-skeleton-while-refreshing.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-11-realtime-voice-now-shows-a-clear-connection-indicator-comple.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-11-replay-event-counts-stay-hidden-until-recordings-finish-load.md +6 -0
  70. package/corpus/templates/analytics/changelog/2026-07-11-replay-storage-key-mismatches-show-actionable-setup-guidance.md +6 -0
  71. package/corpus/templates/analytics/changelog/2026-07-11-replay-timelines-group-continuous-scrolling-and-dev-tools-co.md +6 -0
  72. package/corpus/templates/analytics/changelog/2026-07-11-session-filters-now-stay-compact-in-a-settings-menu-at-mediu.md +6 -0
  73. package/corpus/templates/analytics/changelog/2026-07-11-session-replay-console-errors-now-link-to-matching-issues-or.md +6 -0
  74. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-load-large-recordings-faster-and-surface-fai.md +6 -0
  75. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-preserve-recorded-styles-and-viewport-layout.md +6 -0
  76. package/corpus/templates/analytics/changelog/2026-07-11-session-replays-stay-centered-instead-of-clipping-wide-recor.md +6 -0
  77. package/corpus/templates/analytics/package.json +1 -1
  78. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +1 -303
  79. package/corpus/templates/analytics/server/handlers/session-replay.ts +57 -0
  80. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +1 -1
  81. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +16 -3
  82. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +12 -0
  83. package/corpus/templates/analytics/server/lib/session-replay.ts +208 -2
  84. package/corpus/templates/analytics/server/plugins/onboarding.ts +7 -79
  85. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/chunks.get.ts +1 -0
  86. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +2 -2
  87. package/corpus/templates/assets/app/global.css +4 -4
  88. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +0 -1
  89. package/corpus/templates/assets/app/routes/library.tsx +0 -1
  90. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +2 -2
  91. package/corpus/templates/brain/app/global.css +5 -5
  92. package/corpus/templates/brain/app/root.tsx +0 -3
  93. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +2 -2
  94. package/corpus/templates/calendar/app/root.tsx +1 -0
  95. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +2 -2
  96. package/corpus/templates/clips/app/hooks/use-auto-title.ts +74 -8
  97. package/corpus/templates/clips/app/hooks/use-library.ts +12 -25
  98. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +20 -22
  99. package/corpus/templates/clips/changelog/2026-07-11-generated-titles-no-longer-cause-continuous-background-refreshes.md +6 -0
  100. package/corpus/templates/clips/changelog/2026-07-11-s3-storage-setup-stays-collapsed-until-you-choose-it-as-the-.md +6 -0
  101. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +2 -2
  102. package/corpus/templates/content/app/hooks/use-comments.ts +0 -1
  103. package/corpus/templates/content/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
  104. package/corpus/templates/design/app/components/layout/Header.tsx +1 -1
  105. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +2 -2
  106. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +1 -2
  107. package/corpus/templates/macros/app/root.tsx +0 -3
  108. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +2 -2
  109. package/corpus/templates/mail/app/hooks/use-compose-state.ts +1 -0
  110. package/corpus/templates/mail/app/root.tsx +1 -12
  111. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +2 -2
  112. package/corpus/templates/plan/app/hooks/use-navigation-state.ts +5 -2
  113. package/corpus/templates/plan/app/hooks/use-plans.ts +6 -13
  114. package/corpus/templates/plan/app/pages/PlansPage.tsx +5 -27
  115. package/corpus/templates/plan/changelog/2026-07-11-plan-refreshes-only-when-content-changes.md +6 -0
  116. package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +4 -1
  117. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +1 -1
  118. package/corpus/templates/slides/app/components/layout/Header.tsx +1 -1
  119. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -0
  120. package/corpus/templates/slides/app/hooks/use-slide-comments.ts +7 -31
  121. package/corpus/templates/slides/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
  122. package/dist/action-change-marker.d.ts +1 -0
  123. package/dist/action-change-marker.d.ts.map +1 -1
  124. package/dist/action-change-marker.js +7 -1
  125. package/dist/action-change-marker.js.map +1 -1
  126. package/dist/changelog/parse.d.ts +4 -2
  127. package/dist/changelog/parse.d.ts.map +1 -1
  128. package/dist/changelog/parse.js +5 -3
  129. package/dist/changelog/parse.js.map +1 -1
  130. package/dist/client/AgentPanel.d.ts.map +1 -1
  131. package/dist/client/AgentPanel.js +2 -3
  132. package/dist/client/AgentPanel.js.map +1 -1
  133. package/dist/client/AssistantChat.d.ts +7 -0
  134. package/dist/client/AssistantChat.d.ts.map +1 -1
  135. package/dist/client/AssistantChat.js +62 -40
  136. package/dist/client/AssistantChat.js.map +1 -1
  137. package/dist/client/analytics.d.ts.map +1 -1
  138. package/dist/client/analytics.js +26 -0
  139. package/dist/client/analytics.js.map +1 -1
  140. package/dist/client/blocks/library/wireframe-kit.d.ts +2 -0
  141. package/dist/client/blocks/library/wireframe-kit.d.ts.map +1 -1
  142. package/dist/client/blocks/library/wireframe-kit.js +9 -2
  143. package/dist/client/blocks/library/wireframe-kit.js.map +1 -1
  144. package/dist/client/chat-model-groups.d.ts.map +1 -1
  145. package/dist/client/chat-model-groups.js +24 -0
  146. package/dist/client/chat-model-groups.js.map +1 -1
  147. package/dist/client/composer/RealtimeVoiceMode.d.ts +40 -4
  148. package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
  149. package/dist/client/composer/RealtimeVoiceMode.js +172 -42
  150. package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
  151. package/dist/client/composer/TiptapComposer.d.ts +4 -0
  152. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  153. package/dist/client/composer/TiptapComposer.js +47 -29
  154. package/dist/client/composer/TiptapComposer.js.map +1 -1
  155. package/dist/client/composer/VoiceButton.d.ts.map +1 -1
  156. package/dist/client/composer/VoiceButton.js +2 -2
  157. package/dist/client/composer/VoiceButton.js.map +1 -1
  158. package/dist/client/composer/index.d.ts +2 -2
  159. package/dist/client/composer/index.d.ts.map +1 -1
  160. package/dist/client/composer/index.js +1 -1
  161. package/dist/client/composer/index.js.map +1 -1
  162. package/dist/client/composer/useRealtimeVoiceMode.d.ts +100 -3
  163. package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  164. package/dist/client/composer/useRealtimeVoiceMode.js +952 -51
  165. package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
  166. package/dist/client/index.d.ts +1 -1
  167. package/dist/client/index.d.ts.map +1 -1
  168. package/dist/client/index.js +1 -1
  169. package/dist/client/index.js.map +1 -1
  170. package/dist/client/progress/RunsTray.d.ts.map +1 -1
  171. package/dist/client/progress/RunsTray.js +10 -1
  172. package/dist/client/progress/RunsTray.js.map +1 -1
  173. package/dist/client/settings/SecretsSection.d.ts +2 -3
  174. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  175. package/dist/client/settings/SecretsSection.js +46 -22
  176. package/dist/client/settings/SecretsSection.js.map +1 -1
  177. package/dist/client/settings/SettingsPanel.d.ts +9 -0
  178. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  179. package/dist/client/settings/SettingsPanel.js +46 -12
  180. package/dist/client/settings/SettingsPanel.js.map +1 -1
  181. package/dist/client/use-action.d.ts +2 -0
  182. package/dist/client/use-action.d.ts.map +1 -1
  183. package/dist/client/use-action.js +10 -0
  184. package/dist/client/use-action.js.map +1 -1
  185. package/dist/client/use-agent-chat-home-handoff.d.ts +2 -0
  186. package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
  187. package/dist/client/use-agent-chat-home-handoff.js +7 -1
  188. package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
  189. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  190. package/dist/client/use-agent-engine-configured.js +17 -3
  191. package/dist/client/use-agent-engine-configured.js.map +1 -1
  192. package/dist/client/use-db-sync.d.ts +8 -2
  193. package/dist/client/use-db-sync.d.ts.map +1 -1
  194. package/dist/client/use-db-sync.js +57 -32
  195. package/dist/client/use-db-sync.js.map +1 -1
  196. package/dist/client/use-pausing-interval.d.ts.map +1 -1
  197. package/dist/client/use-pausing-interval.js +16 -4
  198. package/dist/client/use-pausing-interval.js.map +1 -1
  199. package/dist/integrations/scope-store.js +1 -1
  200. package/dist/integrations/scope-store.js.map +1 -1
  201. package/dist/integrations/usage-budget-store.js +1 -1
  202. package/dist/integrations/usage-budget-store.js.map +1 -1
  203. package/dist/localization/default-messages.d.ts +41 -1
  204. package/dist/localization/default-messages.d.ts.map +1 -1
  205. package/dist/localization/default-messages.js +41 -1
  206. package/dist/localization/default-messages.js.map +1 -1
  207. package/dist/mcp-client/workspace-servers.js +4 -4
  208. package/dist/mcp-client/workspace-servers.js.map +1 -1
  209. package/dist/observability/routes.d.ts +5 -5
  210. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  211. package/dist/secrets/crypto.d.ts +7 -6
  212. package/dist/secrets/crypto.d.ts.map +1 -1
  213. package/dist/secrets/crypto.js +30 -11
  214. package/dist/secrets/crypto.js.map +1 -1
  215. package/dist/secrets/routes.d.ts +9 -9
  216. package/dist/server/action-change.d.ts +1 -0
  217. package/dist/server/action-change.d.ts.map +1 -1
  218. package/dist/server/action-change.js +4 -1
  219. package/dist/server/action-change.js.map +1 -1
  220. package/dist/server/action-routes.d.ts.map +1 -1
  221. package/dist/server/action-routes.js +5 -0
  222. package/dist/server/action-routes.js.map +1 -1
  223. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  224. package/dist/server/poll.d.ts.map +1 -1
  225. package/dist/server/poll.js +1 -0
  226. package/dist/server/poll.js.map +1 -1
  227. package/dist/server/realtime-voice.d.ts +7 -0
  228. package/dist/server/realtime-voice.d.ts.map +1 -1
  229. package/dist/server/realtime-voice.js +222 -12
  230. package/dist/server/realtime-voice.js.map +1 -1
  231. package/dist/styles/agent-native.css +2 -2
  232. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  233. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  234. package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  235. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
  236. package/dist/vite/client.d.ts.map +1 -1
  237. package/dist/vite/client.js +6 -1
  238. package/dist/vite/client.js.map +1 -1
  239. package/package.json +2 -2
  240. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
  241. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
  242. package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
  243. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
@@ -8,6 +8,11 @@ import {
8
8
  useT,
9
9
  } from "@agent-native/core/client";
10
10
  import { SESSION_REPLAY_AGENT_ACCESS_PARAM } from "@shared/session-replay-agent-access";
11
+ import {
12
+ isFailedSessionReplayNetworkStatus,
13
+ SESSION_REPLAY_CONSOLE_EVENT_TAG,
14
+ SESSION_REPLAY_NETWORK_EVENT_TAG,
15
+ } from "@shared/session-replay-diagnostics";
11
16
  import {
12
17
  IconArrowLeft,
13
18
  IconCheck,
@@ -58,6 +63,7 @@ import { getIdToken } from "@/lib/auth";
58
63
  import { cn } from "@/lib/utils";
59
64
 
60
65
  import { extractReplayDiagnostics } from "./session-replay-devtools";
66
+ import type { ReplayDevToolsDiagnostics } from "./session-replay-devtools";
61
67
  import {
62
68
  type SessionIssueMatch,
63
69
  SessionDevToolsPanel,
@@ -156,15 +162,16 @@ type ReplayViewportDimensions = {
156
162
 
157
163
  const DEFAULT_PLAYER_WIDTH = 1024;
158
164
  const DEFAULT_PLAYER_HEIGHT = 640;
159
- const MIN_REPLAY_DISPLAY_ASPECT_RATIO = 0.45;
160
- const MAX_REPLAY_DISPLAY_ASPECT_RATIO = 3;
161
165
  const DEFAULT_SPEED = 2;
162
166
  const SPEED_OPTIONS = [0.5, 1, 2, 4, 8];
163
167
  const SKIP_STEP_MS = 5000;
164
168
  const MIN_IDLE_SKIP_MS = 8000;
165
169
  const IDLE_EDGE_PAD_MS = 1200;
166
- const REPLAY_CHUNK_FETCH_CONCURRENCY = 6;
170
+ const REPLAY_CHUNK_BATCH_MAX_CHUNKS = 20;
171
+ const REPLAY_CHUNK_BATCH_MAX_DECLARED_BYTES = 4 * 1024 * 1024;
172
+ const REPLAY_CHUNK_BATCH_FETCH_CONCURRENCY = 3;
167
173
  const REPLAY_CHUNK_UNAVAILABLE_MESSAGE = "Session replay chunk is unavailable";
174
+ const SCROLL_MARKER_BURST_MS = 1_000;
168
175
  const DEFAULT_DEVTOOLS_HEIGHT = 220;
169
176
  const MIN_STAGE_HEIGHT_PX = 240;
170
177
  const SCRUBBER_MARKER_LIMIT = 500;
@@ -174,16 +181,6 @@ const TIMELINE_FOLLOW_PAUSE_MS = 4000;
174
181
  const SUPPRESS_OVERLAYS_CSS = [
175
182
  "[data-radix-popper-content-wrapper], .Toastify, [class*='toast'], [class*='Toast'], [class*='Snackbar'] { display: none !important; }",
176
183
  ];
177
- const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
178
- const SESSION_REPLAY_NETWORK_EVENT_TAG = "agent-native.network";
179
-
180
- const EMPTY_DIAGNOSTICS: ReturnType<typeof extractReplayDiagnostics> = {
181
- console: [],
182
- network: [],
183
- consoleErrorCount: 0,
184
- networkFailedCount: 0,
185
- };
186
-
187
184
  type ReplayConsoleDiagnostics = ReturnType<
188
185
  typeof extractReplayDiagnostics
189
186
  >["console"];
@@ -237,18 +234,16 @@ const INCREMENTAL_SOURCE = {
237
234
  } as const;
238
235
 
239
236
  const MOUSE_INTERACTION = {
237
+ MouseUp: 0,
238
+ MouseDown: 1,
240
239
  Click: 2,
240
+ ContextMenu: 3,
241
241
  DblClick: 4,
242
242
  Focus: 5,
243
+ TouchStart: 7,
244
+ TouchEnd: 9,
243
245
  } as const;
244
246
 
245
- const INTERACTION_SOURCES = new Set<number>([
246
- INCREMENTAL_SOURCE.MouseInteraction,
247
- INCREMENTAL_SOURCE.Scroll,
248
- INCREMENTAL_SOURCE.Input,
249
- INCREMENTAL_SOURCE.TouchMove,
250
- ]);
251
-
252
247
  export default function SessionDetailPage() {
253
248
  const t = useT();
254
249
  const { recordingId = "" } = useParams();
@@ -506,9 +501,13 @@ function ReplayPlayer({
506
501
  const scrubbingRef = useRef(false);
507
502
  const scrubResumePlayingRef = useRef(false);
508
503
 
509
- const displayDims = clampReplayDisplayDimensions(streamedDims ?? initialDims);
510
- const playerWidth = displayDims?.width ?? DEFAULT_PLAYER_WIDTH;
511
- const playerHeight = displayDims?.height ?? DEFAULT_PLAYER_HEIGHT;
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;
512
511
  const skipRanges = useMemo(() => buildIdleSkipRanges(events), [events]);
513
512
  const skipRangesRef = useLiveRef(skipRanges);
514
513
  const skipInactiveRef = useLiveRef(skipInactive);
@@ -520,13 +519,11 @@ function ReplayPlayer({
520
519
  () => currentUrlAt(events, currentTime),
521
520
  [events, currentTime],
522
521
  );
523
- const diagnostics = useMemo(
524
- () => (devToolsOpen ? extractReplayDiagnostics(events) : EMPTY_DIAGNOSTICS),
525
- [devToolsOpen, events],
522
+ const diagnostics = useMemo(() => extractReplayDiagnostics(events), [events]);
523
+ const devToolsIssueCount = replayDevToolsIssueCount(
524
+ diagnostics,
525
+ response.isComplete,
526
526
  );
527
- const devToolsIssueCount = devToolsOpen
528
- ? diagnostics.consoleErrorCount + diagnostics.networkFailedCount
529
- : response.recording.errorCount;
530
527
 
531
528
  // Resolve captured console errors in this replay to their Sentry-style issue
532
529
  // groups (one batched, access-scoped call, server-computed fingerprints) so
@@ -572,6 +569,7 @@ function ReplayPlayer({
572
569
  if (!el) return;
573
570
  const update = () => {
574
571
  const next = Math.min(
572
+ 1,
575
573
  el.clientWidth / playerWidth,
576
574
  el.clientHeight / playerHeight,
577
575
  );
@@ -879,10 +877,13 @@ function ReplayPlayer({
879
877
 
880
878
  return (
881
879
  <TooltipProvider>
882
- <div ref={playerShellRef} className="flex min-h-0 flex-1 flex-col">
883
- <Card className="flex min-h-0 flex-1 overflow-hidden">
884
- <CardContent className="flex min-h-0 flex-1 flex-col p-0">
885
- <div className="flex min-h-0 flex-1 flex-col bg-muted/20 p-2">
880
+ <div
881
+ ref={playerShellRef}
882
+ className="flex min-h-0 min-w-0 flex-1 flex-col"
883
+ >
884
+ <Card className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
885
+ <CardContent className="flex min-h-0 min-w-0 flex-1 flex-col p-0">
886
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-muted/20 p-2">
886
887
  {currentUrl ? (
887
888
  <div
888
889
  className="flex h-8 shrink-0 items-center rounded-t-md border border-b-0 bg-background px-3 font-mono text-xs text-muted-foreground"
@@ -1042,8 +1043,10 @@ function ReplayPlayer({
1042
1043
  "inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors hover:bg-muted",
1043
1044
  devToolsOpen &&
1044
1045
  "border-primary/40 bg-primary/10 text-primary",
1046
+ !response.isComplete && "cursor-not-allowed opacity-50",
1045
1047
  )}
1046
1048
  onClick={() => setDevToolsOpen((value) => !value)}
1049
+ disabled={!response.isComplete}
1047
1050
  aria-pressed={devToolsOpen}
1048
1051
  aria-expanded={devToolsOpen}
1049
1052
  >
@@ -1061,12 +1064,14 @@ function ReplayPlayer({
1061
1064
  ) : null}
1062
1065
  </button>
1063
1066
 
1064
- <span className="ms-auto hidden text-xs text-muted-foreground lg:inline">
1065
- {t("sessions.replayEventCount", {
1066
- events: String(response.eventCount),
1067
- })}
1068
- {response.truncated ? ` ${t("sessions.truncated")}` : ""}
1069
- </span>
1067
+ {response.isComplete ? (
1068
+ <span className="ms-auto hidden text-xs text-muted-foreground lg:inline">
1069
+ {t("sessions.replayEventCount", {
1070
+ events: String(response.eventCount),
1071
+ })}
1072
+ {response.truncated ? ` ${t("sessions.truncated")}` : ""}
1073
+ </span>
1074
+ ) : null}
1070
1075
  </div>
1071
1076
 
1072
1077
  {devToolsOpen ? (
@@ -1078,6 +1083,7 @@ function ReplayPlayer({
1078
1083
  onHeightChange={setDevToolsHeight}
1079
1084
  onSeek={(ms) => seek(ms, true)}
1080
1085
  issueMatches={issueMatches}
1086
+ issueMatching={issueMatchQuery.isFetching}
1081
1087
  />
1082
1088
  ) : null}
1083
1089
 
@@ -1464,7 +1470,9 @@ function useSessionReplayPlayback(recordingId: string) {
1464
1470
  let loadedCount = 0;
1465
1471
  let loadedBytes = 0;
1466
1472
  let unavailableChunks = 0;
1467
- let nextIndex = 0;
1473
+ const chunkIndexBySeq = new Map(
1474
+ manifest.chunks.map((chunk, index) => [chunk.seq, index]),
1475
+ );
1468
1476
 
1469
1477
  const publish = (force = false) => {
1470
1478
  const complete = loadedCount >= manifest.chunks.length;
@@ -1513,31 +1521,20 @@ function useSessionReplayPlayback(recordingId: string) {
1513
1521
 
1514
1522
  publish();
1515
1523
 
1516
- async function worker() {
1517
- while (!cancelled && nextIndex < manifest.chunks.length) {
1518
- const index = nextIndex;
1519
- nextIndex += 1;
1520
- const chunk = await fetchReplayChunk(manifest.chunks[index], {
1521
- agentAccessToken,
1522
- });
1523
- loadedChunks[index] = chunk;
1524
- loadedCount += 1;
1525
- loadedBytes += manifest.chunks[index].byteLength;
1526
- if (chunk.unavailable) unavailableChunks += 1;
1524
+ await fetchReplayChunks(
1525
+ manifest.chunks,
1526
+ { agentAccessToken },
1527
+ (batch) => {
1528
+ for (const chunk of batch) {
1529
+ const index = chunkIndexBySeq.get(chunk.seq);
1530
+ if (index === undefined || loadedChunks[index]) continue;
1531
+ loadedChunks[index] = chunk;
1532
+ loadedCount += 1;
1533
+ loadedBytes += manifest.chunks[index].byteLength;
1534
+ if (chunk.unavailable) unavailableChunks += 1;
1535
+ }
1527
1536
  if (!cancelled) publish();
1528
- }
1529
- }
1530
-
1531
- await Promise.all(
1532
- Array.from(
1533
- {
1534
- length: Math.min(
1535
- REPLAY_CHUNK_FETCH_CONCURRENCY,
1536
- manifest.chunks.length,
1537
- ),
1538
- },
1539
- worker,
1540
- ),
1537
+ },
1541
1538
  );
1542
1539
  if (!cancelled) publish(true);
1543
1540
  } catch (loadError) {
@@ -1635,27 +1632,118 @@ async function fetchReplayManifest(
1635
1632
  async function fetchReplayChunks(
1636
1633
  chunks: SessionReplayManifestResponse["chunks"],
1637
1634
  options: FetchSessionReplayPlaybackOptions,
1635
+ onBatch?: (chunks: ReplayChunkEvents[]) => void,
1638
1636
  ): Promise<ReplayChunkEvents[]> {
1639
1637
  const results = new Array<ReplayChunkEvents>(chunks.length);
1638
+ const indexBySeq = new Map(chunks.map((chunk, index) => [chunk.seq, index]));
1639
+ const batches = partitionReplayChunkBatches(chunks);
1640
1640
  let nextIndex = 0;
1641
1641
 
1642
1642
  async function worker() {
1643
- while (nextIndex < chunks.length) {
1643
+ while (nextIndex < batches.length) {
1644
1644
  const index = nextIndex;
1645
1645
  nextIndex += 1;
1646
- results[index] = await fetchReplayChunk(chunks[index], options);
1646
+ const loaded = await fetchReplayChunkBatch(batches[index], options);
1647
+ for (const chunk of loaded) {
1648
+ const resultIndex = indexBySeq.get(chunk.seq);
1649
+ if (resultIndex !== undefined) results[resultIndex] = chunk;
1650
+ }
1651
+ onBatch?.(loaded);
1647
1652
  }
1648
1653
  }
1649
1654
 
1650
1655
  await Promise.all(
1651
1656
  Array.from(
1652
- { length: Math.min(REPLAY_CHUNK_FETCH_CONCURRENCY, chunks.length) },
1657
+ {
1658
+ length: Math.min(REPLAY_CHUNK_BATCH_FETCH_CONCURRENCY, batches.length),
1659
+ },
1653
1660
  worker,
1654
1661
  ),
1655
1662
  );
1656
1663
  return results;
1657
1664
  }
1658
1665
 
1666
+ export function partitionReplayChunkBatches(
1667
+ chunks: SessionReplayManifestResponse["chunks"],
1668
+ ): Array<SessionReplayManifestResponse["chunks"]> {
1669
+ const batches: Array<SessionReplayManifestResponse["chunks"]> = [];
1670
+ let batch: SessionReplayManifestResponse["chunks"] = [];
1671
+ let declaredBytes = 0;
1672
+
1673
+ for (const chunk of chunks) {
1674
+ const exceedsChunkLimit = batch.length >= REPLAY_CHUNK_BATCH_MAX_CHUNKS;
1675
+ const exceedsByteLimit =
1676
+ batch.length > 0 &&
1677
+ declaredBytes + chunk.byteLength > REPLAY_CHUNK_BATCH_MAX_DECLARED_BYTES;
1678
+ if (exceedsChunkLimit || exceedsByteLimit) {
1679
+ batches.push(batch);
1680
+ batch = [];
1681
+ declaredBytes = 0;
1682
+ }
1683
+ batch.push(chunk);
1684
+ declaredBytes += chunk.byteLength;
1685
+ }
1686
+ if (batch.length) batches.push(batch);
1687
+ return batches;
1688
+ }
1689
+
1690
+ async function fetchReplayChunkBatch(
1691
+ chunks: SessionReplayManifestResponse["chunks"],
1692
+ options: FetchSessionReplayPlaybackOptions,
1693
+ ): Promise<ReplayChunkEvents[]> {
1694
+ const recordingId = replayRecordingIdFromChunkPath(chunks[0]?.bytesPath);
1695
+ if (!recordingId) {
1696
+ return Promise.all(chunks.map((chunk) => fetchReplayChunk(chunk, options)));
1697
+ }
1698
+ const query = new URLSearchParams({
1699
+ seqs: chunks.map((chunk) => chunk.seq).join(","),
1700
+ });
1701
+ const response = await fetchReplayApi(
1702
+ `/api/session-replay/recordings/${encodeURIComponent(recordingId)}/chunks?${query}`,
1703
+ options.agentAccessToken,
1704
+ );
1705
+ if (!response.ok) {
1706
+ if (response.status === 404 || response.status === 405) {
1707
+ return Promise.all(
1708
+ chunks.map((chunk) => fetchReplayChunk(chunk, options)),
1709
+ );
1710
+ }
1711
+ throw await replayFetchError(response);
1712
+ }
1713
+
1714
+ const payload = (await response.json()) as { chunks?: unknown };
1715
+ const returnedChunks = Array.isArray(payload?.chunks)
1716
+ ? payload.chunks.filter(isReplayChunkEvents)
1717
+ : [];
1718
+ const returnedBySeq = new Map(
1719
+ returnedChunks.map((chunk) => [chunk.seq, chunk]),
1720
+ );
1721
+
1722
+ return Promise.all(
1723
+ chunks.map(async (manifestChunk) => {
1724
+ const loaded = returnedBySeq.get(manifestChunk.seq);
1725
+ return loaded ?? fetchReplayChunk(manifestChunk, options);
1726
+ }),
1727
+ );
1728
+ }
1729
+
1730
+ function replayRecordingIdFromChunkPath(path: string | undefined): string {
1731
+ if (!path) return "";
1732
+ const match = path.match(/\/recordings\/([^/]+)\/chunks\//);
1733
+ return match ? decodeURIComponent(match[1]) : "";
1734
+ }
1735
+
1736
+ function isReplayChunkEvents(value: unknown): value is ReplayChunkEvents {
1737
+ return (
1738
+ isRecord(value) &&
1739
+ typeof value.seq === "number" &&
1740
+ typeof value.checksum === "string" &&
1741
+ typeof value.byteLength === "number" &&
1742
+ typeof value.eventCount === "number" &&
1743
+ Array.isArray(value.events)
1744
+ );
1745
+ }
1746
+
1659
1747
  async function fetchReplayChunk(
1660
1748
  chunk: SessionReplayManifestResponse["chunks"][number],
1661
1749
  options: FetchSessionReplayPlaybackOptions,
@@ -1758,7 +1846,7 @@ function useReplayEvents(
1758
1846
  ): AnyReplayEvent[] {
1759
1847
  return useMemo(
1760
1848
  () =>
1761
- sanitizeReplayEvents(
1849
+ normalizeReplayEvents(
1762
1850
  response.chunks
1763
1851
  .flatMap((chunk) => chunk.events)
1764
1852
  .filter((event) => event && typeof event === "object"),
@@ -1767,187 +1855,66 @@ function useReplayEvents(
1767
1855
  );
1768
1856
  }
1769
1857
 
1770
- export function sanitizeReplayEvents(events: unknown[]): AnyReplayEvent[] {
1771
- return events
1772
- .map((event) => sanitizeReplayEvent(event))
1773
- .filter((event): event is AnyReplayEvent => Boolean(event))
1774
- .sort((a, b) => Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0));
1775
- }
1776
-
1777
- function sanitizeReplayEvent(event: unknown): AnyReplayEvent | null {
1778
- if (!isRecord(event)) return null;
1779
- let copy: AnyReplayEvent;
1780
- try {
1781
- copy = JSON.parse(JSON.stringify(event));
1782
- } catch {
1783
- copy = { ...event };
1784
- }
1785
- if (copy.type === RRWEB_EVENT_TYPE.FullSnapshot && isRecord(copy.data)) {
1786
- const node = sanitizeSerializedNode(copy.data.node);
1787
- if (!node) return null;
1788
- copy.data.node = node;
1789
- }
1790
- if (
1791
- copy.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
1792
- copy.data?.source === INCREMENTAL_SOURCE.Mutation &&
1793
- isRecord(copy.data)
1794
- ) {
1795
- copy.data = sanitizeMutationData(copy.data);
1796
- }
1797
- return copy;
1798
- }
1799
-
1800
- function sanitizeMutationData(data: AnyRecord): AnyRecord {
1801
- const next = { ...data };
1802
- if (Array.isArray(next.adds)) {
1803
- next.adds = next.adds
1804
- .map((add) => {
1805
- if (!isRecord(add)) return add;
1806
- const node = sanitizeSerializedNode(add.node);
1807
- if (!node) return null;
1808
- return { ...add, node };
1809
- })
1810
- .filter(Boolean);
1811
- }
1812
- if (Array.isArray(next.attributes)) {
1813
- next.attributes = next.attributes.map((attributeMutation) => {
1814
- if (
1815
- !isRecord(attributeMutation) ||
1816
- !isRecord(attributeMutation.attributes)
1817
- ) {
1818
- return attributeMutation;
1819
- }
1820
- return {
1821
- ...attributeMutation,
1822
- attributes: sanitizeAttributes(attributeMutation.attributes),
1823
- };
1824
- });
1825
- }
1826
- if (Array.isArray(next.texts)) {
1827
- next.texts = next.texts.map((textMutation) => {
1828
- if (!isRecord(textMutation)) return textMutation;
1829
- const copy = { ...textMutation };
1830
- if (
1831
- typeof copy.value === "string" &&
1832
- containsStylesheetNetworkLoad(copy.value)
1833
- ) {
1834
- copy.value = sanitizeCssText(copy.value);
1835
- }
1836
- if (
1837
- typeof copy.textContent === "string" &&
1838
- containsStylesheetNetworkLoad(copy.textContent)
1839
- ) {
1840
- copy.textContent = sanitizeCssText(copy.textContent);
1841
- }
1842
- return copy;
1843
- });
1844
- }
1845
- return next;
1846
- }
1847
-
1848
- function sanitizeSerializedNode(node: unknown): AnyRecord | null {
1849
- if (!isRecord(node)) return node as AnyRecord;
1850
- const next: AnyRecord = { ...node };
1851
- const tagName =
1852
- typeof next.tagName === "string" ? next.tagName.toLowerCase() : "";
1853
- if (next.type === 2 && tagName === "script") {
1854
- return {
1855
- ...next,
1856
- tagName: "noscript",
1857
- attributes: {},
1858
- childNodes: [],
1859
- };
1860
- }
1861
- if (
1862
- next.type === 3 &&
1863
- typeof next.textContent === "string" &&
1864
- containsStylesheetNetworkLoad(next.textContent)
1865
- ) {
1866
- next.textContent = sanitizeCssText(next.textContent);
1867
- }
1868
- if (
1869
- next.type === 2 &&
1870
- tagName === "link" &&
1871
- isScriptLikeLink(next.attributes)
1872
- ) {
1873
- return null;
1874
- }
1875
- if (isRecord(next.attributes)) {
1876
- next.attributes = sanitizeAttributes(next.attributes);
1877
- }
1878
- if (Array.isArray(next.childNodes)) {
1879
- next.childNodes = next.childNodes
1880
- .map((child) => sanitizeSerializedNode(child))
1881
- .filter(Boolean);
1882
- }
1883
- return next;
1884
- }
1885
-
1886
- function containsStylesheetNetworkLoad(value: string): boolean {
1887
- return /@import\b/i.test(value) || /\burl\s*\(/i.test(value);
1888
- }
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
+ ]);
1889
1870
 
1890
- function sanitizeCssText(value: string): string {
1891
- if (!containsStylesheetNetworkLoad(value)) return value;
1871
+ function stripReplayCssUrls(value: string): string {
1892
1872
  return value
1893
- .replace(/@import\s+(?:url\s*\()?[^;{}]+;?/gi, "")
1894
- .replace(/\burl\s*\(\s*((?:\\.|[^\\)])*)\)/gi, sanitizeCssUrlToken);
1873
+ .replace(/@import\s+(?:url\()?[^;)]+(?:\))?\s*;?/gi, "")
1874
+ .replace(/url\(\s*[^)]*\)/gi, "none");
1895
1875
  }
1896
1876
 
1897
- function sanitizeCssUrlToken(match: string, rawValue: string): string {
1898
- const urlValue = rawValue.trim();
1899
- const unquoted = urlValue.replace(/^(['"])(.*)\1$/, "$2").trim();
1900
- if (/^(?:data:|blob:|#)/i.test(unquoted)) return match;
1901
- return "none";
1902
- }
1903
-
1904
- function sanitizeAttributes(attributes: AnyRecord): AnyRecord {
1905
- const next: AnyRecord = {};
1906
- for (const [key, value] of Object.entries(attributes)) {
1907
- const normalized = key.toLowerCase();
1908
- if (normalized.startsWith("on")) continue;
1909
- if (normalized === "srcdoc") continue;
1910
- if (isReplayResourceAttribute(normalized)) continue;
1911
- if (normalized === "style") {
1912
- const style = sanitizeCssText(String(value));
1913
- if (style.trim()) next[key] = style;
1914
- continue;
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";
1915
1889
  }
1916
- if (normalized === "_csstext") {
1917
- const cssText = sanitizeCssText(String(value));
1918
- if (cssText.trim()) next[key] = cssText;
1919
- continue;
1890
+ if (
1891
+ normalizedKey === "style" ||
1892
+ normalizedKey === "_csstext" ||
1893
+ normalizedKey === "csstext"
1894
+ ) {
1895
+ return stripReplayCssUrls(value);
1920
1896
  }
1921
- next[key] = value;
1897
+ return value;
1922
1898
  }
1923
- return next;
1924
- }
1925
1899
 
1926
- function isReplayResourceAttribute(name: string): boolean {
1927
- return (
1928
- name === "src" ||
1929
- name === "srcset" ||
1930
- name === "href" ||
1931
- name === "xlink:href" ||
1932
- name === "poster" ||
1933
- name === "data" ||
1934
- name === "action" ||
1935
- name === "formaction" ||
1936
- name === "background" ||
1937
- name === "cite"
1900
+ return Object.fromEntries(
1901
+ Object.entries(value).map(([entryKey, entryValue]) => [
1902
+ entryKey,
1903
+ sanitizeReplayValue(entryValue, entryKey),
1904
+ ]),
1938
1905
  );
1939
1906
  }
1940
1907
 
1941
- function isScriptLikeLink(attributes: unknown): boolean {
1942
- if (!isRecord(attributes)) return false;
1943
- const rel = String(attributes.rel ?? "").toLowerCase();
1944
- const as = String(attributes.as ?? "").toLowerCase();
1945
- const href = String(attributes.href ?? "").toLowerCase();
1946
- return (
1947
- rel === "modulepreload" ||
1948
- (rel === "preload" && as === "script") ||
1949
- (rel === "prefetch" && href.endsWith(".js"))
1950
- );
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));
1951
1918
  }
1952
1919
 
1953
1920
  export function buildReplayMarkers(events: AnyReplayEvent[]): ReplayMarker[] {
@@ -2007,6 +1974,23 @@ export function buildReplayMarkers(events: AnyReplayEvent[]): ReplayMarker[] {
2007
1974
  ["Y", event.data?.y],
2008
1975
  ]),
2009
1976
  });
1977
+ } else if (
1978
+ event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
1979
+ event.data?.source === INCREMENTAL_SOURCE.Scroll
1980
+ ) {
1981
+ markers.push({
1982
+ id: `scroll-${timestamp}-${markers.length}`,
1983
+ timestamp,
1984
+ offsetMs: Math.max(0, timestamp - startedAt),
1985
+ kind: "custom",
1986
+ label: "Scroll",
1987
+ detail: pointerDetail(event.data),
1988
+ fields: markerFields([
1989
+ ["Element id", event.data?.id],
1990
+ ["X", event.data?.x],
1991
+ ["Y", event.data?.y],
1992
+ ]),
1993
+ });
2010
1994
  } else if (event.type === RRWEB_EVENT_TYPE.Custom) {
2011
1995
  const marker = customReplayMarker(
2012
1996
  event,
@@ -2017,7 +2001,53 @@ export function buildReplayMarkers(events: AnyReplayEvent[]): ReplayMarker[] {
2017
2001
  if (marker) markers.push(marker);
2018
2002
  }
2019
2003
  }
2020
- return markers.sort((a, b) => a.offsetMs - b.offsetMs);
2004
+ return collapseScrollMarkerBursts(
2005
+ markers.sort((a, b) => a.offsetMs - b.offsetMs),
2006
+ );
2007
+ }
2008
+
2009
+ function collapseScrollMarkerBursts(markers: ReplayMarker[]): ReplayMarker[] {
2010
+ const collapsed: ReplayMarker[] = [];
2011
+ const lastScrollByTarget = new Map<
2012
+ string,
2013
+ { index: number; timestamp: number }
2014
+ >();
2015
+ for (const marker of markers) {
2016
+ if (marker.label !== "Scroll") {
2017
+ collapsed.push(marker);
2018
+ continue;
2019
+ }
2020
+ const target =
2021
+ marker.fields?.find((field) => field.label === "Element id")?.value ??
2022
+ "viewport";
2023
+ const previous = lastScrollByTarget.get(target);
2024
+ if (
2025
+ previous &&
2026
+ marker.timestamp - previous.timestamp <= SCROLL_MARKER_BURST_MS
2027
+ ) {
2028
+ // Keep one marker per continuous scroll gesture, using its final
2029
+ // position while retaining the first marker's stable id/time.
2030
+ collapsed[previous.index] = {
2031
+ ...marker,
2032
+ id: collapsed[previous.index].id,
2033
+ timestamp: collapsed[previous.index].timestamp,
2034
+ offsetMs: collapsed[previous.index].offsetMs,
2035
+ };
2036
+ previous.timestamp = marker.timestamp;
2037
+ continue;
2038
+ }
2039
+ const index = collapsed.push(marker) - 1;
2040
+ lastScrollByTarget.set(target, { index, timestamp: marker.timestamp });
2041
+ }
2042
+ return collapsed;
2043
+ }
2044
+
2045
+ export function replayDevToolsIssueCount(
2046
+ diagnostics: ReplayDevToolsDiagnostics,
2047
+ isComplete: boolean,
2048
+ ): number {
2049
+ if (!isComplete) return 0;
2050
+ return diagnostics.consoleErrorCount + diagnostics.networkFailedCount;
2021
2051
  }
2022
2052
 
2023
2053
  function customReplayMarker(
@@ -2055,7 +2085,33 @@ function customReplayMarker(
2055
2085
  }
2056
2086
 
2057
2087
  if (tag === SESSION_REPLAY_NETWORK_EVENT_TAG) {
2058
- return null;
2088
+ const status = Number(payload.status ?? 0);
2089
+ if (
2090
+ payload.ok !== false &&
2091
+ (!Number.isFinite(status) || !isFailedSessionReplayNetworkStatus(status))
2092
+ ) {
2093
+ return null;
2094
+ }
2095
+ const method =
2096
+ typeof payload.method === "string" ? payload.method : undefined;
2097
+ const url = typeof payload.url === "string" ? payload.url : undefined;
2098
+ const error = typeof payload.error === "string" ? payload.error : undefined;
2099
+ return {
2100
+ id: `network-${timestamp}-${index}`,
2101
+ timestamp,
2102
+ offsetMs,
2103
+ kind: "custom",
2104
+ label: "Network error", // i18n-ignore: timeline protocol label.
2105
+ detail: [method, url].filter(Boolean).join(" ") || error,
2106
+ severity: "error",
2107
+ fields: markerFields([
2108
+ ["Method", method],
2109
+ ["URL", url],
2110
+ ["Status", Number.isFinite(status) ? status : undefined],
2111
+ ["Error", error],
2112
+ ["Duration", payload.durationMs],
2113
+ ]),
2114
+ };
2059
2115
  }
2060
2116
 
2061
2117
  return {
@@ -2068,7 +2124,7 @@ function customReplayMarker(
2068
2124
  };
2069
2125
  }
2070
2126
 
2071
- function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
2127
+ export function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
2072
2128
  const startedAt = replayStartedAt(events);
2073
2129
  const interactions: number[] = [];
2074
2130
  let lastTimestamp = startedAt;
@@ -2076,13 +2132,7 @@ function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
2076
2132
  const timestamp = Number(event.timestamp ?? 0);
2077
2133
  if (!Number.isFinite(timestamp) || timestamp <= 0) continue;
2078
2134
  lastTimestamp = Math.max(lastTimestamp, timestamp);
2079
- if (event.type === RRWEB_EVENT_TYPE.Meta) {
2080
- interactions.push(timestamp);
2081
- } else if (
2082
- event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
2083
- typeof event.data?.source === "number" &&
2084
- INTERACTION_SOURCES.has(event.data.source)
2085
- ) {
2135
+ if (isReplayActivityEvent(event)) {
2086
2136
  interactions.push(timestamp);
2087
2137
  }
2088
2138
  }
@@ -2107,6 +2157,33 @@ function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
2107
2157
  return ranges;
2108
2158
  }
2109
2159
 
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;
2163
+
2164
+ const source = event.data?.source;
2165
+ if (
2166
+ source === INCREMENTAL_SOURCE.Input ||
2167
+ source === INCREMENTAL_SOURCE.Scroll ||
2168
+ source === INCREMENTAL_SOURCE.TouchMove
2169
+ ) {
2170
+ return true;
2171
+ }
2172
+ if (source !== INCREMENTAL_SOURCE.MouseInteraction) return false;
2173
+
2174
+ const interactionType = event.data?.type;
2175
+ return (
2176
+ interactionType === MOUSE_INTERACTION.MouseUp ||
2177
+ interactionType === MOUSE_INTERACTION.MouseDown ||
2178
+ interactionType === MOUSE_INTERACTION.Click ||
2179
+ interactionType === MOUSE_INTERACTION.ContextMenu ||
2180
+ interactionType === MOUSE_INTERACTION.DblClick ||
2181
+ interactionType === MOUSE_INTERACTION.Focus ||
2182
+ interactionType === MOUSE_INTERACTION.TouchStart ||
2183
+ interactionType === MOUSE_INTERACTION.TouchEnd
2184
+ );
2185
+ }
2186
+
2110
2187
  function pushIdleRange(
2111
2188
  ranges: SkipRange[],
2112
2189
  fromTs: number,
@@ -2195,26 +2272,6 @@ export function normalizeReplayDimensions(
2195
2272
  };
2196
2273
  }
2197
2274
 
2198
- export function clampReplayDisplayDimensions(
2199
- dims: ReplayViewportDimensions | null,
2200
- ): ReplayViewportDimensions | null {
2201
- if (!dims) return null;
2202
- const aspect = dims.width / dims.height;
2203
- if (aspect > MAX_REPLAY_DISPLAY_ASPECT_RATIO) {
2204
- return {
2205
- width: Math.round(dims.height * MAX_REPLAY_DISPLAY_ASPECT_RATIO),
2206
- height: dims.height,
2207
- };
2208
- }
2209
- if (aspect < MIN_REPLAY_DISPLAY_ASPECT_RATIO) {
2210
- return {
2211
- width: dims.width,
2212
- height: Math.round(dims.width / MIN_REPLAY_DISPLAY_ASPECT_RATIO),
2213
- };
2214
- }
2215
- return dims;
2216
- }
2217
-
2218
2275
  export function filterReplayMarkers(
2219
2276
  markers: ReplayMarker[],
2220
2277
  query: string,
@@ -2237,10 +2294,14 @@ export function filterReplayMarkers(
2237
2294
  }
2238
2295
 
2239
2296
  function replayStartedAt(events: AnyReplayEvent[]): number {
2240
- const first = events.find((event) =>
2241
- Number.isFinite(Number(event.timestamp)),
2242
- );
2243
- return Number(first?.timestamp ?? 0);
2297
+ let startedAt = Number.POSITIVE_INFINITY;
2298
+ for (const event of events) {
2299
+ const timestamp = Number(event.timestamp);
2300
+ if (Number.isFinite(timestamp) && timestamp > 0) {
2301
+ startedAt = Math.min(startedAt, timestamp);
2302
+ }
2303
+ }
2304
+ return Number.isFinite(startedAt) ? startedAt : 0;
2244
2305
  }
2245
2306
 
2246
2307
  function replayDuration(events: AnyReplayEvent[]): number {