@brett_lamy/docstream 1.22.1 → 1.24.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 (38) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/replay/ReplayDevtools.d.ts +6 -3
  6. package/dist/replay/ReplayDevtools.d.ts.map +1 -1
  7. package/dist/replay/ReplayDevtools.js +38 -16
  8. package/dist/replay/ReplayDevtools.js.map +1 -1
  9. package/dist/replay/ReplayFile.d.ts +7 -1
  10. package/dist/replay/ReplayFile.d.ts.map +1 -1
  11. package/dist/replay/ReplayFile.js +17 -3
  12. package/dist/replay/ReplayFile.js.map +1 -1
  13. package/dist/replay/ReplayPlayer.d.ts +18 -4
  14. package/dist/replay/ReplayPlayer.d.ts.map +1 -1
  15. package/dist/replay/ReplayPlayer.js +130 -20
  16. package/dist/replay/ReplayPlayer.js.map +1 -1
  17. package/dist/replay/devtools.d.ts +5 -0
  18. package/dist/replay/devtools.d.ts.map +1 -1
  19. package/dist/replay/devtools.js +3 -2
  20. package/dist/replay/devtools.js.map +1 -1
  21. package/dist/replay/index.d.ts +3 -1
  22. package/dist/replay/index.d.ts.map +1 -1
  23. package/dist/replay/index.js +2 -1
  24. package/dist/replay/index.js.map +1 -1
  25. package/dist/replay/inspections.d.ts +36 -0
  26. package/dist/replay/inspections.d.ts.map +1 -0
  27. package/dist/replay/inspections.js +64 -0
  28. package/dist/replay/inspections.js.map +1 -0
  29. package/dist/styles.css +34 -8
  30. package/package.json +1 -1
  31. package/src/index.ts +1 -1
  32. package/src/replay/ReplayDevtools.tsx +42 -19
  33. package/src/replay/ReplayFile.tsx +16 -3
  34. package/src/replay/ReplayPlayer.tsx +152 -28
  35. package/src/replay/devtools.ts +8 -2
  36. package/src/replay/index.ts +3 -1
  37. package/src/replay/inspections.ts +105 -0
  38. package/src/styles.css +34 -8
package/dist/styles.css CHANGED
@@ -4260,6 +4260,24 @@
4260
4260
  transform: translateY(5px);
4261
4261
  }
4262
4262
 
