@mingozhou/dsh-replay 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/README.zh.md +164 -0
  4. package/cordis.patch.yml +6 -0
  5. package/lib/client.js +2478 -0
  6. package/lib/core.js +720 -0
  7. package/lib/index.js +111 -0
  8. package/lib/types/demo/main.d.ts +1 -0
  9. package/lib/types/demo/samples.d.ts +21 -0
  10. package/lib/types/src/client/api.d.ts +43 -0
  11. package/lib/types/src/client/components/AuditView.d.ts +8 -0
  12. package/lib/types/src/client/components/CompareView.d.ts +9 -0
  13. package/lib/types/src/client/components/ForkTreeView.d.ts +7 -0
  14. package/lib/types/src/client/components/ItemDetail.d.ts +6 -0
  15. package/lib/types/src/client/components/OverviewView.d.ts +8 -0
  16. package/lib/types/src/client/components/ReplayApp.d.ts +12 -0
  17. package/lib/types/src/client/components/ReplayModal.d.ts +8 -0
  18. package/lib/types/src/client/components/SessionPicker.d.ts +6 -0
  19. package/lib/types/src/client/components/TimelineView.d.ts +6 -0
  20. package/lib/types/src/client/export.d.ts +13 -0
  21. package/lib/types/src/client/i18n.d.ts +24 -0
  22. package/lib/types/src/client/index.d.ts +12 -0
  23. package/lib/types/src/client/mascot-image.d.ts +1 -0
  24. package/lib/types/src/client/mascot.d.ts +17 -0
  25. package/lib/types/src/client/viewer-entry.d.ts +1 -0
  26. package/lib/types/src/core/audit.d.ts +39 -0
  27. package/lib/types/src/core/compare.d.ts +31 -0
  28. package/lib/types/src/core/cost.d.ts +30 -0
  29. package/lib/types/src/core/decode.d.ts +25 -0
  30. package/lib/types/src/core/forktree.d.ts +33 -0
  31. package/lib/types/src/core/index.d.ts +7 -0
  32. package/lib/types/src/core/timeline.d.ts +96 -0
  33. package/lib/types/src/core/types.d.ts +88 -0
  34. package/lib/types/src/index.d.ts +20 -0
  35. package/lib/viewer.js +812 -0
  36. package/package.json +107 -0
  37. package/scripts/build.mjs +131 -0
  38. package/src/client/api.ts +119 -0
  39. package/src/client/components/AuditView.tsx +57 -0
  40. package/src/client/components/CompareView.tsx +186 -0
  41. package/src/client/components/ForkTreeView.tsx +123 -0
  42. package/src/client/components/ItemDetail.tsx +117 -0
  43. package/src/client/components/OverviewView.tsx +258 -0
  44. package/src/client/components/ReplayApp.tsx +205 -0
  45. package/src/client/components/ReplayModal.tsx +53 -0
  46. package/src/client/components/SessionPicker.tsx +60 -0
  47. package/src/client/components/TimelineView.tsx +296 -0
  48. package/src/client/export.ts +43 -0
  49. package/src/client/i18n.ts +360 -0
  50. package/src/client/index.ts +146 -0
  51. package/src/client/mascot-image.ts +1 -0
  52. package/src/client/mascot.tsx +192 -0
  53. package/src/client/styles.css +719 -0
  54. package/src/client/viewer-entry.tsx +66 -0
  55. package/src/core/audit.ts +227 -0
  56. package/src/core/compare.ts +0 -0
  57. package/src/core/cost.ts +61 -0
  58. package/src/core/decode.ts +125 -0
  59. package/src/core/forktree.ts +104 -0
  60. package/src/core/index.ts +7 -0
  61. package/src/core/timeline.ts +396 -0
  62. package/src/core/types.ts +90 -0
  63. package/src/index.ts +157 -0
  64. package/tsconfig.json +18 -0
  65. package/types.d.ts +15 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Lightweight EN/中文 i18n for the replay UI. The dictionary is plain data;
