@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,16 @@
1
1
  import React, { useMemo, useRef, useState } from 'react'
2
2
  import type { AuditReport } from '../../core/audit.js'
3
- import { estimateCost, formatUsd } from '../../core/cost.js'
3
+ import {
4
+ DEFAULT_PRICING,
5
+ estimateCostDetailed,
6
+ formatMoney,
7
+ type PricingEntry,
8
+ type PricingRate,
9
+ } from '../../core/cost.js'
4
10
  import type { TimelineModel } from '../../core/timeline.js'
5
11
  import { formatClock, formatDuration, formatTokens } from '../api.js'
6
12
  import { useI18n } from '../i18n.js'
13
+ import { resetPricing, setRate, usePricing } from '../pricing.js'
7
14
 
8
15
  /** Cumulative token samples for the line chart. */
9
16
  interface Point {
@@ -37,13 +44,13 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
37
44
  const points = useMemo<Point[]>(() => {
38
45
  let cumInput = 0
39
46
  let cumOutput = 0
40
- const seqTime = new Map(model.items.map(i => [i.seq, i.time]))
41
- return model.usageSamples.map(s => {
42
- cumInput += s.usage.inputTokens + (s.usage.cacheReadTokens ?? 0) + (s.usage.cacheWriteTokens ?? 0)
43
- cumOutput += s.usage.outputTokens
47
+ const seqTime = new Map(model.items.map(item => [item.seq, item.time]))
48
+ return model.usageSamples.map(sample => {
49
+ cumInput += sample.usage.inputTokens + (sample.usage.cacheReadTokens ?? 0) + (sample.usage.cacheWriteTokens ?? 0)
50
+ cumOutput += sample.usage.outputTokens
44
51
  return {
45
- x: seqTime.get(s.seq) ?? model.startTime,
46
- seq: s.seq, turn: s.turn, step: s.step,
52
+ x: seqTime.get(sample.seq) ?? model.startTime,
53
+ seq: sample.seq, turn: sample.turn, step: sample.step,
47
54
  cumInput, cumOutput,
48
55
  }
49
56
  })
@@ -60,7 +67,7 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
60
67
  const sy = (y: number): number => PAD.top + plotH - (y / yMax) * plotH
61
68
 
62
69
  const path = (key: 'cumInput' | 'cumOutput'): string =>
63
- points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${sx(p.x).toFixed(1)} ${sy(p[key]).toFixed(1)}`).join(' ')
70
+ points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${sx(point.x).toFixed(1)} ${sy(point[key]).toFixed(1)}`).join(' ')
64
71
  const area = (key: 'cumInput' | 'cumOutput'): string =>
65
72
  `${path(key)} L ${sx(points.at(-1)!.x).toFixed(1)} ${sy(0)} L ${sx(points[0].x).toFixed(1)} ${sy(0)} Z`
66
73
 
@@ -71,16 +78,18 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
71
78
  const rect = svgRef.current?.getBoundingClientRect()
72
79
  if (!rect) return
73
80
  const mx = ((event.clientX - rect.left) / rect.width) * W
74
- let best = 0
75
- let bestDist = Number.POSITIVE_INFINITY
76
- points.forEach((p, i) => {
77
- const d = Math.abs(sx(p.x) - mx)
78
- if (d < bestDist) {
79
- bestDist = d
80
- best = i
81
- }
82
- })
83
- setHover(best)
81
+ // Binary search the nearest point by x; mousemove should be O(log n).
82
+ let low = 0
83
+ let high = points.length - 1
84
+ while (low < high) {
85
+ const mid = (low + high) >> 1
86
+ if (sx(points[mid].x) < mx) low = mid + 1
87
+ else high = mid
88
+ }
89
+ const candidate = low > 0 && Math.abs(sx(points[low - 1].x) - mx) <= Math.abs(sx(points[low].x) - mx)
90
+ ? low - 1
91
+ : low
92
+ setHover(candidate)
84
93
  }
85
94
 
86
95
  return (
@@ -108,11 +117,11 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
108
117
  onMouseMove={onMove}
109
118
  onMouseLeave={() => setHover(undefined)}
110
119
  >
111
- {ticks.map(v => (
112
- <g key={v}>
113
- <line className="dshr-grid" x1={PAD.left} x2={W - PAD.right} y1={sy(v)} y2={sy(v)} />
114
- <text className="dshr-axis-label" x={PAD.left - 8} y={sy(v) + 3} textAnchor="end">
115
- {formatTokens(v)}
120
+ {ticks.map(value => (
121
+ <g key={value}>
122
+ <line className="dshr-grid" x1={PAD.left} x2={W - PAD.right} y1={sy(value)} y2={sy(value)} />
123
+ <text className="dshr-axis-label" x={PAD.left - 8} y={sy(value) + 3} textAnchor="end">
124
+ {formatTokens(value)}
116
125
  </text>
117
126
  </g>
118
127
  ))}
@@ -125,7 +134,6 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
125
134
  <path d={area('cumInput')} fill="var(--dshr-s-assistant)" opacity={0.1} />
126
135
  <path d={path('cumInput')} className="dshr-line" stroke="var(--dshr-s-assistant)" />
127
136
  <path d={path('cumOutput')} className="dshr-line" stroke="var(--dshr-s-tool)" />
128
- {/* end markers + end labels */}
129
137
  <circle cx={sx(points.at(-1)!.x)} cy={sy(points.at(-1)!.cumInput)} r={4}
130
138
  fill="var(--dshr-s-assistant)" stroke="var(--dshr-bg)" strokeWidth={2} />
131
139
  <circle cx={sx(points.at(-1)!.x)} cy={sy(points.at(-1)!.cumOutput)} r={4}
@@ -177,13 +185,13 @@ function ToolTimeBars({ model }: { model: TimelineModel }): React.ReactElement |
177
185
  byTool.set(call.name, entry)
178
186
  }
179
187
  return [...byTool.entries()]
180
- .map(([name, v]) => ({ name, ...v }))
188
+ .map(([name, value]) => ({ name, ...value }))
181
189
  .sort((a, b) => b.ms - a.ms)
182
190
  .slice(0, 8)
183
191
  }, [model])
184
192
 
185
193
  if (rows.length === 0) return null
186
- const max = Math.max(...rows.map(r => r.ms), 1)
194
+ const max = Math.max(...rows.map(row => row.ms), 1)
187
195
 
188
196
  return (
189
197
  <div className="dshr-panel">
@@ -207,6 +215,64 @@ function ToolTimeBars({ model }: { model: TimelineModel }): React.ReactElement |
207
215
  )
208
216
  }
209
217
 
218
+ function PricingEditor({ pricing, onClose }: { pricing: PricingEntry[]; onClose: () => void }): React.ReactElement {
219
+ const { t } = useI18n()
220
+ const [draft, setDraft] = useState<Record<string, PricingRate>>(() =>
221
+ Object.fromEntries(pricing.map(entry => [entry.label, { ...entry.rate }])),
222
+ )
223
+ const update = (label: string, key: 'input' | 'cacheRead' | 'output', value: string): void => {
224
+ const number = Number(value)
225
+ if (!Number.isFinite(number) || number < 0) return
226
+ setDraft(current => ({ ...current, [label]: { ...current[label], [key]: number } }))
227
+ }
228
+ const save = (): void => {
229
+ for (const entry of DEFAULT_PRICING) setRate(entry.label, draft[entry.label])
230
+ onClose()
231
+ }
232
+ return (
233
+ <div className="dshr-price-editor">
234
+ <table className="dshr-table dshr-price-table">
235
+ <thead>
236
+ <tr>
237
+ <th>{t('ov.perModel')}</th>
238
+ <th>{t('ov.inputTokens')}</th>
239
+ <th>{t('detail.cacheRead')}</th>
240
+ <th>{t('ov.outputTokens')}</th>
241
+ </tr>
242
+ </thead>
243
+ <tbody>
244
+ {DEFAULT_PRICING.map(entry => (
245
+ <tr key={entry.label}>
246
+ <td>{entry.label}</td>
247
+ {(['input', 'cacheRead', 'output'] as const).map(key => (
248
+ <td key={key}>
249
+ <input
250
+ className="dshr-price-input"
251
+ type="number"
252
+ min={0}
253
+ step={0.01}
254
+ value={draft[entry.label][key]}
255
+ onChange={event => update(entry.label, key, event.target.value)}
256
+ aria-label={`${entry.label} ${key}`}
257
+ />
258
+ </td>
259
+ ))}
260
+ </tr>
261
+ ))}
262
+ </tbody>
263
+ </table>
264
+ <div className="dshr-price-actions">
265
+ <button className="dshr-btn" onClick={() => { resetPricing(); onClose() }}>
266
+ {t('ov.priceReset')}
267
+ </button>
268
+ <span className="dshr-spacer" />
269
+ <button className="dshr-btn" onClick={onClose}>{t('exp.cancel')}</button>
270
+ <button className="dshr-btn dshr-btn-primary" onClick={save}>{t('exp.confirm')}</button>
271
+ </div>
272
+ </div>
273
+ )
274
+ }
275
+
210
276
  export function OverviewView({
211
277
  model,
212
278
  report,
@@ -217,9 +283,12 @@ export function OverviewView({
217
283
  onOpenAudit: () => void
218
284
  }): React.ReactElement {
219
285
  const { t } = useI18n()
220
- const steps = model.turns.reduce((n, turn) => n + turn.steps.length, 0)
221
- const alerts = report.counts.critical + report.counts.serious
222
- const cost = estimateCost(model.totals, model.models)
286
+ const pricing = usePricing()
287
+ const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD')
288
+ const [editing, setEditing] = useState(false)
289
+ const steps = model.turns.reduce((count, turn) => count + turn.steps.length, 0)
290
+ const risk = report.counts.critical + report.counts.serious
291
+ const cost = estimateCostDetailed(model.modelTotals, pricing)
223
292
  const tiles: { value: string; label: string; alert?: boolean; onClick?: () => void }[] = [
224
293
  { value: formatDuration(model.durationMs), label: t('cmp.duration') },
225
294
  { value: String(model.turns.length), label: t('cmp.turns') },
@@ -227,11 +296,13 @@ export function OverviewView({
227
296
  { value: String(model.toolCalls.length), label: t('cmp.toolCalls') },
228
297
  { value: formatTokens(model.totals.billedInput), label: t('ov.inputTokens') },
229
298
  { value: formatTokens(model.totals.output), label: t('ov.outputTokens') },
230
- ...(cost !== undefined
231
- ? [{ value: formatUsd(cost.usd), label: t('ov.cost', { label: cost.label }) }]
232
- : []),
233
- { value: String(report.findings.length), label: t('ov.findings'), alert: alerts > 0, onClick: onOpenAudit },
299
+ {
300
+ value: cost !== undefined ? formatMoney(cost.usd, currency) : '—',
301
+ label: cost !== undefined ? t('ov.cost', { label: cost.label }) : t('ov.noCost'),
302
+ },
303
+ { value: String(risk), label: t('ov.risk'), alert: risk > 0, onClick: onOpenAudit },
234
304
  ]
305
+
235
306
  return (
236
307
  <div className="dshr-overview">
237
308
  <div className="dshr-tiles">
@@ -245,7 +316,7 @@ export function OverviewView({
245
316
  >
246
317
  <span className="dshr-tile-value">
247
318
  {tile.value}
248
- {tile.alert ? <span className="dshr-tile-flag">⛔ {alerts}</span> : null}
319
+ {tile.alert ? <span className="dshr-tile-flag">⛔ {risk}</span> : null}
249
320
  </span>
250
321
  <span className="dshr-tile-label">{tile.label}</span>
251
322
  </button>
@@ -253,6 +324,51 @@ export function OverviewView({
253
324
  </div>
254
325
  <CumulativeTokensChart model={model} />
255
326
  <ToolTimeBars model={model} />
327
+ <div className="dshr-panel dshr-cost-panel">
328
+ <div className="dshr-panel-head">
329
+ <span className="dshr-panel-title">{t('ov.costPanel')}</span>
330
+ <span className="dshr-spacer" />
331
+ <span className="dshr-segmented">
332
+ <button data-active={currency === 'USD' || undefined} onClick={() => setCurrency('USD')}>USD</button>
333
+ <button data-active={currency === 'CNY' || undefined} onClick={() => setCurrency('CNY')}>CNY</button>
334
+ </span>
335
+ <button className="dshr-tab" onClick={() => setEditing(value => !value)}>
336
+ {t('ov.editPrices')}
337
+ </button>
338
+ </div>
339
+ {cost !== undefined ? (
340
+ <>
341
+ <div className="dshr-cost-total">
342
+ <span>{t('ov.cost', { label: cost.label })}</span>
343
+ <strong>{formatMoney(cost.usd, currency)}</strong>
344
+ </div>
345
+ {cost.parts.length > 1 && (
346
+ <table className="dshr-table dshr-cost-table">
347
+ <thead>
348
+ <tr>
349
+ <th>{t('ov.perModel')}</th>
350
+ <th>{t('dash.tokens')}</th>
351
+ <th>{t('dash.cost')}</th>
352
+ </tr>
353
+ </thead>
354
+ <tbody>
355
+ {cost.parts.map(part => (
356
+ <tr key={part.model}>
357
+ <td>{part.model}</td>
358
+ <td>{formatTokens(part.totals.billedInput + part.totals.output)}</td>
359
+ <td>{formatMoney(part.usd, currency)}</td>
360
+ </tr>
361
+ ))}
362
+ </tbody>
363
+ </table>
364
+ )}
365
+ </>
366
+ ) : (
367
+ <p className="dshr-cost-note">{t('ov.noCost')}</p>
368
+ )}
369
+ <p className="dshr-cost-note">{t('ov.costNote')}</p>
370
+ {editing && <PricingEditor pricing={pricing} onClose={() => setEditing(false)} />}
371
+ </div>
256
372
  </div>
257
373
  )
258
374
  }
@@ -2,10 +2,18 @@ import React, { useEffect, useMemo, useState } from 'react'
2
2
  import { auditSession } from '../../core/audit.js'
3
3
  import { buildTimeline } from '../../core/timeline.js'
4
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'
5
+ import {
6
+ formatDuration,
7
+ formatTokens,
8
+ shortId,
9
+ type ReplayApi,
10
+ type SessionEntry,
11
+ type SessionSummaryEntry,
12
+ } from '../api.js'
13
+ import { I18nContext, makeT, setLang as setGlobalLang, useLang, type Lang } from '../i18n.js'
8
14
  import { MascotState } from '../mascot.js'
15
+ import { DashboardView } from './DashboardView.js'
16
+ import { ExportDialog } from './ExportDialog.js'
9
17
  import { SessionPicker } from './SessionPicker.js'
10
18
  import { AuditView } from './AuditView.js'
11
19
  import { CompareView } from './CompareView.js'
@@ -13,9 +21,9 @@ import { ForkTreeView } from './ForkTreeView.js'
13
21
  import { OverviewView } from './OverviewView.js'
14
22
  import { TimelineView } from './TimelineView.js'
15
23
 
16
- type Tab = 'overview' | 'timeline' | 'audit' | 'forks' | 'compare'
24
+ type Tab = 'dashboard' | 'overview' | 'timeline' | 'audit' | 'forks' | 'compare'
17
25
 
18
- const TABS: { id: Tab; labelKey: string }[] = [
26
+ const TABS: { id: Exclude<Tab, 'dashboard'>; labelKey: string }[] = [
19
27
  { id: 'overview', labelKey: 'tab.overview' },
20
28
  { id: 'timeline', labelKey: 'tab.timeline' },
21
29
  { id: 'audit', labelKey: 'tab.audit' },
@@ -28,56 +36,133 @@ export function ReplayApp({
28
36
  sessionId,
29
37
  initialLang,
30
38
  onSwitchSession,
39
+ standalone = false,
31
40
  }: {
32
41
  api: ReplayApi
33
- /** Omit to start on the session picker (modal / standalone modes). */
42
+ /** Omit to start on the session archive (modal / standalone modes). */
34
43
  sessionId?: string
35
44
  /** Override the detected language ('en' | 'zh'). */
36
45
  initialLang?: Lang
37
46
  /** Optional host-provided navigation; falls back to internal state. */
38
47
  onSwitchSession?: (id: string) => void
48
+ /** Exported HTML viewer: one session, no archive/forks/compare/export. */
49
+ standalone?: boolean
39
50
  }): React.ReactElement {
40
- const [lang, setLangState] = useState<Lang>(initialLang ?? detectLang())
41
- const [tab, setTab] = useState<Tab>('overview')
51
+ const lang = useLang()
52
+ const t = useMemo(() => makeT(lang), [lang])
53
+ const [tab, setTab] = useState<Tab>(sessionId !== undefined ? 'overview' : 'dashboard')
42
54
  const [currentId, setCurrentId] = useState<string | undefined>(sessionId)
43
55
  const [session, setSession] = useState<ParsedSession>()
44
56
  const [entries, setEntries] = useState<SessionEntry[]>([])
45
- const [error, setError] = useState<string>()
57
+ const [summaries, setSummaries] = useState<SessionSummaryEntry[]>([])
58
+ const [listLoading, setListLoading] = useState(true)
59
+ const [listError, setListError] = useState<string>()
60
+ const [sessionLoading, setSessionLoading] = useState(false)
61
+ const [sessionError, setSessionError] = useState<string>()
62
+ const [refreshTick, setRefreshTick] = useState(0)
63
+ const [updatedAt, setUpdatedAt] = useState<number>()
46
64
  const [jumpToSeq, setJumpToSeq] = useState<number>()
65
+ const [jumpNonce, setJumpNonce] = useState(0)
66
+ const [exporting, setExporting] = useState(false)
47
67
 
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])
68
+ const i18n = useMemo(() => ({ lang, t, setLang: setGlobalLang }), [lang, t])
54
69
 
55
70
  useEffect(() => {
56
- if (sessionId !== undefined) setCurrentId(sessionId)
71
+ if (initialLang !== undefined) setGlobalLang(initialLang)
72
+ }, [initialLang])
73
+
74
+ useEffect(() => {
75
+ if (sessionId !== undefined) {
76
+ setCurrentId(sessionId)
77
+ setTab('overview')
78
+ }
57
79
  }, [sessionId])
58
80
 
81
+ // The session list and summaries load once, not on every session switch.
59
82
  useEffect(() => {
60
83
  let cancelled = false
61
- setError(undefined)
62
- if (currentId !== undefined) {
63
- setSession(undefined)
84
+ setListLoading(true)
85
+ setListError(undefined)
86
+ api
87
+ .listSessions()
88
+ .then(list => {
89
+ if (cancelled) return
90
+ setEntries([...list].sort((a, b) => b.header.createdAt - a.header.createdAt))
91
+ setListLoading(false)
92
+ setUpdatedAt(Date.now())
93
+ })
94
+ .catch(error => {
95
+ if (cancelled) return
96
+ setListError(String(error))
97
+ setListLoading(false)
98
+ })
99
+ if (api.getSummaries !== undefined) {
64
100
  api
65
- .getSession(currentId)
66
- .then(s => !cancelled && setSession(s))
67
- .catch(e => !cancelled && setError(String(e)))
101
+ .getSummaries(30)
102
+ .then(list => !cancelled && setSummaries(list))
103
+ .catch(() => {
104
+ /* dashboard cards are optional; the session list still works */
105
+ })
106
+ }
107
+ return () => {
108
+ cancelled = true
109
+ }
110
+ }, [api, refreshTick])
111
+
112
+ // Only the current session is fetched on selection.
113
+ useEffect(() => {
114
+ if (currentId === undefined) {
115
+ setSession(undefined)
116
+ setSessionError(undefined)
117
+ return
68
118
  }
119
+ let cancelled = false
120
+ setSessionLoading(true)
121
+ setSessionError(undefined)
122
+ setSession(undefined)
69
123
  api
70
- .listSessions()
71
- .then(list => !cancelled && setEntries(list))
72
- .catch(() => {})
124
+ .getSession(currentId)
125
+ .then(value => {
126
+ if (cancelled) return
127
+ setSession(value)
128
+ setSessionLoading(false)
129
+ setUpdatedAt(Date.now())
130
+ })
131
+ .catch(error => {
132
+ if (cancelled) return
133
+ setSessionError(String(error))
134
+ setSessionLoading(false)
135
+ })
73
136
  return () => {
74
137
  cancelled = true
75
138
  }
76
- }, [api, currentId])
139
+ }, [api, currentId, refreshTick])
77
140
 
78
141
  const model = useMemo(() => (session ? buildTimeline(session) : undefined), [session])
79
142
  const report = useMemo(() => (session ? auditSession(session) : undefined), [session])
80
- const title = entries.find(e => e.header.id === currentId)?.title ?? model?.title
143
+ const currentEntry = entries.find(entry => entry.header.id === currentId)
144
+ const title = currentEntry?.title ?? model?.title
145
+
146
+ const availableTabs = useMemo(
147
+ () =>
148
+ TABS.filter(item => {
149
+ if (standalone && (item.id === 'forks' || item.id === 'compare')) return false
150
+ return true
151
+ }),
152
+ [standalone],
153
+ )
154
+
155
+ useEffect(() => {
156
+ if (currentId === undefined) {
157
+ setTab('dashboard')
158
+ return
159
+ }
160
+ if (tab === 'dashboard') {
161
+ setTab('overview')
162
+ return
163
+ }
164
+ if (!availableTabs.some(item => item.id === tab)) setTab('overview')
165
+ }, [availableTabs, currentId, tab])
81
166
 
82
167
  const switchSession = (id: string): void => {
83
168
  setJumpToSeq(undefined)
@@ -86,65 +171,99 @@ export function ReplayApp({
86
171
  setTab('overview')
87
172
  }
88
173
 
174
+ const backToArchive = (): void => {
175
+ setCurrentId(undefined)
176
+ setSession(undefined)
177
+ setTab('dashboard')
178
+ }
179
+
180
+ const refresh = (): void => setRefreshTick(tick => tick + 1)
181
+
89
182
  const jump = (seq: number): void => {
90
183
  setTab('timeline')
91
184
  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
- }
185
+ setJumpNonce(nonce => nonce + 1)
106
186
  }
107
187
 
108
188
  const langToggle = (
109
189
  <button
110
190
  className="dshr-tab"
111
- onClick={() => setLang(lang === 'en' ? 'zh' : 'en')}
112
- title={lang === 'en' ? '切换到中文' : 'Switch to English'}
113
- aria-label="language"
191
+ onClick={() => setGlobalLang(lang === 'en' ? 'zh' : 'en')}
192
+ title={t('lang.switch')}
193
+ aria-label={t('lang.switch')}
114
194
  >
115
195
  {lang === 'en' ? '中' : 'EN'}
116
196
  </button>
117
197
  )
118
198
 
199
+ const updated = updatedAt !== undefined
200
+ ? t('app.updated', {
201
+ time: new Date(updatedAt).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en', {
202
+ hour: '2-digit',
203
+ minute: '2-digit',
204
+ }),
205
+ })
206
+ : undefined
207
+
119
208
  if (currentId === undefined) {
120
209
  return (
121
210
  <I18nContext.Provider value={i18n}>
122
211
  <div className="dshr-root">
123
212
  <div className="dshr-header">
124
- <span className="dshr-title">Replay</span>
213
+ <span className="dshr-title">{t('dash.title')}</span>
214
+ <span className="dshr-header-sub">{t('app.reviewSub')}</span>
125
215
  <span className="dshr-spacer" />
216
+ {updated !== undefined && <span className="dshr-header-stat">{updated}</span>}
217
+ <button className="dshr-tab" onClick={refresh} title={t('app.refresh')}>
218
+ ↻ {t('app.refresh')}
219
+ </button>
126
220
  {langToggle}
127
221
  </div>
128
- <SessionPicker api={api} onPick={switchSession} />
222
+ {listError !== undefined ? (
223
+ <MascotState mood="alert" text={t('app.error', { error: listError })} />
224
+ ) : api.getSummaries !== undefined ? (
225
+ <DashboardView
226
+ entries={entries}
227
+ summaries={summaries}
228
+ loading={listLoading}
229
+ onPick={switchSession}
230
+ />
231
+ ) : (
232
+ <SessionPicker entries={entries} loading={listLoading} onPick={switchSession} />
233
+ )}
129
234
  </div>
130
235
  </I18nContext.Provider>
131
236
  )
132
237
  }
133
- if (error !== undefined) {
238
+
239
+ if (sessionError !== undefined) {
134
240
  return (
135
241
  <I18nContext.Provider value={i18n}>
136
242
  <div className="dshr-root">
137
243
  <div className="dshr-header">
138
- <span className="dshr-title">Replay</span>
244
+ {!standalone && (
245
+ <button className="dshr-tab" onClick={backToArchive}>
246
+ ← {t('app.back')}
247
+ </button>
248
+ )}
249
+ <span className="dshr-title">{t('modal.title')}</span>
139
250
  <span className="dshr-spacer" />
140
251
  {langToggle}
141
252
  </div>
142
- <MascotState mood="alert" text={t('app.error', { error })} />
253
+ <div className="dshr-center dshr-error-state">
254
+ <div>
255
+ <p>{t('app.error', { error: sessionError })}</p>
256
+ <button className="dshr-btn" onClick={refresh}>
257
+ {t('app.retry')}
258
+ </button>
259
+ </div>
260
+ </div>
143
261
  </div>
144
262
  </I18nContext.Provider>
145
263
  )
146
264
  }
147
- if (session === undefined || model === undefined || report === undefined) {
265
+
266
+ if (sessionLoading || session === undefined || model === undefined || report === undefined) {
148
267
  return (
149
268
  <I18nContext.Provider value={i18n}>
150
269
  <div className="dshr-root">
@@ -158,9 +277,14 @@ export function ReplayApp({
158
277
  <I18nContext.Provider value={i18n}>
159
278
  <div className="dshr-root">
160
279
  <div className="dshr-header">
161
- <span className="dshr-title">Replay</span>
280
+ {!standalone && (
281
+ <button className="dshr-tab" onClick={backToArchive} title={t('app.back')}>
282
+ ←
283
+ </button>
284
+ )}
285
+ <span className="dshr-title">{t('modal.title')}</span>
162
286
  <nav className="dshr-tabs">
163
- {TABS.map(item => (
287
+ {availableTabs.map(item => (
164
288
  <button
165
289
  key={item.id}
166
290
  className="dshr-tab"
@@ -175,14 +299,19 @@ export function ReplayApp({
175
299
  ))}
176
300
  </nav>
177
301
  <span className="dshr-spacer" />
178
- <span className="dshr-header-stat">
302
+ <span className="dshr-header-stat dshr-header-session">
179
303
  {title ?? shortId(currentId)} · {t('hdr.turns', { n: model.turns.length })} ·{' '}
180
304
  {formatTokens(model.totals.billedInput + model.totals.output)} tok ·{' '}
181
305
  {formatDuration(model.durationMs)}
182
306
  {model.models.length > 0 ? ` · ${model.models.join(', ')}` : ''}
183
307
  </span>
184
- {api.getViewerSource !== undefined && (
185
- <button className="dshr-tab" onClick={() => void handleExport()} title={t('exp.button')}>
308
+ {currentEntry?.live === true && <span className="dshr-live-dot" title={t('picker.live')} />}
309
+ {updated !== undefined && <span className="dshr-header-stat">{updated}</span>}
310
+ <button className="dshr-tab" onClick={refresh} title={t('app.refresh')}>
311
+ ↻
312
+ </button>
313
+ {!standalone && api.getViewerSource !== undefined && (
314
+ <button className="dshr-tab" onClick={() => setExporting(true)} title={t('exp.button')}>
186
315
  ⬇ {t('exp.button')}
187
316
  </button>
188
317
  )}
@@ -191,13 +320,31 @@ export function ReplayApp({
191
320
  {tab === 'overview' && (
192
321
  <OverviewView model={model} report={report} onOpenAudit={() => setTab('audit')} />
193
322
  )}
194
- {tab === 'timeline' && <TimelineView model={model} jumpToSeq={jumpToSeq} />}
195
- {tab === 'audit' && <AuditView report={report} model={model} onJump={jump} />}
196
- {tab === 'forks' && (
323
+ {tab === 'timeline' && (
324
+ <TimelineView model={model} jumpToSeq={jumpToSeq} jumpNonce={jumpNonce} />
325
+ )}
326
+ {tab === 'audit' && (
327
+ <AuditView report={report} model={model} onJump={jump} sessionId={currentId} />
328
+ )}
329
+ {tab === 'forks' && !standalone && (
197
330
  <ForkTreeView entries={entries} currentId={currentId} onSelect={switchSession} />
198
331
  )}
199
- {tab === 'compare' && (
200
- <CompareView api={api} session={session} entries={entries} title={title} />
332
+ {tab === 'compare' && !standalone && (
333
+ <CompareView
334
+ api={api}
335
+ session={session}
336
+ entries={entries}
337
+ title={title}
338
+ onOpenSession={switchSession}
339
+ />
340
+ )}
341
+ {exporting && (
342
+ <ExportDialog
343
+ api={api}
344
+ session={session}
345
+ title={title}
346
+ onClose={() => setExporting(false)}
347
+ />
201
348
  )}
202
349
  </div>
203
350
  </I18nContext.Provider>