@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.
- package/README.md +225 -164
- package/README.zh.md +225 -164
- package/assets/icon.png +0 -0
- package/lib/client.js +955 -2419
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +8 -2
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- 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
|
|
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')
|
|
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 () =>
|
|
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
|
|
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, {
|
|
2
|
-
import {
|
|
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
|
-
|
|
7
|
+
entries,
|
|
8
|
+
loading = false,
|
|
8
9
|
onPick,
|
|
9
10
|
}: {
|
|
10
|
-
|
|
11
|
+
entries: SessionEntry[]
|
|
12
|
+
loading?: boolean
|
|
11
13
|
onPick: (id: string) => void
|
|
12
14
|
}): React.ReactElement {
|
|
13
|
-
const { t } = useI18n()
|
|
14
|
-
const [
|
|
15
|
-
const [
|
|
15
|
+
const { t, lang } = useI18n()
|
|
16
|
+
const [query, setQuery] = useState('')
|
|
17
|
+
const [workspace, setWorkspace] = useState('')
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
.
|
|
25
|
-
|
|
26
|
-
cancelled = true
|
|
27
|
-
}
|
|
28
|
-
}, [api])
|
|
32
|
+
.sort((a, b) => b.header.createdAt - a.header.createdAt)
|
|
33
|
+
}, [entries, query, workspace])
|
|
29
34
|
|
|
30
|
-
if (
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 [
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
155
|
+
const index = items.findIndex(item => item.seq >= jumpToSeq)
|
|
130
156
|
if (index >= 0) {
|
|
131
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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(
|
|
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)
|
|
181
|
-
|
|
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(
|
|
198
|
-
title=
|
|
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={
|
|
209
|
-
|
|
210
|
-
|
|
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={
|
|
224
|
-
setQuery(
|
|
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(
|
|
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
|
|
257
|
-
|
|
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">
|
|
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
|
),
|
package/src/client/export.ts
CHANGED
|
@@ -11,16 +11,26 @@ export interface ExportSession {
|
|
|
11
11
|
title?: string
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export
|
|
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
|
-
<
|
|
32
|
+
<meta name="generator" content="dsh-replay" />
|
|
33
|
+
<title>dsh-replay — ${title.replaceAll('&', '&').replaceAll('<', '<')}</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
|
+
}
|