4263
+ /* The time window: a band under the track, on the same geometry as the markers (4px padding, half a thumb). */
4264
+ :where([data-slot="replay-preview-window"]) {
4265
+ position: absolute;
4266
+ top: 50%;
4267
+ left: calc(10px + (100% - 20px) * var(--replay-window-start, 0));
4268
+ width: max(4px, calc((100% - 20px) * var(--replay-window-size, 1)));
4269
+ height: 10px;
4270
+ border-radius: 999px;
4271
+ background: color-mix(in srgb, var(--primary, #4f46e5) 34%, transparent);
4272
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary, #4f46e5) 60%, transparent);
4273
+ translate: 0 -50%;
4274
+ pointer-events: none;
4275
+ }
4276
+
4277
+ :where([data-slot="replay-preview-scrubber"][data-markers] > [data-slot="replay-preview-window"]) {
4278
+ translate: 0 calc(-50% + 5px);
4279
+ }
4280
+
4263
4281
  :where([data-slot="replay-preview-markers"] > button) {
4264
4282
  position: absolute;
4265
4283
  top: 50%;
@@ -4309,6 +4327,8 @@
4309
4327
  }
4310
4328
 
4311
4329
  :where([data-slot="replay-preview-scrubber"] > input[type="range"]) {
4330
+ /* Above the time-window band. */
4331
+ position: relative;
4312
4332
  --replay-track: 4px;
4313
4333
  --replay-thumb: 12px;
4314
4334
  appearance: none;
@@ -4331,8 +4351,11 @@
4331
4351
  [data-slot="replay-preview-scrubber"] > input[type="range"]::-webkit-slider-runnable-track {
4332
4352
  height: var(--replay-track);
4333
4353
  border-radius: 999px;
4354
+ /* Played: from the time window's start (0 without one) to the playhead. */
4334
4355
  background: linear-gradient(
4335
4356
  to right,
4357
+ color-mix(in srgb, var(--muted-foreground, #71717a) 26%, transparent) var(--replay-progress-start, 0%),
4358
+ var(--primary, #4f46e5) var(--replay-progress-start, 0%),
4336
4359
  var(--primary, #4f46e5) var(--replay-progress, 0%),
4337
4360
  color-mix(in srgb, var(--muted-foreground, #71717a) 26%, transparent) var(--replay-progress, 0%)
4338
4361
  );
@@ -4601,19 +4624,17 @@
4601
4624
  box-shadow: inset 0 0 0 2px var(--ring, #6366f1);
4602
4625
  }
4603
4626
 
4604
- :where([data-slot="replay-devtools-tabs"] [data-part="at"]) {
4605
- display: inline-flex;
4606
- align-items: center;
4607
- margin-inline-start: auto;
4608
- padding-inline: 8px;
4609
- color: var(--replay-devtools-dim);
4610
- font-variant-numeric: tabular-nums;
4611
- }
4627
+
4628
+
4612
4629
 
4613
4630
  :where([data-slot="replay-devtools-tabs"] > [data-slot="replay-devtools-close"], [data-slot="replay-devtools-detail-tabs"] > [data-slot="replay-devtools-close"]) {
4614
4631
  padding: 0 8px;
4615
4632
  }
4616
4633
 
4634
+ :where([data-slot="replay-devtools-tabs"] > [data-slot="replay-devtools-close"]) {
4635
+ margin-inline-start: auto;
4636
+ }
4637
+
4617
4638
  :where([data-slot="replay-devtools-pane"]) {
4618
4639
  display: flex;
4619
4640
  flex: 1 1 0%;
@@ -4882,6 +4903,11 @@
4882
4903
  :where([data-slot="replay-devtools-table"] tbody tr:focus-visible) {
4883
4904
  box-shadow: inset 0 0 0 2px var(--ring, #6366f1);
4884
4905
  }
4906
+ /* The inspected request or message: a bar down its leading edge (the selection tint shows it's open). */
4907
+ :where([data-slot="replay-devtools-table"] tbody tr[data-inspected], [data-slot="replay-devtools-message"][data-inspected]) {
4908
+ box-shadow: inset 3px 0 0 var(--primary, #2563eb);
4909
+ }
4910
+
4885
4911
 
4886
4912
  :where([data-slot="replay-devtools-table"] tr[data-failed]) {
4887
4913
  color: var(--replay-devtools-red);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.22.1",
3
+ "version": "1.24.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/index.ts CHANGED
@@ -90,7 +90,7 @@ export type {
90
90
  } from "./replay/ReplayPreview"
91
91
  export type { ReplayEvent } from "./replay/types"
92
92
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
93
- export { ReplayFile, isRrwebUrl } from "./replay/ReplayFile"
93
+ export { ReplayFile, isRrwebUrl, splitReplayFragment } from "./replay/ReplayFile"
94
94
  export { ImageThumbnail, leadingThumbnail, thumbnailLabel } from "./docs/ImageThumbnail"
95
95
  export type { ReplayFileProps } from "./replay/ReplayFile"
96
96
  export { VideoEmbed } from "./video"
@@ -5,10 +5,12 @@
5
5
  waiting at the playhead shows as pending), and a request's row opens its headers, payload and response.
6
6
 
7
7
  Everything is a function of the playhead: scrub back and the lists shrink to what DevTools showed then. A row's
8
- time jumps the player there. Esc closes the panel. Plain React + plain CSS, like the player. */
8
+ time jumps the player there. Esc closes the panel. An inspection (inspections.ts) selects its request or
9
+ message and marks it. Plain React + plain CSS, like the player. */
9
10
  import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'
10
11
  import { ChevronRight, CircleX, Info, Network, SquareTerminal, TriangleAlert, X } from 'lucide-react'
11
12
  import type { ReplayConsoleEntry, ReplayDevtoolsState, ReplayRequest } from './devtools'
13
+ import type { ResolvedReplayInspection } from './inspections'
12
14
 
13
15
  export type ReplayDevtoolsTab = 'console' | 'network'
14
16
 
@@ -55,14 +57,16 @@ export function DevtoolsButtons({ state, open, onOpen }: {
55
57
  )
56
58
  }
57
59
 
58
- /** The panel over the page: Console and Network, cut at the playhead. */
59
- export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
60
- state: ReplayDevtoolsState; at: number; tab: ReplayDevtoolsTab; onTab: (tab: ReplayDevtoolsTab) => void
61
- onClose: () => void; onSeek: (ms: number) => void
60
+ /** The panel over the page: Console and Network, cut at the playhead. `inspection` selects and marks its target;
61
+ * `autoFocus` (default true) moves focus into the panel as it opens — off when an inspection opened it mid-play. */
62
+ export function DevtoolsPanel({ state, tab, onTab, onClose, onSeek, inspection, autoFocus = true }: {
63
+ state: ReplayDevtoolsState; tab: ReplayDevtoolsTab; onTab: (tab: ReplayDevtoolsTab) => void
64
+ onClose: () => void; onSeek: (ms: number) => void; inspection?: ResolvedReplayInspection | null; autoFocus?: boolean
62
65
  }) {
63
66
  const panel = useRef<HTMLDivElement>(null)
64
- const [height, setHeight] = useState(58)
65
- useEffect(() => { panel.current?.focus({ preventScroll: true }) }, [])
67
+ // Opened by an inspection, the panel starts taller: its point is the request's details.
68
+ const [height, setHeight] = useState(inspection ? 72 : 58)
69
+ useEffect(() => { if (autoFocus) panel.current?.focus({ preventScroll: true }) }, [autoFocus])
66
70
 
67
71
  // Drag the top edge to resize, as a fraction of the stage.
68
72
  const resize = (e: ReactPointerEvent<HTMLDivElement>) => {
@@ -116,10 +120,11 @@ export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
116
120
  </button>
117
121
  ))}
118
122
  </div>
119
- <span data-part="at">As of {time(at)}</span>
120
123
  <button type="button" data-slot="replay-devtools-close" aria-label="Close DevTools" onClick={onClose}><X size={14} /></button>
121
124
  </div>
122
- {tab === 'console' ? <ConsolePane state={state} onSeek={onSeek} /> : <NetworkPane state={state} onSeek={onSeek} />}
125
+ {tab === 'console'
126
+ ? <ConsolePane state={state} onSeek={onSeek} inspected={inspection?.tab === 'console' ? inspection : null} />
127
+ : <NetworkPane state={state} onSeek={onSeek} inspected={inspection?.tab === 'network' ? inspection : null} />}
123
128
  </div>
124
129
  )
125
130
  }
@@ -129,7 +134,9 @@ export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
129
134
  type LevelFilter = 'all' | 'error' | 'warn' | 'info'
130
135
  const LEVEL_ICON = { error: CircleX, warn: TriangleAlert, info: Info } as const
131
136
 
132
- function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (ms: number) => void }) {
137
+ function ConsolePane({ state, onSeek, inspected }: {
138
+ state: ReplayDevtoolsState; onSeek: (ms: number) => void; inspected: ResolvedReplayInspection | null
139
+ }) {
133
140
  const [query, setQuery] = useState('')
134
141
  const [level, setLevel] = useState<LevelFilter>('all')
135
142
  const list = useRef<HTMLDivElement>(null)
@@ -139,12 +146,17 @@ function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
139
146
  (level === 'all' || (level === 'info' ? e.level === 'info' || e.level === 'log' || e.level === 'debug' : e.level === level)) &&
140
147
  (!q || e.message.toLowerCase().includes(q)))
141
148
  }, [state.console, query, level])
142
- // Follow new messages as they're logged, unless you've scrolled up to read.
149
+ // Follow new messages as they're logged, unless you've scrolled up to read (or an inspection is showing one).
143
150
  const pinned = useRef(true)
151
+ const inspectedId = inspected?.messageId
152
+ const shown = inspectedId !== undefined && rows.some((r) => r.id === inspectedId)
144
153
  useEffect(() => {
145
154
  const el = list.current
146
- if (el && pinned.current) el.scrollTop = el.scrollHeight
147
- }, [rows.length])
155
+ if (el && pinned.current && !shown) el.scrollTop = el.scrollHeight
156
+ }, [rows.length, shown])
157
+ useEffect(() => {
158
+ if (shown) list.current?.querySelector('[data-inspected]')?.scrollIntoView?.({ block: 'nearest' })
159
+ }, [shown, inspected?.id])
148
160
  return (
149
161
  <div data-slot="replay-devtools-pane" data-pane="console">
150
162
  <div data-slot="replay-devtools-toolbar">
@@ -167,7 +179,7 @@ function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
167
179
  aria-label="Console messages"
168
180
  onScroll={(e) => { const el = e.currentTarget; pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8 }}
169
181
  >
170
- {rows.map((entry) => <ConsoleRow key={entry.id} entry={entry} onSeek={onSeek} />)}
182
+ {rows.map((entry) => <ConsoleRow key={entry.id} entry={entry} onSeek={onSeek} inspected={entry.id === inspectedId} />)}
171
183
  {!rows.length ? <p data-slot="replay-devtools-empty">{state.console.length ? 'No messages match the filter' : 'Nothing logged yet'}</p> : null}
172
184
  {state.counts.laterMessages ? <p data-slot="replay-devtools-later">{state.counts.laterMessages} more later in the recording</p> : null}
173
185
  </div>
@@ -175,7 +187,7 @@ function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
175
187
  )
176
188
  }
177
189
 
178
- function ConsoleRow({ entry, onSeek }: { entry: ReplayConsoleEntry; onSeek: (ms: number) => void }) {
190
+ function ConsoleRow({ entry, onSeek, inspected }: { entry: ReplayConsoleEntry; onSeek: (ms: number) => void; inspected: boolean }) {
179
191
  const Glyph = entry.level === 'error' || entry.level === 'warn' || entry.level === 'info' ? LEVEL_ICON[entry.level] : null
180
192
  const body = (
181
193
  <>
@@ -184,7 +196,7 @@ function ConsoleRow({ entry, onSeek }: { entry: ReplayConsoleEntry; onSeek: (ms:
184
196
  </>
185
197
  )
186
198
  return (
187
- <div data-slot="replay-devtools-message" data-level={entry.level}>
199
+ <div data-slot="replay-devtools-message" data-level={entry.level} data-inspected={inspected || undefined}>
188
200
  {entry.trace?.length ? (
189
201
  <details>
190
202
  <summary>{body}</summary>
@@ -203,10 +215,20 @@ type Row = ReplayRequest & { pending: boolean }
203
215
 
204
216
  const statusText = (r: Row) => (r.pending ? '(pending)' : r.status === undefined ? (r.failed ? '(failed)' : '—') : r.status === 0 ? '(failed)' : String(r.status))
205
217
 
206
- function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (ms: number) => void }) {
218
+ function NetworkPane({ state, onSeek, inspected }: {
219
+ state: ReplayDevtoolsState; onSeek: (ms: number) => void; inspected: ResolvedReplayInspection | null
220
+ }) {
207
221
  const [query, setQuery] = useState('')
208
222
  const [filter, setFilter] = useState<RequestFilter>('all')
209
- const [selectedId, setSelectedId] = useState<string | null>(null)
223
+ const inspectedId = inspected?.requestId
224
+ const [selectedId, setSelectedId] = useState<string | null>(inspectedId ?? null)
225
+ const table = useRef<HTMLDivElement>(null)
226
+ // A new inspection selects its request (once it has been sent) and brings its row into view.
227
+ useEffect(() => { if (inspectedId) setSelectedId(inspectedId) }, [inspected?.id, inspectedId])
228
+ const sent = inspectedId !== undefined && state.network.some((r) => r.id === inspectedId)
229
+ useEffect(() => {
230
+ if (sent) table.current?.querySelector('[data-inspected]')?.scrollIntoView?.({ block: 'nearest' })
231
+ }, [sent, inspected?.id])
210
232
  const rows = useMemo(() => {
211
233
  const q = query.trim().toLowerCase()
212
234
  return state.network.filter((r) =>
@@ -235,7 +257,7 @@ function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
235
257
  </div>
236
258
  </div>
237
259
  <div data-slot="replay-devtools-network">
238
- <div data-slot="replay-devtools-table-wrap">
260
+ <div ref={table} data-slot="replay-devtools-table-wrap">
239
261
  <table data-slot="replay-devtools-table">
240
262
  <thead>
241
263
  <tr>
@@ -251,6 +273,7 @@ function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
251
273
  data-failed={(r.failed && !r.pending) || undefined}
252
274
  data-pending={r.pending || undefined}
253
275
  aria-selected={r.id === selectedId}
276
+ data-inspected={r.id === inspectedId || undefined}
254
277
  tabIndex={0}
255
278
  onClick={() => setSelectedId(r.id === selectedId ? null : r.id)}
256
279
  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(r.id === selectedId ? null : r.id) } }}
@@ -11,10 +11,12 @@ export interface ReplayFileProps extends Omit<ReplayPlayerOptions, 'url'> {
11
11
  /**
12
12
  * An rrweb recording file (a JSON event array, or `{ events }`), played in the replay browser frame.
13
13
  * The URL goes through `AssetResolverProvider`, and the file is fetched only once the frame nears the
14
- * viewport, so a page of recordings doesn't download them all up front.
14
+ * viewport, so a page of recordings doesn't download them all up front. A media fragment on the URL,
15
+ * `recording.rrweb#t=4.7,7.4` (seconds), is its `timeWindow`.
15
16
  */
16
17
  export function ReplayFile({ url, ...options }: ReplayFileProps) {
17
- const src = useAsset(url);
18
+ const { file, segment } = splitReplayFragment(url);
19
+ const src = useAsset(file);
18
20
  const ref = useRef<HTMLDivElement>(null);
19
21
  const [near, setNear] = useState(false);
20
22
 
@@ -28,7 +30,18 @@ export function ReplayFile({ url, ...options }: ReplayFileProps) {
28
30
  }, [near]);
29
31
 
30
32
  if (!near || src === undefined) return <div ref={ref} className="docstream-asset-pending" data-slot="replay-file" aria-busy="true" />;
31
- return <ReplayPreview source={{ kind: 'events-url', url: src }} chrome="browser" {...options} />;
33
+ return <ReplayPreview source={{ kind: 'events-url', url: src }} chrome="browser" timeWindow={segment} {...options} />;
34
+ }
35
+
36
+ /** Splits `#t=start,end` (a media fragment, in seconds; `#t=start` runs to the end) off a recording's URL. */
37
+ export function splitReplayFragment(url: string): { file: string; segment?: [number, number] } {
38
+ const hash = url.indexOf('#');
39
+ if (hash < 0) return { file: url };
40
+ const m = url.slice(hash + 1).match(/(?:^|&)t=(?:npt:)?([\d.]*)(?:,([\d.]+))?(?:&|$)/);
41
+ if (!m) return { file: url };
42
+ const start = m[1] ? Number(m[1]) * 1000 : 0;
43
+ const end = m[2] ? Number(m[2]) * 1000 : Infinity;
44
+ return { file: url.slice(0, hash), segment: Number.isFinite(start) && end > start ? [start, end] : undefined };
32
45
  }
33
46
 
34
47
  const RRWEB = /\.rrweb(?:[?#]|$)/i;
@@ -2,7 +2,9 @@
2
2
  plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
3
3
  follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
4
4
  it, a speed control, fullscreen, a drawn cursor whose clicks ripple, and DevTools: Console and Network buttons
5
- in the top bar with what had been logged and requested by the playhead, which open a DevTools panel over the page. Until the player is ready a
5
+ in the top bar with what had been logged and requested by the playhead, which open a DevTools panel over the page,
6
+ and inspections (inspections.ts) that open it on a request or message as playback reaches them. A time window
7
+ highlights a segment on the scrubber, opens there and plays just that, until you scrub outside it. Until the player is ready a
6
8
  poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
7
9
 
8
10
  Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
@@ -18,6 +20,7 @@ import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
18
20
  import { ReplayStage } from './ReplayStage'
19
21
  import { DevtoolsButtons, DevtoolsPanel, type ReplayDevtoolsTab } from './ReplayDevtools'
20
22
  import { getReplayDevtools, replayDevtoolsAt } from './devtools'
23
+ import { getReplayInspections, replayInspectionAt, resolveReplayInspections, type ReplayInspection } from './inspections'
21
24
  import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
22
25
  import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
23
26
  import type { ReplayStageHandle } from './stage-types'
@@ -36,6 +39,8 @@ export interface ReplayPreviewHandle {
36
39
  seek(ms: number): void
37
40
  toggle(): void
38
41
  getCurrentTime(): number
42
+ /** Jump to an inspection (by its id) and open DevTools on what it found. */
43
+ inspect(id: string): void
39
44
  }
40
45
 
41
46
  /** Options shared by every `ReplayPreview` (an `events` player or a `source` embed). */
@@ -52,9 +57,14 @@ export interface ReplayPlayerOptions {
52
57
  /** Play/pause, scrubber, speed and fullscreen (default true). */
53
58
  controls?: boolean
54
59
  autoplay?: boolean
60
+ /** Restart at the end (with a `timeWindow`, at the window's end, from its start). */
55
61
  loop?: boolean
56
- /** Where the player starts, in ms from the start. */
62
+ /** Where the player starts, in ms from the start (default 0, or the `timeWindow`'s start). */
57
63
  initialTime?: number
64
+ /** `[start, end]` in ms: the segment that matters. The player opens at `start` with the segment highlighted on the
65
+ * scrubber, and playing from inside it stops at `end` (play again to replay it). Scrub or seek outside it and
66
+ * playback runs on through the rest of the recording. */
67
+ timeWindow?: readonly [number, number]
58
68
  /** Speeds the speed button cycles through; the first is the initial speed (default 1, 2, 4, 0.5). */
59
69
  speeds?: number[]
60
70
  /** Markers on the scrubber rail: `true` (default) reads them from the recording, `false` hides the rail,
@@ -63,11 +73,17 @@ export interface ReplayPlayerOptions {
63
73
  /** Which recorded marker kinds to show (default all). */
64
74
  markerKinds?: ReplayMarkerKind[]
65
75
  /** Console and Network buttons (in the top bar, or beside the controls without the browser chrome) that open a
66
- * DevTools panel cut at the playhead. `true` (default) shows them when the recording has console messages or
67
- * requests; `false` hides them. */
76
+ * DevTools panel cut at the playhead, shown when the recording has console messages, requests or inspections.
77
+ * `disableDevtools` removes them, the panel and the inspections. */
78
+ disableDevtools?: boolean
79
+ /** @deprecated Use `disableDevtools`. `false` is the same as `disableDevtools`. */
68
80
  devtools?: boolean
69
81
  /** Open the DevTools panel on this tab at mount. */
70
82
  defaultDevtoolsTab?: ReplayDevtoolsTab
83
+ /** Moments where a request or console message was inspected. As playback reaches one, DevTools opens on its
84
+ * tab with that request or message selected and the label above it; playback doesn't stop. Added to the
85
+ * recording's own `inspect` custom events, and to the rail as checkpoints. */
86
+ inspections?: ReplayInspection[]
71
87
  /** Draw the recorded pointer and click ripples (default true). */
72
88
  cursor?: boolean
73
89
  /** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
@@ -97,8 +113,8 @@ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
97
113
 
98
114
  export function ReplayPlayer({
99
115
  events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
100
- autoplay = false, loop = false, initialTime = 0, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
101
- cursor = true, devtools = true, defaultDevtoolsTab, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
116
+ autoplay = false, loop = false, initialTime: initialTimeProp, timeWindow, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
117
+ cursor = true, disableDevtools = false, devtools = true, defaultDevtoolsTab, inspections, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
102
118
  className, style,
103
119
  }: ReplayPlayerOptions & { events: ReplayEvent[] }) {
104
120
  const rootRef = useRef<HTMLDivElement>(null)
@@ -107,14 +123,35 @@ export function ReplayPlayer({
107
123
  const meta = useMemo(() => getReplayMeta(events), [events])
108
124
  const track = useMemo(() => getReplayPointerTrack(events), [events])
109
125
  const recorded = useMemo(() => getReplayMarkers(events), [events])
110
- const shown = useMemo(() => {
111
- const list = Array.isArray(markers) ? markers : markers ? recorded : []
112
- return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
113
- }, [markers, recorded, markerKinds])
114
126
 
127
+ const toolsOn = devtools && !disableDevtools
115
128
  const tools = useMemo(() => getReplayDevtools(events), [events])
116
- const showTools = devtools && (tools.console.length > 0 || tools.network.length > 0)
129
+ const resolved = useMemo(
130
+ () => (toolsOn ? resolveReplayInspections([...getReplayInspections(events), ...(inspections ?? [])], tools) : []),
131
+ [toolsOn, events, inspections, tools],
132
+ )
133
+ const showTools = toolsOn && (tools.console.length > 0 || tools.network.length > 0 || resolved.length > 0)
117
134
  const [panel, setPanel] = useState<ReplayDevtoolsTab | null>(defaultDevtoolsTab ?? null)
135
+ // Whether an inspection (rather than a press) opened the panel: it then leaves focus where it was.
136
+ const [autoOpened, setAutoOpened] = useState(false)
137
+
138
+ const shown = useMemo(() => {
139
+ // Inspections from the prop join the rail as checkpoints (the recording's own are already among `recorded`).
140
+ const own: ReplayMarker[] = toolsOn ? (inspections ?? []).map((inspection, i) => ({
141
+ id: `inspection-${i}`, kind: 'custom', time: Math.max(0, inspection.time), label: inspection.label ?? 'Inspected',
142
+ })) : []
143
+ const list = Array.isArray(markers) ? markers : markers ? [...recorded, ...own].sort((a, b) => a.time - b.time) : []
144
+ return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
145
+ }, [markers, recorded, markerKinds, inspections, toolsOn])
146
+
147
+ // The time window, clamped to the recording; dropped when it's empty.
148
+ const span = useMemo(() => {
149
+ if (!timeWindow) return null
150
+ const start = Math.max(0, Math.min(timeWindow[0], meta.duration))
151
+ const end = Math.max(start, Math.min(timeWindow[1], meta.duration))
152
+ return end - start >= 1 ? { start, end } : null
153
+ }, [timeWindow, meta.duration])
154
+ const initialTime = initialTimeProp ?? span?.start ?? 0
118
155
 
119
156
  const [visible, setVisible] = useState(!lazy)
120
157
  const [ready, setReady] = useState(false)
@@ -122,6 +159,8 @@ export function ReplayPlayer({
122
159
  const [time, setTime] = useState(initialTime)
123
160
  const [playing, setPlaying] = useState(false)
124
161
  const [finished, setFinished] = useState(false)
162
+ // Stopped at the window's end: the center button replays the segment.
163
+ const [windowDone, setWindowDone] = useState(false)
125
164
  const [speedIndex, setSpeedIndex] = useState(0)
126
165
  const [fullscreen, setFullscreen] = useState(false)
127
166
  const duration = meta.duration
@@ -151,17 +190,73 @@ export function ReplayPlayer({
151
190
  return () => clearTimeout(timer)
152
191
  }, [ready, reduced])
153
192
 
154
- const handle = useMemo<ReplayPreviewHandle>(() => ({
155
- play: (offset) => { setFinished(false); stage.current?.play(offset) },
156
- pause: (offset) => stage.current?.pause(offset),
157
- seek: (ms) => { setFinished(false); stage.current?.seek(ms) },
158
- toggle: () => { setFinished(false); stage.current?.toggle() },
159
- getCurrentTime: () => stage.current?.getCurrentTime() ?? 0,
160
- }), [])
193
+ const resolvedRef = useRef(resolved)
194
+ resolvedRef.current = resolved
195
+ // The window, the playhead and the play state, for the handle (which never re-creates) and onTime.
196
+ const spanRef = useRef(span)
197
+ spanRef.current = span
198
+ const timeRef = useRef(initialTime)
199
+ const playingRef = useRef(false)
200
+ // Whether playback is held to the window: it is when it starts (or is moved) inside it.
201
+ const inWindow = (ms: number) => !!spanRef.current && ms >= spanRef.current.start && ms < spanRef.current.end
202
+ const held = useRef(inWindow(initialTime))
203
+ const handle = useMemo<ReplayPreviewHandle>(() => {
204
+ const play = (offset?: number) => {
205
+ const w = spanRef.current
206
+ if (!w) { setFinished(false); return stage.current?.play(offset) }
207
+ let from = offset ?? timeRef.current
208
+ // Play at the window's end replays the window.
209
+ if (w && offset === undefined && Math.abs(from - w.end) < 60) from = w.start
210
+ held.current = inWindow(from)
211
+ setFinished(false)
212
+ setWindowDone(false)
213
+ if (from !== timeRef.current) stage.current?.play(from)
214
+ else stage.current?.play(offset)
215
+ }
216
+ const seek = (ms: number) => {
217
+ held.current = inWindow(ms)
218
+ setFinished(false)
219
+ setWindowDone(false)
220
+ stage.current?.seek(ms)
221
+ }
222
+ return {
223
+ play,
224
+ pause: (offset) => stage.current?.pause(offset),
225
+ seek,
226
+ toggle: () => {
227
+ if (!spanRef.current) { setFinished(false); return stage.current?.toggle() }
228
+ return playingRef.current ? stage.current?.pause() : play()
229
+ },
230
+ getCurrentTime: () => stage.current?.getCurrentTime() ?? 0,
231
+ inspect: (id) => {
232
+ const inspection = resolvedRef.current.find((x) => x.id === id)
233
+ if (!inspection) return
234
+ seek(inspection.time)
235
+ setPanel(inspection.tab)
236
+ setAutoOpened(false)
237
+ },
238
+ }
239
+ }, [])
161
240
  useImperativeHandle(playerRef, () => handle, [handle])
162
241
 
163
- const onTime = useCallback((ms: number) => { setTime(ms); onTimeUpdate?.(ms) }, [onTimeUpdate])
242
+ const onTime = useCallback((ms: number) => {
243
+ timeRef.current = ms
244
+ // Held to the window: stop at its end, or go round again with `loop`.
245
+ const w = spanRef.current
246
+ if (w && held.current && playingRef.current && ms >= w.end) {
247
+ if (loop) stage.current?.seek(w.start)
248
+ else {
249
+ stage.current?.pause(w.end)
250
+ setWindowDone(true)
251
+ ms = w.end
252
+ timeRef.current = ms
253
+ }
254
+ }
255
+ setTime(ms)
256
+ onTimeUpdate?.(ms)
257
+ }, [onTimeUpdate, loop])
164
258
  const onPlaying = useCallback((p: boolean) => {
259
+ playingRef.current = p
165
260
  setPlaying(p)
166
261
  if (p) setFinished(false)
167
262
  onPlayingChange?.(p)
@@ -209,7 +304,23 @@ export function ReplayPlayer({
209
304
  }, [url, meta.href, recorded, time])
210
305
 
211
306
  const toolsState = useMemo(() => replayDevtoolsAt(tools, time), [tools, time])
212
- const openTools = (tab: ReplayDevtoolsTab) => setPanel((p) => (p === tab ? null : tab))
307
+ const openTools = (tab: ReplayDevtoolsTab) => { setAutoOpened(false); setPanel((p) => (p === tab ? null : tab)) }
308
+
309
+ // Inspect mode: the inspection at the playhead opens DevTools on what it found. Only a change of inspection does
310
+ // (closing the panel keeps it closed until the next one); scrubbing back before the first closes what it opened.
311
+ const inspection = showTools ? replayInspectionAt(resolved, time) : undefined
312
+ const inspectionId = inspection?.id
313
+ const inspectionTab = inspection?.tab
314
+ const lastInspection = useRef<string | undefined>(undefined)
315
+ useEffect(() => {
316
+ if (inspectionId === lastInspection.current) return
317
+ const had = lastInspection.current
318
+ lastInspection.current = inspectionId
319
+ if (inspectionTab) {
320
+ setPanel(inspectionTab)
321
+ setAutoOpened(true)
322
+ } else if (had !== undefined && autoOpened) setPanel(null)
323
+ }, [inspectionId, inspectionTab, autoOpened])
213
324
  const toolButtons = showTools ? <DevtoolsButtons state={toolsState} open={panel} onOpen={openTools} /> : null
214
325
 
215
326
  const w = meta.width || 1280
@@ -247,7 +358,7 @@ export function ReplayPlayer({
247
358
  events={events}
248
359
  fit="contain"
249
360
  speed={speed}
250
- loop={loop}
361
+ loop={loop && !span}
251
362
  autoplay={autoplay}
252
363
  initialTime={initialTime}
253
364
  showMouse={false}
@@ -269,11 +380,12 @@ export function ReplayPlayer({
269
380
  {showTools && panel ? (
270
381
  <DevtoolsPanel
271
382
  state={toolsState}
272
- at={time}
273
383
  tab={panel}
274
384
  onTab={setPanel}
275
385
  onClose={() => { setPanel(null); rootRef.current?.focus({ preventScroll: true }) }}
276
386
  onSeek={(ms) => handle.seek(ms)}
387
+ inspection={inspection}
388
+ autoFocus={!autoOpened}
277
389
  />
278
390
  ) : null}
279
391
 
@@ -282,11 +394,11 @@ export function ReplayPlayer({
282
394
  <button
283
395
  type="button"
284
396
  tabIndex={-1}
285
- aria-label={finished ? 'Replay from the start' : 'Play'}
397
+ aria-label={finished ? 'Replay from the start' : windowDone ? 'Replay the segment' : 'Play'}
286
398
  // The overlay leaves as playback starts; keep focus (and the keys) in the player.
287
399
  onClick={() => { rootRef.current?.focus({ preventScroll: true }); toggle() }}
288
400
  >
289
- {finished ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
401
+ {finished || windowDone ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
290
402
  </button>
291
403
  </div>
292
404
  ) : null}
@@ -303,6 +415,7 @@ export function ReplayPlayer({
303
415
  <Scrubber
304
416
  time={time}
305
417
  duration={duration}
418
+ span={span}
306
419
  markers={shown}
307
420
  disabled={!ready}
308
421
  onSeek={(ms) => handle.seek(ms)}
@@ -391,8 +504,8 @@ const PointerOverlay = memo(function PointerOverlay({ track, time, reduced }: {
391
504
  )
392
505
  })
393
506
 
394
- function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
395
- time: number; duration: number; markers: ReplayMarker[]; disabled: boolean
507
+ function Scrubber({ time, duration, span, markers, disabled, onSeek, onMarker }: {
508
+ time: number; duration: number; span: { start: number; end: number } | null; markers: ReplayMarker[]; disabled: boolean
396
509
  onSeek: (ms: number) => void; onMarker: (m: ReplayMarker) => void
397
510
  }) {
398
511
  const max = Math.max(1, duration)
@@ -411,6 +524,14 @@ function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
411
524
  }, [markers, max])
412
525
  return (
413
526
  <div data-slot="replay-preview-scrubber" data-markers={markers.length ? '' : undefined}>
527
+ {span ? (
528
+ <div
529
+ data-slot="replay-preview-window"
530
+ aria-hidden="true"
531
+ title={`${formatReplayTime(span.start)}–${formatReplayTime(span.end)}`}
532
+ style={{ '--replay-window-start': span.start / max, '--replay-window-size': (span.end - span.start) / max } as CSSProperties}
533
+ />
534
+ ) : null}
414
535
  {markers.length ? (
415
536
  <div data-slot="replay-preview-markers">
416
537
  {markers.map((m) => (
@@ -430,13 +551,16 @@ function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
430
551
  <input
431
552
  type="range"
432
553
  aria-label="Playback position"
433
- aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}`}
554
+ aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}${span ? `, segment ${formatReplayTime(span.start)} to ${formatReplayTime(span.end)}` : ''}`}
434
555
  min={0}
435
556
  max={max}
436
557
  step={10}
437
558
  value={value}
438
559
  disabled={disabled}
439
- style={{ '--replay-progress': `${(value / max) * 100}%` } as CSSProperties}
560
+ style={{
561
+ '--replay-progress': `${(value / max) * 100}%`,
562
+ ...(span ? { '--replay-progress-start': `${(span.start / max) * 100}%` } : {}),
563
+ } as CSSProperties}
440
564
  onChange={(e) => onSeek(Number(e.currentTarget.value))}
441
565
  />
442
566
  </div>
@@ -17,6 +17,8 @@ export type ReplayConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
17
17
  export interface ReplayConsoleEntry {
18
18
  /** Stable id (`console-<n>`). */
19
19
  id: string
20
+ /** The recording's own id for the message (`payload.id` of a custom event), which an inspection can name. */
21
+ key?: string
20
22
  /** Milliseconds from the start of the recording. */
21
23
  time: number
22
24
  level: ReplayConsoleLevel
@@ -33,6 +35,9 @@ export interface ReplayConsoleEntry {
33
35
  export interface ReplayRequest {
34
36
  /** Stable id (`request-<n>`). */
35
37
  id: string
38
+ /** The recording's own id for the request (`id` / `requestId`, e.g. a Replay request id), which an
39
+ * inspection can name. */
40
+ key?: string
36
41
  /** When the request was sent, in ms from the start of the recording. */
37
42
  time: number
38
43
  /** How long it took, when known: the response arrived at `time + duration`. */
@@ -153,6 +158,7 @@ export function getReplayDevtools(events: readonly ReplayEvent[]): ReplayDevtool
153
158
  // request was sent that far before it (its own duration, for a lone request).
154
159
  const sent = Math.max(0, startTime !== undefined && batchEnd !== undefined ? at - (batchEnd - startTime) : at - (duration ?? 0))
155
160
  requests.push({
161
+ key: str(raw.requestId) ?? str(raw.id),
156
162
  time: sent,
157
163
  duration,
158
164
  method: (str(raw.method) ?? 'GET').toUpperCase(),
@@ -197,10 +203,10 @@ export function getReplayDevtools(events: readonly ReplayEvent[]): ReplayDevtool
197
203
  const detail = str(payload.message) ?? str(payload.detail)
198
204
  const message = detail ? `Uncaught ${label}: ${detail}` : `Uncaught ${label}`
199
205
  const trace = Array.isArray(payload.stack) ? payload.stack.map(String) : str(payload.stack)?.split('\n').map((l) => l.trim()).filter(Boolean)
200
- consoleEntries.push({ time, level: 'error', message, args: [message], trace: trace?.length ? trace : undefined, source: 'exception' })
206
+ consoleEntries.push({ key: str(payload.id), time, level: 'error', message, args: [message], trace: trace?.length ? trace : undefined, source: 'exception' })
201
207
  } else if (CONSOLE_TAGS.has(tag)) {
202
208
  const args = Array.isArray(payload.args) ? payload.args.map((a) => asText(a) ?? '') : [str(payload.message) ?? str(payload.label) ?? asText(payload.detail) ?? '']
203
- consoleEntries.push({ time, level: levelOf(payload.level ?? tag), message: args.join(' '), args, source: 'custom' })
209
+ consoleEntries.push({ key: str(payload.id), time, level: levelOf(payload.level ?? tag), message: args.join(' '), args, source: 'custom' })
204
210
  }
205
211
  }
206
212
  }