@agent-native/core 0.78.8 → 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.
Files changed (181) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +25 -0
  3. package/corpus/core/docs/content/actions.md +4 -2
  4. package/corpus/core/docs/content/agent-surfaces.md +9 -0
  5. package/corpus/core/docs/content/extensions.md +10 -0
  6. package/corpus/core/docs/content/generative-ui.md +217 -0
  7. package/corpus/core/docs/content/key-concepts.md +2 -2
  8. package/corpus/core/docs/content/mcp-apps.md +3 -5
  9. package/corpus/core/docs/content/native-chat-ui.md +9 -5
  10. package/corpus/core/docs/content/tracking.md +9 -3
  11. package/corpus/core/package.json +1 -1
  12. package/corpus/core/src/action-ui.ts +1 -0
  13. package/corpus/core/src/client/analytics.ts +195 -12
  14. package/corpus/core/src/client/chat/index.ts +1 -0
  15. package/corpus/core/src/client/chat/tool-call-display.tsx +5 -2
  16. package/corpus/core/src/client/chat/widgets/InlineExtensionWidget.tsx +139 -0
  17. package/corpus/core/src/client/chat/widgets/builtin-tool-renderers.tsx +27 -1
  18. package/corpus/core/src/client/conversation/AgentConversation.tsx +38 -0
  19. package/corpus/core/src/client/conversation/code-agent-transcript.ts +9 -0
  20. package/corpus/core/src/client/conversation/types.ts +4 -0
  21. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +27 -5
  22. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +26 -5
  23. package/corpus/core/src/client/extensions/InlineExtensionFrame.tsx +654 -0
  24. package/corpus/core/src/client/extensions/iframe-bridge.ts +30 -0
  25. package/corpus/core/src/client/extensions/index.ts +5 -0
  26. package/corpus/core/src/client/index.ts +1 -0
  27. package/corpus/core/src/client/session-replay.ts +89 -14
  28. package/corpus/core/src/code-agents/transcript-normalizer.ts +14 -0
  29. package/corpus/core/src/extensions/actions.ts +218 -1
  30. package/corpus/core/src/extensions/html-shell.ts +70 -3
  31. package/corpus/core/src/extensions/routes.ts +12 -4
  32. package/corpus/core/src/index.browser.ts +1 -0
  33. package/corpus/core/src/index.ts +1 -0
  34. package/corpus/core/src/mcp/build-server.ts +1 -1
  35. package/corpus/core/src/mcp/embed-app.ts +11 -1
  36. package/corpus/core/src/server/agent-chat-plugin.ts +21 -9
  37. package/corpus/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +13 -0
  38. package/corpus/core/src/templates/workspace-core/.agents/skills/extensions/references/api.md +2 -0
  39. package/corpus/core/src/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +115 -0
  40. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +198 -59
  41. package/corpus/templates/analytics/AGENTS.md +12 -0
  42. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1105 -0
  43. package/corpus/templates/analytics/actions/dashboard-panel-order.ts +169 -0
  44. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +89 -6
  45. package/corpus/templates/analytics/actions/mutate-dashboard.ts +333 -0
  46. package/corpus/templates/analytics/actions/reorder-dashboard-panels.ts +214 -0
  47. package/corpus/templates/analytics/actions/send-dashboard-report-now.ts +48 -21
  48. package/corpus/templates/analytics/actions/update-dashboard.ts +193 -100
  49. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +11 -0
  50. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +1 -1
  51. package/corpus/templates/analytics/app/global.css +81 -10
  52. package/corpus/templates/analytics/app/i18n-data.ts +249 -4
  53. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +3 -3
  54. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +41 -1
  55. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +1 -1
  56. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +4 -4
  57. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +48 -18
  58. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +32 -0
  59. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +204 -70
  60. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +1101 -227
  61. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +160 -133
  62. package/corpus/templates/analytics/changelog/2026-06-26-agents-can-now-edit-dashboards-through-a-typed-mutation-scri.md +6 -0
  63. package/corpus/templates/analytics/changelog/2026-06-26-dashboard-bar-chart-hovers-stay-subtle-in-dark-mode.md +6 -0
  64. package/corpus/templates/analytics/changelog/2026-06-26-dashboard-chart-moves-are-now-handled-by-panel-id-making-sim.md +6 -0
  65. package/corpus/templates/analytics/changelog/2026-06-26-dashboard-charts-drag-smoothly-with-stable-cards-inactive-ch.md +6 -0
  66. package/corpus/templates/analytics/changelog/2026-06-26-dashboard-charts-show-a-refresh-spinner-while-keeping-existi.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-06-26-dashboard-edits-stay-stable-while-chart-order-and-deletions-.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-06-26-dashboard-email-reports-queue-immediate-sends.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-06-26-explorer-dashboards-now-drag-charts-with-a-stable-preview-an.md +6 -0
  70. package/corpus/templates/analytics/changelog/2026-06-26-session-replay-playback-can-now-be-toggled-by-clicking-the-r.md +6 -0
  71. package/corpus/templates/analytics/changelog/2026-06-26-session-replays-now-open-from-the-full-row-and-play-with-tim.md +6 -0
  72. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +21 -0
  73. package/corpus/templates/analytics/server/lib/dashboard-report.ts +41 -17
  74. package/corpus/templates/analytics/server/plugins/agent-chat.ts +5 -0
  75. package/dist/action-ui.d.ts +1 -0
  76. package/dist/action-ui.d.ts.map +1 -1
  77. package/dist/action-ui.js +1 -0
  78. package/dist/action-ui.js.map +1 -1
  79. package/dist/client/analytics.d.ts.map +1 -1
  80. package/dist/client/analytics.js +173 -12
  81. package/dist/client/analytics.js.map +1 -1
  82. package/dist/client/chat/index.d.ts +1 -1
  83. package/dist/client/chat/index.d.ts.map +1 -1
  84. package/dist/client/chat/index.js +1 -1
  85. package/dist/client/chat/index.js.map +1 -1
  86. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  87. package/dist/client/chat/tool-call-display.js +3 -1
  88. package/dist/client/chat/tool-call-display.js.map +1 -1
  89. package/dist/client/chat/widgets/InlineExtensionWidget.d.ts +17 -0
  90. package/dist/client/chat/widgets/InlineExtensionWidget.d.ts.map +1 -0
  91. package/dist/client/chat/widgets/InlineExtensionWidget.js +85 -0
  92. package/dist/client/chat/widgets/InlineExtensionWidget.js.map +1 -0
  93. package/dist/client/chat/widgets/builtin-tool-renderers.d.ts.map +1 -1
  94. package/dist/client/chat/widgets/builtin-tool-renderers.js +15 -2
  95. package/dist/client/chat/widgets/builtin-tool-renderers.js.map +1 -1
  96. package/dist/client/conversation/AgentConversation.d.ts.map +1 -1
  97. package/dist/client/conversation/AgentConversation.js +33 -0
  98. package/dist/client/conversation/AgentConversation.js.map +1 -1
  99. package/dist/client/conversation/code-agent-transcript.js +8 -0
  100. package/dist/client/conversation/code-agent-transcript.js.map +1 -1
  101. package/dist/client/conversation/types.d.ts +4 -0
  102. package/dist/client/conversation/types.d.ts.map +1 -1
  103. package/dist/client/conversation/types.js.map +1 -1
  104. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  105. package/dist/client/extensions/EmbeddedExtension.js +29 -1
  106. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  107. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  108. package/dist/client/extensions/ExtensionViewer.js +28 -1
  109. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  110. package/dist/client/extensions/InlineExtensionFrame.d.ts +25 -0
  111. package/dist/client/extensions/InlineExtensionFrame.d.ts.map +1 -0
  112. package/dist/client/extensions/InlineExtensionFrame.js +488 -0
  113. package/dist/client/extensions/InlineExtensionFrame.js.map +1 -0
  114. package/dist/client/extensions/iframe-bridge.d.ts +2 -0
  115. package/dist/client/extensions/iframe-bridge.d.ts.map +1 -1
  116. package/dist/client/extensions/iframe-bridge.js +23 -0
  117. package/dist/client/extensions/iframe-bridge.js.map +1 -1
  118. package/dist/client/extensions/index.d.ts +1 -0
  119. package/dist/client/extensions/index.d.ts.map +1 -1
  120. package/dist/client/extensions/index.js +1 -0
  121. package/dist/client/extensions/index.js.map +1 -1
  122. package/dist/client/index.d.ts +1 -1
  123. package/dist/client/index.d.ts.map +1 -1
  124. package/dist/client/index.js +1 -1
  125. package/dist/client/index.js.map +1 -1
  126. package/dist/client/session-replay.d.ts +11 -1
  127. package/dist/client/session-replay.d.ts.map +1 -1
  128. package/dist/client/session-replay.js +74 -14
  129. package/dist/client/session-replay.js.map +1 -1
  130. package/dist/code-agents/transcript-normalizer.d.ts +2 -0
  131. package/dist/code-agents/transcript-normalizer.d.ts.map +1 -1
  132. package/dist/code-agents/transcript-normalizer.js +8 -0
  133. package/dist/code-agents/transcript-normalizer.js.map +1 -1
  134. package/dist/collab/awareness.d.ts +2 -2
  135. package/dist/collab/awareness.d.ts.map +1 -1
  136. package/dist/collab/routes.d.ts +2 -2
  137. package/dist/extensions/actions.d.ts.map +1 -1
  138. package/dist/extensions/actions.js +200 -1
  139. package/dist/extensions/actions.js.map +1 -1
  140. package/dist/extensions/html-shell.d.ts.map +1 -1
  141. package/dist/extensions/html-shell.js +70 -3
  142. package/dist/extensions/html-shell.js.map +1 -1
  143. package/dist/extensions/routes.d.ts.map +1 -1
  144. package/dist/extensions/routes.js +11 -4
  145. package/dist/extensions/routes.js.map +1 -1
  146. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  147. package/dist/index.browser.d.ts +1 -1
  148. package/dist/index.browser.d.ts.map +1 -1
  149. package/dist/index.browser.js +1 -1
  150. package/dist/index.browser.js.map +1 -1
  151. package/dist/index.d.ts +1 -1
  152. package/dist/index.d.ts.map +1 -1
  153. package/dist/index.js +1 -1
  154. package/dist/index.js.map +1 -1
  155. package/dist/mcp/build-server.js +1 -1
  156. package/dist/mcp/build-server.js.map +1 -1
  157. package/dist/mcp/embed-app.d.ts.map +1 -1
  158. package/dist/mcp/embed-app.js +11 -1
  159. package/dist/mcp/embed-app.js.map +1 -1
  160. package/dist/observability/routes.d.ts +7 -7
  161. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  162. package/dist/resources/handlers.d.ts +2 -2
  163. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  164. package/dist/server/agent-chat-plugin.js +21 -9
  165. package/dist/server/agent-chat-plugin.js.map +1 -1
  166. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  167. package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +13 -0
  168. package/dist/templates/workspace-core/.agents/skills/extensions/references/api.md +2 -0
  169. package/dist/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +115 -0
  170. package/docs/content/actions.md +4 -2
  171. package/docs/content/agent-surfaces.md +9 -0
  172. package/docs/content/extensions.md +10 -0
  173. package/docs/content/generative-ui.md +217 -0
  174. package/docs/content/key-concepts.md +2 -2
  175. package/docs/content/mcp-apps.md +3 -5
  176. package/docs/content/native-chat-ui.md +9 -5
  177. package/docs/content/tracking.md +9 -3
  178. package/package.json +1 -1
  179. package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +13 -0
  180. package/src/templates/workspace-core/.agents/skills/extensions/references/api.md +2 -0
  181. 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 { useEffect, useMemo, useRef, useState } from "react";
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
- Card,
22
- CardContent,
23
- CardDescription,
24
- CardHeader,
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
- Table,
30
- TableBody,
31
- TableCell,
32
- TableHead,
33
- TableHeader,
34
- TableRow,
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 { send, isGenerating, codeRequiredDialog } = useSendToAgentChat();
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="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 md:px-6">
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-4 md:flex-row md:items-start md:justify-between">
102
- <div className="min-w-0 space-y-3">
103
- <Button variant="ghost" size="sm" asChild className="-ms-2 w-fit">
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
- <div className="space-y-2">
110
- <div className="flex flex-wrap items-center gap-2">
111
- <IconPlayerPlay className="h-5 w-5 text-primary" />
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
- · {formatDateTime(recording.startedAt)} -{" "}
122
- {formatDateTime(
123
- recording.endedAt ??
124
- recording.lastIngestedAt ??
125
- recording.startedAt,
126
- )}
127
- </p>
128
- ) : null}
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
- <SessionStats recording={recording} />
163
- <ReplayPlayer response={data} />
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 SessionStats({ recording }: { recording: SessionRecordingSummary }) {
208
+ function AskSessionPopover({
209
+ recording,
210
+ }: {
211
+ recording: SessionRecordingSummary;
212
+ }) {
172
213
  const t = useT();
173
- const stats = [
174
- [t("sessions.started"), formatDateTime(recording.startedAt)],
175
- [t("sessions.duration"), formatDuration(recording.durationMs)],
176
- [t("sessions.events"), formatNumber(recording.eventCount)],
177
- [t("sessions.pages"), formatNumber(recording.pageCount)],
178
- [t("sessions.errors"), formatNumber(recording.errorCount)],
179
- [t("sessions.visitor"), visitorLabel(recording, t)],
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
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
183
- {stats.map(([label, value]) => (
184
- <Card key={label}>
185
- <CardContent className="p-4">
186
- <p className="text-xs font-medium uppercase text-muted-foreground">
187
- {label}
188
- </p>
189
- <p className="mt-1 truncate text-sm font-medium">{value}</p>
190
- </CardContent>
191
- </Card>
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({ response }: { response: SessionReplayEventsResponse }) {
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 rootRef = useRef<HTMLDivElement>(null);
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 events = useMemo(
204
- () =>
205
- response.chunks
206
- .flatMap((chunk) => chunk.events)
207
- .filter((event) => event && typeof event === "object")
208
- .sort((a: any, b: any) => {
209
- const at = Number(a.timestamp ?? 0);
210
- const bt = Number(b.timestamp ?? 0);
211
- return at - bt;
212
- }),
213
- [response.chunks],
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 (!rootRef.current) return;
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 === 0) {
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 || !rootRef.current) return;
414
+ if (cancelled || !stageRootRef.current) return;
230
415
 
231
- rootRef.current.innerHTML = "";
416
+ stageRootRef.current.innerHTML = "";
417
+ setStreamedDims(null);
232
418
  localReplayer = new Replayer(events as any[], {
233
- root: rootRef.current,
234
- skipInactive: true,
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.play();
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
- <Card>
264
- <CardHeader className="pb-4">
265
- <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
266
- <div>
267
- <CardTitle className="flex items-center gap-2 text-base">
268
- <IconPlayerPlay className="h-4 w-4" />
269
- {t("sessions.replayPlayer")}
270
- </CardTitle>
271
- <CardDescription>
272
- {t("sessions.chunkAndEventCount", {
273
- chunks: String(response.chunks.length),
274
- events: String(response.eventCount),
275
- })}
276
- {response.truncated ? ` ${t("sessions.truncated")}` : ""}
277
- </CardDescription>
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
- <div className="flex items-center gap-2">
280
- <Button
281
- type="button"
282
- variant="outline"
283
- size="sm"
284
- disabled={status !== "ready"}
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
- {t("sessions.restart")}
292
- </Button>
628
+ </ReplayIconButton>
293
629
  <Button
294
630
  type="button"
295
- variant="outline"
296
631
  size="icon"
297
- aria-label={t("sessions.play")}
298
- disabled={status !== "ready"}
299
- onClick={() => replayerRef.current?.play?.()}
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
- <IconPlayerPlay className="h-4 w-4" />
637
+ {playing ? (
638
+ <IconPlayerPause className="h-4 w-4" />
639
+ ) : (
640
+ <IconPlayerPlay className="h-4 w-4" />
641
+ )}
302
642
  </Button>
303
- <Button
304
- type="button"
305
- variant="outline"
306
- size="icon"
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
- <IconPlayerPause className="h-4 w-4" />
312
- </Button>
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
- </div>
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
- <p className="text-sm text-muted-foreground">
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
- </p>
719
+ </div>
338
720
  ) : null}
339
- </div>
340
- </CardContent>
341
- </Card>
721
+ </CardContent>
722
+ </Card>
723
+ </TooltipProvider>
342
724
  );
343
725
  }
344
726
 
345
- function ReplayChunksTable({
346
- response,
727
+ function ReplayScrubber({
728
+ currentTime,
729
+ totalTime,
730
+ markers,
731
+ skipRanges,
732
+ skipInactive,
733
+ disabled,
734
+ onSeek,
347
735
  }: {
348
- response: SessionReplayEventsResponse;
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
- <Card>
353
- <CardHeader>
354
- <CardTitle className="text-base">
355
- {t("sessions.replayChunks")}
356
- </CardTitle>
357
- <CardDescription>
358
- {t("sessions.replayChunksDescription")}
359
- </CardDescription>
360
- </CardHeader>
361
- <CardContent className="p-0">
362
- <div className="overflow-x-auto">
363
- <Table>
364
- <TableHeader>
365
- <TableRow>
366
- <TableHead>{t("sessions.sequence")}</TableHead>
367
- <TableHead>{t("sessions.events")}</TableHead>
368
- <TableHead>{t("sessions.size")}</TableHead>
369
- <TableHead>{t("sessions.checksum")}</TableHead>
370
- </TableRow>
371
- </TableHeader>
372
- <TableBody>
373
- {response.chunks.map((chunk) => (
374
- <TableRow key={chunk.seq}>
375
- <TableCell className="font-mono text-xs">
376
- {chunk.seq}
377
- </TableCell>
378
- <TableCell>{formatNumber(chunk.eventCount)}</TableCell>
379
- <TableCell>{formatBytes(chunk.byteLength)}</TableCell>
380
- <TableCell className="max-w-[360px] truncate font-mono text-xs">
381
- {chunk.checksum}
382
- </TableCell>
383
- </TableRow>
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
- </TableBody>
386
- </Table>
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="space-y-4">
396
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
397
- {Array.from({ length: 6 }).map((_, index) => (
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 formatBytes(value: number): string {
446
- if (!Number.isFinite(value) || value <= 0) return "0 B";
447
- if (value < 1024) return `${value} B`;
448
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
449
- return `${(value / (1024 * 1024)).toFixed(1)} MB`;
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
  }