@heixui/dsh-session-suite 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2974 @@
1
+ /**
2
+ * dsh-session-suite — Client half.
3
+ *
4
+ * Adds, on top of the shipped Web UI:
5
+ *
6
+ * sidebar.workspaces.session.menu.item share rows + a destructive delete row
7
+ * conversation.chat.assistant-actions rewind / delete-message actions
8
+ * conversation.input.dock the inline "rewind here?" confirm bar
9
+ * shell.overlay the delete-session dialog and toasts
10
+ * settings.section the snapshot / memory manager page
11
+ *
12
+ * Only `react` is required: Harness Client packages are not importable from a
13
+ * plugin, so the controls below are hand-written against the host's theme
14
+ * tokens and interaction patterns.
15
+ */
16
+
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-session-suite',
19
+ factory(require) {
20
+ const React = require('react')
21
+ const h = React.createElement
22
+ const { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } = React
23
+
24
+ const NS = 'session-suite'
25
+ const ROUTE = 'api/session-suite'
26
+ const API_TIMEOUT_MS = 20000
27
+
28
+ // ---------------------------------------------------------------- locale
29
+
30
+ const DICTS = {
31
+ en: {
32
+ 'menu.delete': 'Delete session',
33
+ 'menu.shareMarkdown': 'Share as Markdown',
34
+ 'menu.shareText': 'Copy conversation as text',
35
+ 'menu.shareJson': 'Share as JSON',
36
+ 'menu.snapshots': 'Session snapshots',
37
+ 'menu.share': 'Share session',
38
+ 'menu.segment': 'Delete part of the conversation',
39
+ 'share.title': 'Share this conversation',
40
+ 'share.hint': 'Choose the turns to include.',
41
+ 'share.all': 'Select all',
42
+ 'share.none': 'Clear',
43
+ 'share.selected': '{count} of {total} turns selected',
44
+ 'share.image': 'Image',
45
+ 'share.html': 'Web page',
46
+ 'share.markdown': 'Markdown',
47
+ 'share.json': 'JSON',
48
+ 'share.copy': 'Copy text',
49
+ 'share.metaTurns': '{turns} turns · {messages} messages',
50
+ 'share.exportedAt': 'exported {time}',
51
+ 'share.imageFooter': 'DeepSeek Harness · dsh-session-suite',
52
+ 'share.rendering': 'Rendering…',
53
+ 'share.needSelection': 'Select at least one turn',
54
+ 'share.done': 'Saved',
55
+ 'share.copied': 'Copied to the clipboard',
56
+ 'share.linkNote': 'A public link would need a server hosting the conversation. The web page file is portable: anyone can open it, and it prints to PDF.',
57
+ 'share.curate': 'Final answers only',
58
+ 'share.curateHint': 'Keeps your prompt and the closing reply of each turn, and drops the narration between tool calls.',
59
+ 'share.failed': 'Could not export',
60
+ 'segment.title': 'Delete part of the conversation',
61
+ 'segment.hint': 'Everything from the first selected turn onwards leaves the conversation. Workspace files are never touched; memory recorded in that range is removed.',
62
+ 'segment.warning': 'Turns after your selection are removed too: a session log can only be cut, not spliced.',
63
+ 'segment.confirm': 'Delete',
64
+ 'segment.done': 'Conversation trimmed',
65
+ 'segment.failed': 'Could not delete that part',
66
+ 'segment.needSelection': 'Select at least one turn',
67
+ 'segment.whole': 'This removes every turn in the session; the session and its files stay.',
68
+ 'menu.shareCopied': 'Conversation copied to the clipboard',
69
+ 'menu.shareFailed': 'Could not export this session',
70
+ 'menu.shareEmpty': 'This session has no messages to share yet',
71
+ 'message.rewind': 'Rewind to this step',
72
+ 'message.rewindTitle': 'Rewind to this step?',
73
+ 'message.rewindBody':
74
+ 'Restores the workspace files and session memory saved for this step, forks the conversation up to this message, and archives the current session. An undo snapshot is kept for 10 minutes.',
75
+ 'message.rewindConfirm': 'Rewind',
76
+ 'message.rewinding': 'Rewinding…',
77
+ 'message.rewindDone': 'Rewound to this step',
78
+ 'message.rewindFailed': 'Could not rewind this session',
79
+ 'message.resend': 'Resend this turn',
80
+ 'message.resendTitle': 'Resend this turn?',
81
+ 'message.resendBody':
82
+ 'The question will be asked again in a new branch of this conversation, so the model answers from scratch instead of building on the reply you are rejecting. The current conversation stays as a branch, and workspace files are not touched.',
83
+ 'message.resendConfirm': 'Resend',
84
+ 'message.resending': 'Resending…',
85
+ 'message.resendDone': 'Resent as a new turn',
86
+ 'message.resendStaged': 'Prompt staged for editing',
87
+ 'message.resendFailed': 'Could not resend this turn',
88
+ 'message.edit': 'Edit and resend',
89
+ 'message.editTitle': 'Edit and resend',
90
+ 'message.editBody':
91
+ 'Change what you asked; it is sent as a new turn in this same conversation. Memories recorded after that turn are cleared, and workspace files are not touched. Note: the DSH log is append-only, so the original question and its reply stay in the history above rather than being deleted.',
92
+ 'message.editConfirm': 'Resend edited',
93
+ 'message.undo': 'Undo this rewind',
94
+ 'message.undoDone': 'Rewind undone',
95
+ 'message.delete': 'Delete this turn',
96
+ 'message.deleteTitle': 'Delete this turn?',
97
+ 'message.deleteBody':
98
+ 'The prompt, the reply, and the steps in between all leave the conversation. Only the log and session memory are trimmed — workspace files stay exactly as they are.',
99
+ 'message.deleteConfirm': 'Delete',
100
+ 'message.deleteDone': 'Deleted this turn',
101
+ 'message.deleteFailed': 'Could not delete this message',
102
+ 'message.deleteOnly': 'This is the only message in the session. Deleting it also deletes the session.',
103
+ 'dialog.cancel': 'Cancel',
104
+ 'dialog.delete': 'Delete',
105
+ 'dialog.deleteTitle': 'Delete session',
106
+ 'dialog.deleteBody':
107
+ 'The session and everything inside it will be removed from local storage, including its conversation log, snapshots, and memory. This cannot be undone.',
108
+ 'dialog.deleting': 'Deleting…',
109
+ 'dialog.deleteDone': 'Session deleted',
110
+ 'dialog.deleteStale': 'Deleted — press Ctrl+R if the row is still listed',
111
+ 'dialog.deleteFailed': 'Could not delete the session',
112
+ 'dialog.deleteSubagents': 'This will also delete {n} subagent session(s) started by it.',
113
+ 'dialog.deleteWouldRefuse': 'Something here is still running. Stop it first — nothing has been deleted.',
114
+ 'dialog.sessionRunning': 'This session is running. Stop it before deleting.',
115
+ 'settings.title': 'Session snapshots',
116
+ 'settings.description': 'Workspace checkpoints captured for a session, with disk usage, locking, and restore controls.',
117
+ 'settings.empty': 'No snapshots yet. A checkpoint is captured at the start of each turn.',
118
+ 'settings.capture': 'Capture now',
119
+ 'settings.capturing': 'Capturing…',
120
+ 'settings.restore': 'Restore files',
121
+ 'settings.restoreConfirm': 'Restore the files recorded in this snapshot? The conversation is not changed.',
122
+ 'settings.restoreDone': 'Workspace restored',
123
+ 'settings.restoreFailed': 'Could not restore this snapshot',
124
+ 'settings.remove': 'Delete snapshot',
125
+ 'settings.lock': 'Lock',
126
+ 'settings.unlock': 'Unlock',
127
+ 'settings.locked': 'Locked',
128
+ 'settings.budget': 'Keeps the newest {keep} automatic snapshots',
129
+ 'settings.usage': 'Snapshots use {size}',
130
+ 'settings.files': '{count} files',
131
+ 'settings.turn': 'Turn {turn}',
132
+ 'settings.temporary': 'Temporary',
133
+ 'settings.undoBundles': 'Undo bundles',
134
+ 'settings.undoBundle': 'Rollback at turn {turn}',
135
+ 'settings.undoRestore': 'Undo rollback',
136
+ 'settings.undoHint': 'Available for 10 minutes after a rollback.',
137
+ 'settings.memory': 'Session memory',
138
+ 'settings.memoryEmpty': 'Nothing remembered in this session yet.',
139
+ 'settings.memoryAdd': 'Add memory',
140
+ 'settings.memoryPlaceholder': 'A durable fact, rule, or preference',
141
+ 'settings.memorySave': 'Save',
142
+ 'settings.memoryRemove': 'Remove',
143
+ 'settings.loading': 'Loading snapshots…',
144
+ 'settings.session': 'Session',
145
+ 'settings.noSessions': 'No sessions are available yet.',
146
+ 'toast.captured': 'Snapshot captured',
147
+ 'toast.removed': 'Snapshot deleted',
148
+ 'toast.locked': 'Snapshot locked',
149
+ 'toast.unlocked': 'Snapshot unlocked',
150
+ 'toast.memoryAdded': 'Memory saved',
151
+ 'toast.memoryRemoved': 'Memory removed',
152
+ 'toast.failed': 'Something went wrong',
153
+ 'settings.details': 'Details',
154
+ 'memory.title': 'Long-term memory',
155
+ 'memory.description': 'Durable facts and conventions shared across sessions, kept in two stores: this workspace, and global.',
156
+ 'memory.tab.all': 'All',
157
+ 'memory.tab.workspace': 'This workspace',
158
+ 'memory.tab.global': 'Global',
159
+ 'memory.tab.archived': 'Cold archive',
160
+ 'memory.global': 'Global',
161
+ 'memory.workspace': 'Workspace',
162
+ 'memory.workspacePicker': 'Workspace',
163
+ 'memory.workspaceNone': 'No workspace detected',
164
+ 'memory.rule': 'Rule',
165
+ 'memory.summary': 'Summary',
166
+ 'memory.frozen': 'Frozen',
167
+ 'memory.archived': 'Archived',
168
+ 'memory.score': 'score {score}',
169
+ 'memory.access': '{count} recalls',
170
+ 'memory.empty': 'Nothing remembered yet. Say remember: … in brackets, or add one below.',
171
+ 'memory.explainTitle': 'What would this question recall?',
172
+ 'memory.explainHint':
173
+ 'Type a question to see which memories it pulls in, how many the evidence justifies, and why each of the rest was left out. Nothing is recorded — this is a dry run.',
174
+ 'memory.explainPlaceholder': 'a question, e.g. why did the cache hit rate drop',
175
+ 'memory.explainRun': 'Test',
176
+ 'memory.explainInjected': 'Injecting {n} of a possible {limit}',
177
+ 'memory.explainNone': 'Nothing matched, so nothing would be injected. That is the intended answer, not a failure.',
178
+ 'memory.explainOmitted': '{n} left out',
179
+ 'memory.whyNoMatch': 'no match',
180
+ 'memory.whyCapacity': 'over budget',
181
+ 'memory.filterAllTags': 'All tags',
182
+ 'memory.filterAnyTime': 'Any time',
183
+ 'memory.filterToday': 'Last 24h',
184
+ 'memory.filterWeek': 'Last 7 days',
185
+ 'memory.filterMonth': 'Last 30 days',
186
+ 'memory.filterClear': 'Clear filters',
187
+ 'memory.filterCount': '{n} in library',
188
+ 'memory.placeholder': 'A durable fact, rule, or preference',
189
+ 'memory.save': 'Save',
190
+ 'memory.added': 'Memory saved',
191
+ 'memory.removed': 'Memory deleted',
192
+ 'memory.freeze': 'Freeze',
193
+ 'memory.unfreeze': 'Unfreeze',
194
+ 'memory.remove': 'Delete',
195
+ 'memory.pendingTitle': 'Suggested memories',
196
+ 'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
197
+ 'memory.accept': 'Keep',
198
+ 'memory.reject': 'Discard',
199
+ 'memory.accepted': 'Kept',
200
+ 'memory.rejected': 'Discarded',
201
+ 'memory.maintain': 'Re-score and compress now',
202
+ 'memory.maintained': 'Memory rescored',
203
+ 'memory.maintainedAt': 'Last maintained',
204
+ 'memory.maintainedNever': 'Not maintained yet — runs automatically once memories change',
205
+ 'memory.maintainedScored': 'scored',
206
+ 'memory.maintainedCompressed': 'compressed',
207
+ 'memory.maintainedRules': 'rules',
208
+ 'memory.implicit': 'Suggest memories automatically',
209
+ 'memory.clearCold': 'Clear cold archive',
210
+ 'memory.coldCleared': 'Cold archive cleared',
211
+ },
212
+ zh: {
213
+ 'menu.delete': '删除任务',
214
+ 'menu.shareMarkdown': '分享为 Markdown',
215
+ 'menu.shareText': '复制对话文本',
216
+ 'menu.shareJson': '分享为 JSON',
217
+ 'menu.snapshots': '会话快照',
218
+ 'menu.share': '分享会话',
219
+ 'menu.segment': '删除对话片段',
220
+ 'share.title': '分享这段对话',
221
+ 'share.hint': '选择要包含的对话轮次。',
222
+ 'share.all': '全选',
223
+ 'share.none': '清空',
224
+ 'share.selected': '已选 {count} / {total} 轮',
225
+ 'share.image': '生成图片',
226
+ 'share.html': '网页文件',
227
+ 'share.markdown': 'Markdown',
228
+ 'share.json': 'JSON',
229
+ 'share.copy': '复制文本',
230
+ 'share.metaTurns': '{turns} 轮 · {messages} 条消息',
231
+ 'share.exportedAt': '导出于 {time}',
232
+ 'share.imageFooter': 'DeepSeek Harness · dsh-session-suite',
233
+ 'share.rendering': '正在生成…',
234
+ 'share.needSelection': '请至少选择一轮对话',
235
+ 'share.done': '已保存',
236
+ 'share.copied': '已复制到剪贴板',
237
+ 'share.linkNote': '在线链接需要一台服务器来托管内容;这里生成的是可离线打开的网页文件,发给谁都能打开,也能直接打印成 PDF。',
238
+ 'share.curate': '只导出最终回答',
239
+ 'share.curateHint': '每轮只保留你的提问和最终答复,丢掉工具调用之间的过程性说明。',
240
+ 'share.failed': '导出失败',
241
+ 'segment.title': '删除对话片段',
242
+ 'segment.hint': '从选中的第一轮开始,之后的内容都会从会话里移除。工作区文件不会改动,这一段里记录的记忆会一并清理。',
243
+ 'segment.warning': '选中轮次之后的内容也会一起移除:会话日志只能从中间截断,无法挖空再拼接。',
244
+ 'segment.confirm': '删除',
245
+ 'segment.done': '对话已裁剪',
246
+ 'segment.failed': '删除失败',
247
+ 'segment.needSelection': '请至少选择一轮对话',
248
+ 'segment.whole': '这会清空会话里全部对话,但会话本身和工作区文件都保留。',
249
+ 'menu.shareCopied': '对话已复制到剪贴板',
250
+ 'menu.shareFailed': '会话导出失败',
251
+ 'menu.shareEmpty': '这个会话还没有可分享的消息',
252
+ 'message.rewind': '回滚到此步骤',
253
+ 'message.rewindTitle': '确定要回滚到此步骤并重新开始吗?',
254
+ 'message.rewindBody':
255
+ '会把工作区文件和会话记忆恢复到这一步,并把对话分叉到这条消息,同时归档当前会话。撤销快照保留 10 分钟。',
256
+ 'message.rewindConfirm': '确认回滚',
257
+ 'message.rewinding': '正在回滚…',
258
+ 'message.rewindDone': '已回滚到此步骤',
259
+ 'message.rewindFailed': '回滚失败',
260
+ 'message.resend': '重发这一轮',
261
+ 'message.resendTitle': '确定要重发这一轮吗?',
262
+ 'message.resendBody':
263
+ '会在当前对话的一个新分支里重新提问,让模型从头回答,而不是在你已否定的回答上继续。当前对话会保留为分支,工作区文件不会有任何改动。',
264
+ 'message.resendConfirm': '重发',
265
+ 'message.resending': '正在重发…',
266
+ 'message.resendDone': '已作为新一轮重发',
267
+ 'message.resendStaged': '已填入输入框,可先修改再发送',
268
+ 'message.resendFailed': '重发失败',
269
+ 'message.edit': '编辑并重发',
270
+ 'message.editTitle': '编辑并重发',
271
+ 'message.editBody':
272
+ '修改你原本的提问,改完后会作为新的一轮直接发在当前会话里。那一轮之后记录的会话记忆会被清掉,工作区文件不会有任何改动。注意:DSH 的对话记录是只追加的,所以原来的提问和回答仍会留在上面的历史里,不会被删除。',
273
+ 'message.editConfirm': '重发修改后的提问',
274
+ 'message.undo': '撤销本次回滚',
275
+ 'message.undoDone': '已撤销回滚',
276
+ 'message.delete': '删除这一轮',
277
+ 'message.deleteTitle': '确定要删除这一轮吗?',
278
+ 'message.deleteBody': '这一轮的提问、回答,以及中间的过程说明都会从会话中移除。只清理对话记录与会话记忆,工作区文件不会有任何改动。',
279
+ 'message.deleteConfirm': '删除',
280
+ 'message.deleteDone': '已删除这一轮',
281
+ 'message.deleteFailed': '删除消息失败',
282
+ 'message.deleteOnly': '这是当前会话中唯一的消息,删除后会同时删除该会话。',
283
+ 'dialog.cancel': '取消',
284
+ 'dialog.delete': '删除',
285
+ 'dialog.deleteTitle': '删除任务',
286
+ 'dialog.deleteBody': '删除后,这个会话及其所有内容将从本地移除,包括聊天记录、快照和会话记忆,且无法恢复。',
287
+ 'dialog.deleting': '正在删除…',
288
+ 'dialog.deleteDone': '会话已删除',
289
+ 'dialog.deleteStale': '已删除;列表若还在,按 Ctrl+R 刷新即可',
290
+ 'dialog.deleteFailed': '删除会话失败',
291
+ 'dialog.deleteSubagents': '它启动的 {n} 个子代理会话也会一并删除。',
292
+ 'dialog.deleteWouldRefuse': '这里有会话仍在运行,请先停止;目前没有删除任何内容。',
293
+ 'dialog.sessionRunning': '这个会话正在运行,请先停止再删除。',
294
+ 'settings.title': '会话快照',
295
+ 'settings.description': '查看会话的工作区检查点、占用空间,并锁定、删除或恢复它们。',
296
+ 'settings.empty': '还没有快照。每一轮对话开始时都会自动记录一次。',
297
+ 'settings.capture': '立即创建快照',
298
+ 'settings.capturing': '正在创建…',
299
+ 'settings.restore': '恢复文件',
300
+ 'settings.restoreConfirm': '恢复这份快照记录的文件?对话内容不会改变。',
301
+ 'settings.restoreDone': '工作区已恢复',
302
+ 'settings.restoreFailed': '恢复快照失败',
303
+ 'settings.remove': '删除快照',
304
+ 'settings.lock': '锁定',
305
+ 'settings.unlock': '解锁',
306
+ 'settings.locked': '已锁定',
307
+ 'settings.budget': '每个会话保留最近 {keep} 个自动快照',
308
+ 'settings.usage': '快照占用 {size}',
309
+ 'settings.files': '{count} 个文件',
310
+ 'settings.turn': '第 {turn} 轮',
311
+ 'settings.temporary': '临时',
312
+ 'settings.undoBundles': '撤销包',
313
+ 'settings.undoBundle': '第 {turn} 轮的回滚',
314
+ 'settings.undoRestore': '撤销回滚',
315
+ 'settings.undoHint': '回滚后 10 分钟内可撤销。',
316
+ 'settings.memory': '会话记忆',
317
+ 'settings.memoryEmpty': '这个会话还没有记录记忆。',
318
+ 'settings.memoryAdd': '新增记忆',
319
+ 'settings.memoryPlaceholder': '写入一条长期有效的事实、规则或偏好',
320
+ 'settings.memorySave': '保存',
321
+ 'settings.memoryRemove': '删除',
322
+ 'settings.loading': '正在读取快照…',
323
+ 'settings.session': '会话',
324
+ 'settings.noSessions': '还没有可用的会话。',
325
+ 'toast.captured': '已创建快照',
326
+ 'toast.removed': '已删除快照',
327
+ 'toast.locked': '已锁定快照',
328
+ 'toast.unlocked': '已解锁快照',
329
+ 'toast.memoryAdded': '记忆已保存',
330
+ 'toast.memoryRemoved': '记忆已删除',
331
+ 'toast.failed': '操作失败',
332
+ 'settings.details': '详情',
333
+ 'memory.title': '长期记忆',
334
+ 'memory.description': '跨会话保留的稳定事实与约定,分两个库:本工作区私有,以及全局。',
335
+ 'memory.tab.all': '全部',
336
+ 'memory.tab.workspace': '本工作区',
337
+ 'memory.tab.global': '全局',
338
+ 'memory.tab.archived': '冷归档',
339
+ 'memory.global': '全局',
340
+ 'memory.workspace': '本工作区',
341
+ 'memory.workspacePicker': '工作区',
342
+ 'memory.workspaceNone': '未检测到工作区',
343
+ 'memory.rule': '规则',
344
+ 'memory.summary': '摘要',
345
+ 'memory.frozen': '已冻结',
346
+ 'memory.archived': '已归档',
347
+ 'memory.score': '评分 {score}',
348
+ 'memory.access': '召回 {count} 次',
349
+ 'memory.empty': '还没有记忆。可以说【记住:…】,或在下面手动添加。',
350
+ 'memory.explainTitle': '这句话会召回什么?',
351
+ 'memory.explainHint':
352
+ '输入一个问题,看它会召回哪几条记忆、证据够支持几条、其余的各自因为什么被排除。不会记录任何东西,这是一次试算。',
353
+ 'memory.explainPlaceholder': '一个问题,例如:缓存命中率为什么掉了',
354
+ 'memory.explainRun': '试算',
355
+ 'memory.explainInjected': '注入 {n} 条(上限 {limit})',
356
+ 'memory.explainNone': '没有任何匹配,因此不会注入任何记忆。这是预期结果,不是故障。',
357
+ 'memory.explainOmitted': '排除了 {n} 条',
358
+ 'memory.whyNoMatch': '未命中',
359
+ 'memory.whyCapacity': '超出预算',
360
+ 'memory.filterAllTags': '全部标签',
361
+ 'memory.filterAnyTime': '不限时间',
362
+ 'memory.filterToday': '最近 24 小时',
363
+ 'memory.filterWeek': '最近 7 天',
364
+ 'memory.filterMonth': '最近 30 天',
365
+ 'memory.filterClear': '清除筛选',
366
+ 'memory.filterCount': '共 {n} 条',
367
+ 'memory.placeholder': '写入一条长期有效的事实、规则或偏好',
368
+ 'memory.save': '保存',
369
+ 'memory.added': '记忆已保存',
370
+ 'memory.removed': '记忆已删除',
371
+ 'memory.freeze': '冻结',
372
+ 'memory.unfreeze': '解冻',
373
+ 'memory.remove': '删除',
374
+ 'memory.pendingTitle': '待确认的记忆',
375
+ 'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
376
+ 'memory.accept': '保留',
377
+ 'memory.reject': '丢弃',
378
+ 'memory.accepted': '已保留',
379
+ 'memory.rejected': '已丢弃',
380
+ 'memory.maintain': '重新评分并压缩',
381
+ 'memory.maintained': '记忆已重新评分',
382
+ 'memory.maintainedAt': '上次维护',
383
+ 'memory.maintainedNever': '尚未维护过 — 记忆有变化时会自动运行',
384
+ 'memory.maintainedScored': '已评分',
385
+ 'memory.maintainedCompressed': '已压缩',
386
+ 'memory.maintainedRules': '条规则',
387
+ 'memory.implicit': '自动识别并建议记忆',
388
+ 'memory.clearCold': '清空冷归档',
389
+ 'memory.coldCleared': '冷归档已清空',
390
+ },
391
+ }
392
+
393
+ // ----------------------------------------------------------------- store
394
+
395
+ /** Minimal external store: one snapshot, many subscribers. */
396
+ function createStore(initial) {
397
+ let value = initial
398
+ const listeners = new Set()
399
+ return {
400
+ get: () => value,
401
+ set(next) {
402
+ value = next
403
+ for (const listener of listeners) listener()
404
+ },
405
+ patch(partial) {
406
+ value = { ...(value ?? {}), ...partial }
407
+ for (const listener of listeners) listener()
408
+ },
409
+ subscribe(listener) {
410
+ listeners.add(listener)
411
+ return () => listeners.delete(listener)
412
+ },
413
+ }
414
+ }
415
+
416
+ const pending = createStore(null)
417
+ const dialog = createStore(null)
418
+ const toast = createStore(null)
419
+ const revision = createStore(0)
420
+
421
+ /**
422
+ * Host-facing capabilities captured from the plugin context in `apply`, so
423
+ * deep components can reach them without prop drilling.
424
+ */
425
+ const runtime = {
426
+ openSession: undefined,
427
+ archiveSession: undefined,
428
+ cwd: undefined,
429
+ sessionId: undefined,
430
+ resolveCwd: undefined,
431
+ }
432
+
433
+ function useStore(store) {
434
+ return useSyncExternalStore(store.subscribe, store.get, store.get)
435
+ }
436
+
437
+ function bump() {
438
+ revision.set(revision.get() + 1)
439
+ }
440
+
441
+ let toastTimer
442
+ function notify(message, tone) {
443
+ toast.set({ id: Date.now(), message, tone: tone ?? 'default' })
444
+ clearTimeout(toastTimer)
445
+ toastTimer = setTimeout(() => toast.set(null), 3200)
446
+ }
447
+
448
+ // ------------------------------------------------------------------- api
449
+
450
+ async function call(payload, init) {
451
+ const controller = new AbortController()
452
+ const timer = setTimeout(() => controller.abort(), init?.timeoutMs ?? API_TIMEOUT_MS)
453
+ try {
454
+ const response = await fetch(ROUTE, {
455
+ method: 'POST',
456
+ headers: { 'content-type': 'application/json' },
457
+ body: JSON.stringify(payload),
458
+ signal: controller.signal,
459
+ })
460
+ const text = await response.text()
461
+ let parsed
462
+ try {
463
+ parsed = JSON.parse(text)
464
+ } catch {
465
+ throw new Error(text.slice(0, 300) || `HTTP ${response.status}`)
466
+ }
467
+ if (!parsed.ok) {
468
+ const error = new Error(parsed.error || `HTTP ${response.status}`)
469
+ error.code = parsed.code
470
+ throw error
471
+ }
472
+ return parsed.data
473
+ } finally {
474
+ clearTimeout(timer)
475
+ }
476
+ }
477
+
478
+ /** Save a Blob through the browser's download manager. */
479
+ function saveBlob(blob, fileName) {
480
+ const url = URL.createObjectURL(blob)
481
+ const anchor = document.createElement('a')
482
+ anchor.href = url
483
+ anchor.download = fileName
484
+ document.body.appendChild(anchor)
485
+ anchor.click()
486
+ anchor.remove()
487
+ setTimeout(() => URL.revokeObjectURL(url), 4000)
488
+ }
489
+
490
+ // ------------------------------------------------------------------ icons
491
+
492
+ function icon(children, size) {
493
+ return h(
494
+ 'svg',
495
+ {
496
+ viewBox: '0 0 16 16',
497
+ width: size ?? 16,
498
+ height: size ?? 16,
499
+ fill: 'none',
500
+ stroke: 'currentColor',
501
+ strokeWidth: 1.4,
502
+ strokeLinecap: 'round',
503
+ strokeLinejoin: 'round',
504
+ 'aria-hidden': true,
505
+ className: 'dss-icon',
506
+ },
507
+ ...children,
508
+ )
509
+ }
510
+
511
+ const Icons = {
512
+ rewind: () => icon([h('path', { key: 'a', d: 'M3.2 8a4.8 4.8 0 1 0 1.5-3.5' }), h('path', { key: 'b', d: 'M2.6 2.4v3h3' })]),
513
+ // A pencil, not a circular arrow: this edits the user's question, it does
514
+ // not repeat or restore anything. The circle was confusing in the action
515
+ // row, where rewind already uses a circular arrow.
516
+ edit: () =>
517
+ icon([
518
+ h('path', { key: 'a', d: 'M3 13.2 11.7 4.5l1.8 1.8L4.8 15H3z' }),
519
+ h('path', { key: 'b', d: 'M10.4 5.8l1.8 1.8 1.3-1.3a.9.9 0 0 0 0-1.3l-.5-.5a.9.9 0 0 0-1.3 0z' }),
520
+ ]),
521
+ trash: () =>
522
+ icon([
523
+ h('path', { key: 'a', d: 'M2.6 4.3h10.8' }),
524
+ h('path', { key: 'b', d: 'M6.3 4.3V2.9h3.4v1.4' }),
525
+ h('path', { key: 'c', d: 'M4.2 4.3l.6 8.2a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9l.6-8.2' }),
526
+ ]),
527
+ download: () =>
528
+ icon([h('path', { key: 'a', d: 'M8 2.4v7.2' }), h('path', { key: 'b', d: 'M5 6.8 8 9.8l3-3' }), h('path', { key: 'c', d: 'M3 12.6h10' })]),
529
+ clipboard: () =>
530
+ icon([
531
+ h('rect', { key: 'a', x: 3.4, y: 3.2, width: 9.2, height: 10.4, rx: 1.6 }),
532
+ h('path', { key: 'b', d: 'M6 2.4h4v1.8H6z' }),
533
+ ]),
534
+ code: () => icon([h('path', { key: 'a', d: 'M5.6 4.6 2.6 8l3 3.4' }), h('path', { key: 'b', d: 'M10.4 4.6 13.4 8l-3 3.4' })]),
535
+ cut: () =>
536
+ icon([
537
+ h('circle', { key: 'a', cx: 4.4, cy: 11.6, r: 1.9 }),
538
+ h('circle', { key: 'b', cx: 11.6, cy: 11.6, r: 1.9 }),
539
+ h('path', { key: 'c', d: 'M5.8 10.2 11.2 2.6' }),
540
+ h('path', { key: 'd', d: 'M10.2 10.2 4.8 2.6' }),
541
+ ]),
542
+ lock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.8 0V7' })]),
543
+ unlock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.6-.8' })]),
544
+ restore: () => icon([h('path', { key: 'a', d: 'M3 8a5 5 0 1 1 1.6 3.7' }), h('path', { key: 'b', d: 'M2.6 2.8v3h3' })]),
545
+ close: () => icon([h('path', { key: 'a', d: 'M4 4l8 8' }), h('path', { key: 'b', d: 'M12 4l-8 8' })]),
546
+ info: () =>
547
+ icon([
548
+ h('circle', { key: 'a', cx: 8, cy: 8, r: 5.6 }),
549
+ h('path', { key: 'b', d: 'M8 7.2v4' }),
550
+ h('path', { key: 'c', d: 'M8 5.1v.1' }),
551
+ ]),
552
+ warning: () =>
553
+ icon([
554
+ h('circle', { key: 'a', cx: 8, cy: 8, r: 5.6 }),
555
+ h('path', { key: 'b', d: 'M8 4.9v3.7' }),
556
+ h('path', { key: 'c', d: 'M8 10.9v.1' }),
557
+ ]),
558
+ plus: () => icon([h('path', { key: 'a', d: 'M8 3.4v9.2' }), h('path', { key: 'b', d: 'M3.4 8h9.2' })]),
559
+ arrowRight: () => icon([h('path', { key: 'a', d: 'M3.4 8h9.2' }), h('path', { key: 'b', d: 'M9 4.4 12.6 8 9 11.6' })]),
560
+ }
561
+
562
+ // ----------------------------------------------------------------- styles
563
+
564
+ const CSS = `
565
+ .dss-icon { display: block; flex: none; }
566
+ .dss-menu-item-wrap { display: block; }
567
+ .dss-menu-item {
568
+ display: flex; align-items: center; gap: 6px; width: 100%;
569
+ min-height: 34px; padding: 6px 8px; border: 0;
570
+ border-radius: var(--dsw-radius-md, 8px); background: transparent;
571
+ font-size: 13px; line-height: 20px; text-align: left; cursor: pointer;
572
+ color: var(--dsw-alias-label-primary, #1f1f1f);
573
+ }
574
+ .dss-menu-item:hover, .dss-menu-item:focus-visible {
575
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 8%, transparent);
576
+ outline: none;
577
+ }
578
+ .dss-menu-item:disabled { opacity: .4; cursor: not-allowed; }
579
+ .dss-menu-item.dss-danger { color: var(--dsw-alias-state-error-primary, #d93025); }
580
+ .dss-menu-item.dss-danger:hover {
581
+ background: var(--dsw-alias-interactive-bg-hover-danger, color-mix(in srgb, var(--dsw-alias-state-error-primary, #d93025) 12%, transparent));
582
+ }
583
+ .dss-menu-separator {
584
+ height: .5px; margin: 4px 2px;
585
+ background: var(--dsw-alias-border-l2, rgba(0,0,0,.1));
586
+ }
587
+ .dss-menu-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; }
588
+ .dss-menu-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
589
+
590
+ .dss-action {
591
+ display: inline-flex; align-items: center; justify-content: center;
592
+ width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--dsw-radius-sm, 6px);
593
+ background: transparent; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, #6b6b6b));
594
+ cursor: pointer; transition: background .15s ease, color .15s ease;
595
+ }
596
+ .dss-action:hover {
597
+ background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, var(--dsw-alias-label-primary, #000) 8%, transparent));
598
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
599
+ }
600
+ .dss-action.dss-danger:hover {
601
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d93025) 14%, transparent);
602
+ color: var(--dsw-alias-state-error-primary, #d93025);
603
+ }
604
+ .dss-action:disabled { opacity: .4; cursor: progress; }
605
+
606
+ .dss-banner {
607
+ display: flex; gap: 10px; align-items: flex-start;
608
+ margin: 0 0 8px; padding: 12px 14px; border-radius: 10px;
609
+ background: var(--dsw-alias-bg-layer-1, #f4f4f4);
610
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
611
+ color: var(--dsw-alias-label-primary, #1f1f1f);
612
+ font-size: 13px; line-height: 20px;
613
+ }
614
+ .dss-banner-icon { color: var(--dsw-alias-brand-primary, #2f6feb); margin-top: 1px; }
615
+ .dss-banner-main { flex: 1 1 auto; min-width: 0; display: flex; gap: 10px; }
616
+ .dss-banner-text { display: flex; flex-direction: column; gap: 2px; }
617
+ .dss-banner-title { font-weight: 600; }
618
+ .dss-banner-body { color: var(--dsw-alias-label-secondary, #6b6b6b); }
619
+ .dss-banner-actions { display: flex; gap: 8px; align-items: center; flex: none; }
620
+
621
+ .dss-btn {
622
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
623
+ height: 30px; padding: 0 14px; border-radius: 8px; font-size: 13px;
624
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
625
+ background: var(--dsw-alias-bg-layer-1, #fff);
626
+ color: var(--dsw-alias-label-primary, #1f1f1f); cursor: pointer;
627
+ transition: background .15s ease, border-color .15s ease;
628
+ }
629
+ .dss-btn:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 6%, var(--dsw-alias-bg-layer-1, #fff)); }
630
+ .dss-btn:disabled { opacity: .55; cursor: progress; }
631
+ .dss-btn.dss-primary {
632
+ background: var(--dsw-alias-label-primary, #1f1f1f);
633
+ border-color: var(--dsw-alias-label-primary, #1f1f1f);
634
+ color: var(--dsw-alias-bg-base, #fff);
635
+ }
636
+ .dss-btn.dss-primary:hover { opacity: .9; }
637
+ .dss-btn.dss-critical {
638
+ background: var(--dsw-alias-state-error-primary, #d93025);
639
+ border-color: var(--dsw-alias-state-error-primary, #d93025);
640
+ color: #fff;
641
+ }
642
+ .dss-btn.dss-critical:hover { opacity: .9; }
643
+ .dss-btn.dss-ghost { border-color: transparent; background: transparent; }
644
+ .dss-btn.dss-ghost:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 6%, transparent); }
645
+ .dss-btn.dss-small { height: 26px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
646
+
647
+ .dss-scrim {
648
+ position: fixed; inset: 0; z-index: 60; display: flex;
649
+ align-items: center; justify-content: center; padding: 24px;
650
+ background: rgba(15, 18, 24, .32); pointer-events: auto;
651
+ }
652
+ .dss-dialog {
653
+ width: min(440px, 100%); border-radius: 12px; padding: 18px 18px 16px;
654
+ background: var(--dsw-alias-bg-overlay, #fff);
655
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
656
+ box-shadow: 0 18px 48px rgba(15, 18, 24, .22);
657
+ color: var(--dsw-alias-label-primary, #1f1f1f);
658
+ pointer-events: auto;
659
+ }
660
+ .dss-dialog-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
661
+ .dss-dialog-icon { display: inline-flex; flex: none; color: var(--dsw-alias-state-error-primary, #d93025); }
662
+ .dss-dialog-title { flex: 1 1 auto; font-size: 15px; font-weight: 600; }
663
+ .dss-dialog-title.dss-critical { color: var(--dsw-alias-state-error-primary, #d93025); }
664
+ .dss-dialog-body { font-size: 13px; line-height: 21px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
665
+ .dss-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
666
+ .dss-dialog-error { margin-top: 10px; font-size: 12px; color: var(--dsw-alias-state-error-primary, #d93025); }
667
+ .dss-dialog.dss-wide { width: min(720px, 100%); }
668
+ .dss-dialog-scroll { max-height: min(62vh, 560px); overflow-y: auto; margin-top: 12px; padding-right: 4px; }
669
+ .dss-dialog-actions.dss-wrap { flex-wrap: wrap; }
670
+
671
+ .dss-picker-scroll { margin-top: 10px; }
672
+ .dss-picker { display: flex; flex-direction: column; gap: 2px; }
673
+ .dss-pick {
674
+ display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px;
675
+ border-radius: 8px; cursor: pointer; user-select: none;
676
+ }
677
+ .dss-pick:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 5%, transparent); }
678
+ .dss-pick input { margin-top: 3px; accent-color: var(--dsw-alias-brand-primary, #2563eb); }
679
+ .dss-pick-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
680
+ .dss-pick-title {
681
+ font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary, #1f1f1f);
682
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
683
+ }
684
+ .dss-pick-sub {
685
+ font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b);
686
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
687
+ }
688
+ .dss-note { margin-top: 10px; font-size: 12.5px; color: var(--dsw-alias-state-success-primary, #1a7f37); }
689
+ .dss-curate {
690
+ display: flex; align-items: flex-start; gap: 10px; margin-top: 12px;
691
+ padding: 10px 12px; border-radius: 8px; cursor: pointer;
692
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
693
+ background: var(--dsw-alias-bg-layer-1, #fff);
694
+ }
695
+ .dss-curate input { margin-top: 3px; accent-color: var(--dsw-alias-brand-primary, #2563eb); }
696
+ .dss-curate-text { display: flex; flex-direction: column; gap: 2px; }
697
+ .dss-curate-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); }
698
+ .dss-curate-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
699
+ .dss-curate-inline { margin-top: 0; padding: 6px 10px; border-color: transparent; background: transparent; }
700
+
701
+ .dss-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
702
+ .dss-tab {
703
+ height: 28px; padding: 0 12px; border-radius: 999px; font-size: 12.5px; cursor: pointer;
704
+ border: 1px solid transparent; background: transparent;
705
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
706
+ }
707
+ .dss-tab:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 6%, transparent); }
708
+ .dss-tab.dss-tab-on {
709
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 12%, transparent);
710
+ color: var(--dsw-alias-brand-primary, #2563eb); font-weight: 600;
711
+ }
712
+ .dss-mem-pending {
713
+ display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 10px;
714
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 30%, transparent);
715
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 6%, transparent);
716
+ }
717
+ .dss-mem-pending-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
718
+ .dss-warn {
719
+ margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 12.5px; line-height: 19px;
720
+ color: var(--dsw-alias-state-warn-primary, #9a6700);
721
+ background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 12%, transparent);
722
+ }
723
+ .dss-hint { margin-top: 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
724
+
725
+ .dss-toast {
726
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
727
+ z-index: 70; padding: 9px 14px; border-radius: 999px; pointer-events: none;
728
+ font-size: 13px; color: var(--dsw-alias-bg-base, #fff);
729
+ background: var(--dsw-alias-label-primary, #1f1f1f);
730
+ box-shadow: 0 8px 24px rgba(15, 18, 24, .24);
731
+ }
732
+ .dss-toast.dss-error { background: var(--dsw-alias-state-error-primary, #d93025); }
733
+
734
+ .dss-section { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
735
+ .dss-section.dss-embedded { max-width: none; gap: 10px; }
736
+ .dss-section h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
737
+ .dss-section-head { display: flex; flex-direction: column; gap: 4px; }
738
+ .dss-section-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
739
+ .dss-section-desc { font-size: 12.5px; line-height: 19px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
740
+ .dss-card {
741
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
742
+ border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
743
+ }
744
+ .dss-row {
745
+ display: flex; align-items: center; gap: 10px; padding: 10px 12px;
746
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
747
+ }
748
+ .dss-row:last-child { border-bottom: 0; }
749
+ .dss-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
750
+ .dss-row-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); display: flex; align-items: center; gap: 6px; }
751
+ .dss-row-meta { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); display: flex; gap: 10px; flex-wrap: wrap; }
752
+ .dss-badge {
753
+ display: inline-flex; align-items: center; height: 18px; padding: 0 6px;
754
+ border-radius: 999px; font-size: 11px; line-height: 18px;
755
+ background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 7%, transparent);
756
+ color: var(--dsw-alias-label-secondary, #6b6b6b);
757
+ }
758
+ .dss-badge.dss-locked { background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2f6feb) 14%, transparent); color: var(--dsw-alias-brand-primary, #2f6feb); }
759
+ .dss-badge.dss-temp { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b07b00) 16%, transparent); color: var(--dsw-alias-state-warn-primary, #b07b00); }
760
+ .dss-empty { padding: 18px 12px; font-size: 13px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
761
+ .dss-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
762
+ .dss-select {
763
+ height: 30px; border-radius: 8px; padding: 0 8px; font-size: 13px;
764
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
765
+ background: var(--dsw-alias-bg-layer-1, #fff);
766
+ color: var(--dsw-alias-label-primary, #1f1f1f); max-width: 100%;
767
+ }
768
+ .dss-ws-picker { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 2px 0 6px; }
769
+ .dss-ws-label { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
770
+ .dss-ws-picker .dss-select { flex: 0 1 260px; min-width: 0; }
771
+ .dss-maint {
772
+ display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;
773
+ padding-top: 8px; font-size: 11px; line-height: 1.5;
774
+ color: var(--dsw-alias-label-tertiary, #8a8a8a);
775
+ }
776
+ .dss-maint-when { flex: 0 0 auto; }
777
+ .dss-maint-report { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
778
+ .dss-input {
779
+ flex: 1 1 auto; min-width: 0; height: 30px; padding: 0 9px; font-size: 13px;
780
+ border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
781
+ background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #1f1f1f);
782
+ }
783
+ .dss-group-title { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary, #6b6b6b); }
784
+ .dss-memory-text { font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary, #1f1f1f); word-break: break-word; }
785
+ .dss-row-actions { display: flex; align-items: center; gap: 4px; flex: none; }
786
+ .dss-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: dss-spin .8s linear infinite; }
787
+ @keyframes dss-spin { to { transform: rotate(360deg); } }
788
+ `
789
+
790
+ function installStyles() {
791
+ const element = document.createElement('style')
792
+ element.setAttribute('data-dsh-session-suite', '')
793
+ element.textContent = CSS
794
+ document.head.appendChild(element)
795
+ return () => element.remove()
796
+ }
797
+
798
+ // ------------------------------------------------------------- primitives
799
+
800
+ function MenuRow({ label, iconNode, danger, separator, disabled, onSelect }) {
801
+ const className = ['dss-menu-item', danger ? 'dss-danger' : ''].filter(Boolean).join(' ')
802
+ const button = h(
803
+ 'button',
804
+ { type: 'button', role: 'menuitem', className, disabled, title: label, onClick: onSelect },
805
+ iconNode ? h('span', { className: 'dss-menu-icon' }, iconNode) : null,
806
+ h('span', { className: 'dss-menu-label' }, label),
807
+ )
808
+ return h(
809
+ 'div',
810
+ { className: 'dss-menu-item-wrap' },
811
+ separator ? h('div', { className: 'dss-menu-separator', role: 'separator' }) : null,
812
+ button,
813
+ )
814
+ }
815
+
816
+ function DialogFrame({ title, critical, wide, onClose, children }) {
817
+ const ref = useRef(null)
818
+ useEffect(() => {
819
+ const onKey = event => {
820
+ if (event.key === 'Escape') {
821
+ event.stopPropagation()
822
+ onClose()
823
+ }
824
+ }
825
+ document.addEventListener('keydown', onKey, true)
826
+ ref.current?.focus()
827
+ return () => document.removeEventListener('keydown', onKey, true)
828
+ }, [onClose])
829
+ return h(
830
+ 'div',
831
+ {
832
+ className: 'dss-scrim',
833
+ onMouseDown: event => {
834
+ if (event.target === event.currentTarget) onClose()
835
+ },
836
+ },
837
+ h(
838
+ 'div',
839
+ {
840
+ className: 'dss-dialog' + (wide ? ' dss-wide' : ''),
841
+ role: 'dialog',
842
+ 'aria-modal': 'true',
843
+ 'aria-label': title,
844
+ tabIndex: -1,
845
+ ref,
846
+ },
847
+ h(
848
+ 'div',
849
+ { className: 'dss-dialog-head' },
850
+ critical
851
+ ? h('span', { className: 'dss-dialog-icon', 'aria-hidden': true }, h(Icons.warning, null))
852
+ : null,
853
+ h('div', { className: 'dss-dialog-title' }, title),
854
+ h('button', { type: 'button', className: 'dss-action', onClick: onClose, 'aria-label': 'Close' }, h(Icons.close, null)),
855
+ ),
856
+ children,
857
+ ),
858
+ )
859
+ }
860
+
861
+ // --------------------------------------------------------------- overlay
862
+
863
+ function SelectSessionDialog({ state, onClose }) {
864
+ const [busy, setBusy] = useState(false)
865
+ const [error, setError] = useState(null)
866
+ const [preview, setPreview] = useState(null)
867
+ const t = state.t ?? (key => key)
868
+
869
+ // Deleting a session also deletes the subagent sessions it started, so the
870
+ // dialog says how many before the user commits rather than after. A failed
871
+ // preview never blocks the dialog: the confirm button still works and the
872
+ // Host reports the real reason on submit.
873
+ useEffect(() => {
874
+ if (state.mode !== 'delete-session' || !state.sessionId) return undefined
875
+ let alive = true
876
+ call({ action: 'previewDelete', sessionId: state.sessionId })
877
+ .then(data => {
878
+ if (alive) setPreview(data)
879
+ })
880
+ .catch(() => {})
881
+ return () => {
882
+ alive = false
883
+ }
884
+ }, [state.mode, state.sessionId])
885
+
886
+ const confirm = async () => {
887
+ setBusy(true)
888
+ setError(null)
889
+ try {
890
+ if (state.mode === 'delete-session') {
891
+ // Close the open view first: the Host then finds the session idle
892
+ // and the sidebar drops it without leaving a dead panel behind.
893
+ try {
894
+ await runtime.archiveSession?.(state.sessionId)
895
+ } catch {
896
+ // The Host route below reports the precise reason (e.g. still running).
897
+ }
898
+ const removed = await call({ action: 'deleteSession', sessionId: state.sessionId })
899
+ // Repeat the archive: the Host now knows the session is gone, so this
900
+ // pass always takes the durable path and drops the sidebar row.
901
+ try {
902
+ await runtime.archiveSession?.(state.sessionId)
903
+ } catch {
904
+ // Already archived is the common, fine outcome.
905
+ }
906
+ notify(removed?.stillListed ? t('dialog.deleteStale') : t('dialog.deleteDone'))
907
+ } else if (state.onConfirm) {
908
+ await state.onConfirm()
909
+ if (state.doneMessage) notify(state.doneMessage)
910
+ }
911
+ onClose()
912
+ bump()
913
+ } catch (failure) {
914
+ const message = failure?.code === 'session-running' ? t('dialog.sessionRunning') : failure?.message ?? t('toast.failed')
915
+ setError(message)
916
+ } finally {
917
+ setBusy(false)
918
+ }
919
+ }
920
+
921
+ const linked = preview && Array.isArray(preview.subagents) ? preview.subagents.length : 0
922
+ const terminating = preview?.willRefuse === true
923
+ const body = state.mode === 'delete-session'
924
+ ? h(
925
+ 'div',
926
+ { className: 'dss-dialog-body' },
927
+ t('dialog.deleteBody'),
928
+ state.onlyMessage ? h('div', { style: { marginTop: 8 } }, t('message.deleteOnly')) : null,
929
+ linked > 0
930
+ ? h(
931
+ 'div',
932
+ { style: { marginTop: 8, fontWeight: 600 } },
933
+ t('dialog.deleteSubagents', { n: linked }),
934
+ )
935
+ : null,
936
+ terminating
937
+ ? h('div', { style: { marginTop: 8 } }, t('dialog.deleteWouldRefuse'))
938
+ : null,
939
+ )
940
+ : h('div', { className: 'dss-dialog-body' }, state.body)
941
+
942
+ return h(
943
+ DialogFrame,
944
+ { title: state.mode === 'delete-session' ? t('dialog.deleteTitle') : state.title, critical: true, onClose: busy ? () => {} : onClose },
945
+ body,
946
+ error ? h('div', { className: 'dss-dialog-error' }, error) : null,
947
+ h(
948
+ 'div',
949
+ { className: 'dss-dialog-actions' },
950
+ h('button', { type: 'button', className: 'dss-btn', onClick: onClose, disabled: busy }, t('dialog.cancel')),
951
+ h(
952
+ 'button',
953
+ { type: 'button', className: 'dss-btn dss-critical', onClick: confirm, disabled: busy },
954
+ busy ? h('span', { className: 'dss-spin' }) : null,
955
+ busy ? t('dialog.deleting') : (state.confirmLabel ?? t('dialog.delete')),
956
+ ),
957
+ ),
958
+ )
959
+ }
960
+
961
+ // ------------------------------------------------------ turn selection
962
+
963
+ /** Load the turn outline a share or segment dialog works on. */
964
+ function useOutline(sessionId) {
965
+ const [state, setState] = useState({ loading: true, outline: null, error: null })
966
+ useEffect(() => {
967
+ let alive = true
968
+ call({ action: 'outline', sessionId })
969
+ .then(outline => alive && setState({ loading: false, outline, error: null }))
970
+ .catch(error => alive && setState({ loading: false, outline: null, error: error.message }))
971
+ return () => {
972
+ alive = false
973
+ }
974
+ }, [sessionId])
975
+ return state
976
+ }
977
+
978
+ /** Every turn starts selected: sharing or deleting everything is the default. */
979
+ function useTurnSelection(outline) {
980
+ const [selected, setSelected] = useState(() => new Set())
981
+ useEffect(() => {
982
+ setSelected(new Set((outline?.turns ?? []).map(row => row.turn)))
983
+ }, [outline])
984
+ const toggle = useCallback(turn => {
985
+ setSelected(current => {
986
+ const next = new Set(current)
987
+ if (next.has(turn)) next.delete(turn)
988
+ else next.add(turn)
989
+ return next
990
+ })
991
+ }, [])
992
+ const all = useCallback(() => setSelected(new Set((outline?.turns ?? []).map(row => row.turn))), [outline])
993
+ const none = useCallback(() => setSelected(new Set()), [])
994
+ return { selected, toggle, all, none }
995
+ }
996
+
997
+ function rangesOf(outline, selected) {
998
+ return (outline?.turns ?? [])
999
+ .filter(row => selected.has(row.turn))
1000
+ .map(row => ({ startSeq: row.startSeq, endSeq: row.endSeq }))
1001
+ }
1002
+
1003
+ function TurnPicker({ outline, selected, onToggle, t }) {
1004
+ const turns = outline?.turns ?? []
1005
+ if (turns.length === 0) return h('div', { className: 'dss-empty' }, t('settings.empty'))
1006
+ return h(
1007
+ 'div',
1008
+ { className: 'dss-picker' },
1009
+ ...turns.map(row => {
1010
+ const checked = selected.has(row.turn)
1011
+ return h(
1012
+ 'label',
1013
+ { key: row.turn, className: 'dss-pick' + (checked ? ' dss-pick-on' : '') },
1014
+ h('input', { type: 'checkbox', checked, onChange: () => onToggle(row.turn) }),
1015
+ h(
1016
+ 'span',
1017
+ { className: 'dss-pick-main' },
1018
+ h('span', { className: 'dss-pick-title' }, `${t('settings.turn', { turn: row.turn })} · ${row.preview || '—'}`),
1019
+ row.answerPreview ? h('span', { className: 'dss-pick-sub' }, row.answerPreview) : null,
1020
+ ),
1021
+ )
1022
+ }),
1023
+ )
1024
+ }
1025
+
1026
+ function SelectionToolbar({ t, outline, selected, onAll, onNone }) {
1027
+ const total = outline?.turns?.length ?? 0
1028
+ return h(
1029
+ 'div',
1030
+ { className: 'dss-toolbar' },
1031
+ h('span', { className: 'dss-group-title' }, t('share.selected', { count: selected.size, total })),
1032
+ h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: onAll }, t('share.all')),
1033
+ h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: onNone }, t('share.none')),
1034
+ )
1035
+ }
1036
+
1037
+ /**
1038
+ * Draw the selected conversation into a PNG with the 2D canvas API.
1039
+ *
1040
+ * The Host already parsed the Markdown (export format `blocks`), so this is
1041
+ * purely layout: inline runs become wrapped lines, blocks become one card per
1042
+ * reply, and the user's prompts sit in blue bubbles on the right.
1043
+ */
1044
+ async function renderConversationImage({ title, meta, footer, messages }) {
1045
+ const WIDTH = 880
1046
+ const PAGE_PAD = 40
1047
+ const CARD_PAD = 22
1048
+ const SCALE = 2
1049
+ const SANS = '-apple-system, "Segoe UI", "Microsoft YaHei", Roboto, sans-serif'
1050
+ const MONO = 'ui-monospace, SFMono-Regular, Consolas, monospace'
1051
+ const font = (size, weight) => `${weight ? `${weight} ` : ''}${size}px ${SANS}`
1052
+ const mono = size => `${size}px ${MONO}`
1053
+ const palette = {
1054
+ page: '#f4f5f7',
1055
+ card: '#ffffff',
1056
+ fg: '#1b1d21',
1057
+ muted: '#8a8f98',
1058
+ user: '#2563eb',
1059
+ userFg: '#ffffff',
1060
+ quote: '#f5f7fa',
1061
+ accent: '#2563eb',
1062
+ code: '#f6f7f9',
1063
+ codeFg: '#3a3f46',
1064
+ chipFg: '#3a3f46',
1065
+ line: '#eceef1',
1066
+ }
1067
+
1068
+ const measure = document.createElement('canvas').getContext('2d')
1069
+ const cardWidth = WIDTH - PAGE_PAD * 2
1070
+ const contentWidth = cardWidth - CARD_PAD * 2
1071
+ const bubbleMax = Math.round(cardWidth * 0.8)
1072
+
1073
+ /** Decode a data URL into an image the canvas can draw. */
1074
+ const loadImage = source =>
1075
+ new Promise(resolve => {
1076
+ if (!source) {
1077
+ resolve(null)
1078
+ return
1079
+ }
1080
+ const image = new Image()
1081
+ image.onload = () => resolve(image)
1082
+ image.onerror = () => resolve(null)
1083
+ image.src = source
1084
+ })
1085
+
1086
+ // Screenshots arrive inline (the Host already resolved the attachments), so
1087
+ // they are decoded before layout to know their aspect ratio.
1088
+ const prepared = []
1089
+ for (const message of messages) {
1090
+ const images = []
1091
+ for (const image of message.images ?? []) {
1092
+ const element = await loadImage(image.dataUrl)
1093
+ if (element) images.push({ ...image, element })
1094
+ }
1095
+ prepared.push({ ...message, images })
1096
+ }
1097
+
1098
+ const roundRect = (ctx, x, y, w, h, r) => {
1099
+ ctx.beginPath()
1100
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, w, h, r)
1101
+ else {
1102
+ const radius = Math.min(r, w / 2, h / 2)
1103
+ ctx.moveTo(x + radius, y)
1104
+ ctx.arcTo(x + w, y, x + w, y + h, radius)
1105
+ ctx.arcTo(x + w, y + h, x, y + h, radius)
1106
+ ctx.arcTo(x, y + h, x, y, radius)
1107
+ ctx.arcTo(x, y, x + w, y, radius)
1108
+ ctx.closePath()
1109
+ }
1110
+ }
1111
+
1112
+ /** Wrap inline runs into lines of styled segments. */
1113
+ const wrapRuns = (runs, maxWidth, baseFont, baseFill) => {
1114
+ const lines = []
1115
+ let segments = []
1116
+ let width = 0
1117
+ const append = segment => {
1118
+ const last = segments[segments.length - 1]
1119
+ if (last && last.font === segment.font && last.fill === segment.fill) last.text += segment.text
1120
+ else segments.push({ ...segment })
1121
+ }
1122
+ for (const run of runs ?? []) {
1123
+ const style = baseFont
1124
+ ? { font: baseFont, fill: baseFill ?? palette.fg }
1125
+ : run.code
1126
+ ? { font: mono(13), fill: palette.chipFg }
1127
+ : { font: font(15, run.bold ? '600' : undefined), fill: palette.fg }
1128
+ measure.font = style.font
1129
+ for (const char of String(run.text ?? '')) {
1130
+ const advance = measure.measureText(char).width
1131
+ if (width + advance > maxWidth && segments.length > 0) {
1132
+ lines.push({ segments, width })
1133
+ segments = []
1134
+ width = 0
1135
+ }
1136
+ append({ text: char, font: style.font, fill: style.fill })
1137
+ width += advance
1138
+ }
1139
+ }
1140
+ lines.push({ segments, width })
1141
+ return lines
1142
+ }
1143
+
1144
+ /** Wrap one plain string with a fixed style. */
1145
+ const wrapPlain = (text, textFont, fill, maxWidth) => {
1146
+ const lines = []
1147
+ let segments = []
1148
+ let width = 0
1149
+ measure.font = textFont
1150
+ for (const char of String(text)) {
1151
+ const advance = measure.measureText(char).width
1152
+ if (width + advance > maxWidth && segments.length > 0) {
1153
+ lines.push({ segments, width })
1154
+ segments = []
1155
+ width = 0
1156
+ }
1157
+ const last = segments[segments.length - 1]
1158
+ if (last) last.text += char
1159
+ else segments.push({ text: char, font: textFont, fill })
1160
+ width += advance
1161
+ }
1162
+ lines.push({ segments, width })
1163
+ return lines
1164
+ }
1165
+
1166
+ const groupSections = () => {
1167
+ const sections = []
1168
+ for (const message of prepared) {
1169
+ if (message.role === 'user') {
1170
+ sections.push({ role: 'user', blocks: message.blocks ?? [], images: message.images ?? [] })
1171
+ continue
1172
+ }
1173
+ const last = sections[sections.length - 1]
1174
+ if (last && last.role === 'assistant' && last.turn === message.turn) last.blocks.push(...(message.blocks ?? []))
1175
+ else sections.push({ role: 'assistant', turn: message.turn, blocks: [...(message.blocks ?? [])] })
1176
+ }
1177
+ return sections
1178
+ }
1179
+
1180
+ /** Lay out one assistant card's Markdown blocks. */
1181
+ const layoutBlocks = blocks => {
1182
+ const ops = []
1183
+ let y = 0
1184
+ for (const block of blocks) {
1185
+ if (block.type === 'heading') {
1186
+ const size = block.level <= 2 ? 19 : block.level === 3 ? 17 : 15
1187
+ const lines = wrapRuns(block.runs, contentWidth, font(size, '600'), palette.fg)
1188
+ if (y > 0) y += 12
1189
+ for (const line of lines) {
1190
+ ops.push({ kind: 'line', x: 0, y: y + Math.round(size * 0.85) + 4, segments: line.segments })
1191
+ y += Math.round(size * 1.55)
1192
+ }
1193
+ y += 4
1194
+ continue
1195
+ }
1196
+ if (block.type === 'paragraph') {
1197
+ for (const line of wrapRuns(block.runs, contentWidth, font(15), palette.fg)) {
1198
+ ops.push({ kind: 'line', x: 0, y: y + 17, segments: line.segments })
1199
+ y += 24
1200
+ }
1201
+ y += 9
1202
+ continue
1203
+ }
1204
+ if (block.type === 'list') {
1205
+ for (const [index, item] of (block.items ?? []).entries()) {
1206
+ const indent = Math.min(3, item.indent ?? 0) * 18
1207
+ const lines = wrapRuns(item.runs, contentWidth - indent - 22, font(15), palette.fg)
1208
+ for (const [lineIndex, line] of lines.entries()) {
1209
+ if (lineIndex === 0) {
1210
+ ops.push({
1211
+ kind: 'text',
1212
+ x: indent,
1213
+ y: y + 17,
1214
+ text: block.ordered ? `${(block.start ?? 1) + index}.` : '•',
1215
+ font: font(15),
1216
+ fill: palette.muted,
1217
+ })
1218
+ }
1219
+ ops.push({ kind: 'line', x: indent + 22, y: y + 17, segments: line.segments })
1220
+ y += 24
1221
+ }
1222
+ }
1223
+ y += 9
1224
+ continue
1225
+ }
1226
+ if (block.type === 'quote') {
1227
+ const lines = []
1228
+ for (const runs of block.lines ?? []) lines.push(...wrapRuns(runs, contentWidth - 34, font(15), palette.fg))
1229
+ const height = lines.length * 24 + 16
1230
+ ops.push({ kind: 'quote', y, height })
1231
+ lines.forEach((line, index) => {
1232
+ ops.push({ kind: 'line', x: 16, y: y + 8 + index * 24 + 17, segments: line.segments })
1233
+ })
1234
+ y += height + 10
1235
+ continue
1236
+ }
1237
+ if (block.type === 'code') {
1238
+ const lines = String(block.text ?? '')
1239
+ .split('\n')
1240
+ .flatMap(raw => wrapPlain(raw === '' ? ' ' : raw, mono(13), palette.codeFg, contentWidth - 26))
1241
+ const height = lines.length * 21 + 18
1242
+ ops.push({ kind: 'code', y, height })
1243
+ lines.forEach((line, index) => {
1244
+ ops.push({ kind: 'line', x: 13, y: y + 9 + index * 21 + 15, segments: line.segments })
1245
+ })
1246
+ y += height + 10
1247
+ continue
1248
+ }
1249
+ if (block.type === 'table') {
1250
+ const columnCount = Math.max(1, block.headers?.length ?? 0, ...(block.rows ?? []).map(row => row.length))
1251
+ const plainOf = runs => (runs ?? []).map(run => run.text ?? '').join('')
1252
+ const natural = new Array(columnCount).fill(52)
1253
+ const bump = (runs, column, bold) => {
1254
+ measure.font = font(13, bold ? '600' : undefined)
1255
+ natural[column] = Math.max(natural[column], measure.measureText(plainOf(runs)).width + 20)
1256
+ }
1257
+ ;(block.headers ?? []).forEach((runs, column) => bump(runs, column, true))
1258
+ for (const row of block.rows ?? []) row.forEach((runs, column) => bump(runs, column, false))
1259
+ const naturalTotal = natural.reduce((sum, value) => sum + value, 0)
1260
+ const factor = naturalTotal > contentWidth ? contentWidth / naturalTotal : 1
1261
+ const widths = natural.map(value => Math.round(value * factor))
1262
+ const rows = [
1263
+ { header: true, cells: block.headers ?? [] },
1264
+ ...(block.rows ?? []).map(row => ({ header: false, cells: row })),
1265
+ ]
1266
+ const laid = rows.map(row => ({
1267
+ header: row.header,
1268
+ cells: row.cells.map((runs, column) =>
1269
+ wrapRuns(runs, Math.max(24, widths[column] - 16), font(13, row.header ? '600' : undefined), palette.fg),
1270
+ ),
1271
+ }))
1272
+ const rowHeights = laid.map(row => Math.max(...row.cells.map(lines => lines.length)) * 21 + 14)
1273
+ const height = rowHeights.reduce((sum, value) => sum + value, 0)
1274
+ ops.push({ kind: 'table', y, height, widths, rows: laid, rowHeights })
1275
+ y += height + 10
1276
+ continue
1277
+ }
1278
+ if (block.type === 'hr') {
1279
+ ops.push({ kind: 'hr', y })
1280
+ y += 16
1281
+ }
1282
+ }
1283
+ return { ops, height: Math.max(0, y - 9) }
1284
+ }
1285
+
1286
+ const sections = groupSections().map(section => {
1287
+ if (section.role === 'user') {
1288
+ const lines = []
1289
+ for (const block of section.blocks) {
1290
+ if (block.type === 'code') {
1291
+ lines.push(...wrapPlain(block.text ?? '', mono(13), palette.userFg, bubbleMax - 30))
1292
+ continue
1293
+ }
1294
+ if (block.type === 'list') {
1295
+ for (const item of block.items ?? []) {
1296
+ const prefix = block.ordered ? '· ' : '· '
1297
+ lines.push(...wrapRuns([{ text: prefix }, ...(item.runs ?? [])], bubbleMax - 30, font(15), palette.userFg))
1298
+ }
1299
+ continue
1300
+ }
1301
+ lines.push(...wrapRuns(block.runs ?? [], bubbleMax - 30, font(15), palette.userFg))
1302
+ }
1303
+ const width = Math.min(bubbleMax, Math.max(80, ...lines.map(line => line.width)) + 30)
1304
+ const height = lines.length * 24 + 20
1305
+ // Screenshots sit above the prompt, right-aligned like the bubble. They
1306
+ // take the full content width — a screenshot is usually the point of the
1307
+ // message, and a narrow slot is what made them look downscaled. Aspect
1308
+ // ratio is preserved; a tall one is capped by height instead.
1309
+ const images = []
1310
+ for (const image of section.images ?? []) {
1311
+ if (!image.element) continue
1312
+ const ratio = image.element.height / image.element.width || 1
1313
+ let width = Math.min(contentWidth, image.element.width)
1314
+ let height = width * ratio
1315
+ const maxHeight = 900
1316
+ if (height > maxHeight) {
1317
+ height = maxHeight
1318
+ width = Math.round(height / ratio)
1319
+ }
1320
+ images.push({ element: image.element, width: Math.round(width), height: Math.round(height) })
1321
+ }
1322
+ return { kind: 'user', lines, width, height, images }
1323
+ }
1324
+ const layout = layoutBlocks(section.blocks)
1325
+ return { kind: 'assistant', turn: section.turn, ops: layout.ops, height: layout.height + CARD_PAD * 2 }
1326
+ })
1327
+
1328
+ for (const section of sections) {
1329
+ if (section.kind === 'user' && section.images.length > 0) {
1330
+ section.imagesHeight = section.images.reduce((sum, image) => sum + image.height + 10, 0)
1331
+ section.height += section.imagesHeight
1332
+ }
1333
+ }
1334
+
1335
+ const headerHeight = 118
1336
+ const footerHeight = 52
1337
+ const gap = 18
1338
+ const bodyHeight = sections.reduce((sum, section) => sum + section.height + gap, 0)
1339
+ const height = headerHeight + bodyHeight + footerHeight
1340
+
1341
+ const canvas = document.createElement('canvas')
1342
+ canvas.width = WIDTH * SCALE
1343
+ canvas.height = Math.ceil(height * SCALE)
1344
+ const ctx = canvas.getContext('2d')
1345
+ ctx.scale(SCALE, SCALE)
1346
+ ctx.textBaseline = 'alphabetic'
1347
+
1348
+ ctx.fillStyle = palette.page
1349
+ ctx.fillRect(0, 0, WIDTH, height)
1350
+
1351
+ ctx.fillStyle = palette.fg
1352
+ const titleLines = wrapPlain(title || 'Session', font(26, '600'), palette.fg, cardWidth).slice(0, 2)
1353
+ let headerY = PAGE_PAD + 24
1354
+ for (const line of titleLines) {
1355
+ ctx.font = font(26, '600')
1356
+ ctx.fillText(line.segments.map(segment => segment.text).join(''), PAGE_PAD, headerY)
1357
+ headerY += 34
1358
+ }
1359
+ ctx.fillStyle = palette.muted
1360
+ ctx.font = font(12)
1361
+ ctx.fillText(meta, PAGE_PAD, headerY + 4)
1362
+
1363
+ let y = headerHeight
1364
+ for (const section of sections) {
1365
+ if (section.kind === 'user') {
1366
+ let cursorY = y
1367
+ for (const image of section.images ?? []) {
1368
+ const imageX = WIDTH - PAGE_PAD - image.width
1369
+ ctx.save()
1370
+ roundRect(ctx, imageX, cursorY, image.width, image.height, 12)
1371
+ ctx.clip()
1372
+ ctx.drawImage(image.element, imageX, cursorY, image.width, image.height)
1373
+ ctx.restore()
1374
+ ctx.strokeStyle = palette.line
1375
+ roundRect(ctx, imageX, cursorY, image.width, image.height, 12)
1376
+ ctx.stroke()
1377
+ cursorY += image.height + 10
1378
+ }
1379
+ const bubbleTop = y + (section.imagesHeight ?? 0)
1380
+ const bubbleHeight = section.height - (section.imagesHeight ?? 0)
1381
+ const x = WIDTH - PAGE_PAD - section.width
1382
+ ctx.fillStyle = palette.user
1383
+ roundRect(ctx, x, bubbleTop, section.width, bubbleHeight, 14)
1384
+ ctx.fill()
1385
+ section.lines.forEach((line, index) => {
1386
+ let cursor = x + 15
1387
+ const baseline = bubbleTop + 10 + index * 24 + 17
1388
+ for (const segment of line.segments) {
1389
+ ctx.font = segment.font
1390
+ ctx.fillStyle = segment.fill
1391
+ ctx.fillText(segment.text, cursor, baseline)
1392
+ measure.font = segment.font
1393
+ cursor += measure.measureText(segment.text).width
1394
+ }
1395
+ })
1396
+ } else {
1397
+ ctx.save()
1398
+ ctx.shadowColor = 'rgba(16, 18, 20, 0.06)'
1399
+ ctx.shadowBlur = 10
1400
+ ctx.shadowOffsetY = 2
1401
+ ctx.fillStyle = palette.card
1402
+ roundRect(ctx, PAGE_PAD, y, cardWidth, section.height, 14)
1403
+ ctx.fill()
1404
+ ctx.restore()
1405
+
1406
+ const originX = PAGE_PAD + CARD_PAD
1407
+ const originY = y + CARD_PAD
1408
+ for (const op of section.ops) {
1409
+ if (op.kind === 'quote') {
1410
+ ctx.fillStyle = palette.quote
1411
+ roundRect(ctx, originX, originY + op.y, contentWidth, op.height, 10)
1412
+ ctx.fill()
1413
+ ctx.fillStyle = palette.accent
1414
+ ctx.fillRect(originX, originY + op.y, 3, op.height)
1415
+ } else if (op.kind === 'code') {
1416
+ ctx.fillStyle = palette.code
1417
+ roundRect(ctx, originX, originY + op.y, contentWidth, op.height, 10)
1418
+ ctx.fill()
1419
+ } else if (op.kind === 'table') {
1420
+ const tableWidth = op.widths.reduce((sum, value) => sum + value, 0)
1421
+ let rowTop = originY + op.y
1422
+ op.rows.forEach((row, rowIndex) => {
1423
+ const rowHeight = op.rowHeights[rowIndex]
1424
+ if (row.header) {
1425
+ ctx.fillStyle = palette.quote
1426
+ ctx.fillRect(originX, rowTop, tableWidth, rowHeight)
1427
+ }
1428
+ let cellX = originX
1429
+ row.cells.forEach((lines, column) => {
1430
+ lines.forEach((line, lineIndex) => {
1431
+ let cursor = cellX + 8
1432
+ const baseline = rowTop + 7 + lineIndex * 21 + 15
1433
+ for (const segment of line.segments) {
1434
+ ctx.font = segment.font
1435
+ ctx.fillStyle = segment.fill
1436
+ ctx.fillText(segment.text, cursor, baseline)
1437
+ measure.font = segment.font
1438
+ cursor += measure.measureText(segment.text).width
1439
+ }
1440
+ })
1441
+ cellX += op.widths[column]
1442
+ })
1443
+ ctx.strokeStyle = palette.line
1444
+ ctx.beginPath()
1445
+ ctx.moveTo(originX, rowTop + rowHeight)
1446
+ ctx.lineTo(originX + tableWidth, rowTop + rowHeight)
1447
+ ctx.stroke()
1448
+ rowTop += rowHeight
1449
+ })
1450
+ } else if (op.kind === 'hr') {
1451
+ ctx.strokeStyle = palette.line
1452
+ ctx.beginPath()
1453
+ ctx.moveTo(originX, originY + op.y)
1454
+ ctx.lineTo(originX + contentWidth, originY + op.y)
1455
+ ctx.stroke()
1456
+ } else if (op.kind === 'text') {
1457
+ ctx.font = op.font
1458
+ ctx.fillStyle = op.fill
1459
+ ctx.fillText(op.text, originX + op.x, originY + op.y)
1460
+ } else {
1461
+ let cursor = originX + op.x
1462
+ for (const segment of op.segments) {
1463
+ ctx.font = segment.font
1464
+ ctx.fillStyle = segment.fill
1465
+ ctx.fillText(segment.text, cursor, originY + op.y)
1466
+ measure.font = segment.font
1467
+ cursor += measure.measureText(segment.text).width
1468
+ }
1469
+ }
1470
+ }
1471
+ }
1472
+ y += section.height + gap
1473
+ }
1474
+
1475
+ ctx.fillStyle = palette.muted
1476
+ ctx.font = font(12)
1477
+ ctx.textAlign = 'center'
1478
+ ctx.fillText(footer, WIDTH / 2, height - 22)
1479
+ ctx.textAlign = 'left'
1480
+
1481
+ return await new Promise((resolve, reject) => {
1482
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('canvas could not produce a PNG'))), 'image/png')
1483
+ })
1484
+ }
1485
+
1486
+ function ShareDialog({ state, onClose }) {
1487
+ const t = state.t ?? (key => key)
1488
+ const { loading, outline, error } = useOutline(state.sessionId)
1489
+ const { selected, toggle, all, none } = useTurnSelection(outline)
1490
+ const [busy, setBusy] = useState(null)
1491
+ const [note, setNote] = useState(null)
1492
+ const [finalOnly, setFinalOnly] = useState(true)
1493
+
1494
+ const ranges = rangesOf(outline, selected)
1495
+ const ready = ranges.length > 0
1496
+ const mode = finalOnly ? 'final' : 'all'
1497
+
1498
+ const run = async (kind, operation) => {
1499
+ if (!ready) {
1500
+ setNote(t('share.needSelection'))
1501
+ return
1502
+ }
1503
+ setBusy(kind)
1504
+ setNote(null)
1505
+ try {
1506
+ await operation()
1507
+ } catch (failure) {
1508
+ setNote(failure?.message ?? t('share.failed'))
1509
+ } finally {
1510
+ setBusy(null)
1511
+ }
1512
+ }
1513
+
1514
+ const exportFile = format =>
1515
+ run(format, async () => {
1516
+ const result = await call({ action: 'export', sessionId: state.sessionId, format, ranges, mode })
1517
+ saveBlob(new Blob([result.body], { type: result.contentType }), result.fileName)
1518
+ setNote(t('share.done'))
1519
+ })
1520
+
1521
+ const copyText = () =>
1522
+ run('txt', async () => {
1523
+ const result = await call({ action: 'export', sessionId: state.sessionId, format: 'txt', ranges, mode })
1524
+ await navigator.clipboard.writeText(result.body)
1525
+ setNote(t('share.copied'))
1526
+ })
1527
+
1528
+ const exportImage = () =>
1529
+ run('image', async () => {
1530
+ const result = await call({ action: 'export', sessionId: state.sessionId, format: 'blocks', ranges, mode })
1531
+ const payload = JSON.parse(result.body)
1532
+ const turns = new Set(payload.messages.map(message => message.turn).filter(turn => typeof turn === 'number')).size
1533
+ const exported = new Date().toISOString().slice(0, 16).replace('T', ' ')
1534
+ const blob = await renderConversationImage({
1535
+ title: payload.title || outline?.title || 'Session',
1536
+ meta: `${t('share.metaTurns', { turns, messages: payload.messages.length })} · ${t('share.exportedAt', { time: exported })}`,
1537
+ footer: t('share.imageFooter'),
1538
+ messages: payload.messages,
1539
+ })
1540
+ saveBlob(blob, `${(payload.title || 'session').replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 40)}.png`)
1541
+ setNote(t('share.done'))
1542
+ })
1543
+
1544
+ return h(
1545
+ DialogFrame,
1546
+ { title: t('share.title'), wide: true, onClose: busy ? () => {} : onClose },
1547
+ h('div', { className: 'dss-dialog-body' }, t('share.hint')),
1548
+ loading
1549
+ ? h('div', { className: 'dss-empty' }, t('settings.loading'))
1550
+ : error
1551
+ ? h('div', { className: 'dss-dialog-error' }, error)
1552
+ : h(
1553
+ React.Fragment,
1554
+ null,
1555
+ h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none }),
1556
+ h('div', { className: 'dss-dialog-scroll dss-picker-scroll' }, h(TurnPicker, { outline, selected, onToggle: toggle, t })),
1557
+ h(
1558
+ 'label',
1559
+ { className: 'dss-curate' },
1560
+ h('input', {
1561
+ type: 'checkbox',
1562
+ checked: finalOnly,
1563
+ onChange: event => setFinalOnly(event.target.checked),
1564
+ }),
1565
+ h(
1566
+ 'span',
1567
+ { className: 'dss-curate-text' },
1568
+ h('span', { className: 'dss-curate-title' }, t('share.curate')),
1569
+ h('span', { className: 'dss-curate-hint' }, t('share.curateHint')),
1570
+ ),
1571
+ ),
1572
+ note ? h('div', { className: 'dss-note' }, note) : null,
1573
+ h(
1574
+ 'div',
1575
+ { className: 'dss-dialog-actions dss-wrap' },
1576
+ h(
1577
+ 'button',
1578
+ { type: 'button', className: 'dss-btn dss-primary', disabled: !ready || busy !== null, onClick: exportImage },
1579
+ busy === 'image' ? h('span', { className: 'dss-spin' }) : null,
1580
+ t('share.image'),
1581
+ ),
1582
+ h(
1583
+ 'button',
1584
+ { type: 'button', className: 'dss-btn', disabled: !ready || busy !== null, onClick: () => exportFile('html') },
1585
+ t('share.html'),
1586
+ ),
1587
+ h(
1588
+ 'button',
1589
+ { type: 'button', className: 'dss-btn', disabled: !ready || busy !== null, onClick: () => exportFile('md') },
1590
+ t('share.markdown'),
1591
+ ),
1592
+ h(
1593
+ 'button',
1594
+ { type: 'button', className: 'dss-btn', disabled: !ready || busy !== null, onClick: () => exportFile('json') },
1595
+ t('share.json'),
1596
+ ),
1597
+ h(
1598
+ 'button',
1599
+ { type: 'button', className: 'dss-btn dss-ghost', disabled: !ready || busy !== null, onClick: copyText },
1600
+ t('share.copy'),
1601
+ ),
1602
+ ),
1603
+ h('div', { className: 'dss-hint' }, t('share.linkNote')),
1604
+ ),
1605
+ )
1606
+ }
1607
+
1608
+ function SegmentDialog({ state, onClose }) {
1609
+ const t = state.t ?? (key => key)
1610
+ const { loading, outline, error } = useOutline(state.sessionId)
1611
+ const { selected, toggle, all, none } = useTurnSelection(outline)
1612
+ const [busy, setBusy] = useState(false)
1613
+ const [problem, setProblem] = useState(null)
1614
+
1615
+ const turns = outline?.turns ?? []
1616
+ const picked = turns.filter(row => selected.has(row.turn))
1617
+ const first = picked[0]
1618
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1].turn : undefined
1619
+ const partial = picked.length > 0 && picked.length < turns.length
1620
+
1621
+ const confirm = async () => {
1622
+ if (!first) {
1623
+ setProblem(t('segment.needSelection'))
1624
+ return
1625
+ }
1626
+ setBusy(true)
1627
+ setProblem(null)
1628
+ try {
1629
+ const result = await call({ action: 'deleteSegment', sessionId: state.sessionId, startSeq: first.startSeq })
1630
+ if (result?.childId) runtime.openSession?.(result.childId)
1631
+ notify(t('segment.done'))
1632
+ onClose()
1633
+ bump()
1634
+ } catch (failure) {
1635
+ setProblem(failure?.message ?? t('segment.failed'))
1636
+ } finally {
1637
+ setBusy(false)
1638
+ }
1639
+ }
1640
+
1641
+ return h(
1642
+ DialogFrame,
1643
+ { title: t('segment.title'), critical: true, wide: true, onClose: busy ? () => {} : onClose },
1644
+ h('div', { className: 'dss-dialog-body' }, t('segment.hint')),
1645
+ loading
1646
+ ? h('div', { className: 'dss-empty' }, t('settings.loading'))
1647
+ : error
1648
+ ? h('div', { className: 'dss-dialog-error' }, error)
1649
+ : h(
1650
+ React.Fragment,
1651
+ null,
1652
+ h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none }),
1653
+ h('div', { className: 'dss-dialog-scroll dss-picker-scroll' }, h(TurnPicker, { outline, selected, onToggle: toggle, t })),
1654
+ partial ? h('div', { className: 'dss-warn' }, t('segment.warning')) : null,
1655
+ picked.length === turns.length && turns.length > 0 ? h('div', { className: 'dss-warn' }, t('segment.whole')) : null,
1656
+ problem ? h('div', { className: 'dss-dialog-error' }, problem) : null,
1657
+ h(
1658
+ 'div',
1659
+ { className: 'dss-dialog-actions' },
1660
+ h('button', { type: 'button', className: 'dss-btn', onClick: onClose, disabled: busy }, t('dialog.cancel')),
1661
+ h(
1662
+ 'button',
1663
+ { type: 'button', className: 'dss-btn dss-critical', onClick: confirm, disabled: busy || !first },
1664
+ busy ? h('span', { className: 'dss-spin' }) : null,
1665
+ busy ? t('dialog.deleting') : t('segment.confirm'),
1666
+ ),
1667
+ ),
1668
+ ),
1669
+ )
1670
+ }
1671
+
1672
+ /**
1673
+ * The snapshot manager as a dialog, so the same panel is reachable straight
1674
+ * from a session's menu without opening Settings first.
1675
+ */
1676
+ function SnapshotDialog({ state, onClose }) {
1677
+ const t = state.t ?? (key => key)
1678
+ return h(
1679
+ DialogFrame,
1680
+ { title: t('settings.title'), wide: true, onClose },
1681
+ h('div', { className: 'dss-dialog-body' }, t('settings.description')),
1682
+ h(
1683
+ 'div',
1684
+ { className: 'dss-dialog-scroll' },
1685
+ h(SnapshotSection, { t, embedded: true, initialSessionId: state.sessionId }),
1686
+ ),
1687
+ )
1688
+ }
1689
+
1690
+ function SuiteOverlay() {
1691
+ const state = useStore(dialog)
1692
+ const message = useStore(toast)
1693
+ return h(
1694
+ React.Fragment,
1695
+ null,
1696
+ state
1697
+ ? state.mode === 'snapshots'
1698
+ ? h(SnapshotDialog, { key: state.key ?? 'snapshots', state, onClose: () => dialog.set(null) })
1699
+ : state.mode === 'share'
1700
+ ? h(ShareDialog, { key: state.key ?? 'share', state, onClose: () => dialog.set(null) })
1701
+ : state.mode === 'segment'
1702
+ ? h(SegmentDialog, { key: state.key ?? 'segment', state, onClose: () => dialog.set(null) })
1703
+ : state.mode === 'edit-resend'
1704
+ ? h(EditResendDialog, { key: state.key ?? 'edit-resend', state, onClose: () => dialog.set(null) })
1705
+ : h(SelectSessionDialog, { key: state.key ?? 'dialog', state, onClose: () => dialog.set(null) })
1706
+ : null,
1707
+ message
1708
+ ? h(
1709
+ 'div',
1710
+ { key: message.id, className: 'dss-toast' + (message.tone === 'error' ? ' dss-error' : ''), role: 'status' },
1711
+ message.message,
1712
+ )
1713
+ : null,
1714
+ )
1715
+ }
1716
+
1717
+ // ---------------------------------------------------------- session menu
1718
+
1719
+ function useSessionMenuRows(props, t) {
1720
+ const { sessionId, useMenuOpenState } = props
1721
+ const [, setMenuOpen] = useMenuOpenState()
1722
+ return { sessionId, setMenuOpen, t }
1723
+ }
1724
+
1725
+ function ShareRow(props) {
1726
+ const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
1727
+ return h(MenuRow, {
1728
+ label: t('menu.share'),
1729
+ iconNode: h(Icons.download, null),
1730
+ separator: true,
1731
+ onSelect: () => {
1732
+ setMenuOpen(false)
1733
+ dialog.set({ key: `${sessionId}:share`, mode: 'share', sessionId, t })
1734
+ },
1735
+ })
1736
+ }
1737
+
1738
+ function SnapshotsRow(props) {
1739
+ const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
1740
+ return h(MenuRow, {
1741
+ label: t('menu.snapshots'),
1742
+ iconNode: h(Icons.rewind, null),
1743
+ separator: true,
1744
+ onSelect: () => {
1745
+ setMenuOpen(false)
1746
+ dialog.set({ key: `${sessionId}:snapshots`, mode: 'snapshots', sessionId, t })
1747
+ },
1748
+ })
1749
+ }
1750
+
1751
+ function SegmentRow(props) {
1752
+ const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
1753
+ return h(MenuRow, {
1754
+ label: t('menu.segment'),
1755
+ iconNode: h(Icons.cut, null),
1756
+ danger: true,
1757
+ separator: true,
1758
+ onSelect: () => {
1759
+ setMenuOpen(false)
1760
+ dialog.set({ key: `${sessionId}:segment`, mode: 'segment', sessionId, t })
1761
+ },
1762
+ })
1763
+ }
1764
+
1765
+ function DeleteSessionRow(props) {
1766
+ const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
1767
+ return h(MenuRow, {
1768
+ label: t('menu.delete'),
1769
+ iconNode: h(Icons.trash, null),
1770
+ danger: true,
1771
+ separator: true,
1772
+ onSelect: () => {
1773
+ setMenuOpen(false)
1774
+ dialog.set({ key: `${sessionId}:delete`, mode: 'delete-session', sessionId, displayTitle: props.displayTitle, t })
1775
+ },
1776
+ })
1777
+ }
1778
+
1779
+ // ------------------------------------------------------- message actions
1780
+
1781
+ function RewindAction(props) {
1782
+ const { messageId, sessionId } = props
1783
+ const t = props.t
1784
+ const start = useCallback(
1785
+ mode => {
1786
+ pending.set({ key: `${sessionId}:${messageId}:${mode}`, mode, sessionId, messageId, t })
1787
+ },
1788
+ [messageId, sessionId, t],
1789
+ )
1790
+ return h(
1791
+ React.Fragment,
1792
+ null,
1793
+ h(
1794
+ 'button',
1795
+ {
1796
+ type: 'button',
1797
+ className: 'dss-action',
1798
+ title: t('message.rewind'),
1799
+ 'aria-label': t('message.rewind'),
1800
+ onClick: () => start('rewind'),
1801
+ },
1802
+ h(Icons.rewind, null),
1803
+ ),
1804
+ // Edit-and-resend sits in the same action row as rewind and delete. A
1805
+ // separate turnTail row put it on its own line; the user asked for it
1806
+ // alongside the other per-message icons.
1807
+ h(
1808
+ 'button',
1809
+ {
1810
+ type: 'button',
1811
+ className: 'dss-action',
1812
+ title: t('message.edit'),
1813
+ 'aria-label': t('message.edit'),
1814
+ onClick: () => {
1815
+ dialog.set({
1816
+ key: `${sessionId}:edit:${messageId}`,
1817
+ mode: 'edit-resend',
1818
+ sessionId,
1819
+ messageId,
1820
+ t,
1821
+ })
1822
+ },
1823
+ },
1824
+ h(Icons.edit, null),
1825
+ ),
1826
+ h(
1827
+ 'button',
1828
+ {
1829
+ type: 'button',
1830
+ className: 'dss-action dss-danger',
1831
+ title: t('message.delete'),
1832
+ 'aria-label': t('message.delete'),
1833
+ onClick: () => start('delete'),
1834
+ },
1835
+ h(Icons.trash, null),
1836
+ ),
1837
+ )
1838
+ }
1839
+
1840
+ // ------------------------------------------------------- user message edit
1841
+
1842
+ /**
1843
+ * Edit-and-resend, opened from the per-message action row.
1844
+ *
1845
+ * The pencil sits beside rewind and delete in `RewindAction`, which is
1846
+ * registered on `conversation.chat.assistant-actions` — the same row the
1847
+ * user already sees on each answer. We deliberately do NOT replace the
1848
+ * `user` key of `conversation.chat.node`: that slot is occupied by the
1849
+ * shipped bubble renderer (replaceRisk: shadows-shipped-ui), so a plugin bug
1850
+ * there would break every user message in the transcript.
1851
+ *
1852
+ * The dialog edits the text, then the Host trims the memories that turn
1853
+ * recorded and sends the edited text in the same session. Workspace files
1854
+ * are never touched: this is a conversation operation, and restoring files
1855
+ * would discard work done since.
1856
+ * this is a conversation operation, and rolling files back would discard work
1857
+ * done since.
1858
+ */
1859
+ function EditResendDialog({ state, onClose }) {
1860
+ const t = state.t ?? (key => key)
1861
+ const [busy, setBusy] = useState(false)
1862
+ const [error, setError] = useState(null)
1863
+ const [text, setText] = useState(state.originalPrompt ?? '')
1864
+
1865
+ useEffect(() => {
1866
+ if (!state.sessionId) return undefined
1867
+ let alive = true
1868
+ // Pure read: opening the dialog must not fork a session. The old
1869
+ // `mode: 'stage'` call did fork, so merely looking at the dialog created
1870
+ // a branch and could fail before the user had changed anything.
1871
+ call({
1872
+ action: 'resendTurn.preview',
1873
+ sessionId: state.sessionId,
1874
+ ...(state.messageId ? { messageId: state.messageId } : {}),
1875
+ ...(typeof state.turn === 'number' ? { turn: state.turn } : {}),
1876
+ })
1877
+ .then(data => {
1878
+ if (!alive) return
1879
+ if (typeof data?.prompt === 'string' && data.prompt) setText(data.prompt)
1880
+ })
1881
+ .catch(failure => alive && setError(failure?.message ?? t('message.resendFailed')))
1882
+ return () => {
1883
+ alive = false
1884
+ }
1885
+ }, [state.sessionId, state.messageId, state.turn])
1886
+
1887
+ const confirm = async () => {
1888
+ setBusy(true)
1889
+ setError(null)
1890
+ try {
1891
+ const result = await call({
1892
+ action: 'resendTurn',
1893
+ sessionId: state.sessionId,
1894
+ messageId: state.messageId,
1895
+ mode: 'retry',
1896
+ text,
1897
+ })
1898
+ // Resend stays in the current session; there is no child to open.
1899
+ notify(t('message.resendDone'))
1900
+ onClose()
1901
+ bump()
1902
+ } catch (failure) {
1903
+ setError(failure?.message ?? t('message.resendFailed'))
1904
+ } finally {
1905
+ setBusy(false)
1906
+ }
1907
+ }
1908
+
1909
+ return h(
1910
+ DialogFrame,
1911
+ { title: t('message.editTitle'), wide: true, onClose: busy ? () => {} : onClose },
1912
+ h('div', { className: 'dss-dialog-body' }, t('message.editBody')),
1913
+ h('textarea', {
1914
+ className: 'dss-input',
1915
+ style: { minHeight: 120, width: '100%', marginTop: 10 },
1916
+ value: text,
1917
+ onChange: event => setText(event.target.value),
1918
+ }),
1919
+ error ? h('div', { className: 'dss-dialog-error' }, error) : null,
1920
+ h(
1921
+ 'div',
1922
+ { className: 'dss-dialog-actions' },
1923
+ h('button', { type: 'button', className: 'dss-btn dss-small', onClick: onClose, disabled: busy }, t('dialog.cancel')),
1924
+ h(
1925
+ 'button',
1926
+ {
1927
+ type: 'button',
1928
+ className: 'dss-btn dss-small dss-primary',
1929
+ onClick: confirm,
1930
+ disabled: busy || !String(text).trim(),
1931
+ },
1932
+ busy ? h('span', { className: 'dss-spin' }) : null,
1933
+ busy ? t('message.resending') : t('message.editConfirm'),
1934
+ ),
1935
+ ),
1936
+ )
1937
+ }
1938
+
1939
+ // -------------------------------------------------------- rewind banner
1940
+
1941
+ function RewindBanner(props) {
1942
+ const state = useStore(pending)
1943
+ const session = props.session
1944
+ const sessionId = session?.sessionId ?? session?.id
1945
+ const [busy, setBusy] = useState(false)
1946
+ const [error, setError] = useState(null)
1947
+ const t = props.t
1948
+
1949
+ if (!state || !sessionId || state.sessionId !== sessionId) return null
1950
+
1951
+ const close = () => {
1952
+ if (busy) return
1953
+ setError(null)
1954
+ pending.set(null)
1955
+ }
1956
+
1957
+ const confirm = async () => {
1958
+ setBusy(true)
1959
+ setError(null)
1960
+ try {
1961
+ if (state.mode === 'resend') {
1962
+ // The action slot hands us a message id; the Host resolves which
1963
+ // turn it belongs to, forks before that turn's prompt, and re-admits
1964
+ // the prompt in the fork.
1965
+ const result = await call({
1966
+ action: 'resendTurn',
1967
+ sessionId,
1968
+ messageId: state.messageId,
1969
+ })
1970
+ if (result?.childId && props.openSession) props.openSession(result.childId)
1971
+ notify(result?.sent ? t('message.resendDone') : t('message.resendStaged'))
1972
+ pending.set(null)
1973
+ bump()
1974
+ return
1975
+ }
1976
+ const result = await call({
1977
+ action: state.mode === 'delete' ? 'deleteMessage' : 'rollback',
1978
+ sessionId,
1979
+ messageId: state.messageId,
1980
+ })
1981
+ if (result?.childId && props.openSession) {
1982
+ props.openSession(result.childId)
1983
+ }
1984
+ // Report what the user actually asked for. Keying this off
1985
+ // `deletedSession` was wrong: deleteMessage always returns false
1986
+ // (it only trims the conversation), so every delete announced
1987
+ // "rewound to this step" and read as the wrong operation.
1988
+ const done = state.mode === 'delete'
1989
+ ? t('message.deleteDone')
1990
+ : t('message.rewindDone')
1991
+ notify(done)
1992
+ pending.set(null)
1993
+ bump()
1994
+ } catch (failure) {
1995
+ setError(failure?.message ?? t('message.rewindFailed'))
1996
+ } finally {
1997
+ setBusy(false)
1998
+ }
1999
+ }
2000
+
2001
+ const isDelete = state.mode === 'delete'
2002
+ const isResend = state.mode === 'resend'
2003
+ const titleKey = isDelete ? 'message.deleteTitle' : isResend ? 'message.resendTitle' : 'message.rewindTitle'
2004
+ const bodyKey = isDelete ? 'message.deleteBody' : isResend ? 'message.resendBody' : 'message.rewindBody'
2005
+ return h(
2006
+ 'div',
2007
+ { className: 'dss-banner', role: 'group' },
2008
+ h(
2009
+ 'div',
2010
+ { className: 'dss-banner-main' },
2011
+ h('span', { className: 'dss-banner-icon' }, h(Icons.info, null)),
2012
+ h(
2013
+ 'div',
2014
+ { className: 'dss-banner-text' },
2015
+ h('span', { className: 'dss-banner-title' }, t(titleKey)),
2016
+ h('span', { className: 'dss-banner-body' }, t(bodyKey)),
2017
+ error ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
2018
+ ),
2019
+ ),
2020
+ h(
2021
+ 'div',
2022
+ { className: 'dss-banner-actions' },
2023
+ h('button', { type: 'button', className: 'dss-btn dss-small', onClick: close, disabled: busy }, t('dialog.cancel')),
2024
+ h(
2025
+ 'button',
2026
+ { type: 'button', className: 'dss-btn dss-small dss-primary', onClick: confirm, disabled: busy },
2027
+ busy ? h('span', { className: 'dss-spin' }) : null,
2028
+ busy
2029
+ ? t(isResend ? 'message.resending' : 'message.rewinding')
2030
+ : t(isDelete ? 'message.deleteConfirm' : isResend ? 'message.resendConfirm' : 'message.rewindConfirm'),
2031
+ ),
2032
+ ),
2033
+ )
2034
+ }
2035
+
2036
+ // ------------------------------------------------------- settings section
2037
+
2038
+ function SnapshotSection(props) {
2039
+ const t = props.t
2040
+ const rev = useStore(revision)
2041
+ const [sessions, setSessions] = useState(null)
2042
+ const [selected, setSelected] = useState(props.initialSessionId ?? null)
2043
+ const [detail, setDetail] = useState(null)
2044
+ const [error, setError] = useState(null)
2045
+ const [busy, setBusy] = useState(null)
2046
+ const [memoryDraft, setMemoryDraft] = useState('')
2047
+
2048
+ useEffect(() => {
2049
+ let alive = true
2050
+ call({ action: 'list' })
2051
+ .then(rows => {
2052
+ if (!alive) return
2053
+ setSessions(rows)
2054
+ setSelected(current => {
2055
+ if (current && rows.some(row => row.sessionId === current)) return current
2056
+ if (props.initialSessionId && rows.some(row => row.sessionId === props.initialSessionId)) {
2057
+ return props.initialSessionId
2058
+ }
2059
+ // No session was named (the settings page opens this section with
2060
+ // no conversation context), so pick the one the user is most
2061
+ // likely looking for: the most recently active session that has
2062
+ // snapshots, not merely the first row that has any. The previous
2063
+ // fallback depended on list order and, once a session was missing
2064
+ // from the list entirely, showed an unrelated conversation.
2065
+ const ranked = [...rows].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
2066
+ const withSnapshots = ranked.find(row => row.checkpoints > 0)
2067
+ return (withSnapshots ?? ranked[0])?.sessionId ?? null
2068
+ })
2069
+ })
2070
+ .catch(failure => alive && setError(failure.message))
2071
+ return () => {
2072
+ alive = false
2073
+ }
2074
+ }, [rev, props.initialSessionId])
2075
+
2076
+ useEffect(() => {
2077
+ if (!selected) {
2078
+ setDetail(null)
2079
+ return undefined
2080
+ }
2081
+ let alive = true
2082
+ setDetail(null)
2083
+ call({ action: 'detail', sessionId: selected })
2084
+ .then(value => alive && setDetail(value))
2085
+ .catch(failure => alive && setError(failure.message))
2086
+ return () => {
2087
+ alive = false
2088
+ }
2089
+ }, [selected, rev])
2090
+
2091
+ const run = useCallback(
2092
+ async (key, operation, doneMessage) => {
2093
+ setBusy(key)
2094
+ setError(null)
2095
+ try {
2096
+ await operation()
2097
+ if (doneMessage) notify(doneMessage)
2098
+ bump()
2099
+ } catch (failure) {
2100
+ setError(failure.message)
2101
+ notify(failure.message, 'error')
2102
+ } finally {
2103
+ setBusy(null)
2104
+ }
2105
+ },
2106
+ [],
2107
+ )
2108
+
2109
+ const sectionClass = props.embedded ? 'dss-section dss-embedded' : 'dss-section'
2110
+ const head = props.embedded ? null : h(
2111
+ 'div',
2112
+ { className: 'dss-section-head' },
2113
+ h('h2', null, t('settings.title')),
2114
+ h('div', { className: 'dss-section-desc' }, t('settings.description')),
2115
+ )
2116
+
2117
+ if (error && !detail) {
2118
+ return h('div', { className: sectionClass }, head, h('div', { className: 'dss-dialog-error' }, error))
2119
+ }
2120
+ if (!sessions) return h('div', { className: sectionClass }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
2121
+ if (sessions.length === 0) return h('div', { className: sectionClass }, head, h('div', { className: 'dss-empty' }, t('settings.noSessions')))
2122
+
2123
+ const picker = h(
2124
+ 'div',
2125
+ { className: 'dss-toolbar' },
2126
+ h('span', { className: 'dss-group-title' }, t('settings.session')),
2127
+ h(
2128
+ 'select',
2129
+ {
2130
+ className: 'dss-select',
2131
+ value: selected ?? '',
2132
+ onChange: event => setSelected(event.target.value),
2133
+ },
2134
+ ...sessions.map(row =>
2135
+ h('option', { key: row.sessionId, value: row.sessionId }, `${row.title || row.sessionId} · ${row.checkpoints} · ${row.checkpointLabel}`),
2136
+ ),
2137
+ ),
2138
+ h(
2139
+ 'button',
2140
+ {
2141
+ type: 'button',
2142
+ className: 'dss-btn dss-small',
2143
+ disabled: !selected || busy === 'capture',
2144
+ onClick: () => run('capture', () => call({ action: 'capture', sessionId: selected }), t('toast.captured')),
2145
+ },
2146
+ busy === 'capture' ? h('span', { className: 'dss-spin' }) : h(Icons.plus, { size: 14 }),
2147
+ t(busy === 'capture' ? 'settings.capturing' : 'settings.capture'),
2148
+ ),
2149
+ )
2150
+
2151
+ const stats = detail?.stats
2152
+ const statsLine = stats
2153
+ ? h(
2154
+ 'div',
2155
+ { className: 'dss-row-meta' },
2156
+ h('span', null, t('settings.usage', { size: stats.label })),
2157
+ h('span', null, t('settings.budget', { keep: stats.budget })),
2158
+ detail.cwd ? h('span', { title: detail.cwd }, detail.cwd) : null,
2159
+ )
2160
+ : null
2161
+
2162
+ const checkpoints = detail?.checkpoints ?? []
2163
+ const checkpointList =
2164
+ checkpoints.length === 0
2165
+ ? h('div', { className: 'dss-empty' }, t('settings.empty'))
2166
+ : h(
2167
+ 'div',
2168
+ { className: 'dss-card' },
2169
+ ...checkpoints.map(cp =>
2170
+ h(
2171
+ 'div',
2172
+ { className: 'dss-row', key: cp.id },
2173
+ h(
2174
+ 'div',
2175
+ { className: 'dss-row-main' },
2176
+ h(
2177
+ 'div',
2178
+ { className: 'dss-row-title' },
2179
+ h('span', null, cp.label || cp.id),
2180
+ cp.locked ? h('span', { className: 'dss-badge dss-locked' }, t('settings.locked')) : null,
2181
+ cp.temporary ? h('span', { className: 'dss-badge dss-temp' }, t('settings.temporary')) : null,
2182
+ ),
2183
+ h(
2184
+ 'div',
2185
+ { className: 'dss-row-meta' },
2186
+ typeof cp.turn === 'number' ? h('span', null, t('settings.turn', { turn: cp.turn })) : null,
2187
+ h('span', null, t('settings.files', { count: cp.files })),
2188
+ h('span', null, cp.size),
2189
+ h('span', null, new Date(cp.createdAt).toLocaleString()),
2190
+ ),
2191
+ ),
2192
+ h(
2193
+ 'div',
2194
+ { className: 'dss-row-actions' },
2195
+ h(
2196
+ 'button',
2197
+ {
2198
+ type: 'button',
2199
+ className: 'dss-btn dss-small dss-ghost',
2200
+ title: cp.locked ? t('settings.unlock') : t('settings.lock'),
2201
+ disabled: busy === `lock:${cp.id}`,
2202
+ onClick: () =>
2203
+ run(`lock:${cp.id}`, () => call({ action: 'setLocked', sessionId: selected, checkpointId: cp.id, locked: !cp.locked }), cp.locked ? t('toast.unlocked') : t('toast.locked')),
2204
+ },
2205
+ h(cp.locked ? Icons.unlock : Icons.lock, { size: 14 }),
2206
+ ),
2207
+ h(
2208
+ 'button',
2209
+ {
2210
+ type: 'button',
2211
+ className: 'dss-btn dss-small dss-ghost',
2212
+ disabled: busy === `restore:${cp.id}`,
2213
+ onClick: () => {
2214
+ dialog.set({
2215
+ key: `restore:${cp.id}`,
2216
+ mode: 'restore',
2217
+ t,
2218
+ title: t('settings.restore'),
2219
+ body: t('settings.restoreConfirm'),
2220
+ confirmLabel: t('settings.restore'),
2221
+ doneMessage: t('settings.restoreDone'),
2222
+ onConfirm: () => call({ action: 'restore', sessionId: selected, checkpointId: cp.id }),
2223
+ })
2224
+ },
2225
+ },
2226
+ h(Icons.restore, { size: 14 }),
2227
+ t('settings.restore'),
2228
+ ),
2229
+ h(
2230
+ 'button',
2231
+ {
2232
+ type: 'button',
2233
+ className: 'dss-btn dss-small dss-ghost',
2234
+ title: t('settings.remove'),
2235
+ disabled: cp.locked || busy === `remove:${cp.id}`,
2236
+ onClick: () => run(`remove:${cp.id}`, () => call({ action: 'removeCheckpoint', sessionId: selected, checkpointId: cp.id }), t('toast.removed')),
2237
+ },
2238
+ h(Icons.trash, { size: 14 }),
2239
+ ),
2240
+ ),
2241
+ ),
2242
+ ),
2243
+ )
2244
+
2245
+ const undos = detail?.undos ?? []
2246
+ const undoList = undos.length === 0
2247
+ ? null
2248
+ : h(
2249
+ 'div',
2250
+ { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
2251
+ h('div', { className: 'dss-group-title' }, t('settings.undoBundles')),
2252
+ h(
2253
+ 'div',
2254
+ { className: 'dss-card' },
2255
+ ...undos.map(undo =>
2256
+ h(
2257
+ 'div',
2258
+ { className: 'dss-row', key: undo.id },
2259
+ h(
2260
+ 'div',
2261
+ { className: 'dss-row-main' },
2262
+ h('div', { className: 'dss-row-title' }, t('settings.undoBundle', { turn: undo.turn ?? '?' })),
2263
+ h('div', { className: 'dss-row-meta' }, h('span', null, t('settings.undoHint'))),
2264
+ ),
2265
+ h(
2266
+ 'div',
2267
+ { className: 'dss-row-actions' },
2268
+ h(
2269
+ 'button',
2270
+ {
2271
+ type: 'button',
2272
+ className: 'dss-btn dss-small',
2273
+ disabled: busy === `undo:${undo.id}`,
2274
+ onClick: () => run(`undo:${undo.id}`, () => call({ action: 'undo', undoId: undo.id }), t('message.undoDone')),
2275
+ },
2276
+ t('settings.undoRestore'),
2277
+ ),
2278
+ ),
2279
+ ),
2280
+ ),
2281
+ ),
2282
+ )
2283
+
2284
+ const memory = detail?.memory ?? []
2285
+ const memoryBlock = h(
2286
+ 'div',
2287
+ { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
2288
+ h('div', { className: 'dss-group-title' }, t('settings.memory')),
2289
+ h(
2290
+ 'div',
2291
+ { className: 'dss-card' },
2292
+ memory.length === 0
2293
+ ? h('div', { className: 'dss-empty' }, t('settings.memoryEmpty'))
2294
+ : memory.map(entry =>
2295
+ h(
2296
+ 'div',
2297
+ { className: 'dss-row', key: entry.id },
2298
+ h('div', { className: 'dss-row-main' }, h('div', { className: 'dss-memory-text' }, entry.text), h('div', { className: 'dss-row-meta' }, h('span', null, new Date(entry.createdAt).toLocaleString()))),
2299
+ h(
2300
+ 'div',
2301
+ { className: 'dss-row-actions' },
2302
+ h(
2303
+ 'button',
2304
+ {
2305
+ type: 'button',
2306
+ className: 'dss-btn dss-small dss-ghost',
2307
+ title: t('settings.memoryRemove'),
2308
+ onClick: () => run(`mem:${entry.id}`, () => call({ action: 'memory.remove', sessionId: selected, id: entry.id }), t('toast.memoryRemoved')),
2309
+ },
2310
+ h(Icons.trash, { size: 14 }),
2311
+ ),
2312
+ ),
2313
+ ),
2314
+ ),
2315
+ ),
2316
+ h(
2317
+ 'div',
2318
+ { className: 'dss-toolbar' },
2319
+ h('input', {
2320
+ className: 'dss-input',
2321
+ value: memoryDraft,
2322
+ placeholder: t('settings.memoryPlaceholder'),
2323
+ onChange: event => setMemoryDraft(event.target.value),
2324
+ }),
2325
+ h(
2326
+ 'button',
2327
+ {
2328
+ type: 'button',
2329
+ className: 'dss-btn dss-small',
2330
+ disabled: !memoryDraft.trim() || !selected,
2331
+ onClick: () =>
2332
+ run('memory:add', async () => {
2333
+ await call({ action: 'memory.add', sessionId: selected, text: memoryDraft })
2334
+ setMemoryDraft('')
2335
+ }, t('toast.memoryAdded')),
2336
+ },
2337
+ t('settings.memorySave'),
2338
+ ),
2339
+ ),
2340
+ )
2341
+
2342
+ return h(
2343
+ 'div',
2344
+ { className: sectionClass },
2345
+ head,
2346
+ picker,
2347
+ statsLine,
2348
+ checkpointList,
2349
+ undoList,
2350
+ memoryBlock,
2351
+ )
2352
+ }
2353
+
2354
+ // ------------------------------------------------------- long-term memory
2355
+
2356
+ function useWorkspacePath() {
2357
+ return runtime.resolveCwd?.() ?? runtime.cwd
2358
+ }
2359
+
2360
+ /** Last path segment, for a compact workspace label. */
2361
+ function basename(value) {
2362
+ const text = String(value ?? '')
2363
+ const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'))
2364
+ return cut >= 0 ? text.slice(cut + 1) || text : text
2365
+ }
2366
+
2367
+ function MemorySection(props) {
2368
+ const t = props.t
2369
+ const rev = useStore(revision)
2370
+ const [tab, setTab] = useState('all')
2371
+ const [items, setItems] = useState(null)
2372
+ const [pending, setPending] = useState([])
2373
+ const [settings, setSettings] = useState(null)
2374
+ const [error, setError] = useState(null)
2375
+ const [draft, setDraft] = useState('')
2376
+ const [draftScope, setDraftScope] = useState('workspace')
2377
+ const [busy, setBusy] = useState(null)
2378
+ const [known, setKnown] = useState([])
2379
+ // Maintenance bookkeeping, so "did the timer ever run?" is a fact on
2380
+ // screen rather than something the user has to take on trust.
2381
+ const [maint, setMaint] = useState(null)
2382
+ // An explicit pick wins over the guessed path, so the tab never silently
2383
+ // shows another workspace's memories once the user chooses one.
2384
+ const [picked, setPicked] = useState(undefined)
2385
+ // "Which memories would this question pull in?" — answered on demand so
2386
+ // the answer is inspectable rather than something the user has to infer
2387
+ // from what the model happened to say.
2388
+ const [explain, setExplain] = useState(null)
2389
+ const [explainQuery, setExplainQuery] = useState('')
2390
+ const [explainBusy, setExplainBusy] = useState(false)
2391
+ // Filters: a library that grows past a screenful is not worth reading, so
2392
+ // the list has to be narrowable by tag and by recency.
2393
+ const [tagFilter, setTagFilter] = useState('')
2394
+ const [sinceFilter, setSinceFilter] = useState('')
2395
+
2396
+ const detected = useWorkspacePath()
2397
+ const cwd = picked !== undefined ? picked : detected
2398
+
2399
+ useEffect(() => {
2400
+ let alive = true
2401
+ call({ action: 'longterm.workspaces' })
2402
+ .then(list => alive && setKnown(Array.isArray(list) ? list : []))
2403
+ .catch(() => alive && setKnown([]))
2404
+ return () => {
2405
+ alive = false
2406
+ }
2407
+ }, [rev])
2408
+
2409
+ useEffect(() => {
2410
+ let alive = true
2411
+ Promise.all([
2412
+ // Filters go to the engine, not applied client-side, so the export and
2413
+ // this page always agree on what "my error lessons from this week" means.
2414
+ call({
2415
+ action: 'longterm.list',
2416
+ cwd,
2417
+ ...(tagFilter ? { tag: tagFilter } : {}),
2418
+ ...(sinceFilter ? { sinceDays: Number(sinceFilter) } : {}),
2419
+ }),
2420
+ call({ action: 'longterm.pending', cwd }),
2421
+ call({ action: 'longterm.settings' }),
2422
+ call({ action: 'longterm.status' }).catch(() => null),
2423
+ ])
2424
+ .then(([list, waiting, config, status]) => {
2425
+ if (!alive) return
2426
+ setItems(list)
2427
+ setPending(waiting)
2428
+ setSettings(config)
2429
+ setMaint(status)
2430
+ })
2431
+ .catch(failure => alive && setError(failure.message))
2432
+ return () => {
2433
+ alive = false
2434
+ }
2435
+ }, [rev, cwd, tagFilter, sinceFilter])
2436
+
2437
+ const run = useCallback(async (key, operation, message) => {
2438
+ setBusy(key)
2439
+ setError(null)
2440
+ try {
2441
+ await operation()
2442
+ if (message) notify(message)
2443
+ bump()
2444
+ } catch (failure) {
2445
+ setError(failure.message)
2446
+ notify(failure.message, 'error')
2447
+ } finally {
2448
+ setBusy(null)
2449
+ }
2450
+ }, [])
2451
+
2452
+ const runExplain = useCallback(async () => {
2453
+ setExplainBusy(true)
2454
+ setError(null)
2455
+ try {
2456
+ setExplain(await call({ action: 'longterm.explain', cwd, query: explainQuery }))
2457
+ } catch (failure) {
2458
+ setError(failure.message)
2459
+ } finally {
2460
+ setExplainBusy(false)
2461
+ }
2462
+ }, [cwd, explainQuery])
2463
+
2464
+ // Known tags, so the filter is a pick rather than a guess at the exact
2465
+ // spelling — the engine matches tags exactly, so a typo silently empties
2466
+ // the list and looks like a bug.
2467
+ //
2468
+ // This hook MUST stay above the early returns below. Those returns happen
2469
+ // while `items` is still null, and a hook declared after them is called a
2470
+ // different number of times between renders — React then tears the whole
2471
+ // section down and the settings page shows nothing at all.
2472
+ const knownTags = useMemo(() => {
2473
+ const out = new Set()
2474
+ for (const item of items ?? []) {
2475
+ for (const tag of item.tags ?? []) {
2476
+ if (typeof tag === 'string' && tag.trim()) out.add(tag.trim())
2477
+ }
2478
+ }
2479
+ return [...out].sort((a, b) => a.localeCompare(b))
2480
+ }, [items])
2481
+
2482
+ const head = h(
2483
+ 'div',
2484
+ { className: 'dss-section-head' },
2485
+ h('h2', null, t('memory.title')),
2486
+ h('div', { className: 'dss-section-desc' }, t('memory.description')),
2487
+ )
2488
+
2489
+ if (error && !items) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-dialog-error' }, error))
2490
+ if (!items) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
2491
+
2492
+ const visible = items.filter(item => {
2493
+ if (tab === 'workspace') return item.scope === 'workspace'
2494
+ if (tab === 'global') return item.scope === 'global'
2495
+ if (tab === 'archived') return item.archived === true
2496
+ return true
2497
+ })
2498
+
2499
+ const tabs = h(
2500
+ 'div',
2501
+ { className: 'dss-tabs' },
2502
+ ...['all', 'workspace', 'global', 'archived'].map(key =>
2503
+ h(
2504
+ 'button',
2505
+ {
2506
+ key,
2507
+ type: 'button',
2508
+ className: 'dss-tab' + (tab === key ? ' dss-tab-on' : ''),
2509
+ onClick: () => setTab(key),
2510
+ },
2511
+ t(`memory.tab.${key}`),
2512
+ ),
2513
+ ),
2514
+ )
2515
+
2516
+ const filterBlock = h(
2517
+ 'div',
2518
+ { className: 'dss-toolbar' },
2519
+ h(
2520
+ 'select',
2521
+ {
2522
+ className: 'dss-select',
2523
+ value: tagFilter,
2524
+ onChange: event => setTagFilter(event.target.value),
2525
+ },
2526
+ h('option', { value: '' }, t('memory.filterAllTags')),
2527
+ ...knownTags.map(tag => h('option', { key: tag, value: tag }, tag)),
2528
+ ),
2529
+ h(
2530
+ 'select',
2531
+ {
2532
+ className: 'dss-select',
2533
+ value: sinceFilter,
2534
+ onChange: event => setSinceFilter(event.target.value),
2535
+ },
2536
+ h('option', { value: '' }, t('memory.filterAnyTime')),
2537
+ h('option', { value: '1' }, t('memory.filterToday')),
2538
+ h('option', { value: '7' }, t('memory.filterWeek')),
2539
+ h('option', { value: '30' }, t('memory.filterMonth')),
2540
+ ),
2541
+ tagFilter || sinceFilter
2542
+ ? h(
2543
+ 'button',
2544
+ {
2545
+ type: 'button',
2546
+ className: 'dss-action',
2547
+ onClick: () => {
2548
+ setTagFilter('')
2549
+ setSinceFilter('')
2550
+ },
2551
+ },
2552
+ t('memory.filterClear'),
2553
+ )
2554
+ : null,
2555
+ h('span', { className: 'dss-row-meta' }, t('memory.filterCount', { n: (items ?? []).length })),
2556
+ )
2557
+
2558
+ const pendingBlock =
2559
+ pending.length === 0
2560
+ ? null
2561
+ : h(
2562
+ 'div',
2563
+ { className: 'dss-mem-pending' },
2564
+ h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
2565
+ h('div', { className: 'dss-mem-pending-hint' }, t('memory.pendingHint')),
2566
+ ...pending.map(item =>
2567
+ h(
2568
+ 'div',
2569
+ { className: 'dss-row', key: item.id },
2570
+ h(
2571
+ 'div',
2572
+ { className: 'dss-row-main' },
2573
+ h('div', { className: 'dss-memory-text' }, item.text),
2574
+ h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : t('memory.workspace'))),
2575
+ ),
2576
+ h(
2577
+ 'div',
2578
+ { className: 'dss-row-actions' },
2579
+ h(
2580
+ 'button',
2581
+ {
2582
+ type: 'button',
2583
+ className: 'dss-btn dss-small dss-primary',
2584
+ disabled: busy === `keep:${item.id}`,
2585
+ onClick: () => run(`keep:${item.id}`, () => call({ action: 'longterm.settle', id: item.id, accept: true }), t('memory.accepted')),
2586
+ },
2587
+ t('memory.accept'),
2588
+ ),
2589
+ h(
2590
+ 'button',
2591
+ {
2592
+ type: 'button',
2593
+ className: 'dss-btn dss-small dss-ghost',
2594
+ disabled: busy === `drop:${item.id}`,
2595
+ onClick: () => run(`drop:${item.id}`, () => call({ action: 'longterm.settle', id: item.id, accept: false }), t('memory.rejected')),
2596
+ },
2597
+ t('memory.reject'),
2598
+ ),
2599
+ ),
2600
+ ),
2601
+ ),
2602
+ )
2603
+
2604
+ const listBlock =
2605
+ visible.length === 0
2606
+ ? h('div', { className: 'dss-empty' }, t('memory.empty'))
2607
+ : h(
2608
+ 'div',
2609
+ { className: 'dss-card' },
2610
+ ...visible.map(entry =>
2611
+ h(
2612
+ 'div',
2613
+ { className: 'dss-row', key: entry.id },
2614
+ h(
2615
+ 'div',
2616
+ { className: 'dss-row-main' },
2617
+ h('div', { className: 'dss-memory-text' }, entry.text),
2618
+ h(
2619
+ 'div',
2620
+ { className: 'dss-row-meta' },
2621
+ h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
2622
+ entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
2623
+ entry.summary ? h('span', { className: 'dss-badge' }, t('memory.summary')) : null,
2624
+ entry.frozen ? h('span', { className: 'dss-badge dss-locked' }, t('memory.frozen')) : null,
2625
+ entry.archived ? h('span', { className: 'dss-badge dss-temp' }, t('memory.archived')) : null,
2626
+ typeof entry.score === 'number' && !entry.archived ? h('span', null, t('memory.score', { score: entry.score.toFixed(2) })) : null,
2627
+ h('span', null, t('memory.access', { count: entry.accessCount ?? 0 })),
2628
+ ),
2629
+ ),
2630
+ h(
2631
+ 'div',
2632
+ { className: 'dss-row-actions' },
2633
+ h(
2634
+ 'button',
2635
+ {
2636
+ type: 'button',
2637
+ className: 'dss-btn dss-small dss-ghost',
2638
+ title: entry.frozen ? t('memory.unfreeze') : t('memory.freeze'),
2639
+ onClick: () =>
2640
+ run(`freeze:${entry.id}`, () =>
2641
+ call({ action: 'longterm.update', id: entry.id, cwd, scope: entry.scope, frozen: !entry.frozen }),
2642
+ ),
2643
+ },
2644
+ h(entry.frozen ? Icons.unlock : Icons.lock, { size: 14 }),
2645
+ ),
2646
+ h(
2647
+ 'button',
2648
+ {
2649
+ type: 'button',
2650
+ className: 'dss-btn dss-small dss-ghost',
2651
+ title: t('memory.remove'),
2652
+ onClick: () => run(`rm:${entry.id}`, () => call({ action: 'longterm.remove', id: entry.id, cwd, scope: entry.scope }), t('memory.removed')),
2653
+ },
2654
+ h(Icons.trash, { size: 14 }),
2655
+ ),
2656
+ ),
2657
+ ),
2658
+ ),
2659
+ )
2660
+
2661
+ const addBlock = h(
2662
+ 'div',
2663
+ { className: 'dss-toolbar' },
2664
+ h(
2665
+ 'select',
2666
+ { className: 'dss-select', value: draftScope, onChange: event => setDraftScope(event.target.value) },
2667
+ h('option', { value: 'workspace' }, t('memory.workspace')),
2668
+ h('option', { value: 'global' }, t('memory.global')),
2669
+ ),
2670
+ h('input', {
2671
+ className: 'dss-input',
2672
+ value: draft,
2673
+ placeholder: t('memory.placeholder'),
2674
+ onChange: event => setDraft(event.target.value),
2675
+ }),
2676
+ h(
2677
+ 'button',
2678
+ {
2679
+ type: 'button',
2680
+ className: 'dss-btn dss-small',
2681
+ disabled: !draft.trim() || busy === 'add',
2682
+ onClick: () =>
2683
+ run(
2684
+ 'add',
2685
+ async () => {
2686
+ await call({ action: 'longterm.remember', text: draft, scope: draftScope, cwd })
2687
+ setDraft('')
2688
+ },
2689
+ t('memory.added'),
2690
+ ),
2691
+ },
2692
+ t('memory.save'),
2693
+ ),
2694
+ )
2695
+
2696
+ const footer = h(
2697
+ 'div',
2698
+ { className: 'dss-toolbar' },
2699
+ h(
2700
+ 'button',
2701
+ {
2702
+ type: 'button',
2703
+ className: 'dss-btn dss-small',
2704
+ disabled: busy === 'maintain',
2705
+ onClick: () => run('maintain', () => call({ action: 'longterm.maintain', cwd }), t('memory.maintained')),
2706
+ },
2707
+ t('memory.maintain'),
2708
+ ),
2709
+ h(
2710
+ 'label',
2711
+ { className: 'dss-curate dss-curate-inline' },
2712
+ h('input', {
2713
+ type: 'checkbox',
2714
+ checked: settings?.implicit !== false,
2715
+ onChange: event =>
2716
+ run('implicit', () =>
2717
+ call({ action: 'longterm.settings', patch: { implicit: event.target.checked } }),
2718
+ ),
2719
+ }),
2720
+ h('span', { className: 'dss-curate-title' }, t('memory.implicit')),
2721
+ ),
2722
+ h(
2723
+ 'button',
2724
+ {
2725
+ type: 'button',
2726
+ className: 'dss-btn dss-small dss-ghost',
2727
+ onClick: () => run('cold', () => call({ action: 'longterm.remove', cwd, scope: 'workspace' }), t('memory.coldCleared')),
2728
+ },
2729
+ t('memory.clearCold'),
2730
+ ),
2731
+ )
2732
+
2733
+ // The workspace picker only means something where a workspace is part of
2734
+ // the answer. On the 全局 tab every row is a global memory regardless of
2735
+ // which workspace is selected, so leaving the control visible invited the
2736
+ // reasonable question "why does switching it change nothing?" — the honest
2737
+ // fix is to hide it rather than to keep a control that does nothing.
2738
+ const showsWorkspaceScope = tab === 'all' || tab === 'workspace'
2739
+ const workspacePicker =
2740
+ !showsWorkspaceScope || known.length === 0
2741
+ ? null
2742
+ : h(
2743
+ 'div',
2744
+ { className: 'dss-ws-picker' },
2745
+ h('label', { className: 'dss-ws-label' }, t('memory.workspacePicker')),
2746
+ h(
2747
+ 'select',
2748
+ {
2749
+ className: 'dss-select',
2750
+ value: cwd ?? '',
2751
+ onChange: event => setPicked(event.target.value || undefined),
2752
+ },
2753
+ ...(cwd ? [] : [h('option', { key: '__none', value: '' }, t('memory.workspaceNone'))]),
2754
+ ...known.map(item =>
2755
+ h('option', { key: item.key, value: item.cwd }, `${basename(item.cwd)} (${item.entries})`),
2756
+ ),
2757
+ ),
2758
+ )
2759
+
2760
+ // Maintenance is the one part of this feature that runs without the user
2761
+ // present, so its history is shown rather than assumed. "never run" is a
2762
+ // distinct, honest state — it is what an idle timer looks like.
2763
+ const lastRun = maint && typeof maint.lastMaintainedAt === 'number' ? maint.lastMaintainedAt : 0
2764
+ const lastReport = maint?.lastReport
2765
+ const maintLine = h(
2766
+ 'div',
2767
+ { className: 'dss-maint' },
2768
+ h(
2769
+ 'span',
2770
+ { className: 'dss-maint-when' },
2771
+ lastRun > 0
2772
+ ? `${t('memory.maintainedAt')} ${new Date(lastRun).toLocaleString()}`
2773
+ : t('memory.maintainedNever'),
2774
+ ),
2775
+ lastReport
2776
+ ? h(
2777
+ 'span',
2778
+ { className: 'dss-maint-report' },
2779
+ `${t('memory.maintainedScored')} ${lastReport.scored} · ` +
2780
+ `${t('memory.maintainedCompressed')} ${lastReport.compressed} · ` +
2781
+ `${t('memory.maintainedRules')} ${lastReport.rules}`,
2782
+ )
2783
+ : null,
2784
+ )
2785
+
2786
+ const reasonLabel = reason =>
2787
+ reason === 'no-match' ? t('memory.whyNoMatch') : t('memory.whyCapacity')
2788
+
2789
+ const explainBlock = h(
2790
+ 'div',
2791
+ { className: 'dss-card' },
2792
+ h('div', { className: 'dss-row-title' }, t('memory.explainTitle')),
2793
+ h('div', { className: 'dss-hint' }, t('memory.explainHint')),
2794
+ h(
2795
+ 'div',
2796
+ { className: 'dss-row-actions' },
2797
+ h('input', {
2798
+ className: 'dss-input',
2799
+ value: explainQuery,
2800
+ placeholder: t('memory.explainPlaceholder'),
2801
+ onChange: event => setExplainQuery(event.target.value),
2802
+ onKeyDown: event => {
2803
+ if (event.key === 'Enter') runExplain()
2804
+ },
2805
+ }),
2806
+ h(
2807
+ 'button',
2808
+ { className: 'dss-action', disabled: explainBusy || !cwd, onClick: runExplain },
2809
+ explainBusy ? t('settings.loading') : t('memory.explainRun'),
2810
+ ),
2811
+ ),
2812
+ explain
2813
+ ? h(
2814
+ 'div',
2815
+ null,
2816
+ h(
2817
+ 'div',
2818
+ { className: 'dss-row-meta' },
2819
+ t('memory.explainInjected', { n: explain.items.length, limit: explain.effectiveLimit }),
2820
+ ),
2821
+ explain.items.length === 0
2822
+ ? h('div', { className: 'dss-hint' }, t('memory.explainNone'))
2823
+ : null,
2824
+ ...explain.items.map(item =>
2825
+ h('div', { className: 'dss-memory-text', key: `in:${item.id}` }, item.text),
2826
+ ),
2827
+ explain.omitted.length > 0
2828
+ ? h(
2829
+ 'div',
2830
+ { className: 'dss-row-meta', style: { marginTop: 10 } },
2831
+ t('memory.explainOmitted', { n: explain.omitted.length }),
2832
+ )
2833
+ : null,
2834
+ ...explain.omitted
2835
+ .slice(0, 12)
2836
+ .map(item =>
2837
+ h(
2838
+ 'div',
2839
+ { className: 'dss-row-meta', key: `out:${item.id}` },
2840
+ h('span', { className: 'dss-badge dss-temp' }, reasonLabel(item.reason)),
2841
+ h('span', null, ` ${String(item.text).slice(0, 60)}`),
2842
+ ),
2843
+ ),
2844
+ )
2845
+ : null,
2846
+ )
2847
+
2848
+ return h(
2849
+ 'div',
2850
+ { className: 'dss-section' },
2851
+ head,
2852
+ pendingBlock,
2853
+ explainBlock,
2854
+ tabs,
2855
+ filterBlock,
2856
+ workspacePicker,
2857
+ listBlock,
2858
+ addBlock,
2859
+ footer,
2860
+ maintLine,
2861
+ )
2862
+ }
2863
+
2864
+ // ------------------------------------------------------------------ apply
2865
+
2866
+ return {
2867
+ inject: ['slots', 'locale'],
2868
+ apply(ctx) {
2869
+ const t = ctx.locale.bind(NS)
2870
+ ctx.effect(() => ctx.locale.register(NS, DICTS))
2871
+ ctx.effect(installStyles)
2872
+
2873
+ /** Open another session in the main panel (used after a rewind forks one). */
2874
+ const openSession = sessionId => {
2875
+ // Remember which session the panel is showing so the memory page can
2876
+ // resolve that session's workspace directory.
2877
+ runtime.sessionId = sessionId
2878
+ runtime.cwd = undefined
2879
+ const uiWorkspace = ctx.get('uiWorkspace')
2880
+ if (uiWorkspace?.openSession) {
2881
+ uiWorkspace.openSession(sessionId)
2882
+ return
2883
+ }
2884
+ const layout = ctx.get('layout')
2885
+ layout?.selectPanel?.('conversation')
2886
+ }
2887
+ runtime.openSession = openSession
2888
+ // The memory page needs the workspace directory that the settings page is
2889
+ // actually looking at. Resolve it from the session this page was opened
2890
+ // for; when there is none, the page offers the Host's recorded workspace
2891
+ // list instead of guessing, so it never silently reads the global store.
2892
+ runtime.cwd = undefined
2893
+ const resolveRuntimeCwd = () => {
2894
+ if (runtime.cwd) return runtime.cwd
2895
+ if (runtime.sessionId) {
2896
+ try {
2897
+ const binding = ctx.get('sessions')?.binding?.(runtime.sessionId)
2898
+ if (binding?.cwd) runtime.cwd = binding.cwd
2899
+ } catch {
2900
+ // leave undefined; the picker offers the known workspaces instead
2901
+ }
2902
+ }
2903
+ return runtime.cwd
2904
+ }
2905
+ runtime.resolveCwd = resolveRuntimeCwd
2906
+ runtime.archiveSession = async sessionId => {
2907
+ const uiWorkspace = ctx.get('uiWorkspace')
2908
+ if (uiWorkspace?.archiveSession) return uiWorkspace.archiveSession(sessionId, { stopActivity: true })
2909
+ const workspaces = ctx.get('workspaces')
2910
+ if (workspaces?.archiveSession) return workspaces.archiveSession(sessionId, { stopActivity: true })
2911
+ return undefined
2912
+ }
2913
+
2914
+ const withT = (component, extra) => props => h(component, { ...props, t: props.t ?? t, ...extra })
2915
+
2916
+ const menuSlots = [
2917
+ ['sidebar.workspaces.session.menu.item', 'session-suite-share', 460, ShareRow],
2918
+ ['sidebar.workspaces.session.menu.item', 'session-suite-snapshots', 470, SnapshotsRow],
2919
+ ['sidebar.workspaces.session.menu.item', 'session-suite-segment', 880, SegmentRow],
2920
+ ['sidebar.workspaces.session.menu.item', 'session-suite-delete', 900, DeleteSessionRow],
2921
+ ]
2922
+ for (const [name, id, order, component] of menuSlots) {
2923
+ ctx.slots.inject(name, () =>
2924
+ ctx.slots.register({ name, id, order, locale: NS }, withT(component)),
2925
+ )
2926
+ }
2927
+
2928
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
2929
+ ctx.slots.register(
2930
+ { name: 'conversation.chat.assistant-actions', id: 'session-suite-rewind', order: 20, locale: NS },
2931
+ withT(RewindAction),
2932
+ ),
2933
+ )
2934
+
2935
+ ctx.slots.inject('conversation.input.dock', () =>
2936
+ ctx.slots.register(
2937
+ { name: 'conversation.input.dock', id: 'session-suite-rewind-banner', order: 30, locale: NS },
2938
+ withT(RewindBanner, { openSession }),
2939
+ ),
2940
+ )
2941
+
2942
+ ctx.slots.inject('shell.overlay', () =>
2943
+ ctx.slots.register({ name: 'shell.overlay', id: 'session-suite-overlay', order: 40 }, SuiteOverlay),
2944
+ )
2945
+
2946
+ ctx.slots.inject('settings.section', () =>
2947
+ ctx.slots.register(
2948
+ {
2949
+ name: 'settings.section',
2950
+ id: 'session-snapshots',
2951
+ order: 500,
2952
+ locale: NS,
2953
+ label: () => t('settings.title'),
2954
+ },
2955
+ withT(SnapshotSection),
2956
+ ),
2957
+ )
2958
+
2959
+ ctx.slots.inject('settings.section', () =>
2960
+ ctx.slots.register(
2961
+ {
2962
+ name: 'settings.section',
2963
+ id: 'long-term-memory',
2964
+ order: 520,
2965
+ locale: NS,
2966
+ label: () => t('memory.title'),
2967
+ },
2968
+ withT(MemorySection),
2969
+ ),
2970
+ )
2971
+ },
2972
+ }
2973
+ },
2974
+ })