@agent-native/core 0.91.1 → 0.91.2

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 (209) hide show
  1. package/blueprints/action/crud.md +1 -1
  2. package/blueprints/channel/discord.md +1 -1
  3. package/blueprints/provider/stripe.md +1 -1
  4. package/blueprints/sandbox/docker.md +1 -1
  5. package/corpus/README.md +1 -1
  6. package/corpus/core/CHANGELOG.md +14 -0
  7. package/corpus/core/blueprints/action/crud.md +1 -1
  8. package/corpus/core/blueprints/channel/discord.md +1 -1
  9. package/corpus/core/blueprints/provider/stripe.md +1 -1
  10. package/corpus/core/blueprints/sandbox/docker.md +1 -1
  11. package/corpus/core/package.json +6 -5
  12. package/corpus/core/src/agent/run-manager.ts +23 -2
  13. package/corpus/core/src/agent/run-store.ts +2 -2
  14. package/corpus/core/src/cli/add.ts +1 -1
  15. package/corpus/core/src/cli/create.ts +8 -3
  16. package/corpus/core/src/cli/index.ts +5 -4
  17. package/corpus/core/src/client/extensions/ExtensionSlot.tsx +7 -0
  18. package/corpus/core/src/client/session-replay.ts +97 -3
  19. package/corpus/core/src/jobs/scheduler.ts +30 -1
  20. package/corpus/core/src/localization/default-messages.ts +21 -0
  21. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +21 -0
  22. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +21 -0
  23. package/corpus/core/src/templates/default/app/i18n/en-US.ts +21 -0
  24. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +21 -0
  25. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +21 -0
  26. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +20 -0
  27. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +21 -0
  28. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +20 -0
  29. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +21 -0
  30. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +20 -0
  31. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +19 -0
  32. package/corpus/core/src/templates/default/package.json +2 -2
  33. package/corpus/core/src/templates/headless/package.json +3 -3
  34. package/corpus/core/src/templates/workspace-core/package.json +6 -6
  35. package/corpus/core/src/templates/workspace-root/package.json +2 -2
  36. package/corpus/core/src/triggers/dispatcher.ts +30 -2
  37. package/corpus/core/src/vite/client.ts +96 -1
  38. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +21 -2
  39. package/corpus/templates/analytics/app/global.css +26 -10
  40. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  41. package/corpus/templates/analytics/app/i18n-data.ts +25 -0
  42. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +548 -237
  43. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +285 -294
  44. package/corpus/templates/analytics/changelog/2026-07-08-console-details-expand-inline-under-each-row-and-the-event-t.md +6 -0
  45. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-clamps-ultra-wide-viewports-while-keeping-the.md +6 -0
  46. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-keeps-a-usable-stage-when-dev-tools-is-open-a.md +6 -0
  47. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-resumes-after-scrubbing-and-expands-console-r.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-07-08-session-replays-wait-for-full-load-keep-a-normal-viewport-an.md +6 -0
  49. package/corpus/templates/analytics/package.json +2 -2
  50. package/corpus/templates/assets/package.json +2 -2
  51. package/corpus/templates/brain/package.json +2 -2
  52. package/corpus/templates/calendar/AGENTS.md +10 -0
  53. package/corpus/templates/calendar/actions/create-event.ts +5 -1
  54. package/corpus/templates/calendar/actions/event-action-helpers.ts +41 -0
  55. package/corpus/templates/calendar/actions/get-attendee-timezones.ts +28 -0
  56. package/corpus/templates/calendar/actions/set-attendee-timezone.ts +68 -0
  57. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +150 -11
  58. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +36 -1
  59. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +32 -0
  60. package/corpus/templates/calendar/app/hooks/use-attendee-timezones.ts +24 -0
  61. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  62. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  63. package/corpus/templates/calendar/app/lib/attendee-local-time.ts +122 -0
  64. package/corpus/templates/calendar/changelog/2026-07-08-event-details-now-support-extension-widgets-and-you-can-see-.md +6 -0
  65. package/corpus/templates/calendar/changelog/2026-07-08-when-you-invite-guests-to-a-new-event-you-now-appear-in.md +6 -0
  66. package/corpus/templates/calendar/package.json +2 -2
  67. package/corpus/templates/calendar/server/handlers/events.ts +5 -1
  68. package/corpus/templates/calendar/shared/api.ts +6 -0
  69. package/corpus/templates/chat/package.json +2 -2
  70. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +6 -2
  71. package/corpus/templates/clips/actions/create-meeting.ts +8 -3
  72. package/corpus/templates/clips/actions/list-meetings.ts +31 -3
  73. package/corpus/templates/clips/changelog/2026-07-08-after-finishing-a-desktop-recording-reopening-clips-restor.md +6 -0
  74. package/corpus/templates/clips/changelog/2026-07-08-clips-now-pops-up-to-take-notes-when-you-join-an-adhoc-zoom-.md +6 -0
  75. package/corpus/templates/clips/changelog/2026-07-08-desktop-clip-recordings-are-louder-after-capture-mic-only.md +6 -0
  76. package/corpus/templates/clips/changelog/2026-07-08-meeting-reminders-now-appear-1-minute-before-start-stay-unti.md +6 -0
  77. package/corpus/templates/clips/changelog/2026-07-08-the-desktop-meetings-panel-has-room-for-its-drop-shadow-and-.md +6 -0
  78. package/corpus/templates/clips/chrome-extension/package.json +2 -2
  79. package/corpus/templates/clips/desktop/package.json +2 -2
  80. package/corpus/templates/clips/desktop/src/app.tsx +91 -34
  81. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +15 -5
  82. package/corpus/templates/clips/desktop/src/lib/meeting-notification-timing.ts +83 -0
  83. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +21 -0
  84. package/corpus/templates/clips/desktop/src/lib/recorder.ts +98 -20
  85. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +174 -50
  86. package/corpus/templates/clips/desktop/src/styles.css +128 -53
  87. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +364 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
  89. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +5 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +91 -11
  91. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +69 -19
  92. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +36 -1
  93. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +62 -10
  94. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +1 -24
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +36 -1
  96. package/corpus/templates/clips/package.json +2 -2
  97. package/corpus/templates/content/actions/_content-database-personal-view.ts +80 -0
  98. package/corpus/templates/content/actions/get-content-database-personal-view.ts +27 -0
  99. package/corpus/templates/content/actions/update-content-database-personal-view.ts +31 -0
  100. package/corpus/templates/content/actions/update-content-database-view.ts +3 -0
  101. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
  102. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2584 -546
  103. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +220 -18
  104. package/corpus/templates/content/app/hooks/use-content-database.ts +32 -0
  105. package/corpus/templates/content/app/i18n/zh-TW.ts +16 -0
  106. package/corpus/templates/content/app/i18n-data.ts +154 -0
  107. package/corpus/templates/content/changelog/2026-07-08-database-filters-now-support-notion-style-reset-save-for-eve.md +6 -0
  108. package/corpus/templates/content/changelog/2026-07-08-date-filters-can-now-target-a-between-range.md +6 -0
  109. package/corpus/templates/content/package.json +2 -2
  110. package/corpus/templates/content/parity/matrix.md +1 -1
  111. package/corpus/templates/content/parity/matrix.ts +2 -0
  112. package/corpus/templates/content/shared/api.ts +24 -0
  113. package/corpus/templates/design/package.json +2 -2
  114. package/corpus/templates/dispatch/app/i18n/zh-TW.ts +34 -1
  115. package/corpus/templates/dispatch/app/i18n-data.ts +387 -10
  116. package/corpus/templates/dispatch/app/routes/automations.tsx +1 -0
  117. package/corpus/templates/dispatch/changelog/2026-07-08-added-an-automations-page-to-review-pause-and-create.md +6 -0
  118. package/corpus/templates/dispatch/changelog/2026-07-08-destinations-now-shows-outbound-delivery-queue-health.md +6 -0
  119. package/corpus/templates/dispatch/package.json +2 -2
  120. package/corpus/templates/forms/package.json +2 -2
  121. package/corpus/templates/macros/package.json +2 -2
  122. package/corpus/templates/mail/app/hooks/use-emails.ts +56 -44
  123. package/corpus/templates/mail/app/lib/gmail-mutation-queue.ts +325 -0
  124. package/corpus/templates/mail/changelog/2026-07-08-rapid-archive-and-mark-read-now-batch-gmail-updates-so-the-i.md +6 -0
  125. package/corpus/templates/mail/package.json +2 -2
  126. package/corpus/templates/plan/package.json +2 -2
  127. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +46 -29
  128. package/corpus/templates/slides/app/context/DeckContext.tsx +45 -18
  129. package/corpus/templates/slides/app/i18n/ar-SA.ts +4 -0
  130. package/corpus/templates/slides/app/i18n/de-DE.ts +4 -0
  131. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  132. package/corpus/templates/slides/app/i18n/es-ES.ts +4 -0
  133. package/corpus/templates/slides/app/i18n/fr-FR.ts +4 -0
  134. package/corpus/templates/slides/app/i18n/hi-IN.ts +4 -0
  135. package/corpus/templates/slides/app/i18n/ja-JP.ts +4 -0
  136. package/corpus/templates/slides/app/i18n/ko-KR.ts +4 -0
  137. package/corpus/templates/slides/app/i18n/pt-BR.ts +4 -0
  138. package/corpus/templates/slides/app/i18n/zh-CN.ts +4 -0
  139. package/corpus/templates/slides/app/i18n/zh-TW.ts +4 -0
  140. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +115 -0
  141. package/corpus/templates/slides/app/pages/DeckEditor.tsx +22 -13
  142. package/corpus/templates/slides/changelog/2026-07-08-dropping-an-image-onto-a-slide-and-sending-it-to-the-agent-w.md +6 -0
  143. package/corpus/templates/slides/changelog/2026-07-08-undo-works-after-the-agent-edits-a-slide-so-you-can-reverse.md +6 -0
  144. package/corpus/templates/slides/package.json +2 -2
  145. package/dist/agent/run-manager.d.ts.map +1 -1
  146. package/dist/agent/run-manager.js +23 -2
  147. package/dist/agent/run-manager.js.map +1 -1
  148. package/dist/agent/run-store.js +2 -2
  149. package/dist/agent/run-store.js.map +1 -1
  150. package/dist/catalog.json +2 -2
  151. package/dist/cli/add.js +1 -1
  152. package/dist/cli/add.js.map +1 -1
  153. package/dist/cli/create.d.ts.map +1 -1
  154. package/dist/cli/create.js +8 -3
  155. package/dist/cli/create.js.map +1 -1
  156. package/dist/cli/index.js +5 -4
  157. package/dist/cli/index.js.map +1 -1
  158. package/dist/client/extensions/ExtensionSlot.js +6 -0
  159. package/dist/client/extensions/ExtensionSlot.js.map +1 -1
  160. package/dist/client/session-replay.d.ts +8 -0
  161. package/dist/client/session-replay.d.ts.map +1 -1
  162. package/dist/client/session-replay.js +80 -3
  163. package/dist/client/session-replay.js.map +1 -1
  164. package/dist/collab/struct-routes.d.ts +1 -1
  165. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  166. package/dist/jobs/scheduler.js +26 -1
  167. package/dist/jobs/scheduler.js.map +1 -1
  168. package/dist/localization/default-messages.d.ts +19 -0
  169. package/dist/localization/default-messages.d.ts.map +1 -1
  170. package/dist/localization/default-messages.js +19 -0
  171. package/dist/localization/default-messages.js.map +1 -1
  172. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  173. package/dist/resources/handlers.d.ts +2 -2
  174. package/dist/templates/default/app/i18n/ar-SA.ts +21 -0
  175. package/dist/templates/default/app/i18n/de-DE.ts +21 -0
  176. package/dist/templates/default/app/i18n/en-US.ts +21 -0
  177. package/dist/templates/default/app/i18n/es-ES.ts +21 -0
  178. package/dist/templates/default/app/i18n/fr-FR.ts +21 -0
  179. package/dist/templates/default/app/i18n/hi-IN.ts +20 -0
  180. package/dist/templates/default/app/i18n/ja-JP.ts +21 -0
  181. package/dist/templates/default/app/i18n/ko-KR.ts +20 -0
  182. package/dist/templates/default/app/i18n/pt-BR.ts +21 -0
  183. package/dist/templates/default/app/i18n/zh-CN.ts +20 -0
  184. package/dist/templates/default/app/i18n/zh-TW.ts +19 -0
  185. package/dist/templates/default/package.json +2 -2
  186. package/dist/templates/headless/package.json +3 -3
  187. package/dist/templates/workspace-core/package.json +6 -6
  188. package/dist/templates/workspace-root/package.json +2 -2
  189. package/dist/triggers/dispatcher.js +27 -2
  190. package/dist/triggers/dispatcher.js.map +1 -1
  191. package/dist/vite/client.d.ts.map +1 -1
  192. package/dist/vite/client.js +70 -1
  193. package/dist/vite/client.js.map +1 -1
  194. package/package.json +7 -6
  195. package/src/templates/default/app/i18n/ar-SA.ts +21 -0
  196. package/src/templates/default/app/i18n/de-DE.ts +21 -0
  197. package/src/templates/default/app/i18n/en-US.ts +21 -0
  198. package/src/templates/default/app/i18n/es-ES.ts +21 -0
  199. package/src/templates/default/app/i18n/fr-FR.ts +21 -0
  200. package/src/templates/default/app/i18n/hi-IN.ts +20 -0
  201. package/src/templates/default/app/i18n/ja-JP.ts +21 -0
  202. package/src/templates/default/app/i18n/ko-KR.ts +20 -0
  203. package/src/templates/default/app/i18n/pt-BR.ts +21 -0
  204. package/src/templates/default/app/i18n/zh-CN.ts +20 -0
  205. package/src/templates/default/app/i18n/zh-TW.ts +19 -0
  206. package/src/templates/default/package.json +2 -2
  207. package/src/templates/headless/package.json +3 -3
  208. package/src/templates/workspace-core/package.json +6 -6
  209. package/src/templates/workspace-root/package.json +2 -2
