@agent-native/core 0.78.9 → 0.79.0
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +11 -0
- package/corpus/core/docs/content/actions.md +4 -2
- package/corpus/core/docs/content/agent-surfaces.md +9 -0
- package/corpus/core/docs/content/extensions.md +10 -0
- package/corpus/core/docs/content/generative-ui.md +217 -0
- package/corpus/core/docs/content/key-concepts.md +2 -2
- package/corpus/core/docs/content/mcp-apps.md +3 -5
- package/corpus/core/docs/content/native-chat-ui.md +9 -5
- package/corpus/core/docs/content/tracking.md +9 -3
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action-ui.ts +1 -0
- package/corpus/core/src/client/analytics.ts +195 -12
- package/corpus/core/src/client/chat/index.ts +1 -0
- package/corpus/core/src/client/chat/tool-call-display.tsx +5 -2
- package/corpus/core/src/client/chat/widgets/InlineExtensionWidget.tsx +139 -0
- package/corpus/core/src/client/chat/widgets/builtin-tool-renderers.tsx +27 -1
- package/corpus/core/src/client/conversation/AgentConversation.tsx +38 -0
- package/corpus/core/src/client/conversation/code-agent-transcript.ts +9 -0
- package/corpus/core/src/client/conversation/types.ts +4 -0
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +27 -5
- package/corpus/core/src/client/extensions/ExtensionViewer.tsx +26 -5
- package/corpus/core/src/client/extensions/InlineExtensionFrame.tsx +654 -0
- package/corpus/core/src/client/extensions/iframe-bridge.ts +30 -0
- package/corpus/core/src/client/extensions/index.ts +5 -0
- package/corpus/core/src/client/index.ts +1 -0
- package/corpus/core/src/client/session-replay.ts +89 -14
- package/corpus/core/src/code-agents/transcript-normalizer.ts +14 -0
- package/corpus/core/src/extensions/actions.ts +218 -1
- package/corpus/core/src/extensions/html-shell.ts +70 -3
- package/corpus/core/src/extensions/routes.ts +12 -4
- package/corpus/core/src/index.browser.ts +1 -0
- package/corpus/core/src/index.ts +1 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +21 -9
- package/corpus/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +13 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/extensions/references/api.md +2 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +115 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +198 -59
- package/corpus/templates/analytics/AGENTS.md +12 -0
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1105 -0
- package/corpus/templates/analytics/actions/dashboard-panel-order.ts +169 -0
- package/corpus/templates/analytics/actions/get-sql-dashboard.ts +89 -6
- package/corpus/templates/analytics/actions/mutate-dashboard.ts +333 -0
- package/corpus/templates/analytics/actions/reorder-dashboard-panels.ts +214 -0
- package/corpus/templates/analytics/actions/send-dashboard-report-now.ts +48 -21
- package/corpus/templates/analytics/actions/update-dashboard.ts +193 -100
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +11 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/analytics/app/global.css +81 -10
- package/corpus/templates/analytics/app/i18n-data.ts +249 -4
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +3 -3
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +41 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +1 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +4 -4
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +48 -18
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +32 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +204 -70
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +1101 -227
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +160 -133
- package/corpus/templates/analytics/changelog/2026-06-26-agents-can-now-edit-dashboards-through-a-typed-mutation-scri.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-dashboard-bar-chart-hovers-stay-subtle-in-dark-mode.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-dashboard-chart-moves-are-now-handled-by-panel-id-making-sim.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-dashboard-charts-drag-smoothly-with-stable-cards-inactive-ch.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-dashboard-charts-show-a-refresh-spinner-while-keeping-existi.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-dashboard-edits-stay-stable-while-chart-order-and-deletions-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-dashboard-email-reports-queue-immediate-sends.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-explorer-dashboards-now-drag-charts-with-a-stable-preview-an.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-session-replay-playback-can-now-be-toggled-by-clicking-the-r.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-26-session-replays-now-open-from-the-full-row-and-play-with-tim.md +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +21 -0
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +41 -17
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +5 -0
- package/dist/action-ui.d.ts +1 -0
- package/dist/action-ui.d.ts.map +1 -1
- package/dist/action-ui.js +1 -0
- package/dist/action-ui.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +173 -12
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/chat/index.d.ts +1 -1
- package/dist/client/chat/index.d.ts.map +1 -1
- package/dist/client/chat/index.js +1 -1
- package/dist/client/chat/index.js.map +1 -1
- package/dist/client/chat/tool-call-display.d.ts.map +1 -1
- package/dist/client/chat/tool-call-display.js +3 -1
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/chat/widgets/InlineExtensionWidget.d.ts +17 -0
- package/dist/client/chat/widgets/InlineExtensionWidget.d.ts.map +1 -0
- package/dist/client/chat/widgets/InlineExtensionWidget.js +85 -0
- package/dist/client/chat/widgets/InlineExtensionWidget.js.map +1 -0
- package/dist/client/chat/widgets/builtin-tool-renderers.d.ts.map +1 -1
- package/dist/client/chat/widgets/builtin-tool-renderers.js +15 -2
- package/dist/client/chat/widgets/builtin-tool-renderers.js.map +1 -1
- package/dist/client/conversation/AgentConversation.d.ts.map +1 -1
- package/dist/client/conversation/AgentConversation.js +33 -0
- package/dist/client/conversation/AgentConversation.js.map +1 -1
- package/dist/client/conversation/code-agent-transcript.js +8 -0
- package/dist/client/conversation/code-agent-transcript.js.map +1 -1
- package/dist/client/conversation/types.d.ts +4 -0
- package/dist/client/conversation/types.d.ts.map +1 -1
- package/dist/client/conversation/types.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +29 -1
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionViewer.js +28 -1
- package/dist/client/extensions/ExtensionViewer.js.map +1 -1
- package/dist/client/extensions/InlineExtensionFrame.d.ts +25 -0
- package/dist/client/extensions/InlineExtensionFrame.d.ts.map +1 -0
- package/dist/client/extensions/InlineExtensionFrame.js +488 -0
- package/dist/client/extensions/InlineExtensionFrame.js.map +1 -0
- package/dist/client/extensions/iframe-bridge.d.ts +2 -0
- package/dist/client/extensions/iframe-bridge.d.ts.map +1 -1
- package/dist/client/extensions/iframe-bridge.js +23 -0
- package/dist/client/extensions/iframe-bridge.js.map +1 -1
- package/dist/client/extensions/index.d.ts +1 -0
- package/dist/client/extensions/index.d.ts.map +1 -1
- package/dist/client/extensions/index.js +1 -0
- package/dist/client/extensions/index.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/session-replay.d.ts +11 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +74 -14
- package/dist/client/session-replay.js.map +1 -1
- package/dist/code-agents/transcript-normalizer.d.ts +2 -0
- package/dist/code-agents/transcript-normalizer.d.ts.map +1 -1
- package/dist/code-agents/transcript-normalizer.js +8 -0
- package/dist/code-agents/transcript-normalizer.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +200 -1
- package/dist/extensions/actions.js.map +1 -1
- package/dist/extensions/html-shell.d.ts.map +1 -1
- package/dist/extensions/html-shell.js +70 -3
- package/dist/extensions/html-shell.js.map +1 -1
- package/dist/extensions/routes.d.ts.map +1 -1
- package/dist/extensions/routes.js +11 -4
- package/dist/extensions/routes.js.map +1 -1
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.d.ts.map +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +21 -9
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +13 -0
- package/dist/templates/workspace-core/.agents/skills/extensions/references/api.md +2 -0
- package/dist/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +115 -0
- package/docs/content/actions.md +4 -2
- package/docs/content/agent-surfaces.md +9 -0
- package/docs/content/extensions.md +10 -0
- package/docs/content/generative-ui.md +217 -0
- package/docs/content/key-concepts.md +2 -2
- package/docs/content/mcp-apps.md +3 -5
- package/docs/content/native-chat-ui.md +9 -5
- package/docs/content/tracking.md +9 -3
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +13 -0
- package/src/templates/workspace-core/.agents/skills/extensions/references/api.md +2 -0
- package/src/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +115 -0
|
@@ -1,38 +1,48 @@
|
|
|
1
1
|
import {
|
|
2
|
+
PromptComposer,
|
|
2
3
|
useActionQuery,
|
|
3
4
|
useSendToAgentChat,
|
|
4
5
|
useT,
|
|
5
6
|
} from "@agent-native/core/client";
|
|
6
7
|
import {
|
|
7
8
|
IconArrowLeft,
|
|
8
|
-
IconClock,
|
|
9
9
|
IconExclamationCircle,
|
|
10
|
+
IconKeyboard,
|
|
10
11
|
IconMessageCircle,
|
|
12
|
+
IconMouse,
|
|
11
13
|
IconPlayerPause,
|
|
12
14
|
IconPlayerPlay,
|
|
13
15
|
IconPlayerSkipBack,
|
|
16
|
+
IconPlayerSkipForward,
|
|
17
|
+
IconPlayerTrackNext,
|
|
18
|
+
IconRoute,
|
|
19
|
+
IconTimelineEvent,
|
|
14
20
|
} from "@tabler/icons-react";
|
|
15
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
type ReactNode,
|
|
23
|
+
useCallback,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from "react";
|
|
16
29
|
import { Link, useParams } from "react-router";
|
|
17
30
|
|
|
18
|
-
import { Badge } from "@/components/ui/badge";
|
|
19
31
|
import { Button } from "@/components/ui/button";
|
|
32
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
20
33
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
CardTitle,
|
|
26
|
-
} from "@/components/ui/card";
|
|
34
|
+
Popover,
|
|
35
|
+
PopoverContent,
|
|
36
|
+
PopoverTrigger,
|
|
37
|
+
} from "@/components/ui/popover";
|
|
27
38
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
28
39
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} from "@/components/ui/table";
|
|
40
|
+
Tooltip,
|
|
41
|
+
TooltipContent,
|
|
42
|
+
TooltipProvider,
|
|
43
|
+
TooltipTrigger,
|
|
44
|
+
} from "@/components/ui/tooltip";
|
|
45
|
+
import { cn } from "@/lib/utils";
|
|
36
46
|
|
|
37
47
|
type SessionRecordingSummary = {
|
|
38
48
|
id: string;
|
|
@@ -81,12 +91,65 @@ type SessionReplayEventsResponse = {
|
|
|
81
91
|
unavailableChunks: number;
|
|
82
92
|
};
|
|
83
93
|
|
|
94
|
+
type AnyReplayEvent = Record<string, any>;
|
|
95
|
+
type AnyRecord = Record<string, any>;
|
|
96
|
+
|
|
84
97
|
type ReplayPlayerStatus = "idle" | "loading" | "ready" | "error";
|
|
85
98
|
|
|
99
|
+
type ReplayMarker = {
|
|
100
|
+
id: string;
|
|
101
|
+
offsetMs: number;
|
|
102
|
+
timestamp: number;
|
|
103
|
+
kind: "navigation" | "input" | "click" | "custom";
|
|
104
|
+
label: string;
|
|
105
|
+
detail?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type SkipRange = {
|
|
109
|
+
startMs: number;
|
|
110
|
+
endMs: number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const DEFAULT_PLAYER_WIDTH = 1024;
|
|
114
|
+
const DEFAULT_PLAYER_HEIGHT = 640;
|
|
115
|
+
const DEFAULT_SPEED = 2;
|
|
116
|
+
const SPEED_OPTIONS = [0.5, 1, 2, 4, 8];
|
|
117
|
+
const SKIP_STEP_MS = 5000;
|
|
118
|
+
const MIN_IDLE_SKIP_MS = 8000;
|
|
119
|
+
const IDLE_EDGE_PAD_MS = 1200;
|
|
120
|
+
|
|
121
|
+
const RRWEB_EVENT_TYPE = {
|
|
122
|
+
FullSnapshot: 2,
|
|
123
|
+
IncrementalSnapshot: 3,
|
|
124
|
+
Meta: 4,
|
|
125
|
+
Custom: 5,
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
const INCREMENTAL_SOURCE = {
|
|
129
|
+
Mutation: 0,
|
|
130
|
+
MouseInteraction: 2,
|
|
131
|
+
Scroll: 3,
|
|
132
|
+
Input: 5,
|
|
133
|
+
TouchMove: 6,
|
|
134
|
+
} as const;
|
|
135
|
+
|
|
136
|
+
const MOUSE_INTERACTION = {
|
|
137
|
+
Click: 2,
|
|
138
|
+
DblClick: 4,
|
|
139
|
+
Focus: 5,
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
const INTERACTION_SOURCES = new Set<number>([
|
|
143
|
+
INCREMENTAL_SOURCE.MouseInteraction,
|
|
144
|
+
INCREMENTAL_SOURCE.Scroll,
|
|
145
|
+
INCREMENTAL_SOURCE.Input,
|
|
146
|
+
INCREMENTAL_SOURCE.TouchMove,
|
|
147
|
+
]);
|
|
148
|
+
|
|
86
149
|
export default function SessionDetailPage() {
|
|
87
150
|
const t = useT();
|
|
88
151
|
const { recordingId = "" } = useParams();
|
|
89
|
-
const {
|
|
152
|
+
const { codeRequiredDialog } = useSendToAgentChat();
|
|
90
153
|
const { data, isLoading, error } =
|
|
91
154
|
useActionQuery<SessionReplayEventsResponse>(
|
|
92
155
|
"get-session-replay-events",
|
|
@@ -96,56 +159,32 @@ export default function SessionDetailPage() {
|
|
|
96
159
|
const recording = data?.recording;
|
|
97
160
|
|
|
98
161
|
return (
|
|
99
|
-
<div className="
|
|
162
|
+
<div className="flex h-full min-h-0 w-full flex-col gap-3 overflow-hidden">
|
|
100
163
|
{codeRequiredDialog}
|
|
101
|
-
<div className="flex flex-col gap-
|
|
102
|
-
<div className="min-w-0
|
|
103
|
-
<Button variant="ghost" size="sm" asChild className="-
|
|
164
|
+
<div className="flex shrink-0 flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
165
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
166
|
+
<Button variant="ghost" size="sm" asChild className="shrink-0">
|
|
104
167
|
<Link to="/sessions">
|
|
105
168
|
<IconArrowLeft className="h-4 w-4" />
|
|
106
169
|
{t("sessions.backToSessions")}
|
|
107
170
|
</Link>
|
|
108
171
|
</Button>
|
|
109
|
-
|
|
110
|
-
<div className="
|
|
111
|
-
<
|
|
112
|
-
<h1 className="break-all font-mono text-xl font-semibold tracking-normal md:text-2xl">
|
|
113
|
-
{recording?.sessionId ?? recordingId}
|
|
114
|
-
</h1>
|
|
115
|
-
</div>
|
|
116
|
-
{recording ? (
|
|
117
|
-
<p className="max-w-3xl text-sm text-muted-foreground">
|
|
172
|
+
{recording ? (
|
|
173
|
+
<div className="min-w-0 border-l pl-3 text-xs text-muted-foreground">
|
|
174
|
+
<span className="truncate">
|
|
118
175
|
{recording.app ||
|
|
119
176
|
recording.template ||
|
|
120
177
|
t("sessions.unknownApp")}{" "}
|
|
121
|
-
· {
|
|
122
|
-
{
|
|
123
|
-
recording.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
</div>
|
|
178
|
+
· {formatDuration(recording.durationMs)} ·{" "}
|
|
179
|
+
{t("sessions.eventCountCompact", {
|
|
180
|
+
count: formatNumber(recording.eventCount),
|
|
181
|
+
})}{" "}
|
|
182
|
+
· {visitorLabel(recording, t)}
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
130
186
|
</div>
|
|
131
|
-
{recording ?
|
|
132
|
-
<Button
|
|
133
|
-
type="button"
|
|
134
|
-
variant="outline"
|
|
135
|
-
disabled={isGenerating}
|
|
136
|
-
onClick={() =>
|
|
137
|
-
send({
|
|
138
|
-
type: "content",
|
|
139
|
-
submit: true,
|
|
140
|
-
message: `Summarize session replay recording ${recording.id}. Tell me what the user did, where they struggled, and whether errors or rage clicks stood out.`,
|
|
141
|
-
context: `Use get-session-replay-summary and get-session-replay-events with recordingId=${recording.id}. The visible page is /sessions/${recording.id}.`,
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
>
|
|
145
|
-
<IconMessageCircle className="h-4 w-4" />
|
|
146
|
-
{t("sessions.askAgent")}
|
|
147
|
-
</Button>
|
|
148
|
-
) : null}
|
|
187
|
+
{recording ? <AskSessionPopover recording={recording} /> : null}
|
|
149
188
|
</div>
|
|
150
189
|
|
|
151
190
|
{error ? (
|
|
@@ -158,251 +197,1074 @@ export default function SessionDetailPage() {
|
|
|
158
197
|
) : isLoading ? (
|
|
159
198
|
<DetailSkeleton />
|
|
160
199
|
) : data && recording ? (
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
<ReplayChunksTable response={data} />
|
|
165
|
-
</>
|
|
200
|
+
<div className="min-h-0 flex-1">
|
|
201
|
+
<ReplayWorkbench response={data} />
|
|
202
|
+
</div>
|
|
166
203
|
) : null}
|
|
167
204
|
</div>
|
|
168
205
|
);
|
|
169
206
|
}
|
|
170
207
|
|
|
171
|
-
function
|
|
208
|
+
function AskSessionPopover({
|
|
209
|
+
recording,
|
|
210
|
+
}: {
|
|
211
|
+
recording: SessionRecordingSummary;
|
|
212
|
+
}) {
|
|
172
213
|
const t = useT();
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
214
|
+
const [open, setOpen] = useState(false);
|
|
215
|
+
const { send, isGenerating } = useSendToAgentChat();
|
|
216
|
+
|
|
217
|
+
function handleSubmit(text: string) {
|
|
218
|
+
const trimmed = text.trim();
|
|
219
|
+
if (!trimmed || isGenerating) return;
|
|
220
|
+
send({
|
|
221
|
+
message: trimmed,
|
|
222
|
+
context:
|
|
223
|
+
`The user is looking at session replay recording ${recording.id} on /sessions/${recording.id}. ` +
|
|
224
|
+
`Use get-session-replay-summary first, and only use bounded get-session-replay-events if the user asks about timeline details. ` +
|
|
225
|
+
`Keep raw rrweb JSON out of the answer; summarize the session, friction, errors, rage clicks, navigation, and next investigative steps.`,
|
|
226
|
+
submit: true,
|
|
227
|
+
});
|
|
228
|
+
setOpen(false);
|
|
229
|
+
}
|
|
230
|
+
|
|
181
231
|
return (
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
232
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
233
|
+
<PopoverTrigger asChild>
|
|
234
|
+
<Button type="button" variant="outline" disabled={isGenerating}>
|
|
235
|
+
<IconMessageCircle className="h-4 w-4" />
|
|
236
|
+
{t("sessions.askAgent")}
|
|
237
|
+
</Button>
|
|
238
|
+
</PopoverTrigger>
|
|
239
|
+
<PopoverContent
|
|
240
|
+
className="w-[calc(100vw-2rem)] p-3 sm:w-[460px]"
|
|
241
|
+
align="end"
|
|
242
|
+
>
|
|
243
|
+
<div className="px-1 pb-2">
|
|
244
|
+
<p className="text-sm font-semibold text-foreground">
|
|
245
|
+
{t("sessions.askSessionTitle")}
|
|
246
|
+
</p>
|
|
247
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
248
|
+
{t("sessions.askSessionDescription")}
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
<PromptComposer
|
|
252
|
+
autoFocus
|
|
253
|
+
disabled={isGenerating}
|
|
254
|
+
placeholder={t("sessions.askSessionPlaceholder")}
|
|
255
|
+
draftScope={`analytics:session-replay:${recording.id}`}
|
|
256
|
+
onSubmit={handleSubmit}
|
|
257
|
+
/>
|
|
258
|
+
</PopoverContent>
|
|
259
|
+
</Popover>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ReplayWorkbench({
|
|
264
|
+
response,
|
|
265
|
+
}: {
|
|
266
|
+
response: SessionReplayEventsResponse;
|
|
267
|
+
}) {
|
|
268
|
+
const events = useReplayEvents(response);
|
|
269
|
+
const markers = useMemo(() => buildReplayMarkers(events), [events]);
|
|
270
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
271
|
+
const [activeMarkerId, setActiveMarkerId] = useState<string | null>(null);
|
|
272
|
+
const seekRef = useRef<(ms: number, autoplay?: boolean) => void>(() => {});
|
|
273
|
+
const registerSeek = useCallback(
|
|
274
|
+
(seek: (ms: number, autoplay?: boolean) => void) => {
|
|
275
|
+
seekRef.current = seek;
|
|
276
|
+
},
|
|
277
|
+
[],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
let active: ReplayMarker | null = null;
|
|
282
|
+
for (const marker of markers) {
|
|
283
|
+
if (marker.offsetMs <= currentTime + 250) active = marker;
|
|
284
|
+
else break;
|
|
285
|
+
}
|
|
286
|
+
setActiveMarkerId(active?.id ?? null);
|
|
287
|
+
}, [currentTime, markers]);
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="grid h-full min-h-0 gap-3 xl:grid-cols-[minmax(0,1fr)_330px]">
|
|
291
|
+
<ReplayPlayer
|
|
292
|
+
events={events}
|
|
293
|
+
markers={markers}
|
|
294
|
+
response={response}
|
|
295
|
+
onTimeUpdate={setCurrentTime}
|
|
296
|
+
registerSeek={registerSeek}
|
|
297
|
+
/>
|
|
298
|
+
<ReplayTimeline
|
|
299
|
+
markers={markers}
|
|
300
|
+
activeMarkerId={activeMarkerId}
|
|
301
|
+
onSeek={(ms) => seekRef.current(ms, true)}
|
|
302
|
+
/>
|
|
193
303
|
</div>
|
|
194
304
|
);
|
|
195
305
|
}
|
|
196
306
|
|
|
197
|
-
function ReplayPlayer({
|
|
307
|
+
function ReplayPlayer({
|
|
308
|
+
events,
|
|
309
|
+
markers,
|
|
310
|
+
response,
|
|
311
|
+
onTimeUpdate,
|
|
312
|
+
registerSeek,
|
|
313
|
+
}: {
|
|
314
|
+
events: AnyReplayEvent[];
|
|
315
|
+
markers: ReplayMarker[];
|
|
316
|
+
response: SessionReplayEventsResponse;
|
|
317
|
+
onTimeUpdate: (ms: number) => void;
|
|
318
|
+
registerSeek: (seek: (ms: number, autoplay?: boolean) => void) => void;
|
|
319
|
+
}) {
|
|
198
320
|
const t = useT();
|
|
199
|
-
const
|
|
321
|
+
const stageAreaRef = useRef<HTMLDivElement>(null);
|
|
322
|
+
const stageRootRef = useRef<HTMLDivElement>(null);
|
|
200
323
|
const replayerRef = useRef<any>(null);
|
|
324
|
+
const rafRef = useRef<number | null>(null);
|
|
201
325
|
const [status, setStatus] = useState<ReplayPlayerStatus>("idle");
|
|
202
326
|
const [error, setError] = useState<string | null>(null);
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
327
|
+
const [playing, setPlaying] = useState(false);
|
|
328
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
329
|
+
const [totalTime, setTotalTime] = useState(0);
|
|
330
|
+
const [speed, setSpeed] = useState(DEFAULT_SPEED);
|
|
331
|
+
const [skipInactive, setSkipInactive] = useState(true);
|
|
332
|
+
const [streamedDims, setStreamedDims] = useState<{
|
|
333
|
+
width: number;
|
|
334
|
+
height: number;
|
|
335
|
+
} | null>(null);
|
|
336
|
+
const [fitScale, setFitScale] = useState(1);
|
|
337
|
+
|
|
338
|
+
const playerWidth = streamedDims?.width ?? DEFAULT_PLAYER_WIDTH;
|
|
339
|
+
const playerHeight = streamedDims?.height ?? DEFAULT_PLAYER_HEIGHT;
|
|
340
|
+
const skipRanges = useMemo(() => buildIdleSkipRanges(events), [events]);
|
|
341
|
+
const skipRangesRef = useLiveRef(skipRanges);
|
|
342
|
+
const skipInactiveRef = useLiveRef(skipInactive);
|
|
343
|
+
const currentTimeRef = useLiveRef(currentTime);
|
|
344
|
+
const playingRef = useLiveRef(playing);
|
|
345
|
+
|
|
346
|
+
const currentUrl = useMemo(
|
|
347
|
+
() => currentUrlAt(events, currentTime),
|
|
348
|
+
[events, currentTime],
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
const el = stageAreaRef.current;
|
|
353
|
+
if (!el) return;
|
|
354
|
+
const update = () => {
|
|
355
|
+
const next = Math.min(
|
|
356
|
+
el.clientWidth / playerWidth,
|
|
357
|
+
el.clientHeight / playerHeight,
|
|
358
|
+
);
|
|
359
|
+
setFitScale(Number.isFinite(next) && next > 0 ? next : 1);
|
|
360
|
+
};
|
|
361
|
+
update();
|
|
362
|
+
const observer = new ResizeObserver(update);
|
|
363
|
+
observer.observe(el);
|
|
364
|
+
return () => observer.disconnect();
|
|
365
|
+
}, [playerHeight, playerWidth]);
|
|
366
|
+
|
|
367
|
+
const updateTime = useCallback(
|
|
368
|
+
(next: number) => {
|
|
369
|
+
setCurrentTime(next);
|
|
370
|
+
onTimeUpdate(next);
|
|
371
|
+
},
|
|
372
|
+
[onTimeUpdate],
|
|
214
373
|
);
|
|
215
374
|
|
|
375
|
+
const seek = useCallback(
|
|
376
|
+
(ms: number, autoplay = playingRef.current) => {
|
|
377
|
+
const replayer = replayerRef.current;
|
|
378
|
+
if (!replayer || status !== "ready") return;
|
|
379
|
+
const clamped = clamp(ms, 0, Math.max(totalTime, 0));
|
|
380
|
+
try {
|
|
381
|
+
if (autoplay) {
|
|
382
|
+
replayer.play(clamped);
|
|
383
|
+
setPlaying(true);
|
|
384
|
+
} else {
|
|
385
|
+
replayer.pause(clamped);
|
|
386
|
+
setPlaying(false);
|
|
387
|
+
}
|
|
388
|
+
} catch (seekError) {
|
|
389
|
+
console.warn("[session-replay] seek failed", seekError);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
updateTime(clamped);
|
|
393
|
+
},
|
|
394
|
+
[playingRef, status, totalTime, updateTime],
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
registerSeek(seek);
|
|
399
|
+
}, [registerSeek, seek]);
|
|
400
|
+
|
|
216
401
|
useEffect(() => {
|
|
217
|
-
if (!
|
|
402
|
+
if (!stageRootRef.current) return;
|
|
218
403
|
let cancelled = false;
|
|
219
404
|
let localReplayer: any = null;
|
|
220
405
|
|
|
221
406
|
async function loadReplay() {
|
|
222
|
-
if (events.length
|
|
407
|
+
if (events.length < 2) {
|
|
223
408
|
throw new Error(t("sessions.noReplayEvents"));
|
|
224
409
|
}
|
|
225
410
|
setStatus("loading");
|
|
226
411
|
setError(null);
|
|
227
412
|
await import("@rrweb/replay/dist/style.css");
|
|
228
413
|
const { Replayer } = await import("@rrweb/replay");
|
|
229
|
-
if (cancelled || !
|
|
414
|
+
if (cancelled || !stageRootRef.current) return;
|
|
230
415
|
|
|
231
|
-
|
|
416
|
+
stageRootRef.current.innerHTML = "";
|
|
417
|
+
setStreamedDims(null);
|
|
232
418
|
localReplayer = new Replayer(events as any[], {
|
|
233
|
-
root:
|
|
234
|
-
|
|
419
|
+
root: stageRootRef.current,
|
|
420
|
+
speed: DEFAULT_SPEED,
|
|
421
|
+
skipInactive: false,
|
|
235
422
|
showWarning: false,
|
|
236
423
|
showDebug: false,
|
|
237
424
|
triggerFocus: false,
|
|
425
|
+
mouseTail: false,
|
|
426
|
+
insertStyleRules: [
|
|
427
|
+
"[data-radix-popper-content-wrapper], .Toastify, [class*='toast'], [class*='Toast'] { display: none !important; }",
|
|
428
|
+
],
|
|
238
429
|
});
|
|
239
430
|
replayerRef.current = localReplayer;
|
|
240
|
-
localReplayer.
|
|
431
|
+
const meta = localReplayer.getMetaData?.();
|
|
432
|
+
const total = Number(meta?.totalTime ?? replayDuration(events));
|
|
433
|
+
setTotalTime(Number.isFinite(total) ? total : 0);
|
|
434
|
+
localReplayer.on?.("finish", () => {
|
|
435
|
+
setPlaying(false);
|
|
436
|
+
const finalTime = Number(localReplayer.getCurrentTime?.() ?? total);
|
|
437
|
+
updateTime(Number.isFinite(finalTime) ? finalTime : total);
|
|
438
|
+
});
|
|
439
|
+
localReplayer.on?.("resize", (payload: unknown) => {
|
|
440
|
+
const dims = payload as { width?: unknown; height?: unknown };
|
|
441
|
+
if (typeof dims.width === "number" && typeof dims.height === "number") {
|
|
442
|
+
setStreamedDims({ width: dims.width, height: dims.height });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
updateTime(0);
|
|
241
446
|
setStatus("ready");
|
|
447
|
+
try {
|
|
448
|
+
localReplayer.play?.(0);
|
|
449
|
+
setPlaying(true);
|
|
450
|
+
} catch (autoplayError) {
|
|
451
|
+
console.warn("[session-replay] autoplay failed", autoplayError);
|
|
452
|
+
try {
|
|
453
|
+
localReplayer.pause?.(0);
|
|
454
|
+
} catch {
|
|
455
|
+
// Some rrweb versions only render after play; the first click still works.
|
|
456
|
+
}
|
|
457
|
+
setPlaying(false);
|
|
458
|
+
}
|
|
242
459
|
}
|
|
243
460
|
|
|
244
461
|
void loadReplay().catch((loadError: any) => {
|
|
245
462
|
if (cancelled) return;
|
|
246
463
|
setError(loadError?.message || String(loadError));
|
|
247
464
|
setStatus("error");
|
|
465
|
+
setPlaying(false);
|
|
248
466
|
});
|
|
249
467
|
|
|
250
468
|
return () => {
|
|
251
469
|
cancelled = true;
|
|
470
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
471
|
+
rafRef.current = null;
|
|
252
472
|
try {
|
|
473
|
+
localReplayer?.pause?.();
|
|
253
474
|
localReplayer?.destroy?.();
|
|
254
475
|
replayerRef.current?.destroy?.();
|
|
255
476
|
} catch {
|
|
256
477
|
// rrweb cleanup is best-effort across versions.
|
|
257
478
|
}
|
|
258
479
|
replayerRef.current = null;
|
|
480
|
+
if (stageRootRef.current) stageRootRef.current.innerHTML = "";
|
|
259
481
|
};
|
|
260
|
-
}, [events, t]);
|
|
482
|
+
}, [events, t, updateTime]);
|
|
483
|
+
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (!playing || status !== "ready") {
|
|
486
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
487
|
+
rafRef.current = null;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const tick = () => {
|
|
492
|
+
const replayer = replayerRef.current;
|
|
493
|
+
if (replayer) {
|
|
494
|
+
let nextTime = Number(
|
|
495
|
+
replayer.getCurrentTime?.() ?? currentTimeRef.current,
|
|
496
|
+
);
|
|
497
|
+
if (skipInactiveRef.current) {
|
|
498
|
+
const range = skipRangesRef.current.find(
|
|
499
|
+
(candidate) =>
|
|
500
|
+
nextTime >= candidate.startMs && nextTime < candidate.endMs - 50,
|
|
501
|
+
);
|
|
502
|
+
if (range) {
|
|
503
|
+
try {
|
|
504
|
+
replayer.play(range.endMs);
|
|
505
|
+
nextTime = range.endMs;
|
|
506
|
+
} catch (skipError) {
|
|
507
|
+
console.warn(
|
|
508
|
+
"[session-replay] skip inactivity failed",
|
|
509
|
+
skipError,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (Number.isFinite(nextTime)) updateTime(nextTime);
|
|
515
|
+
}
|
|
516
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
520
|
+
return () => {
|
|
521
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
522
|
+
rafRef.current = null;
|
|
523
|
+
};
|
|
524
|
+
}, [
|
|
525
|
+
currentTimeRef,
|
|
526
|
+
playing,
|
|
527
|
+
skipInactiveRef,
|
|
528
|
+
skipRangesRef,
|
|
529
|
+
status,
|
|
530
|
+
updateTime,
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
function togglePlay() {
|
|
534
|
+
if (status !== "ready") return;
|
|
535
|
+
const replayer = replayerRef.current;
|
|
536
|
+
if (!replayer) return;
|
|
537
|
+
if (playing) {
|
|
538
|
+
try {
|
|
539
|
+
replayer.pause();
|
|
540
|
+
} catch {
|
|
541
|
+
// Ignore transient rrweb pause errors.
|
|
542
|
+
}
|
|
543
|
+
setPlaying(false);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const restart = totalTime > 0 && currentTime >= totalTime - 50;
|
|
547
|
+
const startAt = restart ? 0 : currentTime;
|
|
548
|
+
try {
|
|
549
|
+
replayer.play(startAt);
|
|
550
|
+
updateTime(startAt);
|
|
551
|
+
setPlaying(true);
|
|
552
|
+
} catch (playError) {
|
|
553
|
+
setError(
|
|
554
|
+
playError instanceof Error ? playError.message : String(playError),
|
|
555
|
+
);
|
|
556
|
+
setStatus("error");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function updateSpeed(next: number) {
|
|
561
|
+
setSpeed(next);
|
|
562
|
+
try {
|
|
563
|
+
replayerRef.current?.setConfig?.({ speed: next });
|
|
564
|
+
} catch {
|
|
565
|
+
// Older rrweb builds may not expose setConfig; new sessions still use it.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const disabled = status !== "ready";
|
|
261
570
|
|
|
262
571
|
return (
|
|
263
|
-
<
|
|
264
|
-
<
|
|
265
|
-
<
|
|
266
|
-
<div>
|
|
267
|
-
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
|
|
572
|
+
<TooltipProvider>
|
|
573
|
+
<Card className="flex min-h-0 overflow-hidden">
|
|
574
|
+
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
|
|
575
|
+
<div className="flex min-h-0 flex-1 flex-col bg-muted/20 p-2">
|
|
576
|
+
{currentUrl ? (
|
|
577
|
+
<div
|
|
578
|
+
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"
|
|
579
|
+
title={currentUrl}
|
|
580
|
+
>
|
|
581
|
+
<span className="truncate">{currentUrl}</span>
|
|
582
|
+
</div>
|
|
583
|
+
) : null}
|
|
584
|
+
<div
|
|
585
|
+
ref={stageAreaRef}
|
|
586
|
+
className={cn(
|
|
587
|
+
"relative min-h-[320px] flex-1 overflow-hidden border bg-white dark:bg-zinc-950",
|
|
588
|
+
currentUrl ? "rounded-b-md" : "rounded-md",
|
|
589
|
+
)}
|
|
590
|
+
>
|
|
591
|
+
<div
|
|
592
|
+
ref={stageRootRef}
|
|
593
|
+
className="an-replay-stage-root absolute left-1/2 top-1/2"
|
|
594
|
+
style={{
|
|
595
|
+
width: playerWidth,
|
|
596
|
+
height: playerHeight,
|
|
597
|
+
transform: `translate(-50%, -50%) scale(${fitScale})`,
|
|
598
|
+
transformOrigin: "center center",
|
|
599
|
+
}}
|
|
600
|
+
/>
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
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"
|
|
604
|
+
disabled={disabled}
|
|
605
|
+
aria-label={playing ? t("sessions.pause") : t("sessions.play")}
|
|
606
|
+
onClick={togglePlay}
|
|
607
|
+
/>
|
|
608
|
+
{status === "loading" ? (
|
|
609
|
+
<div className="absolute inset-0 z-30 grid place-items-center bg-background/70 text-sm text-muted-foreground">
|
|
610
|
+
{t("sessions.replayLoading")}
|
|
611
|
+
</div>
|
|
612
|
+
) : null}
|
|
613
|
+
{status === "error" && error ? (
|
|
614
|
+
<div className="absolute inset-0 z-30 grid place-items-center bg-background/85 p-6 text-center text-sm text-destructive">
|
|
615
|
+
{t("sessions.loadFailed", { message: error })}
|
|
616
|
+
</div>
|
|
617
|
+
) : null}
|
|
618
|
+
</div>
|
|
278
619
|
</div>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
onClick={() => {
|
|
286
|
-
replayerRef.current?.pause?.();
|
|
287
|
-
replayerRef.current?.play?.(0);
|
|
288
|
-
}}
|
|
620
|
+
|
|
621
|
+
<div className="flex shrink-0 flex-wrap items-center gap-2 border-t px-3 py-2">
|
|
622
|
+
<ReplayIconButton
|
|
623
|
+
label={t("sessions.skipBack")}
|
|
624
|
+
disabled={disabled}
|
|
625
|
+
onClick={() => seek(currentTime - SKIP_STEP_MS)}
|
|
289
626
|
>
|
|
290
627
|
<IconPlayerSkipBack className="h-4 w-4" />
|
|
291
|
-
|
|
292
|
-
</Button>
|
|
628
|
+
</ReplayIconButton>
|
|
293
629
|
<Button
|
|
294
630
|
type="button"
|
|
295
|
-
variant="outline"
|
|
296
631
|
size="icon"
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
632
|
+
disabled={disabled}
|
|
633
|
+
onClick={togglePlay}
|
|
634
|
+
aria-label={playing ? t("sessions.pause") : t("sessions.play")}
|
|
635
|
+
className="h-8 w-8"
|
|
300
636
|
>
|
|
301
|
-
|
|
637
|
+
{playing ? (
|
|
638
|
+
<IconPlayerPause className="h-4 w-4" />
|
|
639
|
+
) : (
|
|
640
|
+
<IconPlayerPlay className="h-4 w-4" />
|
|
641
|
+
)}
|
|
302
642
|
</Button>
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
aria-label={t("sessions.pause")}
|
|
308
|
-
disabled={status !== "ready"}
|
|
309
|
-
onClick={() => replayerRef.current?.pause?.()}
|
|
643
|
+
<ReplayIconButton
|
|
644
|
+
label={t("sessions.skipForward")}
|
|
645
|
+
disabled={disabled}
|
|
646
|
+
onClick={() => seek(currentTime + SKIP_STEP_MS)}
|
|
310
647
|
>
|
|
311
|
-
<
|
|
312
|
-
</
|
|
648
|
+
<IconPlayerSkipForward className="h-4 w-4" />
|
|
649
|
+
</ReplayIconButton>
|
|
650
|
+
|
|
651
|
+
<span className="w-12 text-center font-mono text-xs text-muted-foreground">
|
|
652
|
+
{formatClock(currentTime)}
|
|
653
|
+
</span>
|
|
654
|
+
<ReplayScrubber
|
|
655
|
+
currentTime={currentTime}
|
|
656
|
+
totalTime={totalTime}
|
|
657
|
+
markers={markers}
|
|
658
|
+
skipRanges={skipRanges}
|
|
659
|
+
skipInactive={skipInactive}
|
|
660
|
+
disabled={disabled}
|
|
661
|
+
onSeek={(ms) => seek(ms)}
|
|
662
|
+
/>
|
|
663
|
+
<span className="w-12 text-center font-mono text-xs text-muted-foreground">
|
|
664
|
+
{formatClock(totalTime)}
|
|
665
|
+
</span>
|
|
666
|
+
|
|
667
|
+
<div className="flex items-center rounded-md bg-muted p-1">
|
|
668
|
+
{SPEED_OPTIONS.map((option) => (
|
|
669
|
+
<button
|
|
670
|
+
key={option}
|
|
671
|
+
type="button"
|
|
672
|
+
className={cn(
|
|
673
|
+
"rounded px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
|
|
674
|
+
speed === option &&
|
|
675
|
+
"bg-background text-foreground shadow-sm",
|
|
676
|
+
)}
|
|
677
|
+
onClick={() => updateSpeed(option)}
|
|
678
|
+
>
|
|
679
|
+
{option}x
|
|
680
|
+
</button>
|
|
681
|
+
))}
|
|
682
|
+
</div>
|
|
683
|
+
|
|
684
|
+
<Tooltip>
|
|
685
|
+
<TooltipTrigger asChild>
|
|
686
|
+
<button
|
|
687
|
+
type="button"
|
|
688
|
+
className={cn(
|
|
689
|
+
"inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors hover:bg-muted",
|
|
690
|
+
skipInactive &&
|
|
691
|
+
"border-primary/40 bg-primary/10 text-primary",
|
|
692
|
+
)}
|
|
693
|
+
onClick={() => setSkipInactive((value) => !value)}
|
|
694
|
+
aria-pressed={skipInactive}
|
|
695
|
+
>
|
|
696
|
+
<IconPlayerTrackNext className="h-4 w-4" />
|
|
697
|
+
{t("sessions.skipInactive")}
|
|
698
|
+
</button>
|
|
699
|
+
</TooltipTrigger>
|
|
700
|
+
<TooltipContent>
|
|
701
|
+
{skipInactive
|
|
702
|
+
? t("sessions.skipInactiveOn")
|
|
703
|
+
: t("sessions.skipInactiveOff")}
|
|
704
|
+
</TooltipContent>
|
|
705
|
+
</Tooltip>
|
|
706
|
+
<span className="ms-auto hidden text-xs text-muted-foreground lg:inline">
|
|
707
|
+
{t("sessions.replayEventCount", {
|
|
708
|
+
events: String(response.eventCount),
|
|
709
|
+
})}
|
|
710
|
+
{response.truncated ? ` ${t("sessions.truncated")}` : ""}
|
|
711
|
+
</span>
|
|
313
712
|
</div>
|
|
314
|
-
|
|
315
|
-
</CardHeader>
|
|
316
|
-
<CardContent>
|
|
317
|
-
<div className="space-y-3">
|
|
318
|
-
<div
|
|
319
|
-
ref={rootRef}
|
|
320
|
-
className="min-h-[420px] overflow-hidden rounded-md border bg-muted/20"
|
|
321
|
-
/>
|
|
322
|
-
{status === "loading" ? (
|
|
323
|
-
<p className="text-sm text-muted-foreground">
|
|
324
|
-
{t("sessions.replayLoading")}
|
|
325
|
-
</p>
|
|
326
|
-
) : null}
|
|
327
|
-
{status === "error" && error ? (
|
|
328
|
-
<p className="text-sm text-destructive">
|
|
329
|
-
{t("sessions.loadFailed", { message: error })}
|
|
330
|
-
</p>
|
|
331
|
-
) : null}
|
|
713
|
+
|
|
332
714
|
{response.unavailableChunks > 0 ? (
|
|
333
|
-
<
|
|
715
|
+
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
|
|
334
716
|
{t("sessions.unavailableChunks", {
|
|
335
717
|
count: String(response.unavailableChunks),
|
|
336
718
|
})}
|
|
337
|
-
</
|
|
719
|
+
</div>
|
|
338
720
|
) : null}
|
|
339
|
-
</
|
|
340
|
-
</
|
|
341
|
-
</
|
|
721
|
+
</CardContent>
|
|
722
|
+
</Card>
|
|
723
|
+
</TooltipProvider>
|
|
342
724
|
);
|
|
343
725
|
}
|
|
344
726
|
|
|
345
|
-
function
|
|
346
|
-
|
|
727
|
+
function ReplayScrubber({
|
|
728
|
+
currentTime,
|
|
729
|
+
totalTime,
|
|
730
|
+
markers,
|
|
731
|
+
skipRanges,
|
|
732
|
+
skipInactive,
|
|
733
|
+
disabled,
|
|
734
|
+
onSeek,
|
|
347
735
|
}: {
|
|
348
|
-
|
|
736
|
+
currentTime: number;
|
|
737
|
+
totalTime: number;
|
|
738
|
+
markers: ReplayMarker[];
|
|
739
|
+
skipRanges: SkipRange[];
|
|
740
|
+
skipInactive: boolean;
|
|
741
|
+
disabled: boolean;
|
|
742
|
+
onSeek: (ms: number) => void;
|
|
349
743
|
}) {
|
|
350
744
|
const t = useT();
|
|
351
745
|
return (
|
|
352
|
-
<
|
|
353
|
-
<
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
<
|
|
373
|
-
{
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
746
|
+
<div className="relative min-h-8 min-w-[180px] flex-1">
|
|
747
|
+
<input
|
|
748
|
+
type="range"
|
|
749
|
+
className="an-replay-scrub absolute left-0 top-1/2 z-20 w-full -translate-y-1/2"
|
|
750
|
+
min={0}
|
|
751
|
+
max={Math.max(0, totalTime)}
|
|
752
|
+
step={50}
|
|
753
|
+
value={Math.min(currentTime, totalTime)}
|
|
754
|
+
disabled={disabled}
|
|
755
|
+
onChange={(event) => onSeek(Number(event.target.value))}
|
|
756
|
+
aria-label={t("sessions.replayTimeline")}
|
|
757
|
+
/>
|
|
758
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-2 -translate-y-1/2 overflow-hidden rounded-full">
|
|
759
|
+
{skipRanges.map((range) => {
|
|
760
|
+
const left = totalTime > 0 ? (range.startMs / totalTime) * 100 : 0;
|
|
761
|
+
const width =
|
|
762
|
+
totalTime > 0
|
|
763
|
+
? ((range.endMs - range.startMs) / totalTime) * 100
|
|
764
|
+
: 0;
|
|
765
|
+
return (
|
|
766
|
+
<span
|
|
767
|
+
key={`${range.startMs}-${range.endMs}`}
|
|
768
|
+
className={cn(
|
|
769
|
+
"absolute top-0 h-full rounded-full bg-muted-foreground transition-opacity",
|
|
770
|
+
skipInactive ? "opacity-30" : "opacity-10",
|
|
771
|
+
)}
|
|
772
|
+
style={{ left: `${left}%`, width: `${width}%` }}
|
|
773
|
+
/>
|
|
774
|
+
);
|
|
775
|
+
})}
|
|
776
|
+
</div>
|
|
777
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-30 h-5 -translate-y-1/2">
|
|
778
|
+
{markers.slice(0, 200).map((marker) => {
|
|
779
|
+
const left = totalTime > 0 ? (marker.offsetMs / totalTime) * 100 : 0;
|
|
780
|
+
return (
|
|
781
|
+
<span
|
|
782
|
+
key={marker.id}
|
|
783
|
+
className={cn(
|
|
784
|
+
"absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-background",
|
|
785
|
+
marker.kind === "navigation"
|
|
786
|
+
? "bg-amber-500"
|
|
787
|
+
: marker.kind === "input"
|
|
788
|
+
? "bg-emerald-500"
|
|
789
|
+
: marker.kind === "click"
|
|
790
|
+
? "bg-sky-500"
|
|
791
|
+
: "bg-violet-500",
|
|
792
|
+
)}
|
|
793
|
+
style={{ left: `${left}%` }}
|
|
794
|
+
/>
|
|
795
|
+
);
|
|
796
|
+
})}
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function ReplayIconButton({
|
|
803
|
+
label,
|
|
804
|
+
disabled,
|
|
805
|
+
onClick,
|
|
806
|
+
children,
|
|
807
|
+
}: {
|
|
808
|
+
label: string;
|
|
809
|
+
disabled: boolean;
|
|
810
|
+
onClick: () => void;
|
|
811
|
+
children: ReactNode;
|
|
812
|
+
}) {
|
|
813
|
+
return (
|
|
814
|
+
<Tooltip>
|
|
815
|
+
<TooltipTrigger asChild>
|
|
816
|
+
<Button
|
|
817
|
+
type="button"
|
|
818
|
+
variant="outline"
|
|
819
|
+
size="icon"
|
|
820
|
+
className="h-8 w-8"
|
|
821
|
+
aria-label={label}
|
|
822
|
+
disabled={disabled}
|
|
823
|
+
onClick={onClick}
|
|
824
|
+
>
|
|
825
|
+
{children}
|
|
826
|
+
</Button>
|
|
827
|
+
</TooltipTrigger>
|
|
828
|
+
<TooltipContent>{label}</TooltipContent>
|
|
829
|
+
</Tooltip>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function ReplayTimeline({
|
|
834
|
+
markers,
|
|
835
|
+
activeMarkerId,
|
|
836
|
+
onSeek,
|
|
837
|
+
}: {
|
|
838
|
+
markers: ReplayMarker[];
|
|
839
|
+
activeMarkerId: string | null;
|
|
840
|
+
onSeek: (ms: number) => void;
|
|
841
|
+
}) {
|
|
842
|
+
const t = useT();
|
|
843
|
+
const visibleMarkers = markers.slice(0, 120);
|
|
844
|
+
return (
|
|
845
|
+
<Card className="flex min-h-0 overflow-hidden">
|
|
846
|
+
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
|
|
847
|
+
<div className="shrink-0 border-b px-3 py-2">
|
|
848
|
+
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
849
|
+
<IconTimelineEvent className="h-4 w-4" />
|
|
850
|
+
{t("sessions.timeline")}
|
|
851
|
+
</div>
|
|
852
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
853
|
+
{visibleMarkers.length
|
|
854
|
+
? t("sessions.timelineDescription", {
|
|
855
|
+
count: String(visibleMarkers.length),
|
|
856
|
+
})
|
|
857
|
+
: t("sessions.noTimelineEvents")}
|
|
858
|
+
</p>
|
|
859
|
+
</div>
|
|
860
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
861
|
+
{visibleMarkers.length ? (
|
|
862
|
+
<div className="divide-y">
|
|
863
|
+
{visibleMarkers.map((marker) => (
|
|
864
|
+
<button
|
|
865
|
+
key={marker.id}
|
|
866
|
+
type="button"
|
|
867
|
+
className={cn(
|
|
868
|
+
"flex w-full gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-muted/50",
|
|
869
|
+
marker.id === activeMarkerId && "bg-muted",
|
|
870
|
+
)}
|
|
871
|
+
onClick={() => onSeek(marker.offsetMs)}
|
|
872
|
+
>
|
|
873
|
+
<span
|
|
874
|
+
className={cn(
|
|
875
|
+
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border",
|
|
876
|
+
marker.kind === "navigation" &&
|
|
877
|
+
"border-amber-500/35 bg-amber-500/10 text-amber-500",
|
|
878
|
+
marker.kind === "input" &&
|
|
879
|
+
"border-emerald-500/35 bg-emerald-500/10 text-emerald-500",
|
|
880
|
+
marker.kind === "click" &&
|
|
881
|
+
"border-sky-500/35 bg-sky-500/10 text-sky-500",
|
|
882
|
+
marker.kind === "custom" &&
|
|
883
|
+
"border-violet-500/35 bg-violet-500/10 text-violet-500",
|
|
884
|
+
)}
|
|
885
|
+
>
|
|
886
|
+
<MarkerIcon kind={marker.kind} />
|
|
887
|
+
</span>
|
|
888
|
+
<span className="min-w-0 flex-1">
|
|
889
|
+
<span className="flex items-center justify-between gap-3">
|
|
890
|
+
<span className="truncate text-sm font-medium">
|
|
891
|
+
{marker.label}
|
|
892
|
+
</span>
|
|
893
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
894
|
+
{formatClock(marker.offsetMs)}
|
|
895
|
+
</span>
|
|
896
|
+
</span>
|
|
897
|
+
{marker.detail ? (
|
|
898
|
+
<span className="mt-1 block truncate text-xs text-muted-foreground">
|
|
899
|
+
{marker.detail}
|
|
900
|
+
</span>
|
|
901
|
+
) : null}
|
|
902
|
+
</span>
|
|
903
|
+
</button>
|
|
384
904
|
))}
|
|
385
|
-
</
|
|
386
|
-
|
|
905
|
+
</div>
|
|
906
|
+
) : (
|
|
907
|
+
<div className="p-4 text-sm text-muted-foreground">
|
|
908
|
+
{t("sessions.noTimelineEvents")}
|
|
909
|
+
</div>
|
|
910
|
+
)}
|
|
387
911
|
</div>
|
|
388
912
|
</CardContent>
|
|
389
913
|
</Card>
|
|
390
914
|
);
|
|
391
915
|
}
|
|
392
916
|
|
|
917
|
+
function MarkerIcon({ kind }: { kind: ReplayMarker["kind"] }) {
|
|
918
|
+
if (kind === "navigation") return <IconRoute className="h-4 w-4" />;
|
|
919
|
+
if (kind === "input") return <IconKeyboard className="h-4 w-4" />;
|
|
920
|
+
if (kind === "click") return <IconMouse className="h-4 w-4" />;
|
|
921
|
+
return <IconTimelineEvent className="h-4 w-4" />;
|
|
922
|
+
}
|
|
923
|
+
|
|
393
924
|
function DetailSkeleton() {
|
|
394
925
|
return (
|
|
395
|
-
<div className="
|
|
396
|
-
<
|
|
397
|
-
|
|
398
|
-
<Skeleton key={index} className="h-24 w-full" />
|
|
399
|
-
))}
|
|
400
|
-
</div>
|
|
401
|
-
<Skeleton className="h-[520px] w-full" />
|
|
926
|
+
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[minmax(0,1fr)_330px]">
|
|
927
|
+
<Skeleton className="h-full min-h-[420px] w-full" />
|
|
928
|
+
<Skeleton className="h-full min-h-[420px] w-full" />
|
|
402
929
|
</div>
|
|
403
930
|
);
|
|
404
931
|
}
|
|
405
932
|
|
|
933
|
+
function useReplayEvents(
|
|
934
|
+
response: SessionReplayEventsResponse,
|
|
935
|
+
): AnyReplayEvent[] {
|
|
936
|
+
return useMemo(
|
|
937
|
+
() =>
|
|
938
|
+
sanitizeReplayEvents(
|
|
939
|
+
response.chunks
|
|
940
|
+
.flatMap((chunk) => chunk.events)
|
|
941
|
+
.filter((event) => event && typeof event === "object"),
|
|
942
|
+
),
|
|
943
|
+
[response.chunks],
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export function sanitizeReplayEvents(events: unknown[]): AnyReplayEvent[] {
|
|
948
|
+
return events
|
|
949
|
+
.map((event) => sanitizeReplayEvent(event))
|
|
950
|
+
.filter((event): event is AnyReplayEvent => Boolean(event))
|
|
951
|
+
.sort((a, b) => Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function sanitizeReplayEvent(event: unknown): AnyReplayEvent | null {
|
|
955
|
+
if (!isRecord(event)) return null;
|
|
956
|
+
let copy: AnyReplayEvent;
|
|
957
|
+
try {
|
|
958
|
+
copy = JSON.parse(JSON.stringify(event));
|
|
959
|
+
} catch {
|
|
960
|
+
copy = { ...event };
|
|
961
|
+
}
|
|
962
|
+
if (copy.type === RRWEB_EVENT_TYPE.FullSnapshot && isRecord(copy.data)) {
|
|
963
|
+
const node = sanitizeSerializedNode(copy.data.node);
|
|
964
|
+
if (!node) return null;
|
|
965
|
+
copy.data.node = node;
|
|
966
|
+
}
|
|
967
|
+
if (
|
|
968
|
+
copy.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
|
|
969
|
+
copy.data?.source === INCREMENTAL_SOURCE.Mutation &&
|
|
970
|
+
isRecord(copy.data)
|
|
971
|
+
) {
|
|
972
|
+
copy.data = sanitizeMutationData(copy.data);
|
|
973
|
+
}
|
|
974
|
+
return copy;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function sanitizeMutationData(data: AnyRecord): AnyRecord {
|
|
978
|
+
const next = { ...data };
|
|
979
|
+
if (Array.isArray(next.adds)) {
|
|
980
|
+
next.adds = next.adds
|
|
981
|
+
.map((add) => {
|
|
982
|
+
if (!isRecord(add)) return add;
|
|
983
|
+
const node = sanitizeSerializedNode(add.node);
|
|
984
|
+
if (!node) return null;
|
|
985
|
+
return { ...add, node };
|
|
986
|
+
})
|
|
987
|
+
.filter(Boolean);
|
|
988
|
+
}
|
|
989
|
+
if (Array.isArray(next.attributes)) {
|
|
990
|
+
next.attributes = next.attributes.map((attributeMutation) => {
|
|
991
|
+
if (
|
|
992
|
+
!isRecord(attributeMutation) ||
|
|
993
|
+
!isRecord(attributeMutation.attributes)
|
|
994
|
+
) {
|
|
995
|
+
return attributeMutation;
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
...attributeMutation,
|
|
999
|
+
attributes: sanitizeAttributes(attributeMutation.attributes),
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
if (Array.isArray(next.texts)) {
|
|
1004
|
+
next.texts = next.texts.map((textMutation) => {
|
|
1005
|
+
if (!isRecord(textMutation)) return textMutation;
|
|
1006
|
+
const copy = { ...textMutation };
|
|
1007
|
+
if (
|
|
1008
|
+
typeof copy.value === "string" &&
|
|
1009
|
+
containsStylesheetNetworkLoad(copy.value)
|
|
1010
|
+
) {
|
|
1011
|
+
copy.value = "";
|
|
1012
|
+
}
|
|
1013
|
+
if (
|
|
1014
|
+
typeof copy.textContent === "string" &&
|
|
1015
|
+
containsStylesheetNetworkLoad(copy.textContent)
|
|
1016
|
+
) {
|
|
1017
|
+
copy.textContent = "";
|
|
1018
|
+
}
|
|
1019
|
+
return copy;
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
return next;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function sanitizeSerializedNode(node: unknown): AnyRecord | null {
|
|
1026
|
+
if (!isRecord(node)) return node as AnyRecord;
|
|
1027
|
+
const next: AnyRecord = { ...node };
|
|
1028
|
+
const tagName =
|
|
1029
|
+
typeof next.tagName === "string" ? next.tagName.toLowerCase() : "";
|
|
1030
|
+
if (next.type === 2 && tagName === "script") {
|
|
1031
|
+
return {
|
|
1032
|
+
...next,
|
|
1033
|
+
tagName: "noscript",
|
|
1034
|
+
attributes: {},
|
|
1035
|
+
childNodes: [],
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
if (next.type === 2 && tagName === "style") {
|
|
1039
|
+
return {
|
|
1040
|
+
...next,
|
|
1041
|
+
attributes: isRecord(next.attributes)
|
|
1042
|
+
? sanitizeAttributes(next.attributes)
|
|
1043
|
+
: {},
|
|
1044
|
+
childNodes: [],
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
if (
|
|
1048
|
+
next.type === 3 &&
|
|
1049
|
+
typeof next.textContent === "string" &&
|
|
1050
|
+
containsStylesheetNetworkLoad(next.textContent)
|
|
1051
|
+
) {
|
|
1052
|
+
next.textContent = "";
|
|
1053
|
+
}
|
|
1054
|
+
if (
|
|
1055
|
+
next.type === 2 &&
|
|
1056
|
+
tagName === "link" &&
|
|
1057
|
+
isScriptLikeLink(next.attributes)
|
|
1058
|
+
) {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
if (isRecord(next.attributes)) {
|
|
1062
|
+
next.attributes = sanitizeAttributes(next.attributes);
|
|
1063
|
+
}
|
|
1064
|
+
if (Array.isArray(next.childNodes)) {
|
|
1065
|
+
next.childNodes = next.childNodes
|
|
1066
|
+
.map((child) => sanitizeSerializedNode(child))
|
|
1067
|
+
.filter(Boolean);
|
|
1068
|
+
}
|
|
1069
|
+
return next;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function containsStylesheetNetworkLoad(value: string): boolean {
|
|
1073
|
+
return /@import\b/i.test(value) || /\burl\s*\(/i.test(value);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function sanitizeAttributes(attributes: AnyRecord): AnyRecord {
|
|
1077
|
+
const next: AnyRecord = {};
|
|
1078
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
1079
|
+
const normalized = key.toLowerCase();
|
|
1080
|
+
if (normalized.startsWith("on")) continue;
|
|
1081
|
+
if (normalized === "srcdoc") continue;
|
|
1082
|
+
if (isReplayResourceAttribute(normalized)) continue;
|
|
1083
|
+
if (normalized === "style" && /\burl\s*\(/i.test(String(value))) continue;
|
|
1084
|
+
next[key] = value;
|
|
1085
|
+
}
|
|
1086
|
+
return next;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function isReplayResourceAttribute(name: string): boolean {
|
|
1090
|
+
return (
|
|
1091
|
+
name === "src" ||
|
|
1092
|
+
name === "srcset" ||
|
|
1093
|
+
name === "href" ||
|
|
1094
|
+
name === "xlink:href" ||
|
|
1095
|
+
name === "poster" ||
|
|
1096
|
+
name === "data" ||
|
|
1097
|
+
name === "action" ||
|
|
1098
|
+
name === "formaction" ||
|
|
1099
|
+
name === "background" ||
|
|
1100
|
+
name === "cite"
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function isScriptLikeLink(attributes: unknown): boolean {
|
|
1105
|
+
if (!isRecord(attributes)) return false;
|
|
1106
|
+
const rel = String(attributes.rel ?? "").toLowerCase();
|
|
1107
|
+
const as = String(attributes.as ?? "").toLowerCase();
|
|
1108
|
+
const href = String(attributes.href ?? "").toLowerCase();
|
|
1109
|
+
return (
|
|
1110
|
+
rel === "modulepreload" ||
|
|
1111
|
+
(rel === "preload" && as === "script") ||
|
|
1112
|
+
(rel === "prefetch" && href.endsWith(".js"))
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function buildReplayMarkers(events: AnyReplayEvent[]): ReplayMarker[] {
|
|
1117
|
+
const startedAt = replayStartedAt(events);
|
|
1118
|
+
const markers: ReplayMarker[] = [];
|
|
1119
|
+
for (const event of events) {
|
|
1120
|
+
const timestamp = Number(event.timestamp ?? 0);
|
|
1121
|
+
if (!Number.isFinite(timestamp) || timestamp <= 0) continue;
|
|
1122
|
+
if (
|
|
1123
|
+
event.type === RRWEB_EVENT_TYPE.Meta &&
|
|
1124
|
+
typeof event.data?.href === "string"
|
|
1125
|
+
) {
|
|
1126
|
+
const href = event.data.href;
|
|
1127
|
+
markers.push({
|
|
1128
|
+
id: `nav-${timestamp}-${markers.length}`,
|
|
1129
|
+
timestamp,
|
|
1130
|
+
offsetMs: Math.max(0, timestamp - startedAt),
|
|
1131
|
+
kind: "navigation",
|
|
1132
|
+
label: pathLabel(href),
|
|
1133
|
+
detail: href,
|
|
1134
|
+
});
|
|
1135
|
+
} else if (
|
|
1136
|
+
event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
|
|
1137
|
+
event.data?.source === INCREMENTAL_SOURCE.Input
|
|
1138
|
+
) {
|
|
1139
|
+
markers.push({
|
|
1140
|
+
id: `input-${timestamp}-${markers.length}`,
|
|
1141
|
+
timestamp,
|
|
1142
|
+
offsetMs: Math.max(0, timestamp - startedAt),
|
|
1143
|
+
kind: "input",
|
|
1144
|
+
label: "Input",
|
|
1145
|
+
});
|
|
1146
|
+
} else if (
|
|
1147
|
+
event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
|
|
1148
|
+
event.data?.source === INCREMENTAL_SOURCE.MouseInteraction &&
|
|
1149
|
+
(event.data?.type === MOUSE_INTERACTION.Click ||
|
|
1150
|
+
event.data?.type === MOUSE_INTERACTION.DblClick ||
|
|
1151
|
+
event.data?.type === MOUSE_INTERACTION.Focus)
|
|
1152
|
+
) {
|
|
1153
|
+
markers.push({
|
|
1154
|
+
id: `click-${timestamp}-${markers.length}`,
|
|
1155
|
+
timestamp,
|
|
1156
|
+
offsetMs: Math.max(0, timestamp - startedAt),
|
|
1157
|
+
kind: "click",
|
|
1158
|
+
label: event.data?.type === MOUSE_INTERACTION.Focus ? "Focus" : "Click",
|
|
1159
|
+
});
|
|
1160
|
+
} else if (event.type === RRWEB_EVENT_TYPE.Custom) {
|
|
1161
|
+
markers.push({
|
|
1162
|
+
id: `custom-${timestamp}-${markers.length}`,
|
|
1163
|
+
timestamp,
|
|
1164
|
+
offsetMs: Math.max(0, timestamp - startedAt),
|
|
1165
|
+
kind: "custom",
|
|
1166
|
+
label: String(event.data?.tag ?? "Custom event"),
|
|
1167
|
+
detail:
|
|
1168
|
+
typeof event.data?.payload?.message === "string"
|
|
1169
|
+
? event.data.payload.message
|
|
1170
|
+
: undefined,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return markers.sort((a, b) => a.offsetMs - b.offsetMs);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function buildIdleSkipRanges(events: AnyReplayEvent[]): SkipRange[] {
|
|
1178
|
+
const startedAt = replayStartedAt(events);
|
|
1179
|
+
const interactions: number[] = [];
|
|
1180
|
+
let lastTimestamp = startedAt;
|
|
1181
|
+
for (const event of events) {
|
|
1182
|
+
const timestamp = Number(event.timestamp ?? 0);
|
|
1183
|
+
if (!Number.isFinite(timestamp) || timestamp <= 0) continue;
|
|
1184
|
+
lastTimestamp = Math.max(lastTimestamp, timestamp);
|
|
1185
|
+
if (event.type === RRWEB_EVENT_TYPE.Meta) {
|
|
1186
|
+
interactions.push(timestamp);
|
|
1187
|
+
} else if (
|
|
1188
|
+
event.type === RRWEB_EVENT_TYPE.IncrementalSnapshot &&
|
|
1189
|
+
typeof event.data?.source === "number" &&
|
|
1190
|
+
INTERACTION_SOURCES.has(event.data.source)
|
|
1191
|
+
) {
|
|
1192
|
+
interactions.push(timestamp);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
interactions.sort((a, b) => a - b);
|
|
1196
|
+
const ranges: SkipRange[] = [];
|
|
1197
|
+
for (let index = 1; index < interactions.length; index += 1) {
|
|
1198
|
+
pushIdleRange(
|
|
1199
|
+
ranges,
|
|
1200
|
+
interactions[index - 1],
|
|
1201
|
+
interactions[index],
|
|
1202
|
+
startedAt,
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
if (interactions.length) {
|
|
1206
|
+
pushIdleRange(
|
|
1207
|
+
ranges,
|
|
1208
|
+
interactions[interactions.length - 1],
|
|
1209
|
+
lastTimestamp,
|
|
1210
|
+
startedAt,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
return ranges;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function pushIdleRange(
|
|
1217
|
+
ranges: SkipRange[],
|
|
1218
|
+
fromTs: number,
|
|
1219
|
+
toTs: number,
|
|
1220
|
+
startedAt: number,
|
|
1221
|
+
) {
|
|
1222
|
+
if (toTs - fromTs < MIN_IDLE_SKIP_MS) return;
|
|
1223
|
+
const startMs = Math.max(0, fromTs - startedAt + IDLE_EDGE_PAD_MS);
|
|
1224
|
+
const endMs = Math.max(0, toTs - startedAt - IDLE_EDGE_PAD_MS);
|
|
1225
|
+
if (endMs - startMs >= MIN_IDLE_SKIP_MS - IDLE_EDGE_PAD_MS * 2) {
|
|
1226
|
+
ranges.push({ startMs, endMs });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function currentUrlAt(events: AnyReplayEvent[], currentTime: number): string {
|
|
1231
|
+
const startedAt = replayStartedAt(events);
|
|
1232
|
+
let current = "";
|
|
1233
|
+
for (const event of events) {
|
|
1234
|
+
if (event.type !== RRWEB_EVENT_TYPE.Meta) continue;
|
|
1235
|
+
if (typeof event.data?.href !== "string") continue;
|
|
1236
|
+
const offset = Number(event.timestamp ?? 0) - startedAt;
|
|
1237
|
+
if (offset <= currentTime + 50) current = event.data.href;
|
|
1238
|
+
else break;
|
|
1239
|
+
}
|
|
1240
|
+
return current;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function replayStartedAt(events: AnyReplayEvent[]): number {
|
|
1244
|
+
const first = events.find((event) =>
|
|
1245
|
+
Number.isFinite(Number(event.timestamp)),
|
|
1246
|
+
);
|
|
1247
|
+
return Number(first?.timestamp ?? 0);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function replayDuration(events: AnyReplayEvent[]): number {
|
|
1251
|
+
const startedAt = replayStartedAt(events);
|
|
1252
|
+
let endedAt = startedAt;
|
|
1253
|
+
for (const event of events) {
|
|
1254
|
+
endedAt = Math.max(endedAt, Number(event.timestamp ?? 0));
|
|
1255
|
+
}
|
|
1256
|
+
return Math.max(0, endedAt - startedAt);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function pathLabel(href: string): string {
|
|
1260
|
+
try {
|
|
1261
|
+
const url = new URL(href);
|
|
1262
|
+
return `${url.pathname}${url.search}`;
|
|
1263
|
+
} catch {
|
|
1264
|
+
return href;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
406
1268
|
function visitorLabel(
|
|
407
1269
|
recording: SessionRecordingSummary,
|
|
408
1270
|
t: ReturnType<typeof useT>,
|
|
@@ -415,17 +1277,6 @@ function visitorLabel(
|
|
|
415
1277
|
);
|
|
416
1278
|
}
|
|
417
1279
|
|
|
418
|
-
function formatDateTime(value: string): string {
|
|
419
|
-
const date = new Date(value);
|
|
420
|
-
if (!Number.isFinite(date.getTime())) return value;
|
|
421
|
-
return new Intl.DateTimeFormat(undefined, {
|
|
422
|
-
month: "short",
|
|
423
|
-
day: "numeric",
|
|
424
|
-
hour: "numeric",
|
|
425
|
-
minute: "2-digit",
|
|
426
|
-
}).format(date);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
1280
|
function formatDuration(ms: number | null): string {
|
|
430
1281
|
if (!ms || !Number.isFinite(ms) || ms <= 0) return "0s";
|
|
431
1282
|
const seconds = Math.round(ms / 1000);
|
|
@@ -438,13 +1289,36 @@ function formatDuration(ms: number | null): string {
|
|
|
438
1289
|
return `${hours}h ${remainingMinutes}m`;
|
|
439
1290
|
}
|
|
440
1291
|
|
|
1292
|
+
function formatClock(ms: number): string {
|
|
1293
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
1294
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1295
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1296
|
+
const seconds = totalSeconds % 60;
|
|
1297
|
+
if (hours > 0) {
|
|
1298
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
|
1299
|
+
.toString()
|
|
1300
|
+
.padStart(2, "0")}`;
|
|
1301
|
+
}
|
|
1302
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
441
1305
|
function formatNumber(value: number): string {
|
|
442
1306
|
return new Intl.NumberFormat().format(value);
|
|
443
1307
|
}
|
|
444
1308
|
|
|
445
|
-
function
|
|
446
|
-
if (!Number.isFinite(value)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1309
|
+
function clamp(value: number, min: number, max: number): number {
|
|
1310
|
+
if (!Number.isFinite(value)) return min;
|
|
1311
|
+
return Math.min(max, Math.max(min, value));
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function isRecord(value: unknown): value is AnyRecord {
|
|
1315
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function useLiveRef<T>(value: T) {
|
|
1319
|
+
const ref = useRef(value);
|
|
1320
|
+
useEffect(() => {
|
|
1321
|
+
ref.current = value;
|
|
1322
|
+
}, [value]);
|
|
1323
|
+
return ref;
|
|
450
1324
|
}
|