3
+ * `useI18n()` provides `t(key, params)` plus the current language and setter.
4
+ * Language resolution: explicit choice (persisted) > browser language > en.
5
+ */
6
+ import React from 'react'
7
+
8
+ export type Lang = 'en' | 'zh'
9
+
10
+ const STORAGE_KEY = 'dshr-lang'
11
+
12
+ type Dict = Record<string, string>
13
+
14
+ const en: Dict = {
15
+ 'btn.replay': 'Session Replay',
16
+ 'modal.close': 'Close',
17
+ 'picker.title': 'Pick a session to replay',
18
+ 'picker.empty': 'No sessions yet — run something first!',
19
+ 'exp.button': 'Export HTML',
20
+ 'exp.fail': 'Export failed: {error}',
21
+ 'ov.cost': '≈ cost ({label}, est.)',
22
+ 'flt.search': 'filter events…',
23
+ 'app.loadingText': 'Jing Xiaoshen is rewinding the log…',
24
+ 'tab.overview': 'Overview',
25
+ 'ov.tokensChart': 'cumulative tokens',
26
+ 'ov.toolTime': 'tool time by tool',
27
+ 'ov.inputTokens': 'input tokens (billed)',
28
+ 'ov.outputTokens': 'output tokens',
29
+ 'ov.findings': 'audit findings',
30
+ 'tab.timeline': 'Timeline',
31
+ 'tab.audit': 'Audit',
32
+ 'tab.forks': 'Forks',
33
+ 'tab.compare': 'Compare',
34
+ 'app.loading': 'Loading session…',
35
+ 'app.error': 'Failed to load session — {error}. Is the dsh-replay host plugin enabled?',
36
+ 'app.openSession': 'Open a session to replay it.',
37
+ 'hdr.turns': '{n} turns',
38
+ 'play.event': 'event {i}/{n}',
39
+ 'play.position': 'timeline position',
40
+ 'ts.title': 'tokens per step',
41
+ 'ts.input': 'input (billed)',
42
+ 'ts.output': 'output',
43
+ 'ts.total': 'Σ in {in} · out {out}',
44
+ 'ts.cacheRead': ' · cache-read {n}',
45
+ 'ts.barTitle': 'turn {turn} step {step} — in {in}, out {out}',
46
+ 'tl.empty': 'No renderable events in this session yet.',
47
+ 'tl.turn': 'Turn {n}',
48
+ 'kind.user': 'user',
49
+ 'kind.assistant': 'assistant',
50
+ 'kind.tool': 'tool',
51
+ 'kind.meta': 'meta',
52
+ 'detail.message': 'message',
53
+ 'detail.arguments': 'arguments',
54
+ 'detail.result': 'result',
55
+ 'detail.usage': 'token usage (this step)',
56
+ 'detail.eventData': 'event data',
57
+ 'detail.callId': 'call id',
58
+ 'detail.duration': 'duration',
59
+ 'detail.noResult': 'no result recorded',
60
+ 'detail.error': 'error',
61
+ 'detail.inputUncached': 'input (uncached)',
62
+ 'detail.cacheRead': 'cache read',
63
+ 'detail.cacheWrite': 'cache write',
64
+ 'detail.output': 'output',
65
+ 'detail.reasoning': '└ reasoning',
66
+ 'detail.shadowed': 'shadowed by compaction',
67
+ 'sev.critical': 'critical',
68
+ 'sev.serious': 'serious',
69
+ 'sev.warning': 'warning',
70
+ 'sev.info': 'info',
71
+ 'audit.empty': 'No findings — nothing sensitive happened in this session.',
72
+ 'cmp.with': 'Compare with',
73
+ 'cmp.parent': '(parent)',
74
+ 'cmp.fork': '(fork)',
75
+ 'cmp.none': 'No other session to compare with.',
76
+ 'cmp.metric': 'metric',
77
+ 'cmp.delta': 'Δ',
78
+ 'cmp.turns': 'turns',
79
+ 'cmp.steps': 'steps',
80
+ 'cmp.events': 'events',
81
+ 'cmp.toolCalls': 'tool calls',
82
+ 'cmp.toolErrors': 'tool errors',
83
+ 'cmp.inputTokens': 'input tokens (billed)',
84
+ 'cmp.outputTokens': 'output tokens',
85
+ 'cmp.cacheTokens': 'cache-read tokens',
86
+ 'cmp.duration': 'duration',
87
+ 'cmp.toolMix': 'tool mix',
88
+ 'cmp.tool': 'tool',
89
+ 'cmp.divergence':
90
+ 'These sessions share lineage and diverge at seq {seq} — identical before it, different history after.',
91
+ 'cmp.aria': 'session to compare against',
92
+ 'forks.empty': 'No sessions found.',
93
+ 'forks.edge': 'forked @ {n} events',
94
+ 'forks.subagent': ' · subagent',
95
+ 'forks.orphan': ' · parent missing',
96
+ 'forks.aria': 'session fork tree',
97
+ 'forks.untitled': 'Untitled',
98
+ 'forks.forkTag': ' · fork',
99
+ 'forks.hint': 'Session lineage: forked sessions and subagents hang under their parent as a tree; sessions without lineage are listed as cards. Click any card to replay it.',
100
+ // audit finding messages (rule keys)
101
+ 'f.shell.dangerous': 'shell: {why}',
102
+ 'f.shell.plain': 'shell command via {name}',
103
+ 'f.file.sensitive': 'file write touches sensitive path',
104
+ 'f.file.write': 'file mutation via {name}',
105
+ 'f.read.sensitive': 'access to sensitive path via {name}',
106
+ 'f.network': 'network access via {name}',
107
+ 'f.approval.denied': 'user denied an approval',
108
+ 'f.sandbox.change': 'sandbox mode changed',
109
+ 'f.permission.change': 'permission preset changed',
110
+ 'f.subagent': 'subagent spawned ({mode})',
111
+ 'f.tool.error': 'tool failed: {name}',
112
+ 'f.turn.error': 'turn ended abnormally: {kind}',
113
+ // dangerous-shell reasons
114
+ 'why.rm-rf': 'recursive/forced delete',
115
+ 'why.sudo': 'privilege escalation',
116
+ 'why.chmod': 'permission change',
117
+ 'why.curl-pipe': 'pipe remote script to shell',
118
+ 'why.force-push': 'force push',
119
+ 'why.disk': 'raw disk operation',
120
+ 'why.remote': 'remote host access',
121
+ 'why.creds': 'credential material in command',
122
+ // meta timeline labels
123
+ 'meta.turnEnd': 'turn ended: {reason}',
124
+ 'meta.compactSummary': 'context compacted (summary)',
125
+ 'meta.compactPrune': 'context pruned',
126
+ 'meta.approvalAsked': 'approval requested: {tool}',
127
+ 'meta.approvalDecided': 'approval decided: {outcome}',
128
+ 'meta.sandbox': 'sandbox mode changed',
129
+ 'meta.permission': 'permission preset changed',
130
+ 'meta.plan': 'plan mode change',
131
+ 'meta.subagent': 'subagent spawned ({mode})',
132
+ 'meta.seed': 'seed boundary (fork/resume point)',
133
+ 'meta.retry': 'model request retried',
134
+ 'meta.goal': 'goal changed',
135
+ }
136
+
137
+ const zh: Dict = {
138
+ 'btn.replay': '会话回放',
139
+ 'modal.close': '关闭',
140
+ 'picker.title': '选择要回放的会话',
141
+ 'picker.empty': '还没有会话 — 先去跑点什么吧!',
142
+ 'exp.button': '导出 HTML',
143
+ 'exp.fail': '导出失败:{error}',
144
+ 'ov.cost': '≈ 成本({label},估算)',
145
+ 'flt.search': '搜索事件…',
146
+ 'app.loadingText': '鲸小深正在倒带日志…',
147
+ 'tab.overview': '概览',
148
+ 'ov.tokensChart': '累计 Token',
149
+ 'ov.toolTime': '各工具耗时',
150
+ 'ov.inputTokens': '输入 token(计费)',
151
+ 'ov.outputTokens': '输出 token',
152
+ 'ov.findings': '审计发现',
153
+ 'tab.timeline': '时间线',
154
+ 'tab.audit': '审计',
155
+ 'tab.forks': '分支',
156
+ 'tab.compare': '对比',
157
+ 'app.loading': '会话加载中…',
158
+ 'app.error': '会话加载失败 — {error}。请确认 dsh-replay 宿主插件已启用。',
159
+ 'app.openSession': '打开一个会话即可回放。',
160
+ 'hdr.turns': '{n} 轮',
161
+ 'play.event': '事件 {i}/{n}',
162
+ 'play.position': '时间线位置',
163
+ 'ts.title': '每步 token',
164
+ 'ts.input': '输入(计费)',
165
+ 'ts.output': '输出',
166
+ 'ts.total': 'Σ 输入 {in} · 输出 {out}',
167
+ 'ts.cacheRead': ' · 缓存读 {n}',
168
+ 'ts.barTitle': '第 {turn} 轮第 {step} 步 — 输入 {in},输出 {out}',
169
+ 'tl.empty': '该会话暂无可渲染事件。',
170
+ 'tl.turn': '第 {n} 轮',
171
+ 'kind.user': '用户',
172
+ 'kind.assistant': '助手',
173
+ 'kind.tool': '工具',
174
+ 'kind.meta': '元',
175
+ 'detail.message': '消息',
176
+ 'detail.arguments': '参数',
177
+ 'detail.result': '结果',
178
+ 'detail.usage': 'Token 用量(本步)',
179
+ 'detail.eventData': '事件数据',
180
+ 'detail.callId': '调用 ID',
181
+ 'detail.duration': '耗时',
182
+ 'detail.noResult': '未记录结果',
183
+ 'detail.error': '错误',
184
+ 'detail.inputUncached': '输入(未缓存)',
185
+ 'detail.cacheRead': '缓存读',
186
+ 'detail.cacheWrite': '缓存写',
187
+ 'detail.output': '输出',
188
+ 'detail.reasoning': '└ 推理',
189
+ 'detail.shadowed': '已被压缩遮蔽',
190
+ 'sev.critical': '严重',
191
+ 'sev.serious': '较高',
192
+ 'sev.warning': '警告',
193
+ 'sev.info': '信息',
194
+ 'audit.empty': '无发现 — 本会话没有敏感操作。',
195
+ 'cmp.with': '对比对象',
196
+ 'cmp.parent': '(父会话)',
197
+ 'cmp.fork': '(分支)',
198
+ 'cmp.none': '没有其他会话可对比。',
199
+ 'cmp.metric': '指标',
200
+ 'cmp.delta': 'Δ',
201
+ 'cmp.turns': '轮数',
202
+ 'cmp.steps': '步数',
203
+ 'cmp.events': '事件数',
204
+ 'cmp.toolCalls': '工具调用',
205
+ 'cmp.toolErrors': '工具错误',
206
+ 'cmp.inputTokens': '输入 token(计费)',
207
+ 'cmp.outputTokens': '输出 token',
208
+ 'cmp.cacheTokens': '缓存读 token',
209
+ 'cmp.duration': '时长',
210
+ 'cmp.toolMix': '工具分布',
211
+ 'cmp.tool': '工具',
212
+ 'cmp.divergence': '这两个会话同源,自 seq {seq} 起分歧 — 之前完全一致,之后历史不同。',
213
+ 'cmp.aria': '选择对比会话',
214
+ 'forks.empty': '未找到会话。',
215
+ 'forks.edge': '于第 {n} 个事件处分叉',
216
+ 'forks.subagent': ' · 子Agent',
217
+ 'forks.orphan': ' · 父会话缺失',
218
+ 'forks.aria': '会话分支树',
219
+ 'forks.untitled': '未命名会话',
220
+ 'forks.forkTag': ' · 分支',
221
+ 'forks.hint': '会话血缘:fork 出的会话和子 Agent 会挂在父会话下形成树;没有血缘的会话平铺为卡片。点击任意卡片即可回放该会话。',
222
+ 'f.shell.dangerous': '危险 shell:{why}',
223
+ 'f.shell.plain': '通过 {name} 执行 shell 命令',
224
+ 'f.file.sensitive': '文件写入涉及敏感路径',
225
+ 'f.file.write': '通过 {name} 修改文件',
226
+ 'f.read.sensitive': '通过 {name} 访问敏感路径',
227
+ 'f.network': '通过 {name} 访问网络',
228
+ 'f.approval.denied': '用户拒绝了一次审批',
229
+ 'f.sandbox.change': '沙箱模式变更',
230
+ 'f.permission.change': '权限预设变更',
231
+ 'f.subagent': '派生子 Agent({mode})',
232
+ 'f.tool.error': '工具失败:{name}',
233
+ 'f.turn.error': '轮次异常结束:{kind}',
234
+ 'why.rm-rf': '递归/强制删除',
235
+ 'why.sudo': '提权操作',
236
+ 'why.chmod': '权限修改',
237
+ 'why.curl-pipe': '远程脚本管道执行',
238
+ 'why.force-push': '强制推送',
239
+ 'why.disk': '底层磁盘操作',
240
+ 'why.remote': '远程主机访问',
241
+ 'why.creds': '命令中包含凭据',
242
+ 'meta.turnEnd': '轮次结束:{reason}',
243
+ 'meta.compactSummary': '上下文已压缩(摘要)',
244
+ 'meta.compactPrune': '上下文已修剪',
245
+ 'meta.approvalAsked': '请求审批:{tool}',
246
+ 'meta.approvalDecided': '审批结果:{outcome}',
247
+ 'meta.sandbox': '沙箱模式变更',
248
+ 'meta.permission': '权限预设变更',
249
+ 'meta.plan': '计划模式变更',
250
+ 'meta.subagent': '派生子 Agent({mode})',
251
+ 'meta.seed': '种子边界(fork/resume 点)',
252
+ 'meta.retry': '模型请求已重试',
253
+ 'meta.goal': '目标变更',
254
+ }
255
+
256
+ const DICTS: Record<Lang, Dict> = { en, zh }
257
+
258
+ export type T = (key: string, params?: Record<string, string | number>) => string
259
+
260
+ export function makeT(lang: Lang): T {
261
+ return (key, params) => {
262
+ let text = DICTS[lang][key] ?? DICTS.en[key] ?? key
263
+ if (params !== undefined) {
264
+ for (const [name, value] of Object.entries(params)) {
265
+ text = text.replaceAll(`{${name}}`, String(value))
266
+ }
267
+ }
268
+ return text
269
+ }
270
+ }
271
+
272
+ export function detectLang(): Lang {
273
+ try {
274
+ const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
275
+ if (stored === 'en' || stored === 'zh') return stored
276
+ } catch {
277
+ /* storage unavailable (private mode, sandboxed frame) */
278
+ }
279
+ const nav = globalThis.navigator?.language ?? 'en'
280
+ return nav.toLowerCase().startsWith('zh') ? 'zh' : 'en'
281
+ }
282
+
283
+ export function persistLang(lang: Lang): void {
284
+ try {
285
+ globalThis.localStorage?.setItem(STORAGE_KEY, lang)
286
+ } catch {
287
+ /* best effort */
288
+ }
289
+ }
290
+
291
+ export interface I18n {
292
+ lang: Lang
293
+ t: T
294
+ setLang: (lang: Lang) => void
295
+ }
296
+
297
+ export const I18nContext = React.createContext<I18n>({
298
+ lang: 'en',
299
+ t: makeT('en'),
300
+ setLang: () => {},
301
+ })
302
+
303
+ export function useI18n(): I18n {
304
+ return React.useContext(I18nContext)
305
+ }
306
+
307
+ // ---- localization helpers over core-produced structures ----
308
+ import type { AuditFinding } from '../core/audit.js'
309
+ import type { TimelineItem } from '../core/timeline.js'
310
+
311
+ /** Localized finding message; falls back to the stable English title. */
312
+ export function localizeFinding(t: T, finding: AuditFinding): string {
313
+ if (finding.messageKey === undefined) return finding.title
314
+ const params: Record<string, string> = { ...finding.params }
315
+ if (params['whyKey'] !== undefined) params['why'] = t(`why.${params['whyKey']}`)
316
+ return t(finding.messageKey, params)
317
+ }
318
+
319
+ function field(data: unknown, key: string): unknown {
320
+ return typeof data === 'object' && data !== null ? (data as Record<string, unknown>)[key] : undefined
321
+ }
322
+
323
+ /** Localized rail label for meta items; other kinds keep their content labels. */
324
+ export function localizeItemLabel(t: T, item: TimelineItem): string {
325
+ if (item.kind !== 'meta') return item.label
326
+ const d = item.event.data
327
+ switch (item.type) {
328
+ case 'turn/end':
329
+ return t('meta.turnEnd', { reason: String(field(field(d, 'reason'), 'kind') ?? '') })
330
+ case 'compaction/summary':
331
+ return t('meta.compactSummary')
332
+ case 'compaction/prune':
333
+ return t('meta.compactPrune')
334
+ case 'approval/asked':
335
+ return t('meta.approvalAsked', { tool: String(field(d, 'toolName') ?? '') })
336
+ case 'approval/decided': {
337
+ const outcome = field(d, 'outcome') ?? field(d, 'decision')
338
+ return t('meta.approvalDecided', {
339
+ outcome: typeof outcome === 'string' ? outcome : JSON.stringify(outcome),
340
+ })
341
+ }
342
+ case 'sandbox/mode':
343
+ return t('meta.sandbox')
344
+ case 'permission/preset':
345
+ return t('meta.permission')
346
+ case 'plan/mode':
347
+ return t('meta.plan')
348
+ case 'subagent/descriptor':
349
+ return t('meta.subagent', { mode: String(field(d, 'mode') ?? '') })
350
+ case 'session/end-seed':
351
+ return t('meta.seed')
352
+ case 'llm/retry':
353
+ case 'llm/retry-started':
354
+ return t('meta.retry')
355
+ case 'goal/change':
356
+ return t('meta.goal')
357
+ default:
358
+ return item.label
359
+ }
360
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * dsh-replay — browser half.
3
+ *
4
+ * Three registrations:
5
+ * - `conversation.view`: a "Replay" tab inside the open session's view;
6
+ * - `sidebar.footer.action`: a bottom-left sidebar button ("会话回放" /
7
+ * "Session Replay");
8
+ * - `shell.overlay`: the full-screen modal the button opens, with its own
9
+ * session picker (works without an open conversation).
10
+ */
11
+ import React, { useSyncExternalStore } from 'react'
12
+ import { HttpReplayApi } from './api.js'
13
+ import { ReplayApp } from './components/ReplayApp.js'
14
+ import { ReplayModal } from './components/ReplayModal.js'
15
+ import { detectLang, makeT } from './i18n.js'
16
+ import { mascotImage } from './mascot-image.js'
17
+ // esbuild is configured with `loader: { '.css': 'text' }`
18
+ // eslint-disable-next-line import/no-unresolved
19
+ import cssText from './styles.css'
20
+
21
+ export const name = 'dsh-replay'
22
+
23
+ export const inject = ['slots']
24
+
25
+ interface SlotRegistryLike {
26
+ inject: (key: string, callback: () => unknown) => void
27
+ register: (options: Record<string, unknown>, component: unknown) => () => void
28
+ }
29
+
30
+ interface ClientContextLike {
31
+ slots: SlotRegistryLike
32
+ effect: (callback: () => () => void, label?: string) => void
33
+ }
34
+
35
+ const api = new HttpReplayApi()
36
+
37
+ /** Tiny shared open/closed store bridging the sidebar button and the overlay. */
38
+ function createOpenStore(): {
39
+ isOpen: () => boolean
40
+ set: (value: boolean) => void
41
+ subscribe: (listener: () => void) => () => void
42
+ } {
43
+ let open = false
44
+ const listeners = new Set<() => void>()
45
+ return {
46
+ isOpen: () => open,
47
+ set(value: boolean) {
48
+ open = value
49
+ for (const listener of listeners) listener()
50
+ },
51
+ subscribe(listener: () => void) {
52
+ listeners.add(listener)
53
+ return () => listeners.delete(listener)
54
+ },
55
+ }
56
+ }
57
+
58
+ const modalStore = createOpenStore()
59
+
60
+ /** Standard conversation-scope props the slot renderer passes down. */
61
+ function ReplayTab(props: { sessionId?: string }): React.ReactElement {
62
+ return React.createElement(ReplayApp, { api, sessionId: props.sessionId })
63
+ }
64
+
65
+ /**
66
+ * 16×16 icon matching the official sidebar footer style: the mascot avatar
67
+ * when artwork is embedded, otherwise a monochrome rewind glyph in
68
+ * currentColor (same treatment as the built-in settings gear).
69
+ */
70
+ function SidebarIcon(): React.ReactElement {
71
+ if (mascotImage !== undefined) {
72
+ return React.createElement('img', {
73
+ src: mascotImage,
74
+ width: 16,
75
+ height: 16,
76
+ style: { borderRadius: '50%', objectFit: 'cover', display: 'block' },
77
+ alt: '',
78
+ })
79
+ }
80
+ return React.createElement(
81
+ 'svg',
82
+ { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
83
+ React.createElement('path', {
84
+ d: 'M 8 1.7 A 6.3 6.3 0 1 1 2.3 5.3',
85
+ stroke: 'currentColor',
86
+ strokeWidth: 1.5,
87
+ strokeLinecap: 'round',
88
+ fill: 'none',
89
+ }),
90
+ React.createElement('path', { d: 'M 2.1 1.6 L 2.3 5.5 L 6.1 4.9 Z', fill: 'currentColor' }),
91
+ React.createElement('path', { d: 'M 6.7 5.6 L 11 8 L 6.7 10.4 Z', fill: 'currentColor' }),
92
+ )
93
+ }
94
+
95
+ function SidebarReplayButton(): React.ReactElement {
96
+ const t = makeT(detectLang())
97
+ return React.createElement(
98
+ 'button',
99
+ {
100
+ className: 'dshr-sidebar-btn',
101
+ onClick: () => modalStore.set(true),
102
+ title: t('btn.replay'),
103
+ },
104
+ React.createElement(SidebarIcon),
105
+ React.createElement('span', null, t('btn.replay')),
106
+ )
107
+ }
108
+
109
+ function ReplayOverlay(): React.ReactElement | null {
110
+ const open = useSyncExternalStore(modalStore.subscribe, modalStore.isOpen, modalStore.isOpen)
111
+ if (!open) return null
112
+ const t = makeT(detectLang())
113
+ return React.createElement(ReplayModal, {
114
+ api,
115
+ onClose: () => modalStore.set(false),
116
+ closeLabel: t('modal.close'),
117
+ })
118
+ }
119
+
120
+ export function apply(ctx: ClientContextLike): void {
121
+ ctx.effect(() => {
122
+ const style = document.createElement('style')
123
+ style.dataset['plugin'] = 'dsh-replay'
124
+ style.textContent = cssText as unknown as string
125
+ document.head.append(style)
126
+ return () => style.remove()
127
+ }, 'dsh-replay: styles')
128
+
129
+ ctx.slots.inject('conversation.view', () =>
130
+ ctx.slots.register(
131
+ { name: 'conversation.view', id: 'replay', order: 60, label: 'Replay' },
132
+ ReplayTab,
133
+ ),
134
+ )
135
+
136
+ ctx.slots.inject('sidebar.footer.action', () =>
137
+ ctx.slots.register(
138
+ { name: 'sidebar.footer.action', id: 'replay', order: 40 },
139
+ SidebarReplayButton,
140
+ ),
141
+ )
142
+
143
+ ctx.slots.inject('shell.overlay', () =>
144
+ ctx.slots.register({ name: 'shell.overlay', id: 'replay-modal', order: 50 }, ReplayOverlay),
145
+ )
146
+ }