@@ -23,6 +23,7 @@ import {
23
23
  IconPlayerSkipForward,
24
24
  IconPlayerTrackNext,
25
25
  IconRoute,
26
+ IconSearch,
26
27
  IconTerminal2,
27
28
  IconTimelineEvent,
28
29
  } from "@tabler/icons-react";
@@ -34,11 +35,13 @@ import {
34
35
  useMemo,
35
36
  useRef,
36
37
  useState,
38
+ type FormEvent,
37
39
  } from "react";
38
40
  import { Link, useParams } from "react-router";
39
41
 
40
42
  import { Button } from "@/components/ui/button";
41
43
  import { Card, CardContent } from "@/components/ui/card";
44
+ import { Input } from "@/components/ui/input";
42
45
  import {
43
46
  Popover,
44
47
  PopoverContent,
@@ -160,8 +163,18 @@ const MIN_IDLE_SKIP_MS = 8000;
160
163
  const IDLE_EDGE_PAD_MS = 1200;
161
164
  const REPLAY_CHUNK_FETCH_CONCURRENCY = 6;
162
165
  const REPLAY_CHUNK_UNAVAILABLE_MESSAGE = "Session replay chunk is unavailable";
163
- const DEFAULT_DEVTOOLS_HEIGHT = 280;
166
+ const DEFAULT_DEVTOOLS_HEIGHT = 220;
167
+ const MIN_STAGE_HEIGHT_PX = 240;
164
168
  const SCRUBBER_MARKER_LIMIT = 500;
169
+ /** Reject Meta/resize frames that produce the ultra-wide ribbon letterbox.
170
+ * True 21:9 ultrawide is ~2.33; multi-monitor spans and corrupt frames are
171
+ * usually well above that and collapse the stage after fit-to-scale. */
172
+ const MIN_REPLAY_VIEWPORT_WIDTH = 320;
173
+ const MIN_REPLAY_VIEWPORT_HEIGHT = 240;
174
+ const MAX_REPLAY_ASPECT_RATIO = 2.45;
175
+ const MIN_REPLAY_ASPECT_RATIO = 0.5;
176
+ const TIMELINE_MARKER_LIMIT = 300;
177
+ const TIMELINE_FOLLOW_PAUSE_MS = 4000;
165
178
  const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
166
179
  const SESSION_REPLAY_NETWORK_EVENT_TAG = "agent-native.network";
167
180
 
@@ -219,6 +232,7 @@ const INCREMENTAL_SOURCE = {
219
232
  Mutation: 0,
220
233
  MouseInteraction: 2,
221
234
  Scroll: 3,
235
+ ViewportResize: 4,
222
236
  Input: 5,
223
237
  TouchMove: 6,
224
238
  } as const;
@@ -471,12 +485,26 @@ function ReplayPlayer({
471
485
  const [skipInactive, setSkipInactive] = useState(true);
472
486
  const [devToolsOpen, setDevToolsOpen] = useState(false);
473
487
  const [devToolsHeight, setDevToolsHeight] = useState(DEFAULT_DEVTOOLS_HEIGHT);
488
+ const [maxDevToolsHeight, setMaxDevToolsHeight] = useState(420);
489
+ const playerShellRef = useRef<HTMLDivElement>(null);
474
490
  const [streamedDims, setStreamedDims] = useState<{
475
491
  width: number;
476
492
  height: number;
477
493
  } | null>(null);
478
494
  const [fitScale, setFitScale] = useState(1);
479
495
  const initialDims = useMemo(() => replayViewportDimensions(events), [events]);
496
+ const eventsRef = useLiveRef(events);
497
+ // Stable identity for the loaded event set so progressive chunk publishes
498
+ // that only grow the array do not tear down a healthy Replayer mid-playback.
499
+ const eventsIdentity = useMemo(
500
+ () =>
501
+ `${events.length}:${Number(events[0]?.timestamp ?? 0)}:${Number(
502
+ events[events.length - 1]?.timestamp ?? 0,
503
+ )}`,
504
+ [events],
505
+ );
506
+ const scrubbingRef = useRef(false);
507
+ const scrubResumePlayingRef = useRef(false);
480
508
 
481
509
  const playerWidth =
482
510
  streamedDims?.width ?? initialDims?.width ?? DEFAULT_PLAYER_WIDTH;
@@ -556,6 +584,26 @@ function ReplayPlayer({
556
584
  return () => observer.disconnect();
557
585
  }, [playerHeight, playerWidth]);
558
586
 
587
+ // Keep Dev Tools from eating the stage. On short viewports the panel used to
588
+ // shrink the replay area into a ribbon even when Meta dimensions were fine.
589
+ useEffect(() => {
590
+ const el = playerShellRef.current;
591
+ if (!el) return;
592
+ const update = () => {
593
+ const chromeBudget = 140;
594
+ const available = Math.max(
595
+ 160,
596
+ el.clientHeight - MIN_STAGE_HEIGHT_PX - chromeBudget,
597
+ );
598
+ setMaxDevToolsHeight(available);
599
+ setDevToolsHeight((current) => Math.min(current, available));
600
+ };
601
+ update();
602
+ const observer = new ResizeObserver(update);
603
+ observer.observe(el);
604
+ return () => observer.disconnect();
605
+ }, [devToolsOpen]);
606
+
559
607
  const updateTime = useCallback(
560
608
  (next: number) => {
561
609
  setCurrentTime(next);
@@ -586,6 +634,27 @@ function ReplayPlayer({
586
634
  [playingRef, status, totalTime, updateTime],
587
635
  );
588
636
 
637
+ const beginScrub = useCallback(
638
+ (ms: number) => {
639
+ if (!scrubbingRef.current) {
640
+ scrubResumePlayingRef.current = playingRef.current;
641
+ }
642
+ scrubbingRef.current = true;
643
+ seek(ms, false);
644
+ },
645
+ [playingRef, seek],
646
+ );
647
+
648
+ const endScrub = useCallback(
649
+ (ms: number) => {
650
+ const resume = scrubResumePlayingRef.current;
651
+ scrubbingRef.current = false;
652
+ scrubResumePlayingRef.current = false;
653
+ seek(ms, resume);
654
+ },
655
+ [seek],
656
+ );
657
+
589
658
  useEffect(() => {
590
659
  registerSeek(seek);
591
660
  }, [registerSeek, seek]);
@@ -596,21 +665,29 @@ function ReplayPlayer({
596
665
  let localReplayer: any = null;
597
666
 
598
667
  async function loadReplay() {
599
- const hasPlayableEvents = hasPlayableReplayEvents(events);
600
- if (!hasPlayableEvents && !response.isComplete) {
668
+ // Wait for the full recording before creating the Replayer. Progressive
669
+ // chunk publishes used to rebuild the player on every append, which
670
+ // reset the clock, disabled the scrubber, and desynced the playhead.
671
+ const replayEvents = eventsRef.current;
672
+ if (!response.isComplete) {
601
673
  setStatus("loading");
602
674
  setError(null);
603
675
  setPlaying(false);
604
676
  return;
605
677
  }
606
- if (events.length < 2) {
678
+ if (replayEvents.length < 2) {
607
679
  throw new Error(t("sessions.noReplayEvents"));
608
680
  }
609
681
  if (
610
- !events.some((event) => event.type === RRWEB_EVENT_TYPE.FullSnapshot)
682
+ !replayEvents.some(
683
+ (event) => event.type === RRWEB_EVENT_TYPE.FullSnapshot,
684
+ )
611
685
  ) {
612
686
  throw new Error(t("sessions.noReplayEvents"));
613
687
  }
688
+ if (!hasPlayableReplayEvents(replayEvents)) {
689
+ throw new Error(t("sessions.noReplayEvents"));
690
+ }
614
691
  setStatus("loading");
615
692
  setError(null);
616
693
  await import("@rrweb/replay/dist/style.css");
@@ -618,8 +695,16 @@ function ReplayPlayer({
618
695
  if (cancelled || !stageRootRef.current) return;
619
696
 
620
697
  stageRootRef.current.innerHTML = "";
621
- setStreamedDims(initialDims);
622
- localReplayer = new Replayer(events as any[], {
698
+ const frameDims = replayViewportDimensions(replayEvents);
699
+ const playbackEvents = sanitizeReplayViewportEvents(
700
+ replayEvents,
701
+ frameDims ?? {
702
+ width: DEFAULT_PLAYER_WIDTH,
703
+ height: DEFAULT_PLAYER_HEIGHT,
704
+ },
705
+ );
706
+ setStreamedDims(frameDims);
707
+ localReplayer = new Replayer(playbackEvents as any[], {
623
708
  root: stageRootRef.current,
624
709
  speed: speedRef.current,
625
710
  skipInactive: false,
@@ -640,14 +725,14 @@ function ReplayPlayer({
640
725
  });
641
726
  replayerRef.current = localReplayer;
642
727
  const meta = localReplayer.getMetaData?.();
643
- const total = Number(meta?.totalTime ?? replayDuration(events));
728
+ const total = Number(meta?.totalTime ?? replayDuration(replayEvents));
644
729
  setTotalTime(Number.isFinite(total) ? total : 0);
645
730
  const startAt = clamp(
646
731
  currentTimeRef.current,
647
732
  0,
648
733
  Number.isFinite(total) ? total : 0,
649
734
  );
650
- applyReplayFrameDimensions(localReplayer, initialDims);
735
+ applyReplayFrameDimensions(localReplayer, frameDims);
651
736
  localReplayer.on?.("finish", () => {
652
737
  setPlaying(false);
653
738
  const finalTime = Number(localReplayer.getCurrentTime?.() ?? total);
@@ -665,7 +750,8 @@ function ReplayPlayer({
665
750
  localReplayer.on?.("fullsnapshot-rebuilded", () => {
666
751
  applyReplayFrameDimensions(
667
752
  localReplayer,
668
- replayViewportDimensions(events) ?? initialDims,
753
+ replayViewportAt(eventsRef.current, currentTimeRef.current) ??
754
+ frameDims,
669
755
  );
670
756
  revealReplayFrame(localReplayer);
671
757
  });
@@ -686,7 +772,8 @@ function ReplayPlayer({
686
772
  window.requestAnimationFrame(() => {
687
773
  applyReplayFrameDimensions(
688
774
  localReplayer,
689
- replayViewportDimensions(events) ?? initialDims,
775
+ replayViewportAt(eventsRef.current, currentTimeRef.current) ??
776
+ frameDims,
690
777
  );
691
778
  revealReplayFrame(localReplayer);
692
779
  });
@@ -713,10 +800,13 @@ function ReplayPlayer({
713
800
  replayerRef.current = null;
714
801
  if (stageRootRef.current) stageRootRef.current.innerHTML = "";
715
802
  };
803
+ // Key only on isComplete + a stable events identity. Including the events
804
+ // array reference would rebuild the Replayer when the final publish()
805
+ // replaces the chunks object even though the event set is unchanged.
716
806
  }, [
717
807
  currentTimeRef,
718
- events,
719
- initialDims,
808
+ eventsIdentity,
809
+ eventsRef,
720
810
  response.isComplete,
721
811
  speedRef,
722
812
  t,
@@ -732,7 +822,7 @@ function ReplayPlayer({
732
822
 
733
823
  const tick = () => {
734
824
  const replayer = replayerRef.current;
735
- if (replayer) {
825
+ if (replayer && !scrubbingRef.current) {
736
826
  let nextTime = Number(
737
827
  replayer.getCurrentTime?.() ?? currentTimeRef.current,
738
828
  );
@@ -812,210 +902,218 @@ function ReplayPlayer({
812
902
 
813
903
  return (
814
904
  <TooltipProvider>
815
- <Card className="flex min-h-0 overflow-hidden">
816
- <CardContent className="flex min-h-0 flex-1 flex-col p-0">
817
- <div className="flex min-h-0 flex-1 flex-col bg-muted/20 p-2">
818
- {currentUrl ? (
905
+ <div ref={playerShellRef} className="flex min-h-0 flex-1 flex-col">
906
+ <Card className="flex min-h-0 flex-1 overflow-hidden">
907
+ <CardContent className="flex min-h-0 flex-1 flex-col p-0">
908
+ <div className="flex min-h-0 flex-1 flex-col bg-muted/20 p-2">
909
+ {currentUrl ? (
910
+ <div
911
+ 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"
912
+ title={currentUrl}
913
+ >
914
+ <span className="truncate">{currentUrl}</span>
915
+ </div>
916
+ ) : null}
819
917
  <div
820
- 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"
821
- title={currentUrl}
918
+ ref={stageAreaRef}
919
+ className={cn(
920
+ "relative min-h-[200px] flex-1 overflow-hidden border bg-white dark:bg-zinc-950",
921
+ currentUrl ? "rounded-b-md" : "rounded-md",
922
+ !devToolsOpen && "min-h-[320px]",
923
+ )}
822
924
  >
823
- <span className="truncate">{currentUrl}</span>
925
+ <div
926
+ ref={stageRootRef}
927
+ className="an-replay-stage-root absolute left-1/2 top-1/2"
928
+ style={{
929
+ width: playerWidth,
930
+ height: playerHeight,
931
+ transform: `translate(-50%, -50%) scale(${fitScale})`,
932
+ transformOrigin: "center center",
933
+ }}
934
+ />
935
+ <button
936
+ type="button"
937
+ 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"
938
+ disabled={disabled}
939
+ aria-label={
940
+ playing ? t("sessions.pause") : t("sessions.play")
941
+ }
942
+ onClick={togglePlay}
943
+ />
944
+ {status === "loading" ? (
945
+ <div className="absolute inset-0 z-30 grid place-items-center bg-background/70 p-6 text-center text-sm text-muted-foreground">
946
+ <div className="w-full max-w-sm">
947
+ <p>{t("sessions.replayLoading")}</p>
948
+ <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
949
+ <div
950
+ className="h-full rounded-full bg-primary transition-[width]"
951
+ style={{
952
+ width: `${Math.round(loadingPercent * 100)}%`,
953
+ }}
954
+ />
955
+ </div>
956
+ {response.totalChunks > 0 ? (
957
+ <p className="mt-2 font-mono text-[11px]">
958
+ {t("sessions.replayLoadingProgress", {
959
+ loaded: String(response.loadedChunks),
960
+ total: String(response.totalChunks),
961
+ })}
962
+ </p>
963
+ ) : null}
964
+ </div>
965
+ </div>
966
+ ) : null}
967
+ {status === "error" && error ? (
968
+ <div className="absolute inset-0 z-30 grid place-items-center bg-background/85 p-6 text-center text-sm text-destructive">
969
+ {t("sessions.loadFailed", { message: error })}
970
+ </div>
971
+ ) : null}
824
972
  </div>
825
- ) : null}
826
- <div
827
- ref={stageAreaRef}
828
- className={cn(
829
- "relative min-h-[320px] flex-1 overflow-hidden border bg-white dark:bg-zinc-950",
830
- currentUrl ? "rounded-b-md" : "rounded-md",
831
- )}
832
- >
833
- <div
834
- ref={stageRootRef}
835
- className="an-replay-stage-root absolute left-1/2 top-1/2"
836
- style={{
837
- width: playerWidth,
838
- height: playerHeight,
839
- transform: `translate(-50%, -50%) scale(${fitScale})`,
840
- transformOrigin: "center center",
841
- }}
842
- />
843
- <button
973
+ </div>
974
+
975
+ <div className="flex shrink-0 flex-wrap items-center gap-2 border-t px-3 py-2">
976
+ <ReplayIconButton
977
+ label={t("sessions.skipBack")}
978
+ disabled={disabled}
979
+ onClick={() => seek(currentTime - SKIP_STEP_MS)}
980
+ >
981
+ <IconPlayerSkipBack className="h-4 w-4" />
982
+ </ReplayIconButton>
983
+ <Button
844
984
  type="button"
845
- 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"
985
+ size="icon"
846
986
  disabled={disabled}
847
- aria-label={playing ? t("sessions.pause") : t("sessions.play")}
848
987
  onClick={togglePlay}
849
- />
850
- {status === "loading" ? (
851
- <div className="absolute inset-0 z-30 grid place-items-center bg-background/70 p-6 text-center text-sm text-muted-foreground">
852
- <div className="w-full max-w-sm">
853
- <p>{t("sessions.replayLoading")}</p>
854
- <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
855
- <div
856
- className="h-full rounded-full bg-primary transition-[width]"
857
- style={{
858
- width: `${Math.round(loadingPercent * 100)}%`,
859
- }}
860
- />
861
- </div>
862
- {response.totalChunks > 0 ? (
863
- <p className="mt-2 font-mono text-[11px]">
864
- {t("sessions.replayLoadingProgress", {
865
- loaded: String(response.loadedChunks),
866
- total: String(response.totalChunks),
867
- })}
868
- </p>
869
- ) : null}
870
- </div>
871
- </div>
872
- ) : null}
873
- {status === "error" && error ? (
874
- <div className="absolute inset-0 z-30 grid place-items-center bg-background/85 p-6 text-center text-sm text-destructive">
875
- {t("sessions.loadFailed", { message: error })}
876
- </div>
877
- ) : null}
878
- </div>
879
- </div>
988
+ aria-label={playing ? t("sessions.pause") : t("sessions.play")}
989
+ className="h-8 w-8"
990
+ >
991
+ {playing ? (
992
+ <IconPlayerPause className="h-4 w-4" />
993
+ ) : (
994
+ <IconPlayerPlay className="h-4 w-4" />
995
+ )}
996
+ </Button>
997
+ <ReplayIconButton
998
+ label={t("sessions.skipForward")}
999
+ disabled={disabled}
1000
+ onClick={() => seek(currentTime + SKIP_STEP_MS)}
1001
+ >
1002
+ <IconPlayerSkipForward className="h-4 w-4" />
1003
+ </ReplayIconButton>
880
1004
 
881
- <div className="flex shrink-0 flex-wrap items-center gap-2 border-t px-3 py-2">
882
- <ReplayIconButton
883
- label={t("sessions.skipBack")}
884
- disabled={disabled}
885
- onClick={() => seek(currentTime - SKIP_STEP_MS)}
886
- >
887
- <IconPlayerSkipBack className="h-4 w-4" />
888
- </ReplayIconButton>
889
- <Button
890
- type="button"
891
- size="icon"
892
- disabled={disabled}
893
- onClick={togglePlay}
894
- aria-label={playing ? t("sessions.pause") : t("sessions.play")}
895
- className="h-8 w-8"
896
- >
897
- {playing ? (
898
- <IconPlayerPause className="h-4 w-4" />
899
- ) : (
900
- <IconPlayerPlay className="h-4 w-4" />
901
- )}
902
- </Button>
903
- <ReplayIconButton
904
- label={t("sessions.skipForward")}
905
- disabled={disabled}
906
- onClick={() => seek(currentTime + SKIP_STEP_MS)}
907
- >
908
- <IconPlayerSkipForward className="h-4 w-4" />
909
- </ReplayIconButton>
910
-
911
- <span className="w-12 text-center font-mono text-xs text-muted-foreground">
912
- {formatClock(currentTime)}
913
- </span>
914
- <ReplayScrubber
915
- currentTime={currentTime}
916
- totalTime={totalTime}
917
- markers={markers}
918
- skipRanges={skipRanges}
919
- skipInactive={skipInactive}
920
- disabled={disabled}
921
- onSeek={(ms) => seek(ms)}
922
- />
923
- <span className="w-12 text-center font-mono text-xs text-muted-foreground">
924
- {formatClock(totalTime)}
925
- </span>
1005
+ <span className="w-12 text-center font-mono text-xs text-muted-foreground">
1006
+ {formatClock(currentTime)}
1007
+ </span>
1008
+ <ReplayScrubber
1009
+ currentTime={currentTime}
1010
+ totalTime={totalTime}
1011
+ markers={markers}
1012
+ skipRanges={skipRanges}
1013
+ skipInactive={skipInactive}
1014
+ disabled={disabled}
1015
+ onScrub={beginScrub}
1016
+ onScrubEnd={endScrub}
1017
+ />
1018
+ <span className="w-12 text-center font-mono text-xs text-muted-foreground">
1019
+ {formatClock(totalTime)}
1020
+ </span>
926
1021
 
927
- <div className="flex items-center rounded-md bg-muted p-1">
928
- {SPEED_OPTIONS.map((option) => (
929
- <button
930
- key={option}
931
- type="button"
932
- className={cn(
933
- "rounded px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
934
- speed === option &&
935
- "bg-background text-foreground shadow-sm",
936
- )}
937
- onClick={() => updateSpeed(option)}
938
- >
939
- {option}x
940
- </button>
941
- ))}
942
- </div>
1022
+ <div className="flex items-center rounded-md bg-muted p-1">
1023
+ {SPEED_OPTIONS.map((option) => (
1024
+ <button
1025
+ key={option}
1026
+ type="button"
1027
+ className={cn(
1028
+ "rounded px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
1029
+ speed === option &&
1030
+ "bg-background text-foreground shadow-sm",
1031
+ )}
1032
+ onClick={() => updateSpeed(option)}
1033
+ >
1034
+ {option}x
1035
+ </button>
1036
+ ))}
1037
+ </div>
943
1038
 
944
- <Tooltip>
945
- <TooltipTrigger asChild>
946
- <button
947
- type="button"
948
- className={cn(
949
- "inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors hover:bg-muted",
950
- skipInactive &&
951
- "border-primary/40 bg-primary/10 text-primary",
952
- )}
953
- onClick={() => setSkipInactive((value) => !value)}
954
- aria-pressed={skipInactive}
955
- >
956
- <IconPlayerTrackNext className="h-4 w-4" />
957
- {t("sessions.skipInactive")}
958
- </button>
959
- </TooltipTrigger>
960
- <TooltipContent>
961
- {skipInactive
962
- ? t("sessions.skipInactiveOn")
963
- : t("sessions.skipInactiveOff")}
964
- </TooltipContent>
965
- </Tooltip>
966
-
967
- <button
968
- type="button"
969
- className={cn(
970
- "inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors hover:bg-muted",
971
- devToolsOpen && "border-primary/40 bg-primary/10 text-primary",
972
- )}
973
- onClick={() => setDevToolsOpen((value) => !value)}
974
- aria-pressed={devToolsOpen}
975
- aria-expanded={devToolsOpen}
976
- >
977
- <IconTerminal2 className="h-4 w-4" />
978
- {t("sessions.devtools")}
979
- {devToolsIssueCount > 0 ? (
980
- <span
981
- className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 font-mono text-[10px] font-semibold leading-none text-white"
982
- aria-label={t("sessions.devtoolsIssueCount", {
983
- count: String(devToolsIssueCount),
984
- })}
985
- >
986
- {devToolsIssueCount > 99 ? "99+" : devToolsIssueCount}
987
- </span>
988
- ) : null}
989
- </button>
1039
+ <Tooltip>
1040
+ <TooltipTrigger asChild>
1041
+ <button
1042
+ type="button"
1043
+ className={cn(
1044
+ "inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors hover:bg-muted",
1045
+ skipInactive &&
1046
+ "border-primary/40 bg-primary/10 text-primary",
1047
+ )}
1048
+ onClick={() => setSkipInactive((value) => !value)}
1049
+ aria-pressed={skipInactive}
1050
+ >
1051
+ <IconPlayerTrackNext className="h-4 w-4" />
1052
+ {t("sessions.skipInactive")}
1053
+ </button>
1054
+ </TooltipTrigger>
1055
+ <TooltipContent>
1056
+ {skipInactive
1057
+ ? t("sessions.skipInactiveOn")
1058
+ : t("sessions.skipInactiveOff")}
1059
+ </TooltipContent>
1060
+ </Tooltip>
990
1061
 
991
- <span className="ms-auto hidden text-xs text-muted-foreground lg:inline">
992
- {t("sessions.replayEventCount", {
993
- events: String(response.eventCount),
994
- })}
995
- {response.truncated ? ` ${t("sessions.truncated")}` : ""}
996
- </span>
997
- </div>
1062
+ <button
1063
+ type="button"
1064
+ className={cn(
1065
+ "inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors hover:bg-muted",
1066
+ devToolsOpen &&
1067
+ "border-primary/40 bg-primary/10 text-primary",
1068
+ )}
1069
+ onClick={() => setDevToolsOpen((value) => !value)}
1070
+ aria-pressed={devToolsOpen}
1071
+ aria-expanded={devToolsOpen}
1072
+ >
1073
+ <IconTerminal2 className="h-4 w-4" />
1074
+ {t("sessions.devtools")}
1075
+ {devToolsIssueCount > 0 ? (
1076
+ <span
1077
+ className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 font-mono text-[10px] font-semibold leading-none text-white"
1078
+ aria-label={t("sessions.devtoolsIssueCount", {
1079
+ count: String(devToolsIssueCount),
1080
+ })}
1081
+ >
1082
+ {devToolsIssueCount > 99 ? "99+" : devToolsIssueCount}
1083
+ </span>
1084
+ ) : null}
1085
+ </button>
1086
+
1087
+ <span className="ms-auto hidden text-xs text-muted-foreground lg:inline">
1088
+ {t("sessions.replayEventCount", {
1089
+ events: String(response.eventCount),
1090
+ })}
1091
+ {response.truncated ? ` ${t("sessions.truncated")}` : ""}
1092
+ </span>
1093
+ </div>
998
1094
 
999
- {devToolsOpen ? (
1000
- <SessionDevToolsPanel
1001
- diagnostics={diagnostics}
1002
- currentTime={currentTime}
1003
- height={devToolsHeight}
1004
- onHeightChange={setDevToolsHeight}
1005
- onSeek={(ms) => seek(ms, true)}
1006
- issueMatches={issueMatches}
1007
- />
1008
- ) : null}
1095
+ {devToolsOpen ? (
1096
+ <SessionDevToolsPanel
1097
+ diagnostics={diagnostics}
1098
+ currentTime={currentTime}
1099
+ height={Math.min(devToolsHeight, maxDevToolsHeight)}
1100
+ maxHeight={maxDevToolsHeight}
1101
+ onHeightChange={setDevToolsHeight}
1102
+ onSeek={(ms) => seek(ms, true)}
1103
+ issueMatches={issueMatches}
1104
+ />
1105
+ ) : null}
1009
1106
 
1010
- {response.unavailableChunks > 0 ? (
1011
- <div className="border-t px-4 py-2 text-xs text-muted-foreground">
1012
- {t("sessions.unavailableChunks", {
1013
- count: String(response.unavailableChunks),
1014
- })}
1015
- </div>
1016
- ) : null}
1017
- </CardContent>
1018
- </Card>
1107
+ {response.unavailableChunks > 0 ? (
1108
+ <div className="border-t px-4 py-2 text-xs text-muted-foreground">
1109
+ {t("sessions.unavailableChunks", {
1110
+ count: String(response.unavailableChunks),
1111
+ })}
1112
+ </div>
1113
+ ) : null}
1114
+ </CardContent>
1115
+ </Card>
1116
+ </div>
1019
1117
  </TooltipProvider>
1020
1118
  );
1021
1119
  }
@@ -1027,7 +1125,8 @@ function ReplayScrubber({
1027
1125
  skipRanges,
1028
1126
  skipInactive,
1029
1127
  disabled,
1030
- onSeek,
1128
+ onScrub,
1129
+ onScrubEnd,
1031
1130
  }: {
1032
1131
  currentTime: number;
1033
1132
  totalTime: number;
@@ -1035,13 +1134,19 @@ function ReplayScrubber({
1035
1134
  skipRanges: SkipRange[];
1036
1135
  skipInactive: boolean;
1037
1136
  disabled: boolean;
1038
- onSeek: (ms: number) => void;
1137
+ onScrub: (ms: number) => void;
1138
+ onScrubEnd: (ms: number) => void;
1039
1139
  }) {
1040
1140
  const t = useT();
1041
1141
  const scrubberMarkers = useMemo(
1042
1142
  () => visibleScrubberMarkers(markers, totalTime),
1043
1143
  [markers, totalTime],
1044
1144
  );
1145
+
1146
+ function handleScrub(event: FormEvent<HTMLInputElement>) {
1147
+ onScrub(Number(event.currentTarget.value));
1148
+ }
1149
+
1045
1150
  return (
1046
1151
  <div className="relative min-h-8 min-w-[180px] flex-1">
1047
1152
  <input
@@ -1050,9 +1155,16 @@ function ReplayScrubber({
1050
1155
  min={0}
1051
1156
  max={Math.max(0, totalTime)}
1052
1157
  step={50}
1053
- value={Math.min(currentTime, totalTime)}
1158
+ value={Math.min(currentTime, Math.max(totalTime, 0))}
1054
1159
  disabled={disabled}
1055
- onChange={(event) => onSeek(Number(event.target.value))}
1160
+ onInput={handleScrub}
1161
+ onChange={handleScrub}
1162
+ onPointerUp={(event) =>
1163
+ onScrubEnd(Number((event.target as HTMLInputElement).value))
1164
+ }
1165
+ onKeyUp={(event) =>
1166
+ onScrubEnd(Number((event.target as HTMLInputElement).value))
1167
+ }
1056
1168
  aria-label={t("sessions.replayTimeline")}
1057
1169
  />
1058
1170
  <div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-2 -translate-y-1/2 overflow-hidden rounded-full">
@@ -1081,7 +1193,7 @@ function ReplayScrubber({
1081
1193
  <span
1082
1194
  key={marker.id}
1083
1195
  className={cn(
1084
- "absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-background",
1196
+ "absolute top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-90 ring-1 ring-background/80",
1085
1197
  marker.kind === "navigation"
1086
1198
  ? "bg-amber-500"
1087
1199
  : marker.kind === "input"
@@ -1147,38 +1259,94 @@ function ReplayTimeline({
1147
1259
  }) {
1148
1260
  const t = useT();
1149
1261
  const [expandedMarkerId, setExpandedMarkerId] = useState<string | null>(null);
1150
- const visibleMarkers = markers.slice(0, 300);
1262
+ const [query, setQuery] = useState("");
1263
+ const listRef = useRef<HTMLDivElement>(null);
1264
+ const lastManualScrollAtRef = useRef(0);
1265
+ const activeRowRef = useRef<HTMLDivElement | null>(null);
1266
+
1267
+ const filteredMarkers = useMemo(
1268
+ () => filterReplayMarkers(markers, query).slice(0, TIMELINE_MARKER_LIMIT),
1269
+ [markers, query],
1270
+ );
1271
+
1272
+ useEffect(() => {
1273
+ if (!activeMarkerId) return;
1274
+ if (Date.now() - lastManualScrollAtRef.current < TIMELINE_FOLLOW_PAUSE_MS) {
1275
+ return;
1276
+ }
1277
+ const row = activeRowRef.current;
1278
+ const list = listRef.current;
1279
+ if (!row || !list) return;
1280
+ const rowTop = row.offsetTop;
1281
+ const rowBottom = rowTop + row.offsetHeight;
1282
+ if (
1283
+ rowTop >= list.scrollTop &&
1284
+ rowBottom <= list.scrollTop + list.clientHeight
1285
+ ) {
1286
+ return;
1287
+ }
1288
+ list.scrollTo({
1289
+ top: Math.max(0, rowTop - list.clientHeight / 3),
1290
+ behavior: "smooth",
1291
+ });
1292
+ }, [activeMarkerId, filteredMarkers]);
1293
+
1151
1294
  return (
1152
1295
  <Card className="analytics-session-detail-timeline min-h-0 overflow-hidden">
1153
1296
  <CardContent className="flex min-h-0 flex-1 flex-col p-0">
1154
- <div className="shrink-0 border-b px-3 py-2">
1297
+ <div className="shrink-0 space-y-2 border-b px-3 py-2">
1155
1298
  <div className="flex items-center gap-2 text-sm font-semibold">
1156
1299
  <IconTimelineEvent className="h-4 w-4" />
1157
1300
  {t("sessions.timeline")}
1158
1301
  </div>
1159
- <p className="mt-0.5 text-xs text-muted-foreground">
1160
- {visibleMarkers.length
1302
+ <p className="text-xs text-muted-foreground">
1303
+ {filteredMarkers.length
1161
1304
  ? t("sessions.timelineDescription", {
1162
- count: String(visibleMarkers.length),
1305
+ count: String(filteredMarkers.length),
1163
1306
  total: String(markers.length),
1164
1307
  })
1165
1308
  : t("sessions.noTimelineEvents")}
1166
1309
  </p>
1310
+ <div className="relative">
1311
+ <IconSearch className="pointer-events-none absolute start-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
1312
+ <Input
1313
+ type="search"
1314
+ className="h-7 ps-7 text-xs"
1315
+ value={query}
1316
+ placeholder={t("sessions.timelineSearch")}
1317
+ onChange={(event) => setQuery(event.target.value)}
1318
+ />
1319
+ </div>
1167
1320
  </div>
1168
- <div className="min-h-0 flex-1 overflow-auto">
1169
- {visibleMarkers.length ? (
1321
+ <div
1322
+ ref={listRef}
1323
+ className="min-h-0 flex-1 overflow-auto"
1324
+ onWheel={() => {
1325
+ lastManualScrollAtRef.current = Date.now();
1326
+ }}
1327
+ onPointerDown={() => {
1328
+ lastManualScrollAtRef.current = Date.now();
1329
+ }}
1330
+ >
1331
+ {filteredMarkers.length ? (
1170
1332
  <div className="divide-y">
1171
- {visibleMarkers.map((marker) => {
1333
+ {filteredMarkers.map((marker) => {
1172
1334
  const expanded = marker.id === expandedMarkerId;
1335
+ const active = marker.id === activeMarkerId;
1173
1336
  return (
1174
1337
  <div
1175
1338
  key={marker.id}
1176
- className={cn(marker.id === activeMarkerId && "bg-muted")}
1339
+ ref={active ? activeRowRef : undefined}
1340
+ className={cn(
1341
+ "transition-colors duration-300",
1342
+ active && "bg-primary/[0.06] dark:bg-primary/[0.09]",
1343
+ )}
1177
1344
  >
1178
1345
  <button
1179
1346
  type="button"
1180
1347
  className="flex w-full gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-muted/50"
1181
1348
  aria-expanded={expanded}
1349
+ aria-current={active ? "true" : undefined}
1182
1350
  onClick={() => {
1183
1351
  onSeek(marker.offsetMs);
1184
1352
  setExpandedMarkerId((current) =>
@@ -1236,7 +1404,9 @@ function ReplayTimeline({
1236
1404
  </div>
1237
1405
  ) : (
1238
1406
  <div className="p-4 text-sm text-muted-foreground">
1239
- {t("sessions.noTimelineEvents")}
1407
+ {query.trim()
1408
+ ? t("sessions.timelineNoMatches")
1409
+ : t("sessions.noTimelineEvents")}
1240
1410
  </div>
1241
1411
  )}
1242
1412
  </div>
@@ -1323,21 +1493,18 @@ function useSessionReplayPlayback(recordingId: string) {
1323
1493
  let loadedBytes = 0;
1324
1494
  let unavailableChunks = 0;
1325
1495
  let nextIndex = 0;
1326
- let firstPlayablePublished = false;
1327
1496
 
1328
1497
  const publish = (force = false) => {
1329
1498
  const complete = loadedCount >= manifest.chunks.length;
1330
1499
  const chunks = loadedChunks.filter(
1331
1500
  (chunk): chunk is ReplayChunkEvents => Boolean(chunk),
1332
1501
  );
1333
- const shouldPublishEvents =
1334
- force ||
1335
- complete ||
1336
- (!firstPlayablePublished &&
1337
- hasPlayableReplayEvents(chunks.flatMap((chunk) => chunk.events)));
1502
+ // Only hand events to the player once every chunk is in. Partial
1503
+ // publishes used to rebuild the Replayer mid-playback and break the
1504
+ // scrubber; the loading bar still updates while chunks stream in.
1505
+ const shouldPublishEvents = force || complete;
1338
1506
 
1339
1507
  if (shouldPublishEvents) {
1340
- firstPlayablePublished = true;
1341
1508
  setState({
1342
1509
  data: playbackResponseFromChunks(manifest, chunks, {
1343
1510
  isComplete: complete,
@@ -1365,6 +1532,8 @@ function useSessionReplayPlayback(recordingId: string) {
1365
1532
  loadedBytes,
1366
1533
  unavailableChunks,
1367
1534
  }),
1535
+ // Keep the page shell mounted so the player loading bar can show
1536
+ // chunk progress; the Replayer itself still waits for isComplete.
1368
1537
  isLoading: false,
1369
1538
  error: null,
1370
1539
  }));
@@ -2008,18 +2177,55 @@ function hasPlayableReplayEvents(events: unknown[]): boolean {
2008
2177
  export function replayViewportDimensions(
2009
2178
  events: AnyReplayEvent[],
2010
2179
  ): ReplayViewportDimensions | null {
2180
+ // Prefer the most recent sane Meta/ViewportResize frame. Early Meta events
2181
+ // (or corrupt resize payloads) can report absurd aspect ratios that collapse
2182
+ // the stage into an ultra-wide ribbon after fit-to-stage scaling.
2183
+ let best: ReplayViewportDimensions | null = null;
2011
2184
  for (const event of events) {
2012
- if (event.type !== RRWEB_EVENT_TYPE.Meta) continue;
2013
- const dims = normalizeReplayDimensions(
2014
- event.data?.width,
2015
- event.data?.height,
2016
- );
2017
- if (dims) return dims;
2185
+ const dims = dimensionsFromReplayEvent(event);
2186
+ if (dims) best = dims;
2187
+ }
2188
+ return best;
2189
+ }
2190
+
2191
+ export function replayViewportAt(
2192
+ events: AnyReplayEvent[],
2193
+ currentTime: number,
2194
+ ): ReplayViewportDimensions | null {
2195
+ const startedAt = replayStartedAt(events);
2196
+ let current: ReplayViewportDimensions | null = null;
2197
+ for (const event of events) {
2198
+ const dims = dimensionsFromReplayEvent(event);
2199
+ if (!dims) continue;
2200
+ const offset = Number(event.timestamp ?? 0) - startedAt;
2201
+ if (offset <= currentTime + 50) current = dims;
2202
+ else break;
2203
+ }
2204
+ return current ?? replayViewportDimensions(events);
2205
+ }
2206
+
2207
+ function dimensionsFromReplayEvent(
2208
+ event: AnyReplayEvent,
2209
+ ): ReplayViewportDimensions | null {
2210
+ if (event.type === RRWEB_EVENT_TYPE.Meta) {
2211
+ return normalizeReplayDimensions(event.data?.width, event.data?.height);
2212
+ }
2213
+ if (
2214
+ event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
2215
+ event.data?.source === INCREMENTAL_SOURCE.ViewportResize
2216
+ ) {
2217
+ return normalizeReplayDimensions(event.data?.width, event.data?.height);
2018
2218
  }
2019
2219
  return null;
2020
2220
  }
2021
2221
 
2022
- function normalizeReplayDimensions(
2222
+ /**
2223
+ * Prefer a sane viewport for the stage. Returns null only when width/height
2224
+ * are missing or non-positive. Out-of-range aspect ratios are clamped rather
2225
+ * than rejected so genuine wide windows keep their height (and most of their
2226
+ * width) instead of collapsing to the default 1024×640 fallback.
2227
+ */
2228
+ export function normalizeReplayDimensions(
2023
2229
  width: unknown,
2024
2230
  height: unknown,
2025
2231
  ): ReplayViewportDimensions | null {
@@ -2033,9 +2239,114 @@ function normalizeReplayDimensions(
2033
2239
  ) {
2034
2240
  return null;
2035
2241
  }
2242
+ return clampReplayViewport(width, height);
2243
+ }
2244
+
2245
+ /**
2246
+ * Clamp Meta / ViewportResize dimensions into a displayable aspect range.
2247
+ * Multi-monitor spans and corrupt frames often report absurd widths; keep the
2248
+ * captured height and shrink width (or vice versa) so the DOM still roughly
2249
+ * matches the recorded layout.
2250
+ */
2251
+ export function clampReplayViewport(
2252
+ width: number,
2253
+ height: number,
2254
+ ): ReplayViewportDimensions {
2255
+ let nextWidth = Math.max(MIN_REPLAY_VIEWPORT_WIDTH, Math.round(width));
2256
+ let nextHeight = Math.max(MIN_REPLAY_VIEWPORT_HEIGHT, Math.round(height));
2257
+ const aspect = nextWidth / nextHeight;
2258
+ if (aspect > MAX_REPLAY_ASPECT_RATIO) {
2259
+ nextWidth = Math.round(nextHeight * MAX_REPLAY_ASPECT_RATIO);
2260
+ } else if (aspect < MIN_REPLAY_ASPECT_RATIO) {
2261
+ nextHeight = Math.round(nextWidth / MIN_REPLAY_ASPECT_RATIO);
2262
+ }
2263
+ return { width: nextWidth, height: nextHeight };
2264
+ }
2265
+
2266
+ export function filterReplayMarkers(
2267
+ markers: ReplayMarker[],
2268
+ query: string,
2269
+ ): ReplayMarker[] {
2270
+ const needle = query.trim().toLowerCase();
2271
+ if (!needle) return markers;
2272
+ return markers.filter((marker) => {
2273
+ const haystack = [
2274
+ marker.label,
2275
+ marker.detail,
2276
+ marker.kind,
2277
+ marker.severity,
2278
+ ...(marker.fields?.flatMap((field) => [field.label, field.value]) ?? []),
2279
+ ]
2280
+ .filter(Boolean)
2281
+ .join(" ")
2282
+ .toLowerCase();
2283
+ return haystack.includes(needle);
2284
+ });
2285
+ }
2286
+
2287
+ /**
2288
+ * Rewrite Meta / ViewportResize frames so rrweb never applies absurd aspect
2289
+ * ratios that collapse the stage into an ultra-wide ribbon. Prefer clamping
2290
+ * the captured dimensions (preserve height, shrink width) over replacing them
2291
+ * with the default fallback, which would misalign the recorded DOM.
2292
+ */
2293
+ export function sanitizeReplayViewportEvents(
2294
+ events: AnyReplayEvent[],
2295
+ fallback: ReplayViewportDimensions,
2296
+ ): AnyReplayEvent[] {
2297
+ return events.map((event) => {
2298
+ if (event.type === RRWEB_EVENT_TYPE.Meta && isRecord(event.data)) {
2299
+ return rewriteViewportEventData(event, event.data, fallback);
2300
+ }
2301
+ if (
2302
+ event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
2303
+ event.data?.source === INCREMENTAL_SOURCE.ViewportResize &&
2304
+ isRecord(event.data)
2305
+ ) {
2306
+ return rewriteViewportEventData(event, event.data, fallback);
2307
+ }
2308
+ return event;
2309
+ });
2310
+ }
2311
+
2312
+ function rewriteViewportEventData(
2313
+ event: AnyReplayEvent,
2314
+ data: AnyRecord,
2315
+ fallback: ReplayViewportDimensions,
2316
+ ): AnyReplayEvent {
2317
+ const width = data.width;
2318
+ const height = data.height;
2319
+ if (
2320
+ typeof width === "number" &&
2321
+ typeof height === "number" &&
2322
+ Number.isFinite(width) &&
2323
+ Number.isFinite(height) &&
2324
+ width > 0 &&
2325
+ height > 0
2326
+ ) {
2327
+ const clamped = clampReplayViewport(width, height);
2328
+ if (
2329
+ clamped.width === Math.round(width) &&
2330
+ clamped.height === Math.round(height)
2331
+ ) {
2332
+ return event;
2333
+ }
2334
+ return {
2335
+ ...event,
2336
+ data: {
2337
+ ...data,
2338
+ width: clamped.width,
2339
+ height: clamped.height,
2340
+ },
2341
+ };
2342
+ }
2036
2343
  return {
2037
- width: Math.round(width),
2038
- height: Math.round(height),
2344
+ ...event,
2345
+ data: {
2346
+ ...data,
2347
+ width: fallback.width,
2348
+ height: fallback.height,
2349
+ },
2039
2350
  };
2040
2351
  }
2041
2352