@mingozhou/dsh-replay 0.3.3

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/README.zh.md +164 -0
  4. package/cordis.patch.yml +6 -0
  5. package/lib/client.js +2478 -0
  6. package/lib/core.js +720 -0
  7. package/lib/index.js +111 -0
  8. package/lib/types/demo/main.d.ts +1 -0
  9. package/lib/types/demo/samples.d.ts +21 -0
  10. package/lib/types/src/client/api.d.ts +43 -0
  11. package/lib/types/src/client/components/AuditView.d.ts +8 -0
  12. package/lib/types/src/client/components/CompareView.d.ts +9 -0
  13. package/lib/types/src/client/components/ForkTreeView.d.ts +7 -0
  14. package/lib/types/src/client/components/ItemDetail.d.ts +6 -0
  15. package/lib/types/src/client/components/OverviewView.d.ts +8 -0
  16. package/lib/types/src/client/components/ReplayApp.d.ts +12 -0
  17. package/lib/types/src/client/components/ReplayModal.d.ts +8 -0
  18. package/lib/types/src/client/components/SessionPicker.d.ts +6 -0
  19. package/lib/types/src/client/components/TimelineView.d.ts +6 -0
  20. package/lib/types/src/client/export.d.ts +13 -0
  21. package/lib/types/src/client/i18n.d.ts +24 -0
  22. package/lib/types/src/client/index.d.ts +12 -0
  23. package/lib/types/src/client/mascot-image.d.ts +1 -0
  24. package/lib/types/src/client/mascot.d.ts +17 -0
  25. package/lib/types/src/client/viewer-entry.d.ts +1 -0
  26. package/lib/types/src/core/audit.d.ts +39 -0
  27. package/lib/types/src/core/compare.d.ts +31 -0
  28. package/lib/types/src/core/cost.d.ts +30 -0
  29. package/lib/types/src/core/decode.d.ts +25 -0
  30. package/lib/types/src/core/forktree.d.ts +33 -0
  31. package/lib/types/src/core/index.d.ts +7 -0
  32. package/lib/types/src/core/timeline.d.ts +96 -0
  33. package/lib/types/src/core/types.d.ts +88 -0
  34. package/lib/types/src/index.d.ts +20 -0
  35. package/lib/viewer.js +812 -0
  36. package/package.json +107 -0
  37. package/scripts/build.mjs +131 -0
  38. package/src/client/api.ts +119 -0
  39. package/src/client/components/AuditView.tsx +57 -0
  40. package/src/client/components/CompareView.tsx +186 -0
  41. package/src/client/components/ForkTreeView.tsx +123 -0
  42. package/src/client/components/ItemDetail.tsx +117 -0
  43. package/src/client/components/OverviewView.tsx +258 -0
  44. package/src/client/components/ReplayApp.tsx +205 -0
  45. package/src/client/components/ReplayModal.tsx +53 -0
  46. package/src/client/components/SessionPicker.tsx +60 -0
  47. package/src/client/components/TimelineView.tsx +296 -0
  48. package/src/client/export.ts +43 -0
  49. package/src/client/i18n.ts +360 -0
  50. package/src/client/index.ts +146 -0
  51. package/src/client/mascot-image.ts +1 -0
  52. package/src/client/mascot.tsx +192 -0
  53. package/src/client/styles.css +719 -0
  54. package/src/client/viewer-entry.tsx +66 -0
  55. package/src/core/audit.ts +227 -0
  56. package/src/core/compare.ts +0 -0
  57. package/src/core/cost.ts +61 -0
  58. package/src/core/decode.ts +125 -0
  59. package/src/core/forktree.ts +104 -0
  60. package/src/core/index.ts +7 -0
  61. package/src/core/timeline.ts +396 -0
  62. package/src/core/types.ts +90 -0
  63. package/src/index.ts +157 -0
  64. package/tsconfig.json +18 -0
  65. package/types.d.ts +15 -0
