@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
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@mingozhou/dsh-replay",
3
+ "version": "0.3.3",
4
+ "description": "Session replay, fork-tree, audit & compare visualization for DeepSeek Harness — time machine for your agent sessions",
5
+ "main": "lib/index.js",
6
+ "directories": {
7
+ "test": "tests"
8
+ },
9
+ "scripts": {
10
+ "build": "node scripts/build.mjs && tsc -p tsconfig.json",
11
+ "test": "vitest run",
12
+ "demo": "node demo/serve.mjs",
13
+ "screenshots": "node scripts/screenshot.mjs",
14
+ "prepare": "node scripts/build.mjs"
15
+ },
16
+ "keywords": [
17
+ "deepseek-harness",
18
+ "dsh-plugin",
19
+ "dsh",
20
+ "agent",
21
+ "session-replay",
22
+ "observability",
23
+ "audit",
24
+ "timeline",
25
+ "visualization"
26
+ ],
27
+ "author": "MingoZhou",
28
+ "license": "MIT",
29
+ "type": "module",
30
+ "devDependencies": {
31
+ "@types/node": "^26.2.0",
32
+ "@types/react": "^19.2.18",
33
+ "@types/react-dom": "^19.2.4",
34
+ "esbuild": "^0.28.2",
35
+ "playwright": "^1.62.1",
36
+ "react": "^19.2.8",
37
+ "react-dom": "^19.2.8",
38
+ "typescript": "^7.0.2",
39
+ "vitest": "^4.1.10"
40
+ },
41
+ "types": "lib/types/src/index.d.ts",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./lib/types/src/index.d.ts",
45
+ "default": "./lib/index.js"
46
+ },
47
+ "./client": {
48
+ "types": "./lib/types/src/client/index.d.ts",
49
+ "default": "./lib/client.js"
50
+ },
51
+ "./core": {
52
+ "types": "./lib/types/src/core/index.d.ts",
53
+ "default": "./lib/core.js"
54
+ },
55
+ "./cordis.patch.yml": "./cordis.patch.yml",
56
+ "./package.json": "./package.json"
57
+ },
58
+ "files": [
59
+ "lib",
60
+ "cordis.patch.yml",
61
+ "scripts/build.mjs",
62
+ "src",
63
+ "types.d.ts",
64
+ "tsconfig.json"
65
+ ],
66
+ "dsh": {
67
+ "bundle": {
68
+ "patch": "./cordis.patch.yml"
69
+ },
70
+ "client": {
71
+ "platform": "web",
72
+ "inject": [
73
+ "@deepseek-ai/dsh-client-runtime"
74
+ ]
75
+ }
76
+ },
77
+ "peerDependencies": {
78
+ "@deepseek-ai/cordis": "*",
79
+ "@deepseek-ai/dsh-host-webserver": "*",
80
+ "@deepseek-ai/dsh-session-query": "*",
81
+ "react": "^18.2.0"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "@deepseek-ai/cordis": {
85
+ "optional": true
86
+ },
87
+ "@deepseek-ai/dsh-host-webserver": {
88
+ "optional": true
89
+ },
90
+ "@deepseek-ai/dsh-session-query": {
91
+ "optional": true
92
+ },
93
+ "react": {
94
+ "optional": true
95
+ }
96
+ },
97
+ "repository": {
98
+ "type": "git",
99
+ "url": "https://github.com/MingoZhou/dsh-replay"
100
+ },
101
+ "homepage": "https://mingozhou.github.io/dsh-replay/",
102
+ "bugs": "https://github.com/MingoZhou/dsh-replay/issues",
103
+ "publishConfig": {
104
+ "registry": "https://registry.npmjs.org/",
105
+ "access": "public"
106
+ }
107
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Build the three artifacts:
3
+ * lib/index.js — host half (ESM, externals: @deepseek-ai/*, node:*)
4
+ * lib/client.js — browser half, classic script registering a lazy CJS
5
+ * factory with window.__ModuleLoader__ (the dsh web
6
+ * loader contract); externals resolve via the injected
7
+ * require from the loader's module table.
8
+ * demo/dist/demo.js — fully self-contained demo bundle (react inlined).
9
+ */
10
+ import { build } from 'esbuild'
11
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises'
12
+
13
+ const cssAsText = { loader: { '.css': 'text' } }
14
+
15
+ await mkdir('lib', { recursive: true })
16
+ await mkdir('demo/dist', { recursive: true })
17
+
18
+ // ---- optional mascot artwork (assets/jingxiaoshen.{png,jpg,jpeg,webp}) ----
19
+ // Drop your artwork there and it replaces the built-in SVG mascot everywhere
20
+ // in the UI (and in exported HTML). Cap 1.5 MB; square images look best.
21
+ {
22
+ const { readFile, stat } = await import('node:fs/promises')
23
+ const candidates = [
24
+ ['assets/jingxiaoshen.png', 'image/png'],
25
+ ['assets/jingxiaoshen.jpg', 'image/jpeg'],
26
+ ['assets/jingxiaoshen.jpeg', 'image/jpeg'],
27
+ ['assets/jingxiaoshen.webp', 'image/webp'],
28
+ ]
29
+ let source = 'export const mascotImage: string | undefined = undefined\n'
30
+ let found = false
31
+ for (const [file, mime] of candidates) {
32
+ try {
33
+ const info = await stat(file)
34
+ found = true
35
+ if (info.size > 1_500_000) {
36
+ console.warn(`mascot: ${file} is ${Math.round(info.size / 1024)} KB — compress it to ≤1.5 MB to embed; using built-in SVG for now`)
37
+ } else {
38
+ const b64 = (await readFile(file)).toString('base64')
39
+ source = `export const mascotImage: string | undefined = 'data:${mime};base64,${b64}'\n`
40
+ console.log(`mascot: embedded ${file} (${Math.round(info.size / 1024)} KB)`)
41
+ }
42
+ break
43
+ } catch {
44
+ /* try next candidate */
45
+ }
46
+ }
47
+ if (!found) console.log('mascot: no assets/jingxiaoshen.{png,jpg,webp} found — using built-in SVG fallback')
48
+ await writeFile('src/client/mascot-image.ts', source)
49
+ }
50
+
51
+ // ---- host half ----
52
+ await build({
53
+ entryPoints: ['src/index.ts'],
54
+ outfile: 'lib/index.js',
55
+ bundle: true,
56
+ format: 'esm',
57
+ platform: 'node',
58
+ target: 'node22',
59
+ external: ['@deepseek-ai/*', 'node:*'],
60
+ ...{},
61
+ })
62
+
63
+ // ---- core as its own importable entry (for standalone/tooling use) ----
64
+ await build({
65
+ entryPoints: ['src/core/index.ts'],
66
+ outfile: 'lib/core.js',
67
+ bundle: true,
68
+ format: 'esm',
69
+ platform: 'neutral',
70
+ })
71
+
72
+ // ---- browser half ----
73
+ const client = await build({
74
+ entryPoints: ['src/client/index.ts'],
75
+ bundle: true,
76
+ format: 'cjs',
77
+ platform: 'browser',
78
+ jsx: 'automatic',
79
+ ...cssAsText,
80
+ external: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/*'],
81
+ write: false,
82
+ })
83
+ const cjs = client.outputFiles[0].text
84
+ const wrapped = `/* dsh-replay client bundle — registers a lazy CJS factory with the dsh web loader. */
85
+ ;(function () {
86
+ var factory = function (require) {
87
+ var module = { exports: {} };
88
+ (function (require, module, exports) {
89
+ ${cjs}
90
+ })(require, module, module.exports);
91
+ return module.exports;
92
+ };
93
+ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
94
+ window.__ModuleLoader__.load({ id: 'dsh-replay', factory: factory });
95
+ } else if (typeof module !== 'undefined' && module.exports) {
96
+ module.exports = factory(typeof require === 'function' ? require : function (id) {
97
+ throw new Error('unresolvable external: ' + id);
98
+ });
99
+ }
100
+ })();
101
+ `
102
+ await writeFile('lib/client.js', wrapped)
103
+
104
+ // ---- standalone viewer (react inlined; embedded into exported HTML) ----
105
+ await build({
106
+ entryPoints: ['src/client/viewer-entry.tsx'],
107
+ outfile: 'lib/viewer.js',
108
+ bundle: true,
109
+ format: 'iife',
110
+ platform: 'browser',
111
+ jsx: 'automatic',
112
+ ...cssAsText,
113
+ define: { 'process.env.NODE_ENV': '"production"' },
114
+ minify: true,
115
+ })
116
+ await copyFile('lib/viewer.js', 'demo/dist/viewer.js')
117
+
118
+ // ---- demo ----
119
+ await build({
120
+ entryPoints: ['demo/main.tsx'],
121
+ outfile: 'demo/dist/demo.js',
122
+ bundle: true,
123
+ format: 'iife',
124
+ platform: 'browser',
125
+ jsx: 'automatic',
126
+ ...cssAsText,
127
+ define: { 'process.env.NODE_ENV': '"production"' },
128
+ minify: true,
129
+ })
130
+
131
+ console.log('build ok: lib/index.js, lib/core.js, lib/client.js, lib/viewer.js, demo/dist/demo.js')
@@ -0,0 +1,119 @@
1
+ /** Data access for the UI — one HTTP implementation, one in-memory demo implementation. */
2
+ import { fromInspection, parseSessionJsonl } from '../core/decode.js'
3
+ import type { ParsedSession, SessionHeader, StorageRecord } from '../core/types.js'
4
+
5
+ export interface SessionEntry {
6
+ header: SessionHeader
7
+ title?: string
8
+ }
9
+
10
+ export interface ReplayApi {
11
+ listSessions: () => Promise<SessionEntry[]>
12
+ getSession: (id: string) => Promise<ParsedSession>
13
+ /** Source of the standalone viewer bundle, for HTML export (optional). */
14
+ getViewerSource?: () => Promise<string>
15
+ }
16
+
17
+ export class HttpReplayApi implements ReplayApi {
18
+ constructor(private readonly base = '/replay/api') {}
19
+
20
+ private async fetchJson(path: string): Promise<unknown> {
21
+ const response = await fetch(`${this.base}${path}`)
22
+ if (!response.ok) throw new Error(`replay api ${path}: HTTP ${response.status}`)
23
+ return response.json()
24
+ }
25
+
26
+ async listSessions(): Promise<SessionEntry[]> {
27
+ const body = (await this.fetchJson('/sessions')) as { sessions: SessionEntry[] }
28
+ return body.sessions
29
+ }
30
+
31
+ async getSession(id: string): Promise<ParsedSession> {
32
+ const body = (await this.fetchJson(`/session/${encodeURIComponent(id)}`)) as {
33
+ header: SessionHeader
34
+ events: StorageRecord[]
35
+ }
36
+ return fromInspection(body.header, body.events)
37
+ }
38
+
39
+ async getViewerSource(): Promise<string> {
40
+ const response = await fetch(`${this.base}/viewer.js`)
41
+ if (!response.ok) throw new Error(`viewer bundle: HTTP ${response.status}`)
42
+ return response.text()
43
+ }
44
+ }
45
+
46
+ /** Demo/testing implementation over raw JSONL texts keyed by session id. */
47
+ export class StaticReplayApi implements ReplayApi {
48
+ private readonly sessions = new Map<string, ParsedSession>()
49
+ private readonly titles = new Map<string, string>()
50
+ /** Optional URL of the viewer bundle for HTML export (demo mode). */
51
+ viewerUrl?: string
52
+
53
+ constructor(logs: { jsonl: string; title?: string }[]) {
54
+ for (const { jsonl, title } of logs) {
55
+ const parsed = parseSessionJsonl(jsonl)
56
+ this.sessions.set(parsed.header.id, parsed)
57
+ if (title !== undefined) this.titles.set(parsed.header.id, title)
58
+ }
59
+ }
60
+
61
+ /** Build directly from already-decoded sessions (exported HTML, tooling). */
62
+ static fromParsed(list: { header: ParsedSession['header']; events: ParsedSession['events']; title?: string }[]): StaticReplayApi {
63
+ const api = new StaticReplayApi([])
64
+ for (const { header, events, title } of list) {
65
+ api.sessions.set(header.id, { header, events, malformed: [] })
66
+ if (title !== undefined) api.titles.set(header.id, title)
67
+ }
68
+ return api
69
+ }
70
+
71
+ async getViewerSource(): Promise<string> {
72
+ if (this.viewerUrl === undefined) throw new Error('viewer bundle unavailable in this mode')
73
+ const response = await fetch(this.viewerUrl)
74
+ if (!response.ok) throw new Error(`viewer bundle: HTTP ${response.status}`)
75
+ return response.text()
76
+ }
77
+
78
+ async listSessions(): Promise<SessionEntry[]> {
79
+ return [...this.sessions.values()].map(s => ({
80
+ header: s.header,
81
+ title: this.titles.get(s.header.id),
82
+ }))
83
+ }
84
+
85
+ async getSession(id: string): Promise<ParsedSession> {
86
+ const session = this.sessions.get(id)
87
+ if (session === undefined) throw new Error(`unknown session ${id}`)
88
+ return session
89
+ }
90
+ }
91
+
92
+ export function formatDuration(ms: number): string {
93
+ if (!Number.isFinite(ms) || ms < 0) return '—'
94
+ if (ms < 1000) return `${Math.round(ms)}ms`
95
+ const s = ms / 1000
96
+ if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`
97
+ const m = Math.floor(s / 60)
98
+ if (m < 60) return `${m}m ${Math.round(s % 60)}s`
99
+ return `${Math.floor(m / 60)}h ${m % 60}m`
100
+ }
101
+
102
+ export function formatTokens(n: number): string {
103
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
104
+ if (n >= 10_000) return `${Math.round(n / 1000)}k`
105
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
106
+ return String(n)
107
+ }
108
+
109
+ export function formatClock(time: number, startTime: number): string {
110
+ const ms = time - startTime
111
+ const s = Math.floor(ms / 1000)
112
+ const mm = String(Math.floor(s / 60)).padStart(2, '0')
113
+ const ss = String(s % 60).padStart(2, '0')
114
+ return `${mm}:${ss}`
115
+ }
116
+
117
+ export function shortId(id: string): string {
118
+ return id.length > 10 ? `${id.slice(0, 8)}…` : id
119
+ }
@@ -0,0 +1,57 @@
1
+ import React from 'react'
2
+ import type { AuditReport, Severity } from '../../core/audit.js'
3
+ import type { TimelineModel } from '../../core/timeline.js'
4
+ import { formatClock } from '../api.js'
5
+ import { localizeFinding, useI18n } from '../i18n.js'
6
+ import { MascotState } from '../mascot.js'
7
+
8
+ const SEV_META: Record<Severity, { color: string; icon: string }> = {
9
+ critical: { color: 'var(--dshr-critical)', icon: '⛔' },
10
+ serious: { color: 'var(--dshr-serious)', icon: '⚠' },
11
+ warning: { color: 'var(--dshr-warning)', icon: '△' },
12
+ info: { color: 'var(--dshr-good)', icon: 'ℹ' },
13
+ }
14
+
15
+ export function AuditView({
16
+ report,
17
+ model,
18
+ onJump,
19
+ }: {
20
+ report: AuditReport
21
+ model: TimelineModel
22
+ onJump: (seq: number) => void
23
+ }): React.ReactElement {
24
+ const { t } = useI18n()
25
+ return (
26
+ <div className="dshr-audit">
27
+ <div className="dshr-audit-summary">
28
+ {(Object.keys(SEV_META) as Severity[]).map(sev => (
29
+ <div key={sev} className="dshr-audit-tile">
30
+ <div className="dshr-count">{report.counts[sev]}</div>
31
+ <div className="dshr-cap">
32
+ <span className="dshr-sev-dot" style={{ background: SEV_META[sev].color }} />
33
+ {SEV_META[sev].icon} {t(`sev.${sev}`)}
34
+ </div>
35
+ </div>
36
+ ))}
37
+ </div>
38
+ {report.findings.length === 0 ? (
39
+ <MascotState mood="happy" text={t('audit.empty')} />
40
+ ) : (
41
+ report.findings.map((finding, i) => (
42
+ <button key={i} className="dshr-finding" onClick={() => onJump(finding.seq)}>
43
+ <span className="dshr-sev">
44
+ <span className="dshr-sev-dot" style={{ background: SEV_META[finding.severity].color }} />
45
+ {SEV_META[finding.severity].icon} {t(`sev.${finding.severity}`)}
46
+ </span>
47
+ <span className="dshr-finding-title">{localizeFinding(t, finding)}</span>
48
+ {finding.detail !== undefined && finding.detail !== '' && (
49
+ <span className="dshr-finding-detail">{finding.detail}</span>
50
+ )}
51
+ <span className="dshr-item-time">{formatClock(finding.time, model.startTime)}</span>
52
+ </button>
53
+ ))
54
+ )}
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,186 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { compareSessions, type CompareResult } from '../../core/compare.js'
3
+ import type { ParsedSession } from '../../core/types.js'
4
+ import { formatDuration, formatTokens, shortId, type ReplayApi, type SessionEntry } from '../api.js'
5
+ import { useI18n } from '../i18n.js'
6
+
7
+ function Row({
8
+ label,
9
+ a,
10
+ b,
11
+ format = String,
12
+ }: {
13
+ label: string
14
+ a: number
15
+ b: number
16
+ format?: (n: number) => string
17
+ }): React.ReactElement {
18
+ return (
19
+ <tr>
20
+ <td>{label}</td>
21
+ <td>{format(a)}</td>
22
+ <td>{format(b)}</td>
23
+ <td style={{ color: 'var(--dshr-ink-3)' }}>
24
+ {a === b ? '=' : a > b ? `+${format(a - b)}` : `−${format(b - a)}`}
25
+ </td>
26
+ </tr>
27
+ )
28
+ }
29
+
30
+ export function CompareView({
31
+ api,
32
+ session,
33
+ entries,
34
+ title,
35
+ }: {
36
+ api: ReplayApi
37
+ session: ParsedSession
38
+ entries: SessionEntry[]
39
+ title?: string
40
+ }): React.ReactElement {
41
+ const { t } = useI18n()
42
+ const others = useMemo(
43
+ () => entries.filter(e => e.header.id !== session.header.id),
44
+ [entries, session],
45
+ )
46
+ 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 ??
49
+ others[0]?.header.id
50
+ const [otherId, setOtherId] = useState(defaultOther)
51
+ const [other, setOther] = useState<ParsedSession>()
52
+ const [error, setError] = useState<string>()
53
+
54
+ useEffect(() => {
55
+ setOtherId(defaultOther)
56
+ }, [defaultOther])
57
+
58
+ useEffect(() => {
59
+ if (otherId === undefined) return
60
+ let cancelled = false
61
+ api
62
+ .getSession(otherId)
63
+ .then(s => {
64
+ if (!cancelled) {
65
+ setOther(s)
66
+ setError(undefined)
67
+ }
68
+ })
69
+ .catch(e => !cancelled && setError(String(e)))
70
+ return () => {
71
+ cancelled = true
72
+ }
73
+ }, [api, otherId])
74
+
75
+ const result: CompareResult | undefined = useMemo(() => {
76
+ 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])
82
+
83
+ if (others.length === 0) {
84
+ return <div className="dshr-center">{t('cmp.none')}</div>
85
+ }
86
+
87
+ return (
88
+ <div className="dshr-compare">
89
+ <div className="dshr-compare-picker">
90
+ <span>{t('cmp.with')}</span>
91
+ <select
92
+ className="dshr-select"
93
+ value={otherId}
94
+ onChange={e => setOtherId(e.target.value)}
95
+ aria-label={t('cmp.aria')}
96
+ >
97
+ {others.map(entry => (
98
+ <option key={entry.header.id} value={entry.header.id}>
99
+ {entry.title ?? shortId(entry.header.id)}
100
+ {entry.header.id === session.header.parentSession ? ` ${t('cmp.parent')}` : ''}
101
+ {entry.header.parentSession === session.header.id ? ` ${t('cmp.fork')}` : ''}
102
+ </option>
103
+ ))}
104
+ </select>
105
+ </div>
106
+ {error !== undefined && <div className="dshr-center">{error}</div>}
107
+ {result && (
108
+ <>
109
+ {result.related && result.divergenceSeq !== undefined && (
110
+ <div className="dshr-divergence">
111
+ {t('cmp.divergence', { seq: result.divergenceSeq })}
112
+ </div>
113
+ )}
114
+ <div className="dshr-compare-grid">
115
+ <table className="dshr-table">
116
+ <thead>
117
+ <tr>
118
+ <th>{t('cmp.metric')}</th>
119
+ <th>{result.a.title ?? shortId(result.a.id)}</th>
120
+ <th>{result.b.title ?? shortId(result.b.id)}</th>
121
+ <th>{t('cmp.delta')}</th>
122
+ </tr>
123
+ </thead>
124
+ <tbody>
125
+ <Row label={t('cmp.turns')} a={result.a.turns} b={result.b.turns} />
126
+ <Row label={t('cmp.steps')} a={result.a.steps} b={result.b.steps} />
127
+ <Row label={t('cmp.events')} a={result.a.events} b={result.b.events} />
128
+ <Row label={t('cmp.toolCalls')} a={result.a.toolCallCount} b={result.b.toolCallCount} />
129
+ <Row label={t('cmp.toolErrors')} a={result.a.toolErrors} b={result.b.toolErrors} />
130
+ <Row
131
+ label={t('cmp.inputTokens')}
132
+ a={result.a.billedInputTokens}
133
+ b={result.b.billedInputTokens}
134
+ format={formatTokens}
135
+ />
136
+ <Row
137
+ label={t('cmp.outputTokens')}
138
+ a={result.a.outputTokens}
139
+ b={result.b.outputTokens}
140
+ format={formatTokens}
141
+ />
142
+ <Row
143
+ label={t('cmp.cacheTokens')}
144
+ a={result.a.cacheReadTokens}
145
+ b={result.b.cacheReadTokens}
146
+ format={formatTokens}
147
+ />
148
+ <Row
149
+ label={t('cmp.duration')}
150
+ a={result.a.durationMs}
151
+ b={result.b.durationMs}
152
+ format={formatDuration}
153
+ />
154
+ </tbody>
155
+ </table>
156
+ {result.toolNames.length > 0 && (
157
+ <div>
158
+ <div className="dshr-section-label">{t('cmp.toolMix')}</div>
159
+ <table className="dshr-table">
160
+ <thead>
161
+ <tr>
162
+ <th>{t('cmp.tool')}</th>
163
+ <th>{result.a.title ?? shortId(result.a.id)}</th>
164
+ <th>{result.b.title ?? shortId(result.b.id)}</th>
165
+ <th>{t('cmp.delta')}</th>
166
+ </tr>
167
+ </thead>
168
+ <tbody>
169
+ {result.toolNames.map(name => (
170
+ <Row
171
+ key={name}
172
+ label={name}
173
+ a={result.a.toolMix[name] ?? 0}
174
+ b={result.b.toolMix[name] ?? 0}
175
+ />
176
+ ))}
177
+ </tbody>
178
+ </table>
179
+ </div>
180
+ )}
181
+ </div>
182
+ </>
183
+ )}
184
+ </div>
185
+ )
186
+ }