@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.
- package/blueprints/action/crud.md +1 -1
- package/blueprints/channel/discord.md +1 -1
- package/blueprints/provider/stripe.md +1 -1
- package/blueprints/sandbox/docker.md +1 -1
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/blueprints/action/crud.md +1 -1
- package/corpus/core/blueprints/channel/discord.md +1 -1
- package/corpus/core/blueprints/provider/stripe.md +1 -1
- package/corpus/core/blueprints/sandbox/docker.md +1 -1
- package/corpus/core/package.json +6 -5
- package/corpus/core/src/agent/run-manager.ts +23 -2
- package/corpus/core/src/agent/run-store.ts +2 -2
- package/corpus/core/src/cli/add.ts +1 -1
- package/corpus/core/src/cli/create.ts +8 -3
- package/corpus/core/src/cli/index.ts +5 -4
- package/corpus/core/src/client/extensions/ExtensionSlot.tsx +7 -0
- package/corpus/core/src/client/session-replay.ts +97 -3
- package/corpus/core/src/jobs/scheduler.ts +30 -1
- package/corpus/core/src/localization/default-messages.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/de-DE.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/en-US.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/es-ES.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +20 -0
- package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +20 -0
- package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +21 -0
- package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +20 -0
- package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +19 -0
- package/corpus/core/src/templates/default/package.json +2 -2
- package/corpus/core/src/templates/headless/package.json +3 -3
- package/corpus/core/src/templates/workspace-core/package.json +6 -6
- package/corpus/core/src/templates/workspace-root/package.json +2 -2
- package/corpus/core/src/triggers/dispatcher.ts +30 -2
- package/corpus/core/src/vite/client.ts +96 -1
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +21 -2
- package/corpus/templates/analytics/app/global.css +26 -10
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/analytics/app/i18n-data.ts +25 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +548 -237
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +285 -294
- package/corpus/templates/analytics/changelog/2026-07-08-console-details-expand-inline-under-each-row-and-the-event-t.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-session-replay-clamps-ultra-wide-viewports-while-keeping-the.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-session-replay-keeps-a-usable-stage-when-dev-tools-is-open-a.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-session-replay-resumes-after-scrubbing-and-expands-console-r.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-session-replays-wait-for-full-load-keep-a-normal-viewport-an.md +6 -0
- package/corpus/templates/analytics/package.json +2 -2
- package/corpus/templates/assets/package.json +2 -2
- package/corpus/templates/brain/package.json +2 -2
- package/corpus/templates/calendar/AGENTS.md +10 -0
- package/corpus/templates/calendar/actions/create-event.ts +5 -1
- package/corpus/templates/calendar/actions/event-action-helpers.ts +41 -0
- package/corpus/templates/calendar/actions/get-attendee-timezones.ts +28 -0
- package/corpus/templates/calendar/actions/set-attendee-timezone.ts +68 -0
- package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +150 -11
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +36 -1
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +32 -0
- package/corpus/templates/calendar/app/hooks/use-attendee-timezones.ts +24 -0
- package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/calendar/app/i18n-data.ts +20 -0
- package/corpus/templates/calendar/app/lib/attendee-local-time.ts +122 -0
- package/corpus/templates/calendar/changelog/2026-07-08-event-details-now-support-extension-widgets-and-you-can-see-.md +6 -0
- package/corpus/templates/calendar/changelog/2026-07-08-when-you-invite-guests-to-a-new-event-you-now-appear-in.md +6 -0
- package/corpus/templates/calendar/package.json +2 -2
- package/corpus/templates/calendar/server/handlers/events.ts +5 -1
- package/corpus/templates/calendar/shared/api.ts +6 -0
- package/corpus/templates/chat/package.json +2 -2
- package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +6 -2
- package/corpus/templates/clips/actions/create-meeting.ts +8 -3
- package/corpus/templates/clips/actions/list-meetings.ts +31 -3
- package/corpus/templates/clips/changelog/2026-07-08-after-finishing-a-desktop-recording-reopening-clips-restor.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-clips-now-pops-up-to-take-notes-when-you-join-an-adhoc-zoom-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-desktop-clip-recordings-are-louder-after-capture-mic-only.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-meeting-reminders-now-appear-1-minute-before-start-stay-unti.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-the-desktop-meetings-panel-has-room-for-its-drop-shadow-and-.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +2 -2
- package/corpus/templates/clips/desktop/package.json +2 -2
- package/corpus/templates/clips/desktop/src/app.tsx +91 -34
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +15 -5
- package/corpus/templates/clips/desktop/src/lib/meeting-notification-timing.ts +83 -0
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +21 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +98 -20
- package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +174 -50
- package/corpus/templates/clips/desktop/src/styles.css +128 -53
- package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +364 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +5 -0
- package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +91 -11
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +69 -19
- package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +36 -1
- package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +62 -10
- package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +1 -24
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +36 -1
- package/corpus/templates/clips/package.json +2 -2
- package/corpus/templates/content/actions/_content-database-personal-view.ts +80 -0
- package/corpus/templates/content/actions/get-content-database-personal-view.ts +27 -0
- package/corpus/templates/content/actions/update-content-database-personal-view.ts +31 -0
- package/corpus/templates/content/actions/update-content-database-view.ts +3 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2584 -546
- package/corpus/templates/content/app/components/editor/database/filter-sort.ts +220 -18
- package/corpus/templates/content/app/hooks/use-content-database.ts +32 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +16 -0
- package/corpus/templates/content/app/i18n-data.ts +154 -0
- package/corpus/templates/content/changelog/2026-07-08-database-filters-now-support-notion-style-reset-save-for-eve.md +6 -0
- package/corpus/templates/content/changelog/2026-07-08-date-filters-can-now-target-a-between-range.md +6 -0
- package/corpus/templates/content/package.json +2 -2
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +2 -0
- package/corpus/templates/content/shared/api.ts +24 -0
- package/corpus/templates/design/package.json +2 -2
- package/corpus/templates/dispatch/app/i18n/zh-TW.ts +34 -1
- package/corpus/templates/dispatch/app/i18n-data.ts +387 -10
- package/corpus/templates/dispatch/app/routes/automations.tsx +1 -0
- package/corpus/templates/dispatch/changelog/2026-07-08-added-an-automations-page-to-review-pause-and-create.md +6 -0
- package/corpus/templates/dispatch/changelog/2026-07-08-destinations-now-shows-outbound-delivery-queue-health.md +6 -0
- package/corpus/templates/dispatch/package.json +2 -2
- package/corpus/templates/forms/package.json +2 -2
- package/corpus/templates/macros/package.json +2 -2
- package/corpus/templates/mail/app/hooks/use-emails.ts +56 -44
- package/corpus/templates/mail/app/lib/gmail-mutation-queue.ts +325 -0
- package/corpus/templates/mail/changelog/2026-07-08-rapid-archive-and-mark-read-now-batch-gmail-updates-so-the-i.md +6 -0
- package/corpus/templates/mail/package.json +2 -2
- package/corpus/templates/plan/package.json +2 -2
- package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +46 -29
- package/corpus/templates/slides/app/context/DeckContext.tsx +45 -18
- package/corpus/templates/slides/app/i18n/ar-SA.ts +4 -0
- package/corpus/templates/slides/app/i18n/de-DE.ts +4 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
- package/corpus/templates/slides/app/i18n/es-ES.ts +4 -0
- package/corpus/templates/slides/app/i18n/fr-FR.ts +4 -0
- package/corpus/templates/slides/app/i18n/hi-IN.ts +4 -0
- package/corpus/templates/slides/app/i18n/ja-JP.ts +4 -0
- package/corpus/templates/slides/app/i18n/ko-KR.ts +4 -0
- package/corpus/templates/slides/app/i18n/pt-BR.ts +4 -0
- package/corpus/templates/slides/app/i18n/zh-CN.ts +4 -0
- package/corpus/templates/slides/app/i18n/zh-TW.ts +4 -0
- package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +115 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +22 -13
- package/corpus/templates/slides/changelog/2026-07-08-dropping-an-image-onto-a-slide-and-sending-it-to-the-agent-w.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-08-undo-works-after-the-agent-edits-a-slide-so-you-can-reverse.md +6 -0
- package/corpus/templates/slides/package.json +2 -2
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +23 -2
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.js +2 -2
- package/dist/agent/run-store.js.map +1 -1
- package/dist/catalog.json +2 -2
- package/dist/cli/add.js +1 -1
- package/dist/cli/add.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +8 -3
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/index.js +5 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/client/extensions/ExtensionSlot.js +6 -0
- package/dist/client/extensions/ExtensionSlot.js.map +1 -1
- package/dist/client/session-replay.d.ts +8 -0
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +80 -3
- package/dist/client/session-replay.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/jobs/scheduler.js +26 -1
- package/dist/jobs/scheduler.js.map +1 -1
- package/dist/localization/default-messages.d.ts +19 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +19 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/templates/default/app/i18n/ar-SA.ts +21 -0
- package/dist/templates/default/app/i18n/de-DE.ts +21 -0
- package/dist/templates/default/app/i18n/en-US.ts +21 -0
- package/dist/templates/default/app/i18n/es-ES.ts +21 -0
- package/dist/templates/default/app/i18n/fr-FR.ts +21 -0
- package/dist/templates/default/app/i18n/hi-IN.ts +20 -0
- package/dist/templates/default/app/i18n/ja-JP.ts +21 -0
- package/dist/templates/default/app/i18n/ko-KR.ts +20 -0
- package/dist/templates/default/app/i18n/pt-BR.ts +21 -0
- package/dist/templates/default/app/i18n/zh-CN.ts +20 -0
- package/dist/templates/default/app/i18n/zh-TW.ts +19 -0
- package/dist/templates/default/package.json +2 -2
- package/dist/templates/headless/package.json +3 -3
- package/dist/templates/workspace-core/package.json +6 -6
- package/dist/templates/workspace-root/package.json +2 -2
- package/dist/triggers/dispatcher.js +27 -2
- package/dist/triggers/dispatcher.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +70 -1
- package/dist/vite/client.js.map +1 -1
- package/package.json +7 -6
- package/src/templates/default/app/i18n/ar-SA.ts +21 -0
- package/src/templates/default/app/i18n/de-DE.ts +21 -0
- package/src/templates/default/app/i18n/en-US.ts +21 -0
- package/src/templates/default/app/i18n/es-ES.ts +21 -0
- package/src/templates/default/app/i18n/fr-FR.ts +21 -0
- package/src/templates/default/app/i18n/hi-IN.ts +20 -0
- package/src/templates/default/app/i18n/ja-JP.ts +21 -0
- package/src/templates/default/app/i18n/ko-KR.ts +20 -0
- package/src/templates/default/app/i18n/pt-BR.ts +21 -0
- package/src/templates/default/app/i18n/zh-CN.ts +20 -0
- package/src/templates/default/app/i18n/zh-TW.ts +19 -0
- package/src/templates/default/package.json +2 -2
- package/src/templates/headless/package.json +3 -3
- package/src/templates/workspace-core/package.json +6 -6
- 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 =
|
|
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
|
-
|
|
600
|
-
|
|
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 (
|
|
678
|
+
if (replayEvents.length < 2) {
|
|
607
679
|
throw new Error(t("sessions.noReplayEvents"));
|
|
608
680
|
}
|
|
609
681
|
if (
|
|
610
|
-
!
|
|
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
|
-
|
|
622
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
<
|
|
816
|
-
<
|
|
817
|
-
<
|
|
818
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
"
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
985
|
+
size="icon"
|
|
846
986
|
disabled={disabled}
|
|
847
|
-
aria-label={playing ? t("sessions.pause") : t("sessions.play")}
|
|
848
987
|
onClick={togglePlay}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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="
|
|
1160
|
-
{
|
|
1302
|
+
<p className="text-xs text-muted-foreground">
|
|
1303
|
+
{filteredMarkers.length
|
|
1161
1304
|
? t("sessions.timelineDescription", {
|
|
1162
|
-
count: String(
|
|
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
|
|
1169
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2344
|
+
...event,
|
|
2345
|
+
data: {
|
|
2346
|
+
...data,
|
|
2347
|
+
width: fallback.width,
|
|
2348
|
+
height: fallback.height,
|
|
2349
|
+
},
|
|
2039
2350
|
};
|
|
2040
2351
|
}
|
|
2041
2352
|
|