@mingozhou/dsh-replay 0.3.3 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +225 -164
  2. package/README.zh.md +225 -164
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +955 -2419
  5. package/lib/core.js +557 -86
  6. package/lib/index.js +1043 -51
  7. package/lib/types/src/client/api.d.ts +44 -0
  8. package/lib/types/src/client/components/AuditView.d.ts +2 -1
  9. package/lib/types/src/client/components/CompareView.d.ts +2 -1
  10. package/lib/types/src/client/components/DashboardView.d.ts +8 -0
  11. package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
  12. package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
  13. package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
  14. package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
  15. package/lib/types/src/client/components/TimelineView.d.ts +2 -1
  16. package/lib/types/src/client/export.d.ts +6 -1
  17. package/lib/types/src/client/i18n.d.ts +8 -1
  18. package/lib/types/src/client/pricing.d.ts +6 -0
  19. package/lib/types/src/core/audit.d.ts +18 -0
  20. package/lib/types/src/core/compare.d.ts +8 -3
  21. package/lib/types/src/core/cost.d.ts +21 -3
  22. package/lib/types/src/core/index.d.ts +1 -0
  23. package/lib/types/src/core/redact.d.ts +40 -0
  24. package/lib/types/src/core/timeline.d.ts +9 -0
  25. package/lib/types/src/index.d.ts +52 -1
  26. package/lib/viewer.js +282 -29
  27. package/package.json +118 -107
  28. package/scripts/build.mjs +8 -2
  29. package/src/client/api.ts +114 -10
  30. package/src/client/components/AuditView.tsx +89 -10
  31. package/src/client/components/CompareView.tsx +89 -13
  32. package/src/client/components/DashboardView.tsx +232 -0
  33. package/src/client/components/ExportDialog.tsx +136 -0
  34. package/src/client/components/ItemDetail.tsx +36 -4
  35. package/src/client/components/OverviewView.tsx +150 -34
  36. package/src/client/components/ReplayApp.tsx +207 -60
  37. package/src/client/components/ReplayModal.tsx +41 -6
  38. package/src/client/components/SessionPicker.tsx +55 -27
  39. package/src/client/components/TimelineView.tsx +106 -41
  40. package/src/client/export.ts +20 -4
  41. package/src/client/i18n.ts +266 -45
  42. package/src/client/index.ts +7 -5
  43. package/src/client/mascot-image.ts +1 -1
  44. package/src/client/pricing.ts +90 -0
  45. package/src/client/styles.css +250 -1
  46. package/src/client/viewer-entry.tsx +25 -4
  47. package/src/core/audit.ts +274 -67
  48. package/src/core/compare.ts +0 -0
  49. package/src/core/cost.ts +94 -16
  50. package/src/core/index.ts +1 -0
  51. package/src/core/redact.ts +182 -0
  52. package/src/core/timeline.ts +107 -12
  53. package/src/index.ts +290 -54
@@ -1,9 +1,9 @@
1
- import React, { useEffect } from 'react'
1
+ import React, { useEffect, useRef } from 'react'
2
2
  import type { ReplayApi } from '../api.js'
3
3
  import { Mascot } from '../mascot.js'
4
4
  import { ReplayApp } from './ReplayApp.js'
5
5
 
