@mingozhou/dsh-replay 0.4.1 → 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 -148
  2. package/README.zh.md +225 -154
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +954 -2418
  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 +7 -1
  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,8 +1,13 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react'
2
+ import { auditSession } from '../../core/audit.js'
2
3
  import { compareSessions, type CompareResult } from '../../core/compare.js'
4
+ import { estimateCostDetailed, formatMoney } from '../../core/cost.js'
5
+ import { redactText } from '../../core/redact.js'
6
+ import { buildTimeline } from '../../core/timeline.js'
3
7
  import type { ParsedSession } from '../../core/types.js'
4
8
  import { formatDuration, formatTokens, shortId, type ReplayApi, type SessionEntry } from '../api.js'
5
9
  import { useI18n } from '../i18n.js'
10
+ import { usePricing } from '../pricing.js'
6
11
 
7
12
  function Row({
8
13
  label,
@@ -27,25 +32,38 @@ function Row({
27
32
  )
28
33
  }
29
34
 
35
+ function preview(event: { type: string; data: unknown } | undefined): string {
36
+ if (event === undefined) return '—'
37
+ const data = redactText(JSON.stringify(event.data) ?? '').text
38
+ return `${event.type}\n${data.slice(0, 700)}${data.length > 700 ? '…' : ''}`
39
+ }
40
+
30
41
  export function CompareView({
31
42
  api,
32
43
  session,
33
44
  entries,
34
45
  title,
46
+ onOpenSession,
35
47
  }: {
36
48
  api: ReplayApi
37
49
  session: ParsedSession
38
50
  entries: SessionEntry[]
39
51
  title?: string
52
+ onOpenSession?: (id: string) => void
40
53
  }): React.ReactElement {
41
54
  const { t } = useI18n()
55
+ const pricing = usePricing()
42
56
  const others = useMemo(
43
- () => entries.filter(e => e.header.id !== session.header.id),
57
+ () => entries.filter(entry => entry.header.id !== session.header.id),
44
58
  [entries, session],
45
59
  )
60
+ const parentMap = useMemo(
61
+ () => new Map(entries.map(entry => [entry.header.id, entry.header.parentSession])),
62
+ [entries],
63
+ )
46
64
  const defaultOther =
47
- others.find(e => e.header.id === session.header.parentSession)?.header.id ??
48
- others.find(e => e.header.parentSession === session.header.id)?.header.id ??
65
+ others.find(entry => entry.header.id === session.header.parentSession)?.header.id ??
66
+ others.find(entry => entry.header.parentSession === session.header.id)?.header.id ??
49
67
  others[0]?.header.id
50
68
  const [otherId, setOtherId] = useState(defaultOther)
51
69
  const [other, setOther] = useState<ParsedSession>()
@@ -60,13 +78,13 @@ export function CompareView({
60
78
  let cancelled = false
61
79
  api
62
80
  .getSession(otherId)
63
- .then(s => {
81
+ .then(value => {
64
82
  if (!cancelled) {
65
- setOther(s)
83
+ setOther(value)
66
84
  setError(undefined)
67
85
  }
68
86
  })
69
- .catch(e => !cancelled && setError(String(e)))
87
+ .catch(reason => !cancelled && setError(String(reason)))
70
88
  return () => {
71
89
  cancelled = true
72
90
  }
@@ -74,11 +92,32 @@ export function CompareView({
74
92
 
75
93
  const result: CompareResult | undefined = useMemo(() => {
76
94
  if (other === undefined) return undefined
77
- return compareSessions(session, other, {
78
- a: title,
79
- b: others.find(e => e.header.id === other.header.id)?.title,
80
- })
81
- }, [session, other, title, others])
95
+ return compareSessions(
96
+ session,
97
+ other,
98
+ { a: title, b: others.find(entry => entry.header.id === other.header.id)?.title },
99
+ id => parentMap.get(id),
100
+ )
101
+ }, [session, other, title, others, parentMap])
102
+
103
+ const costA = useMemo(() => {
104
+ const model = buildTimeline(session)
105
+ return estimateCostDetailed(model.modelTotals, pricing)
106
+ }, [session, pricing])
107
+ const costB = useMemo(() => {
108
+ if (other === undefined) return undefined
109
+ const model = buildTimeline(other)
110
+ return estimateCostDetailed(model.modelTotals, pricing)
111
+ }, [other, pricing])
112
+ const riskA = useMemo(() => {
113
+ const report = auditSession(session)
114
+ return report.counts.critical + report.counts.serious
115
+ }, [session])
116
+ const riskB = useMemo(() => {
117
+ if (other === undefined) return 0
118
+ const report = auditSession(other)
119
+ return report.counts.critical + report.counts.serious
120
+ }, [other])
82
121
 
83
122
  if (others.length === 0) {
84
123
  return <div className="dshr-center">{t('cmp.none')}</div>
@@ -91,7 +130,7 @@ export function CompareView({
91
130
  <select
92
131
  className="dshr-select"
93
132
  value={otherId}
94
- onChange={e => setOtherId(e.target.value)}
133
+ onChange={event => setOtherId(event.target.value)}
95
134
  aria-label={t('cmp.aria')}
96
135
  >
97
136
  {others.map(entry => (
@@ -108,7 +147,25 @@ export function CompareView({
108
147
  <>
109
148
  {result.related && result.divergenceSeq !== undefined && (
110
149
  <div className="dshr-divergence">
111
- {t('cmp.divergence', { seq: result.divergenceSeq })}
150
+ <div className="dshr-divergence-title">
151
+ {t('cmp.divergence', { seq: result.divergenceSeq })}
152
+ </div>
153
+ <div className="dshr-diff-grid">
154
+ <div className="dshr-diff-side">
155
+ <div className="dshr-section-label">{t('cmp.divergenceA')}</div>
156
+ <pre className="dshr-pre">{preview(result.divergenceA)}</pre>
157
+ <button className="dshr-tab" onClick={() => onOpenSession?.(result.a.id)}>
158
+ {t('forks.open')}
159
+ </button>
160
+ </div>
161
+ <div className="dshr-diff-side">
162
+ <div className="dshr-section-label">{t('cmp.divergenceB')}</div>
163
+ <pre className="dshr-pre">{preview(result.divergenceB)}</pre>
164
+ <button className="dshr-tab" onClick={() => onOpenSession?.(result.b.id)}>
165
+ {t('forks.open')}
166
+ </button>
167
+ </div>
168
+ </div>
112
169
  </div>
113
170
  )}
114
171
  <div className="dshr-compare-grid">
@@ -122,6 +179,10 @@ export function CompareView({
122
179
  </tr>
123
180
  </thead>
124
181
  <tbody>
182
+ <tr>
183
+ <td>{t('cmp.relation')}</td>
184
+ <td colSpan={3}>{t(`cmp.relation.${result.relation}`)}</td>
185
+ </tr>
125
186
  <Row label={t('cmp.turns')} a={result.a.turns} b={result.b.turns} />
126
187
  <Row label={t('cmp.steps')} a={result.a.steps} b={result.b.steps} />
127
188
  <Row label={t('cmp.events')} a={result.a.events} b={result.b.events} />
@@ -151,6 +212,21 @@ export function CompareView({
151
212
  b={result.b.durationMs}
152
213
  format={formatDuration}
153
214
  />
215
+ <Row label={t('cmp.risk')} a={riskA} b={riskB} />
216
+ {costA !== undefined && costB !== undefined && (
217
+ <tr>
218
+ <td>{t('cmp.cost')}</td>
219
+ <td>{formatMoney(costA.usd, 'USD')}</td>
220
+ <td>{formatMoney(costB.usd, 'USD')}</td>
221
+ <td style={{ color: 'var(--dshr-ink-3)' }}>
222
+ {costA.usd === costB.usd
223
+ ? '='
224
+ : costA.usd > costB.usd
225
+ ? `+${formatMoney(costA.usd - costB.usd, 'USD')}`
226
+ : `−${formatMoney(costB.usd - costA.usd, 'USD')}`}
227
+ </td>
228
+ </tr>
229
+ )}
154
230
  </tbody>
155
231
  </table>
156
232
  {result.toolNames.length > 0 && (
@@ -0,0 +1,232 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import { estimateCost, estimateCostDetailed, formatMoney } from '../../core/cost.js'
3
+ import {
4
+ formatDuration,
5
+ formatRelativeTime,
6
+ formatTokens,
7
+ shortId,
8
+ type SessionEntry,
9
+ type SessionSummaryEntry,
10
+ } from '../api.js'
11
+ import { useI18n } from '../i18n.js'
12
+ import { usePricing } from '../pricing.js'
13
+ import { MascotState } from '../mascot.js'
14
+
15
+ type Period = 'month' | 'all'
16
+ type Currency = 'USD' | 'CNY'
17
+
18
+ export function DashboardView({
19
+ entries,
20
+ summaries,
21
+ loading,
22
+ onPick,
23
+ }: {
24
+ entries: SessionEntry[]
25
+ summaries: SessionSummaryEntry[]
26
+ loading: boolean
27
+ onPick: (id: string) => void
28
+ }): React.ReactElement {
29
+ const { t, lang } = useI18n()
30
+ const pricing = usePricing()
31
+ const [query, setQuery] = useState('')
32
+ const [workspace, setWorkspace] = useState('')
33
+ const [period, setPeriod] = useState<Period>('all')
34
+ const [currency, setCurrency] = useState<Currency>('USD')
35
+
36
+ const summaryMap = useMemo(
37
+ () => new Map(summaries.map(summary => [summary.header.id, summary])),
38
+ [summaries],
39
+ )
40
+ const workspaces = useMemo(
41
+ () => [...new Set(entries.map(entry => entry.header.cwd).filter((cwd): cwd is string => typeof cwd === 'string'))].sort(),
42
+ [entries],
43
+ )
44
+ const periodStart = useMemo(() => {
45
+ const now = new Date()
46
+ return period === 'month' ? new Date(now.getFullYear(), now.getMonth(), 1).getTime() : 0
47
+ }, [period])
48
+
49
+ const rows = useMemo(() => {
50
+ const q = query.trim().toLowerCase()
51
+ return entries
52
+ .filter(entry => entry.header.createdAt >= periodStart)
53
+ .filter(entry => workspace === '' || entry.header.cwd === workspace)
54
+ .filter(entry => {
55
+ if (q === '') return true
56
+ const haystack = `${entry.title ?? ''} ${entry.header.id} ${entry.header.cwd ?? ''}`.toLowerCase()
57
+ return haystack.includes(q)
58
+ })
59
+ }, [entries, periodStart, query, workspace])
60
+
61
+ const costFor = (summary: SessionSummaryEntry | undefined): number | undefined => {
62
+ if (summary?.summary === undefined) return undefined
63
+ const detailed = estimateCostDetailed(summary.summary.modelTotals, pricing)
64
+ if (detailed !== undefined) return detailed.usd
65
+ return estimateCost(summary.summary.tokens, summary.summary.models, pricing)?.usd
66
+ }
67
+
68
+ const totals = useMemo(() => {
69
+ let tokens = 0
70
+ let cost = 0
71
+ let risk = 0
72
+ let errors = 0
73
+ for (const row of rows) {
74
+ const summary = summaryMap.get(row.header.id)?.summary
75
+ if (summary === undefined) continue
76
+ tokens += summary.tokens.billedInput + summary.tokens.output
77
+ cost += costFor(summaryMap.get(row.header.id)) ?? 0
78
+ risk += summary.critical + summary.serious
79
+ errors += summary.toolErrors
80
+ }
81
+ return { tokens, cost, risk, errors }
82
+ }, [rows, summaryMap, pricing])
83
+
84
+ if (loading && entries.length === 0) {
85
+ return (
86
+ <div className="dshr-dashboard">
87
+ <div className="dshr-dash-cards">
88
+ {[0, 1, 2, 3].map(index => (
89
+ <div key={index} className="dshr-dash-card dshr-skeleton" />
90
+ ))}
91
+ </div>
92
+ <div className="dshr-dash-list">
93
+ {[0, 1, 2, 3, 4].map(index => (
94
+ <div key={index} className="dshr-dash-row dshr-skeleton" />
95
+ ))}
96
+ </div>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ if (entries.length === 0) {
102
+ return <MascotState mood="happy" text={t('dash.empty')} />
103
+ }
104
+
105
+ return (
106
+ <div className="dshr-dashboard">
107
+ <div className="dshr-dash-head">
108
+ <div>
109
+ <div className="dshr-dash-subtitle">{t('dash.subtitle')}</div>
110
+ <div className="dshr-dash-scanned">{t('dash.scanned', { n: summaries.length })}</div>
111
+ </div>
112
+ <span className="dshr-spacer" />
113
+ <span className="dshr-segmented" role="group" aria-label={t('dash.sessions')}>
114
+ <button data-active={period === 'month' || undefined} onClick={() => setPeriod('month')}>
115
+ {t('dash.thisMonth')}
116
+ </button>
117
+ <button data-active={period === 'all' || undefined} onClick={() => setPeriod('all')}>
118
+ {t('dash.allTime')}
119
+ </button>
120
+ </span>
121
+ </div>
122
+
123
+ <div className="dshr-dash-cards">
124
+ <div className="dshr-dash-card">
125
+ <div className="dshr-dash-value">{rows.length}</div>
126
+ <div className="dshr-dash-label">{t('dash.sessions')}</div>
127
+ </div>
128
+ <div className="dshr-dash-card">
129
+ <div className="dshr-dash-value">{formatTokens(totals.tokens)}</div>
130
+ <div className="dshr-dash-label">{t('dash.tokens')}</div>
131
+ </div>
132
+ <button
133
+ className="dshr-dash-card dshr-dash-card-action"
134
+ onClick={() => setCurrency(value => (value === 'USD' ? 'CNY' : 'USD'))}
135
+ title={t('ov.currency')}
136
+ >
137
+ <div className="dshr-dash-value">{formatMoney(totals.cost, currency)}</div>
138
+ <div className="dshr-dash-label">
139
+ {t('dash.cost')} · {currency}
140
+ </div>
141
+ </button>
142
+ <div className={`dshr-dash-card${totals.risk > 0 ? ' dshr-dash-risk' : ''}`}>
143
+ <div className="dshr-dash-value">{totals.risk}</div>
144
+ <div className="dshr-dash-label">
145
+ {t('dash.risk')} · {t('dash.errors')} {totals.errors}
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <div className="dshr-dash-toolbar">
151
+ <input
152
+ className="dshr-filter-input dshr-dash-search"
153
+ type="search"
154
+ placeholder={t('picker.search')}
155
+ value={query}
156
+ onChange={event => setQuery(event.target.value)}
157
+ aria-label={t('picker.search')}
158
+ />
159
+ <select
160
+ className="dshr-select"
161
+ value={workspace}
162
+ onChange={event => setWorkspace(event.target.value)}
163
+ aria-label={t('picker.allWorkspaces')}
164
+ >
165
+ <option value="">{t('picker.allWorkspaces')}</option>
166
+ {workspaces.map(cwd => (
167
+ <option key={cwd} value={cwd}>
168
+ {cwd}
169
+ </option>
170
+ ))}
171
+ </select>
172
+ <span className="dshr-spacer" />
173
+ <span className="dshr-dash-count">{t('picker.count', { n: rows.length })}</span>
174
+ </div>
175
+
176
+ <div className="dshr-dash-list">
177
+ {rows.map(entry => {
178
+ const summary = summaryMap.get(entry.header.id)
179
+ const data = summary?.summary
180
+ const cost = costFor(summary)
181
+ const risk = data === undefined ? 0 : data.critical + data.serious
182
+ return (
183
+ <button
184
+ key={entry.header.id}
185
+ className="dshr-dash-row"
186
+ onClick={() => onPick(entry.header.id)}
187
+ >
188
+ <span className="dshr-dash-main">
189
+ <span className="dshr-dash-title">
190
+ {entry.title ?? shortId(entry.header.id)}
191
+ {entry.header.origin === 'subagent' && (
192
+ <span className="dshr-picker-tag">{t('picker.subagent')}</span>
193
+ )}
194
+ {entry.header.parentSession !== undefined && entry.header.origin !== 'subagent' && (
195
+ <span className="dshr-picker-tag">{t('picker.fork')}</span>
196
+ )}
197
+ {entry.live === true && <span className="dshr-live-pill">{t('picker.live')}</span>}
198
+ </span>
199
+ <span className="dshr-dash-sub">
200
+ {shortId(entry.header.id)}
201
+ {entry.header.cwd !== undefined ? ` · ${entry.header.cwd}` : ''}
202
+ {' · '}
203
+ {formatRelativeTime(entry.header.createdAt, Date.now(), lang)}
204
+ </span>
205
+ </span>
206
+ <span className="dshr-dash-metrics">
207
+ {data !== undefined ? (
208
+ <>
209
+ <span title={t('cmp.turns')}>{t('hdr.turns', { n: data.turns })}</span>
210
+ <span title={t('dash.tokens')}>
211
+ {formatTokens(data.tokens.billedInput + data.tokens.output)}
212
+ </span>
213
+ <span title={t('dash.cost')}>
214
+ {cost !== undefined ? formatMoney(cost, currency) : '—'}
215
+ </span>
216
+ <span title={t('dash.risk')} className={risk > 0 ? 'dshr-risk-badge' : 'dshr-ok-badge'}>
217
+ {risk > 0 ? `⚠ ${risk}` : '✓'}
218
+ </span>
219
+ <span className="dshr-dash-duration">{formatDuration(data.durationMs)}</span>
220
+ </>
221
+ ) : (
222
+ <span className="dshr-dash-pending">—</span>
223
+ )}
224
+ </span>
225
+ </button>
226
+ )
227
+ })}
228
+ {rows.length === 0 && <MascotState mood="happy" text={t('audit.noRisk')} />}
229
+ </div>
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,136 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { redactSession } from '../../core/redact.js'
3
+ import type { ParsedSession } from '../../core/types.js'
4
+ import { shortId, type ReplayApi } from '../api.js'
5
+ import { buildReplayHtml, downloadReplayHtml, formatBytes } from '../export.js'
6
+ import { useI18n } from '../i18n.js'
7
+
8
+ export function ExportDialog({
9
+ api,
10
+ session,
11
+ title,
12
+ onClose,
13
+ }: {
14
+ api: ReplayApi
15
+ session: ParsedSession
16
+ title?: string
17
+ onClose: () => void
18
+ }): React.ReactElement {
19
+ const { t, lang } = useI18n()
20
+ const [redact, setRedact] = useState(true)
21
+ const [viewer, setViewer] = useState<string>()
22
+ const [error, setError] = useState<string>()
23
+ const [busy, setBusy] = useState(false)
24
+ const [loadingViewer, setLoadingViewer] = useState(true)
25
+
26
+ useEffect(() => {
27
+ const onKey = (event: KeyboardEvent): void => {
28
+ if (event.key === 'Escape') onClose()
29
+ }
30
+ document.addEventListener('keydown', onKey)
31
+ return () => document.removeEventListener('keydown', onKey)
32
+ }, [onClose])
33
+
34
+ useEffect(() => {
35
+ let cancelled = false
36
+ const getViewerSource = api.getViewerSource
37
+ if (getViewerSource === undefined) {
38
+ setError('viewer bundle unavailable')
39
+ setLoadingViewer(false)
40
+ return
41
+ }
42
+ setLoadingViewer(true)
43
+ getViewerSource
44
+ .call(api)
45
+ .then(source => {
46
+ if (cancelled) return
47
+ setViewer(source)
48
+ setLoadingViewer(false)
49
+ })
50
+ .catch(reason => {
51
+ if (cancelled) return
52
+ setError(String(reason))
53
+ setLoadingViewer(false)
54
+ })
55
+ return () => {
56
+ cancelled = true
57
+ }
58
+ }, [api])
59
+
60
+ const redacted = useMemo(
61
+ () => (redact ? redactSession(session) : { session, count: 0, kinds: [] as string[] }),
62
+ [redact, session],
63
+ )
64
+ const html = useMemo(() => {
65
+ if (viewer === undefined) return ''
66
+ return buildReplayHtml(
67
+ viewer,
68
+ [{ header: redacted.session.header, events: redacted.session.events, title }],
69
+ { lang, title },
70
+ )
71
+ }, [lang, redacted, title, viewer])
72
+ const size = useMemo(() => (html === '' ? 0 : new Blob([html]).size), [html])
73
+
74
+ const handleExport = (): void => {
75
+ if (html === '') return
76
+ setBusy(true)
77
+ try {
78
+ downloadReplayHtml(html, `replay-${shortId(session.header.id).replace('…', '')}.html`)
79
+ onClose()
80
+ } catch (reason) {
81
+ setError(String(reason))
82
+ setBusy(false)
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className="dshr-modal-backdrop dshr-export-backdrop" onClick={onClose}>
88
+ <div
89
+ className="dshr-modal dshr-export"
90
+ role="dialog"
91
+ aria-modal="true"
92
+ aria-labelledby="dshr-export-title"
93
+ onClick={event => event.stopPropagation()}
94
+ >
95
+ <div className="dshr-modal-chrome">
96
+ <span className="dshr-modal-title" id="dshr-export-title">
97
+ {t('exp.title')}
98
+ </span>
99
+ <span className="dshr-spacer" />
100
+ <button className="dshr-modal-close" onClick={onClose} aria-label={t('modal.close')}>
101
+ ✕
102
+ </button>
103
+ </div>
104
+ <div className="dshr-export-body">
105
+ <p className="dshr-export-warning">{t('exp.warning')}</p>
106
+ <label className="dshr-check">
107
+ <input type="checkbox" checked={redact} onChange={event => setRedact(event.target.checked)} />
108
+ <span>{t('exp.redact')}</span>
109
+ </label>
110
+ {redact ? (
111
+ <p className="dshr-export-meta">{t('exp.redactions', { n: redacted.count })}</p>
112
+ ) : (
113
+ <p className="dshr-export-danger">{t('exp.includeRaw')}</p>
114
+ )}
115
+ <p className="dshr-export-meta">
116
+ {t('exp.size', { size: loadingViewer ? '…' : formatBytes(size) })}
117
+ </p>
118
+ <p className="dshr-export-note">{t('exp.note')}</p>
119
+ {error !== undefined && <p className="dshr-export-danger">{error}</p>}
120
+ <div className="dshr-export-actions">
121
+ <button className="dshr-btn" onClick={onClose}>
122
+ {t('exp.cancel')}
123
+ </button>
124
+ <button
125
+ className="dshr-btn dshr-btn-primary"
126
+ disabled={busy || loadingViewer || html === ''}
127
+ onClick={handleExport}
128
+ >
129
+ {busy ? '…' : t('exp.confirm')}
130
+ </button>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ )
136
+ }
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { useState } from 'react'
2
2
  import type { TimelineItem, TimelineModel } from '../../core/timeline.js'
3
3
  import { formatClock, formatDuration, formatTokens } from '../api.js'
4
4
  import { useI18n } from '../i18n.js'
@@ -10,6 +10,22 @@ const KIND_COLOR: Record<string, string> = {
10
10
  meta: 'var(--dshr-s-meta)',
11
11
  }
12
12
 
13
+ function CopyButton({ text }: { text: string }): React.ReactElement {
14
+ const { t } = useI18n()
15
+ const [copied, setCopied] = useState(false)
16
+ const copy = (): void => {
17
+ void navigator.clipboard?.writeText(text).then(() => {
18
+ setCopied(true)
19
+ setTimeout(() => setCopied(false), 1500)
20
+ })
21
+ }
22
+ return (
23
+ <button className="dshr-copy-btn" onClick={copy} type="button">
24
+ {copied ? t('detail.copied') : t('detail.copy')}
25
+ </button>
26
+ )
27
+ }
28
+
13
29
  export function ItemDetail({
14
30
  item,
15
31
  model,
@@ -38,9 +54,19 @@ export function ItemDetail({
38
54
  {item.step !== undefined && ` · step ${item.step}`}
39
55
  </div>
40
56
 
57
+ {item.reasoningText !== undefined && item.reasoningText !== '' && (
58
+ <details className="dshr-reasoning">
59
+ <summary>{t('detail.reasoning')}</summary>
60
+ <pre className="dshr-pre">{item.reasoningText}</pre>
61
+ </details>
62
+ )}
63
+
41
64
  {item.text !== undefined && item.text !== '' && (
42
65
  <>
43
- <div className="dshr-section-label">{t('detail.message')}</div>
66
+ <div className="dshr-section-label">
67
+ {t('detail.message')}
68
+ <CopyButton text={item.text} />
69
+ </div>
44
70
  <pre className="dshr-pre">{item.text}</pre>
45
71
  </>
46
72
  )}
@@ -65,11 +91,17 @@ export function ItemDetail({
65
91
  </>
66
92
  )}
67
93
  </dl>
68
- <div className="dshr-section-label">{t('detail.arguments')}</div>
94
+ <div className="dshr-section-label">
95
+ {t('detail.arguments')}
96
+ <CopyButton text={item.toolCall.argumentsPretty ?? item.toolCall.argumentsRaw} />
97
+ </div>
69
98
  <pre className="dshr-pre">{item.toolCall.argumentsPretty ?? item.toolCall.argumentsRaw}</pre>
70
99
  {item.toolCall.resultText !== undefined && item.toolCall.resultText !== '' && (
71
100
  <>
72
- <div className="dshr-section-label">{t('detail.result')}</div>
101
+ <div className="dshr-section-label">
102
+ {t('detail.result')}
103
+ <CopyButton text={item.toolCall.resultText} />
104
+ </div>
73
105
  <pre className="dshr-pre">{item.toolCall.resultText}</pre>
74
106
  </>
75
107
  )}