@@ -0,0 +1,205 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { auditSession } from '../../core/audit.js'
3
+ import { buildTimeline } from '../../core/timeline.js'
4
+ import type { ParsedSession } from '../../core/types.js'
5
+ import { formatDuration, formatTokens, shortId, type ReplayApi, type SessionEntry } from '../api.js'
6
+ import { buildReplayHtml, downloadReplayHtml } from '../export.js'
7
+ import { detectLang, I18nContext, makeT, persistLang, type Lang } from '../i18n.js'
8
+ import { MascotState } from '../mascot.js'
9
+ import { SessionPicker } from './SessionPicker.js'
10
+ import { AuditView } from './AuditView.js'
11
+ import { CompareView } from './CompareView.js'
12
+ import { ForkTreeView } from './ForkTreeView.js'
13
+ import { OverviewView } from './OverviewView.js'
14
+ import { TimelineView } from './TimelineView.js'
15
+
16
+ type Tab = 'overview' | 'timeline' | 'audit' | 'forks' | 'compare'
17
+
18
+ const TABS: { id: Tab; labelKey: string }[] = [
19
+ { id: 'overview', labelKey: 'tab.overview' },
20
+ { id: 'timeline', labelKey: 'tab.timeline' },
21
+ { id: 'audit', labelKey: 'tab.audit' },
22
+ { id: 'forks', labelKey: 'tab.forks' },
23
+ { id: 'compare', labelKey: 'tab.compare' },
24
+ ]
25
+
26
+ export function ReplayApp({
27
+ api,
28
+ sessionId,
29
+ initialLang,
30
+ onSwitchSession,
31
+ }: {
32
+ api: ReplayApi
33
+ /** Omit to start on the session picker (modal / standalone modes). */
34
+ sessionId?: string
35
+ /** Override the detected language ('en' | 'zh'). */
36
+ initialLang?: Lang
37
+ /** Optional host-provided navigation; falls back to internal state. */
38
+ onSwitchSession?: (id: string) => void
39
+ }): React.ReactElement {
40
+ const [lang, setLangState] = useState<Lang>(initialLang ?? detectLang())
41
+ const [tab, setTab] = useState<Tab>('overview')
42
+ const [currentId, setCurrentId] = useState<string | undefined>(sessionId)
43
+ const [session, setSession] = useState<ParsedSession>()
44
+ const [entries, setEntries] = useState<SessionEntry[]>([])
45
+ const [error, setError] = useState<string>()
46
+ const [jumpToSeq, setJumpToSeq] = useState<number>()
47
+
48
+ const t = useMemo(() => makeT(lang), [lang])
49
+ const setLang = (next: Lang): void => {
50
+ setLangState(next)
51
+ persistLang(next)
52
+ }
53
+ const i18n = useMemo(() => ({ lang, t, setLang }), [lang, t])
54
+
55
+ useEffect(() => {
56
+ if (sessionId !== undefined) setCurrentId(sessionId)
57
+ }, [sessionId])
58
+
59
+ useEffect(() => {
60
+ let cancelled = false
61
+ setError(undefined)
62
+ if (currentId !== undefined) {
63
+ setSession(undefined)
64
+ api
65
+ .getSession(currentId)
66
+ .then(s => !cancelled && setSession(s))
67
+ .catch(e => !cancelled && setError(String(e)))
68
+ }
69
+ api
70
+ .listSessions()
71
+ .then(list => !cancelled && setEntries(list))
72
+ .catch(() => {})
73
+ return () => {
74
+ cancelled = true
75
+ }
76
+ }, [api, currentId])
77
+
78
+ const model = useMemo(() => (session ? buildTimeline(session) : undefined), [session])
79
+ const report = useMemo(() => (session ? auditSession(session) : undefined), [session])
80
+ const title = entries.find(e => e.header.id === currentId)?.title ?? model?.title
81
+
82
+ const switchSession = (id: string): void => {
83
+ setJumpToSeq(undefined)
84
+ if (onSwitchSession) onSwitchSession(id)
85
+ setCurrentId(id)
86
+ setTab('overview')
87
+ }
88
+
89
+ const jump = (seq: number): void => {
90
+ setTab('timeline')
91
+ setJumpToSeq(seq)
92
+ }
93
+
94
+ const handleExport = async (): Promise<void> => {
95
+ if (session === undefined || api.getViewerSource === undefined) return
96
+ try {
97
+ const viewer = await api.getViewerSource()
98
+ const html = buildReplayHtml(viewer, [
99
+ { header: session.header, events: session.events, title },
100
+ ])
101
+ downloadReplayHtml(html, `replay-${shortId(session.header.id).replace('…', '')}.html`)
102
+ } catch (e) {
103
+ // eslint-disable-next-line no-alert
104
+ alert(t('exp.fail', { error: String(e) }))
105
+ }
106
+ }
107
+
108
+ const langToggle = (
109
+ <button
110
+ className="dshr-tab"
111
+ onClick={() => setLang(lang === 'en' ? 'zh' : 'en')}
112
+ title={lang === 'en' ? '切换到中文' : 'Switch to English'}
113
+ aria-label="language"
114
+ >
115
+ {lang === 'en' ? '中' : 'EN'}
116
+ </button>
117
+ )
118
+
119
+ if (currentId === undefined) {
120
+ return (
121
+ <I18nContext.Provider value={i18n}>
122
+ <div className="dshr-root">
123
+ <div className="dshr-header">
124
+ <span className="dshr-title">Replay</span>
125
+ <span className="dshr-spacer" />
126
+ {langToggle}
127
+ </div>
128
+ <SessionPicker api={api} onPick={switchSession} />
129
+ </div>
130
+ </I18nContext.Provider>
131
+ )
132
+ }
133
+ if (error !== undefined) {
134
+ return (
135
+ <I18nContext.Provider value={i18n}>
136
+ <div className="dshr-root">
137
+ <div className="dshr-header">
138
+ <span className="dshr-title">Replay</span>
139
+ <span className="dshr-spacer" />
140
+ {langToggle}
141
+ </div>
142
+ <MascotState mood="alert" text={t('app.error', { error })} />
143
+ </div>
144
+ </I18nContext.Provider>
145
+ )
146
+ }
147
+ if (session === undefined || model === undefined || report === undefined) {
148
+ return (
149
+ <I18nContext.Provider value={i18n}>
150
+ <div className="dshr-root">
151
+ <MascotState mood="idle" text={t('app.loadingText')} />
152
+ </div>
153
+ </I18nContext.Provider>
154
+ )
155
+ }
156
+
157
+ return (
158
+ <I18nContext.Provider value={i18n}>
159
+ <div className="dshr-root">
160
+ <div className="dshr-header">
161
+ <span className="dshr-title">Replay</span>
162
+ <nav className="dshr-tabs">
163
+ {TABS.map(item => (
164
+ <button
165
+ key={item.id}
166
+ className="dshr-tab"
167
+ data-active={tab === item.id || undefined}
168
+ onClick={() => setTab(item.id)}
169
+ >
170
+ {t(item.labelKey)}
171
+ {item.id === 'audit' && report.counts.critical + report.counts.serious > 0
172
+ ? ` (${report.counts.critical + report.counts.serious})`
173
+ : ''}
174
+ </button>
175
+ ))}
176
+ </nav>
177
+ <span className="dshr-spacer" />
178
+ <span className="dshr-header-stat">
179
+ {title ?? shortId(currentId)} · {t('hdr.turns', { n: model.turns.length })} ·{' '}
180
+ {formatTokens(model.totals.billedInput + model.totals.output)} tok ·{' '}
181
+ {formatDuration(model.durationMs)}
182
+ {model.models.length > 0 ? ` · ${model.models.join(', ')}` : ''}
183
+ </span>
184
+ {api.getViewerSource !== undefined && (
185
+ <button className="dshr-tab" onClick={() => void handleExport()} title={t('exp.button')}>
186
+ ⬇ {t('exp.button')}
187
+ </button>
188
+ )}
189
+ {langToggle}
190
+ </div>
191
+ {tab === 'overview' && (
192
+ <OverviewView model={model} report={report} onOpenAudit={() => setTab('audit')} />
193
+ )}
194
+ {tab === 'timeline' && <TimelineView model={model} jumpToSeq={jumpToSeq} />}
195
+ {tab === 'audit' && <AuditView report={report} model={model} onJump={jump} />}
196
+ {tab === 'forks' && (
197
+ <ForkTreeView entries={entries} currentId={currentId} onSelect={switchSession} />
198
+ )}
199
+ {tab === 'compare' && (
200
+ <CompareView api={api} session={session} entries={entries} title={title} />
201
+ )}
202
+ </div>
203
+ </I18nContext.Provider>
204
+ )
205
+ }
@@ -0,0 +1,53 @@
1
+ import React, { useEffect } from 'react'
2
+ import type { ReplayApi } from '../api.js'
3
+ import { Mascot } from '../mascot.js'
4
+ import { ReplayApp } from './ReplayApp.js'
5
+
6
+ /** Full-screen modal hosting the replay app, opened from the sidebar button. */
7
+ export function ReplayModal({
8
+ api,
9
+ onClose,
10
+ closeLabel,
11
+ }: {
12
+ api: ReplayApi
13
+ onClose: () => void
14
+ closeLabel: string
15
+ }): React.ReactElement {
16
+ useEffect(() => {
17
+ const onKey = (event: KeyboardEvent): void => {
18
+ if (event.key === 'Escape') onClose()
19
+ }
20
+ document.addEventListener('keydown', onKey)
21
+ return () => document.removeEventListener('keydown', onKey)
22
+ }, [onClose])
23
+
24
+ return (
25
+ <div className="dshr-modal-backdrop" onClick={onClose}>
26
+ <div className="dshr-modal" onClick={event => event.stopPropagation()}>
27
+ <div className="dshr-modal-chrome">
28
+ <Mascot mood="idle" size={30} />
29
+ <span className="dshr-modal-title">dsh-replay</span>
30
+ <span className="dshr-spacer" />
31
+ <a
32
+ className="dshr-modal-github"
33
+ href="https://github.com/MingoZhou/dsh-replay"
34
+ target="_blank"
35
+ rel="noreferrer"
36
+ title="GitHub"
37
+ >
38
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
39
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
40
+ </svg>
41
+ GitHub
42
+ </a>
43
+ <button className="dshr-modal-close" onClick={onClose} aria-label={closeLabel}>
44
+ ✕
45
+ </button>
46
+ </div>
47
+ <div className="dshr-modal-body">
48
+ <ReplayApp api={api} />
49
+ </div>
50
+ </div>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,60 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { formatDuration, shortId, type ReplayApi, type SessionEntry } from '../api.js'
3
+ import { useI18n } from '../i18n.js'
4
+ import { MascotState } from '../mascot.js'
5
+
6
+ export function SessionPicker({
7
+ api,
8
+ onPick,
9
+ }: {
10
+ api: ReplayApi
11
+ onPick: (id: string) => void
12
+ }): React.ReactElement {
13
+ const { t } = useI18n()
14
+ const [entries, setEntries] = useState<SessionEntry[]>()
15
+ const [error, setError] = useState<string>()
16
+
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))
23
+ })
24
+ .catch(e => !cancelled && setError(String(e)))
25
+ return () => {
26
+ cancelled = true
27
+ }
28
+ }, [api])
29
+
30
+ if (error !== undefined) return <MascotState mood="alert" text={error} />
31
+ if (entries === undefined) return <MascotState mood="idle" text={t('app.loadingText')} />
32
+ if (entries.length === 0) return <MascotState mood="happy" text={t('picker.empty')} />
33
+
34
+ const now = Date.now()
35
+ return (
36
+ <div className="dshr-picker">
37
+ <div className="dshr-section-label">{t('picker.title')}</div>
38
+ {entries.map(entry => (
39
+ <button key={entry.header.id} className="dshr-picker-row" onClick={() => onPick(entry.header.id)}>
40
+ <span className="dshr-picker-title">
41
+ {entry.title ?? shortId(entry.header.id)}
42
+ {entry.header.origin === 'subagent' && <span className="dshr-picker-tag">subagent</span>}
43
+ {entry.header.parentSession !== undefined && entry.header.origin !== 'subagent' && (
44
+ <span className="dshr-picker-tag">fork</span>
45
+ )}
46
+ </span>
47
+ <span className="dshr-picker-sub">
48
+ {shortId(entry.header.id)}
49
+ {entry.header.cwd !== undefined ? ` · ${entry.header.cwd}` : ''}
50
+ </span>
51
+ <span className="dshr-picker-age">
52
+ {Number.isFinite(entry.header.createdAt) && entry.header.createdAt <= now
53
+ ? formatDuration(now - entry.header.createdAt)
54
+ : ''}
55
+ </span>
56
+ </button>
57
+ ))}
58
+ </div>
59
+ )
60
+ }
@@ -0,0 +1,296 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { TimelineItem, TimelineModel } from '../../core/timeline.js'
3
+ import { formatClock, formatDuration, formatTokens } from '../api.js'
4
+ import { localizeItemLabel, useI18n } from '../i18n.js'
5
+ import { ItemDetail } from './ItemDetail.js'
6
+
7
+ const SPEEDS = [1, 2, 4, 8, 16]
8
+
9
+ /** Compress log time into playback time: per-item delay from real dt, clamped. */
10
+ function delayFor(prev: TimelineItem | undefined, next: TimelineItem, speed: number): number {
11
+ const dt = prev === undefined ? 0 : Math.max(0, next.time - prev.time)
12
+ return Math.min(1400, Math.max(120, dt)) / speed
13
+ }
14
+
15
+ function TokenStrip({
16
+ model,
17
+ currentSeq,
18
+ onJump,
19
+ }: {
20
+ model: TimelineModel
21
+ currentSeq: number
22
+ onJump: (seq: number) => void
23
+ }): React.ReactElement | null {
24
+ const { t } = useI18n()
25
+ const samples = model.usageSamples
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
+ )
31
+ return (
32
+ <div className="dshr-tokenstrip">
33
+ <div className="dshr-tokenstrip-head">
34
+ <span>{t('ts.title')}</span>
35
+ <span className="dshr-legend">
36
+ <span className="dshr-legend-item">
37
+ <span className="dshr-legend-swatch" style={{ background: 'var(--dshr-s-assistant)' }} />
38
+ {t('ts.input')}
39
+ </span>
40
+ <span className="dshr-legend-item">
41
+ <span className="dshr-legend-swatch" style={{ background: 'var(--dshr-s-tool)' }} />
42
+ {t('ts.output')}
43
+ </span>
44
+ </span>
45
+ <span className="dshr-spacer" />
46
+ <span>
47
+ {t('ts.total', { in: formatTokens(model.totals.billedInput), out: formatTokens(model.totals.output) })}
48
+ {model.totals.cacheRead > 0 ? t('ts.cacheRead', { n: formatTokens(model.totals.cacheRead) }) : ''}
49
+ </span>
50
+ </div>
51
+ <div className="dshr-bars" role="img" aria-label={t('ts.title')}>
52
+ {samples.map(sample => {
53
+ const input =
54
+ sample.usage.inputTokens + (sample.usage.cacheReadTokens ?? 0) + (sample.usage.cacheWriteTokens ?? 0)
55
+ const output = sample.usage.outputTokens
56
+ const isCurrent =
57
+ currentSeq >= sample.seq &&
58
+ (samples.find(s => s.seq > sample.seq)?.seq ?? Number.POSITIVE_INFINITY) > currentSeq
59
+ return (
60
+ <button
61
+ key={`${sample.turn}/${sample.step}`}
62
+ className="dshr-bar"
63
+ data-current={isCurrent || undefined}
64
+ title={t('ts.barTitle', {
65
+ turn: sample.turn, step: sample.step,
66
+ in: formatTokens(input), out: formatTokens(output),
67
+ })}
68
+ onClick={() => onJump(sample.seq)}
69
+ >
70
+ <div className="dshr-bar-out" style={{ height: `${(output / max) * 100}%` }} />
71
+ <div className="dshr-bar-in" style={{ height: `${(input / max) * 100}%` }} />
72
+ </button>
73
+ )
74
+ })}
75
+ </div>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ export function TimelineView({
81
+ model,
82
+ jumpToSeq,
83
+ }: {
84
+ model: TimelineModel
85
+ jumpToSeq?: number
86
+ }): React.ReactElement {
87
+ const { t } = useI18n()
88
+ const [query, setQuery] = useState('')
89
+ const [kinds, setKinds] = useState<Set<string>>(new Set(['user', 'assistant', 'tool', 'meta']))
90
+ const items = useMemo(() => {
91
+ const q = query.trim().toLowerCase()
92
+ return model.items.filter(item => {
93
+ if (!kinds.has(item.kind)) return false
94
+ if (q === '') return true
95
+ return (
96
+ item.label.toLowerCase().includes(q) ||
97
+ (item.text?.toLowerCase().includes(q) ?? false) ||
98
+ (item.toolCall !== undefined &&
99
+ (item.toolCall.name.toLowerCase().includes(q) ||
100
+ item.toolCall.argumentsRaw.toLowerCase().includes(q) ||
101
+ (item.toolCall.resultText?.toLowerCase().includes(q) ?? false)))
102
+ )
103
+ })
104
+ }, [model.items, query, kinds])
105
+ const [cursor, setCursor] = useState(items.length > 0 ? items.length - 1 : 0)
106
+ const [playing, setPlaying] = useState(false)
107
+ const [speed, setSpeed] = useState(4)
108
+ const railRef = useRef<HTMLDivElement>(null)
109
+ const current: TimelineItem | undefined = items[Math.min(cursor, items.length - 1)]
110
+
111
+ useEffect(() => {
112
+ setCursor(c => Math.min(c, Math.max(0, items.length - 1)))
113
+ }, [items.length])
114
+
115
+ const toggleKind = (kind: string): void => {
116
+ setKinds(prev => {
117
+ const next = new Set(prev)
118
+ if (next.has(kind)) {
119
+ if (next.size > 1) next.delete(kind)
120
+ } else {
121
+ next.add(kind)
122
+ }
123
+ return next
124
+ })
125
+ }
126
+
127
+ useEffect(() => {
128
+ if (jumpToSeq === undefined) return
129
+ const index = items.findIndex(i => i.seq >= jumpToSeq)
130
+ if (index >= 0) {
131
+ setCursor(index)
132
+ setPlaying(false)
133
+ }
134
+ }, [jumpToSeq, items])
135
+
136
+ useEffect(() => {
137
+ if (!playing) return
138
+ if (cursor >= items.length - 1) {
139
+ setPlaying(false)
140
+ return
141
+ }
142
+ const timer = setTimeout(
143
+ () => setCursor(c => Math.min(c + 1, items.length - 1)),
144
+ delayFor(items[cursor], items[cursor + 1], speed),
145
+ )
146
+ return () => clearTimeout(timer)
147
+ }, [playing, cursor, speed, items])
148
+
149
+ useEffect(() => {
150
+ const el = railRef.current?.querySelector<HTMLElement>('[data-selected="true"]')
151
+ el?.scrollIntoView({ block: 'nearest' })
152
+ }, [cursor])
153
+
154
+ const grouped = useMemo(() => {
155
+ const rows: ({ head: { turn: number; reason?: string; durationMs?: number } } | { item: TimelineItem; index: number })[] = []
156
+ let lastTurn: number | undefined
157
+ items.forEach((item, index) => {
158
+ if (item.turn !== undefined && item.turn !== lastTurn) {
159
+ lastTurn = item.turn
160
+ const turnModel = model.turns.find(t => t.turn === item.turn)
161
+ rows.push({
162
+ head: {
163
+ turn: item.turn,
164
+ reason: turnModel?.endReason,
165
+ durationMs:
166
+ turnModel?.endTime !== undefined ? turnModel.endTime - turnModel.startTime : undefined,
167
+ },
168
+ })
169
+ }
170
+ rows.push({ item, index })
171
+ })
172
+ return rows
173
+ }, [items, model.turns])
174
+
175
+ if (model.items.length === 0) {
176
+ return <div className="dshr-center">{t('tl.empty')}</div>
177
+ }
178
+
179
+ const togglePlay = (): void => {
180
+ if (!playing && cursor >= items.length - 1) setCursor(0)
181
+ setPlaying(p => !p)
182
+ }
183
+
184
+ return (
185
+ <>
186
+ <div className="dshr-playbar">
187
+ <button
188
+ className="dshr-play-btn"
189
+ data-playing={playing || undefined}
190
+ onClick={togglePlay}
191
+ aria-label={playing ? 'pause' : 'play'}
192
+ >
193
+ {playing ? '❚❚' : '▶'}
194
+ </button>
195
+ <button
196
+ className="dshr-speed"
197
+ onClick={() => setSpeed(s => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length])}
198
+ title="playback speed"
199
+ >
200
+ {speed}×
201
+ </button>
202
+ <input
203
+ className="dshr-scrub"
204
+ type="range"
205
+ min={0}
206
+ max={Math.max(0, items.length - 1)}
207
+ value={Math.min(cursor, Math.max(0, items.length - 1))}
208
+ onChange={e => {
209
+ setCursor(Number(e.target.value))
210
+ setPlaying(false)
211
+ }}
212
+ aria-label={t('play.position')}
213
+ />
214
+ <span className="dshr-clock">
215
+ {current ? formatClock(current.time, model.startTime) : '00:00'} /{' '}
216
+ {formatClock(model.endTime, model.startTime)} · {t('play.event', { i: Math.min(cursor, Math.max(0, items.length - 1)) + 1, n: items.length })}
217
+ </span>
218
+ <input
219
+ className="dshr-filter-input"
220
+ type="search"
221
+ placeholder={t('flt.search')}
222
+ value={query}
223
+ onChange={e => {
224
+ setQuery(e.target.value)
225
+ setPlaying(false)
226
+ }}
227
+ aria-label={t('flt.search')}
228
+ />
229
+ <span className="dshr-kind-chips">
230
+ {(['user', 'assistant', 'tool', 'meta'] as const).map(kind => (
231
+ <button
232
+ key={kind}
233
+ className="dshr-kind-chip"
234
+ data-on={kinds.has(kind) || undefined}
235
+ data-kind={kind}
236
+ onClick={() => toggleKind(kind)}
237
+ >
238
+ <span className="dshr-kind-dot" />
239
+ {t(`kind.${kind}`)}
240
+ </button>
241
+ ))}
242
+ </span>
243
+ </div>
244
+ <TokenStrip
245
+ model={model}
246
+ currentSeq={current?.seq ?? 0}
247
+ onJump={seq => {
248
+ const index = items.findIndex(i => i.seq >= seq)
249
+ if (index >= 0) {
250
+ setCursor(index)
251
+ setPlaying(false)
252
+ }
253
+ }}
254
+ />
255
+ <div className="dshr-timeline">
256
+ <div className="dshr-rail" ref={railRef}>
257
+ {grouped.map((row, i) =>
258
+ 'head' in row ? (
259
+ <div key={`h${row.head.turn}`} className="dshr-turn-head">
260
+ <span>{t('tl.turn', { n: row.head.turn })}</span>
261
+ {row.head.durationMs !== undefined && (
262
+ <span className="dshr-turn-reason">{formatDuration(row.head.durationMs)}</span>
263
+ )}
264
+ {row.head.reason !== undefined && row.head.reason !== 'completed' && (
265
+ <span className="dshr-turn-reason">· {row.head.reason}</span>
266
+ )}
267
+ </div>
268
+ ) : (
269
+ <button
270
+ key={row.item.seq}
271
+ className="dshr-item"
272
+ data-kind={row.item.kind}
273
+ data-selected={row.index === cursor || undefined}
274
+ data-future={row.index > cursor || undefined}
275
+ data-shadowed={row.item.shadowed || undefined}
276
+ onClick={() => {
277
+ setCursor(row.index)
278
+ setPlaying(false)
279
+ }}
280
+ >
281
+ <span className="dshr-item-kind">
282
+ {row.item.kind === 'tool'
283
+ ? row.item.toolCall?.name.slice(0, 10) ?? t('kind.tool')
284
+ : t(`kind.${row.item.kind}`)}
285
+ </span>
286
+ <span className="dshr-item-label">{localizeItemLabel(t, row.item)}</span>
287
+ <span className="dshr-item-time">{formatClock(row.item.time, model.startTime)}</span>
288
+ </button>
289
+ ),
290
+ )}
291
+ </div>
292
+ <div className="dshr-detail">{current && <ItemDetail item={current} model={model} />}</div>
293
+ </div>
294
+ </>
295
+ )
296
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Export a session as a single self-contained HTML file: the viewer bundle
3
+ * (react inlined) + the session data, playable offline and shareable in an
4
+ * issue, a blog post, or a chat.
5
+ */
6
+ import type { ParsedSession } from '../core/types.js'
7
+
8
+ export interface ExportSession {
9
+ header: ParsedSession['header']
10
+ events: ParsedSession['events']
11
+ title?: string
12
+ }
13
+
14
+ export function buildReplayHtml(viewerSource: string, sessions: ExportSession[]): string {
15
+ const data = JSON.stringify({ sessions }).replaceAll('<', '\\u003c')
16
+ const safeViewer = viewerSource.replaceAll('</script>', '<\\/script>')
17
+ const title = sessions[0]?.title ?? 'session replay'
18
+ return `<!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
23
+ <title>dsh-replay — ${title.replaceAll('<', '&lt;')}</title>
24
+ <style>html, body, #root { height: 100%; margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }</style>
25
+ </head>
26
+ <body>
27
+ <div id="root"></div>
28
+ <script>window.__DSHR_DATA__ = ${data}</script>
29
+ <script>${safeViewer}</script>
30
+ </body>
31
+ </html>
32
+ `
33
+ }
34
+
35
+ export function downloadReplayHtml(html: string, filename: string): void {
36
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
37
+ const url = URL.createObjectURL(blob)
38
+ const link = document.createElement('a')
39
+ link.href = url
40
+ link.download = filename
41
+ link.click()
42
+ setTimeout(() => URL.revokeObjectURL(url), 5000)
43
+ }