6
- /** Full-screen modal hosting the replay app, opened from the sidebar button. */
6
+ /** Full-screen modal hosting the session review app, opened from the sidebar. */
7
7
  export function ReplayModal({
8
8
  api,
9
9
  onClose,
@@ -13,17 +13,52 @@ export function ReplayModal({
13
13
  onClose: () => void
14
14
  closeLabel: string
15
15
  }): React.ReactElement {
16
+ const dialogRef = useRef<HTMLDivElement>(null)
17
+ const closeRef = useRef<HTMLButtonElement>(null)
18
+
16
19
  useEffect(() => {
20
+ const previous = document.activeElement as HTMLElement | null
21
+ const previousOverflow = document.body.style.overflow
22
+ document.body.style.overflow = 'hidden'
23
+ closeRef.current?.focus()
17
24
  const onKey = (event: KeyboardEvent): void => {
18
- if (event.key === 'Escape') onClose()
25
+ if (event.key === 'Escape') {
26
+ onClose()
27
+ return
28
+ }
29
+ if (event.key !== 'Tab') return
30
+ const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
31
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
32
+ )
33
+ if (focusable === undefined || focusable.length === 0) return
34
+ const first = focusable[0]
35
+ const last = focusable[focusable.length - 1]
36
+ if (event.shiftKey && document.activeElement === first) {
37
+ event.preventDefault()
38
+ last.focus()
39
+ } else if (!event.shiftKey && document.activeElement === last) {
40
+ event.preventDefault()
41
+ first.focus()
42
+ }
19
43
  }
20
44
  document.addEventListener('keydown', onKey)
21
- return () => document.removeEventListener('keydown', onKey)
45
+ return () => {
46
+ document.removeEventListener('keydown', onKey)
47
+ document.body.style.overflow = previousOverflow
48
+ previous?.focus()
49
+ }
22
50
  }, [onClose])
23
51
 
24
52
  return (
25
53
  <div className="dshr-modal-backdrop" onClick={onClose}>
26
- <div className="dshr-modal" onClick={event => event.stopPropagation()}>
54
+ <div
55
+ ref={dialogRef}
56
+ className="dshr-modal"
57
+ role="dialog"
58
+ aria-modal="true"
59
+ aria-label="dsh-replay"
60
+ onClick={event => event.stopPropagation()}
61
+ >
27
62
  <div className="dshr-modal-chrome">
28
63
  <Mascot mood="idle" size={30} />
29
64
  <span className="dshr-modal-title">dsh-replay</span>
@@ -40,7 +75,7 @@ export function ReplayModal({
40
75
  </svg>
41
76
  GitHub
42
77
  </a>
43
- <button className="dshr-modal-close" onClick={onClose} aria-label={closeLabel}>
78
+ <button ref={closeRef} className="dshr-modal-close" onClick={onClose} aria-label={closeLabel}>
44
79
  ✕
45
80
  </button>
46
81
  </div>
@@ -1,60 +1,88 @@
1
- import React, { useEffect, useState } from 'react'
2
- import { formatDuration, shortId, type ReplayApi, type SessionEntry } from '../api.js'
1
+ import React, { useMemo, useState } from 'react'
2
+ import { formatRelativeTime, shortId, type SessionEntry } from '../api.js'
3
3
  import { useI18n } from '../i18n.js'
4
4
  import { MascotState } from '../mascot.js'
5
5
 
6
6
  export function SessionPicker({
7
- api,
7
+ entries,
8
+ loading = false,
8
9
  onPick,
9
10
  }: {
10
- api: ReplayApi
11
+ entries: SessionEntry[]
12
+ loading?: boolean
11
13
  onPick: (id: string) => void
12
14
  }): React.ReactElement {
13
- const { t } = useI18n()
14
- const [entries, setEntries] = useState<SessionEntry[]>()
15
- const [error, setError] = useState<string>()
15
+ const { t, lang } = useI18n()
16
+ const [query, setQuery] = useState('')
17
+ const [workspace, setWorkspace] = useState('')
16
18
 
17
- useEffect(() => {
18
- let cancelled = false
19
- api
20
- .listSessions()
21
- .then(list => {
22
- if (!cancelled) setEntries([...list].sort((a, b) => b.header.createdAt - a.header.createdAt))
19
+ const workspaces = useMemo(
20
+ () =>
21
+ [...new Set(entries.map(entry => entry.header.cwd).filter((cwd): cwd is string => typeof cwd === 'string'))].sort(),
22
+ [entries],
23
+ )
24
+ const rows = useMemo(() => {
25
+ const q = query.trim().toLowerCase()
26
+ return entries
27
+ .filter(entry => workspace === '' || entry.header.cwd === workspace)
28
+ .filter(entry => {
29
+ if (q === '') return true
30
+ return `${entry.title ?? ''} ${entry.header.id} ${entry.header.cwd ?? ''}`.toLowerCase().includes(q)
23
31
  })
24
- .catch(e => !cancelled && setError(String(e)))
25
- return () => {
26
- cancelled = true
27
- }
28
- }, [api])
32
+ .sort((a, b) => b.header.createdAt - a.header.createdAt)
33
+ }, [entries, query, workspace])
29
34
 
30
- if (error !== undefined) return <MascotState mood="alert" text={error} />
31
- if (entries === undefined) return <MascotState mood="idle" text={t('app.loadingText')} />
35
+ if (loading && entries.length === 0) return <MascotState mood="idle" text={t('app.loadingText')} />
32
36
  if (entries.length === 0) return <MascotState mood="happy" text={t('picker.empty')} />
33
37
 
34
- const now = Date.now()
35
38
  return (
36
39
  <div className="dshr-picker">
37
40
  <div className="dshr-section-label">{t('picker.title')}</div>
38
- {entries.map(entry => (
41
+ <div className="dshr-picker-toolbar">
42
+ <input
43
+ className="dshr-filter-input dshr-picker-search"
44
+ type="search"
45
+ placeholder={t('picker.search')}
46
+ value={query}
47
+ onChange={event => setQuery(event.target.value)}
48
+ aria-label={t('picker.search')}
49
+ />
50
+ <select
51
+ className="dshr-select"
52
+ value={workspace}
53
+ onChange={event => setWorkspace(event.target.value)}
54
+ aria-label={t('picker.allWorkspaces')}
55
+ >
56
+ <option value="">{t('picker.allWorkspaces')}</option>
57
+ {workspaces.map(cwd => (
58
+ <option key={cwd} value={cwd}>
59
+ {cwd}
60
+ </option>
61
+ ))}
62
+ </select>
63
+ <span className="dshr-spacer" />
64
+ <span className="dshr-picker-count">{t('picker.count', { n: rows.length })}</span>
65
+ </div>
66
+ {rows.map(entry => (
39
67
  <button key={entry.header.id} className="dshr-picker-row" onClick={() => onPick(entry.header.id)}>
40
68
  <span className="dshr-picker-title">
41
69
  {entry.title ?? shortId(entry.header.id)}
42
- {entry.header.origin === 'subagent' && <span className="dshr-picker-tag">subagent</span>}
70
+ {entry.header.origin === 'subagent' && <span className="dshr-picker-tag">{t('picker.subagent')}</span>}
43
71
  {entry.header.parentSession !== undefined && entry.header.origin !== 'subagent' && (
44
- <span className="dshr-picker-tag">fork</span>
72
+ <span className="dshr-picker-tag">{t('picker.fork')}</span>
45
73
  )}
74
+ {entry.live === true && <span className="dshr-live-pill">{t('picker.live')}</span>}
46
75
  </span>
47
76
  <span className="dshr-picker-sub">
48
77
  {shortId(entry.header.id)}
49
78
  {entry.header.cwd !== undefined ? ` · ${entry.header.cwd}` : ''}
50
79
  </span>
51
80
  <span className="dshr-picker-age">
52
- {Number.isFinite(entry.header.createdAt) && entry.header.createdAt <= now
53
- ? formatDuration(now - entry.header.createdAt)
54
- : ''}
81
+ {formatRelativeTime(entry.header.createdAt, Date.now(), lang)}
55
82
  </span>
56
83
  </button>
57
84
  ))}
85
+ {rows.length === 0 && <MascotState mood="happy" text={t('audit.empty')} />}
58
86
  </div>
59
87
  )
60
88
  }
@@ -24,10 +24,21 @@ function TokenStrip({
24
24
  const { t } = useI18n()
25
25
  const samples = model.usageSamples
26
26
  if (samples.length === 0) return null
27
- const max = Math.max(
28
- ...samples.map(s => s.usage.inputTokens + (s.usage.cacheReadTokens ?? 0) + (s.usage.cacheWriteTokens ?? 0) + s.usage.outputTokens),
29
- 1,
30
- )
27
+ let max = 1
28
+ for (const sample of samples) {
29
+ const value =
30
+ sample.usage.inputTokens +
31
+ (sample.usage.cacheReadTokens ?? 0) +
32
+ (sample.usage.cacheWriteTokens ?? 0) +
33
+ sample.usage.outputTokens
34
+ if (value > max) max = value
35
+ }
36
+ // Precompute the next sample seq so the current-bar test is O(1), not O(n²).
37
+ const nextSeq = new Map<number, number>()
38
+ for (let index = 0; index < samples.length; index++) {
39
+ const next = samples[index + 1]
40
+ if (next !== undefined) nextSeq.set(samples[index].seq, next.seq)
41
+ }
31
42
  return (
32
43
  <div className="dshr-tokenstrip">
33
44
  <div className="dshr-tokenstrip-head">
@@ -54,8 +65,7 @@ function TokenStrip({
54
65
  sample.usage.inputTokens + (sample.usage.cacheReadTokens ?? 0) + (sample.usage.cacheWriteTokens ?? 0)
55
66
  const output = sample.usage.outputTokens
56
67
  const isCurrent =
57
- currentSeq >= sample.seq &&
58
- (samples.find(s => s.seq > sample.seq)?.seq ?? Number.POSITIVE_INFINITY) > currentSeq
68
+ currentSeq >= sample.seq && (nextSeq.get(sample.seq) ?? Number.POSITIVE_INFINITY) > currentSeq
59
69
  return (
60
70
  <button
61
71
  key={`${sample.turn}/${sample.step}`}
@@ -80,9 +90,11 @@ function TokenStrip({
80
90
  export function TimelineView({
81
91
  model,
82
92
  jumpToSeq,
93
+ jumpNonce,
83
94
  }: {
84
95
  model: TimelineModel
85
96
  jumpToSeq?: number
97
+ jumpNonce?: number
86
98
  }): React.ReactElement {
87
99
  const { t } = useI18n()
88
100
  const [query, setQuery] = useState('')
@@ -95,6 +107,7 @@ export function TimelineView({
95
107
  return (
96
108
  item.label.toLowerCase().includes(q) ||
97
109
  (item.text?.toLowerCase().includes(q) ?? false) ||
110
+ (item.reasoningText?.toLowerCase().includes(q) ?? false) ||
98
111
  (item.toolCall !== undefined &&
99
112
  (item.toolCall.name.toLowerCase().includes(q) ||
100
113
  item.toolCall.argumentsRaw.toLowerCase().includes(q) ||
@@ -102,15 +115,28 @@ export function TimelineView({
102
115
  )
103
116
  })
104
117
  }, [model.items, query, kinds])
105
- const [cursor, setCursor] = useState(items.length > 0 ? items.length - 1 : 0)
118
+ const [selectedSeq, setSelectedSeq] = useState<number>()
106
119
  const [playing, setPlaying] = useState(false)
107
120
  const [speed, setSpeed] = useState(4)
108
121
  const railRef = useRef<HTMLDivElement>(null)
109
- const current: TimelineItem | undefined = items[Math.min(cursor, items.length - 1)]
110
122
 
111
- useEffect(() => {
112
- setCursor(c => Math.min(c, Math.max(0, items.length - 1)))
113
- }, [items.length])
123
+ // Derive the cursor from the selected seq. When a filter hides the selected
124
+ // event, prefer the next visible event instead of jumping to an unrelated
125
+ // array index.
126
+ const cursor = useMemo(() => {
127
+ if (items.length === 0) return 0
128
+ if (selectedSeq === undefined) return items.length - 1
129
+ const exact = items.findIndex(item => item.seq === selectedSeq)
130
+ if (exact >= 0) return exact
131
+ const next = items.findIndex(item => item.seq >= selectedSeq)
132
+ return next >= 0 ? next : items.length - 1
133
+ }, [items, selectedSeq])
134
+ const current: TimelineItem | undefined = items[Math.min(cursor, Math.max(0, items.length - 1))]
135
+
136
+ const select = (seq: number): void => {
137
+ setSelectedSeq(seq)
138
+ setPlaying(false)
139
+ }
114
140
 
115
141
  const toggleKind = (kind: string): void => {
116
142
  setKinds(prev => {
@@ -126,12 +152,12 @@ export function TimelineView({
126
152
 
127
153
  useEffect(() => {
128
154
  if (jumpToSeq === undefined) return
129
- const index = items.findIndex(i => i.seq >= jumpToSeq)
155
+ const index = items.findIndex(item => item.seq >= jumpToSeq)
130
156
  if (index >= 0) {
131
- setCursor(index)
157
+ setSelectedSeq(items[index].seq)
132
158
  setPlaying(false)
133
159
  }
134
- }, [jumpToSeq, items])
160
+ }, [jumpToSeq, jumpNonce, items])
135
161
 
136
162
  useEffect(() => {
137
163
  if (!playing) return
@@ -139,10 +165,10 @@ export function TimelineView({
139
165
  setPlaying(false)
140
166
  return
141
167
  }
142
- const timer = setTimeout(
143
- () => setCursor(c => Math.min(c + 1, items.length - 1)),
144
- delayFor(items[cursor], items[cursor + 1], speed),
145
- )
168
+ const timer = setTimeout(() => {
169
+ const next = items[cursor + 1]
170
+ if (next !== undefined) setSelectedSeq(next.seq)
171
+ }, delayFor(items[cursor], items[cursor + 1], speed))
146
172
  return () => clearTimeout(timer)
147
173
  }, [playing, cursor, speed, items])
148
174
 
@@ -157,7 +183,7 @@ export function TimelineView({
157
183
  items.forEach((item, index) => {
158
184
  if (item.turn !== undefined && item.turn !== lastTurn) {
159
185
  lastTurn = item.turn
160
- const turnModel = model.turns.find(t => t.turn === item.turn)
186
+ const turnModel = model.turns.find(turn => turn.turn === item.turn)
161
187
  rows.push({
162
188
  head: {
163
189
  turn: item.turn,
@@ -177,8 +203,35 @@ export function TimelineView({
177
203
  }
178
204
 
179
205
  const togglePlay = (): void => {
180
- if (!playing && cursor >= items.length - 1) setCursor(0)
181
- setPlaying(p => !p)
206
+ if (!playing && cursor >= items.length - 1) {
207
+ const first = items[0]
208
+ if (first !== undefined) setSelectedSeq(first.seq)
209
+ }
210
+ setPlaying(value => !value)
211
+ }
212
+
213
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
214
+ if (items.length === 0) return
215
+ if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
216
+ event.preventDefault()
217
+ const next = items[Math.min(cursor + 1, items.length - 1)]
218
+ if (next !== undefined) select(next.seq)
219
+ } else if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
220
+ event.preventDefault()
221
+ const previous = items[Math.max(cursor - 1, 0)]
222
+ if (previous !== undefined) select(previous.seq)
223
+ } else if (event.key === 'Home') {
224
+ event.preventDefault()
225
+ const first = items[0]
226
+ if (first !== undefined) select(first.seq)
227
+ } else if (event.key === 'End') {
228
+ event.preventDefault()
229
+ const last = items[items.length - 1]
230
+ if (last !== undefined) select(last.seq)
231
+ } else if (event.key === ' ') {
232
+ event.preventDefault()
233
+ togglePlay()
234
+ }
182
235
  }
183
236
 
184
237
  return (
@@ -188,14 +241,14 @@ export function TimelineView({
188
241
  className="dshr-play-btn"
189
242
  data-playing={playing || undefined}
190
243
  onClick={togglePlay}
191
- aria-label={playing ? 'pause' : 'play'}
244
+ aria-label={playing ? t('tl.pause') : t('tl.play')}
192
245
  >
193
246
  {playing ? '❚❚' : '▶'}
194
247
  </button>
195
248
  <button
196
249
  className="dshr-speed"
197
- onClick={() => setSpeed(s => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length])}
198
- title="playback speed"
250
+ onClick={() => setSpeed(value => SPEEDS[(SPEEDS.indexOf(value) + 1) % SPEEDS.length])}
251
+ title={t('tl.speed')}
199
252
  >
200
253
  {speed}×
201
254
  </button>
@@ -205,9 +258,9 @@ export function TimelineView({
205
258
  min={0}
206
259
  max={Math.max(0, items.length - 1)}
207
260
  value={Math.min(cursor, Math.max(0, items.length - 1))}
208
- onChange={e => {
209
- setCursor(Number(e.target.value))
210
- setPlaying(false)
261
+ onChange={event => {
262
+ const next = items[Number(event.target.value)]
263
+ if (next !== undefined) select(next.seq)
211
264
  }}
212
265
  aria-label={t('play.position')}
213
266
  />
@@ -220,8 +273,8 @@ export function TimelineView({
220
273
  type="search"
221
274
  placeholder={t('flt.search')}
222
275
  value={query}
223
- onChange={e => {
224
- setQuery(e.target.value)
276
+ onChange={event => {
277
+ setQuery(event.target.value)
225
278
  setPlaying(false)
226
279
  }}
227
280
  aria-label={t('flt.search')}
@@ -245,16 +298,20 @@ export function TimelineView({
245
298
  model={model}
246
299
  currentSeq={current?.seq ?? 0}
247
300
  onJump={seq => {
248
- const index = items.findIndex(i => i.seq >= seq)
249
- if (index >= 0) {
250
- setCursor(index)
251
- setPlaying(false)
252
- }
301
+ const index = items.findIndex(item => item.seq >= seq)
302
+ if (index >= 0) select(items[index].seq)
253
303
  }}
254
304
  />
255
305
  <div className="dshr-timeline">
256
- <div className="dshr-rail" ref={railRef}>
257
- {grouped.map((row, i) =>
306
+ <div
307
+ className="dshr-rail"
308
+ ref={railRef}
309
+ tabIndex={0}
310
+ role="listbox"
311
+ aria-label={t('tab.timeline')}
312
+ onKeyDown={onKeyDown}
313
+ >
314
+ {grouped.map((row, index) =>
258
315
  'head' in row ? (
259
316
  <div key={`h${row.head.turn}`} className="dshr-turn-head">
260
317
  <span>{t('tl.turn', { n: row.head.turn })}</span>
@@ -269,21 +326,29 @@ export function TimelineView({
269
326
  <button
270
327
  key={row.item.seq}
271
328
  className="dshr-item"
329
+ role="option"
330
+ aria-selected={row.index === cursor}
272
331
  data-kind={row.item.kind}
273
332
  data-selected={row.index === cursor || undefined}
274
333
  data-future={row.index > cursor || undefined}
275
334
  data-shadowed={row.item.shadowed || undefined}
276
- onClick={() => {
277
- setCursor(row.index)
278
- setPlaying(false)
279
- }}
335
+ onClick={() => select(row.item.seq)}
280
336
  >
281
337
  <span className="dshr-item-kind">
282
338
  {row.item.kind === 'tool'
283
339
  ? row.item.toolCall?.name.slice(0, 10) ?? t('kind.tool')
284
340
  : t(`kind.${row.item.kind}`)}
285
341
  </span>
286
- <span className="dshr-item-label">{localizeItemLabel(t, row.item)}</span>
342
+ <span className="dshr-item-label">
343
+ {row.item.kind === 'tool'
344
+ ? row.item.toolCall?.summary ?? row.item.label
345
+ : localizeItemLabel(t, row.item)}
346
+ </span>
347
+ {row.item.reasoningText !== undefined && row.item.reasoningText !== '' && (
348
+ <span className="dshr-item-thinking" title={t('tl.reasoning')}>
349
+ ✦
350
+ </span>
351
+ )}
287
352
  <span className="dshr-item-time">{formatClock(row.item.time, model.startTime)}</span>
288
353
  </button>
289
354
  ),
@@ -11,16 +11,26 @@ export interface ExportSession {
11
11
  title?: string
12
12
  }
13
13
 
14
- export function buildReplayHtml(viewerSource: string, sessions: ExportSession[]): string {
14
+ export interface BuildReplayOptions {
15
+ lang?: 'en' | 'zh'
16
+ title?: string
17
+ }
18
+
19
+ export function buildReplayHtml(
20
+ viewerSource: string,
21
+ sessions: ExportSession[],
22
+ options: BuildReplayOptions = {},
23
+ ): string {
15
24
  const data = JSON.stringify({ sessions }).replaceAll('<', '\\u003c')
16
25
  const safeViewer = viewerSource.replaceAll('</script>', '<\\/script>')
17
- const title = sessions[0]?.title ?? 'session replay'
26
+ const title = options.title ?? sessions[0]?.title ?? 'session replay'
18
27
  return `<!doctype html>
19
- <html lang="en">
28
+ <html lang="${options.lang ?? 'en'}">
20
29
  <head>
21
30
  <meta charset="utf-8" />
22
31
  <meta name="viewport" content="width=device-width, initial-scale=1" />
23
- <title>dsh-replay — ${title.replaceAll('<', '&lt;')}</title>
32
+ <meta name="generator" content="dsh-replay" />
33
+ <title>dsh-replay — ${title.replaceAll('&', '&amp;').replaceAll('<', '&lt;')}</title>
24
34
  <style>html, body, #root { height: 100%; margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }</style>
25
35
  </head>
26
36
  <body>
@@ -41,3 +51,9 @@ export function downloadReplayHtml(html: string, filename: string): void {
41
51
  link.click()
42
52
  setTimeout(() => URL.revokeObjectURL(url), 5000)
43
53
  }
54
+
55
+ export function formatBytes(bytes: number): string {
56
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
57
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
58
+ return `${bytes} B`
59
+ }