dsh-conversation-recycle-bin 0.3.12 → 0.3.13

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 CHANGED
@@ -1,2221 +1,2237 @@
1
- /**
2
- * dsh-conversation-recycle-bin — Client half.
3
- *
4
- * Hand-authored in the Web shell's module-loader format (`window.__ModuleLoader__.load`
5
- * + `require`), so the package needs no build step. The Client owns every pixel; all
6
- * authority and filesystem work stays on the Host behind the same-origin route.
7
- *
8
- * Surface:
9
- * - one row in the Session "..." menu (`sidebar.workspaces.session.menu.item`);
10
- * - one action in the conversation header (`conversation.session.header.actions`);
11
- * - one dialog host in `shell.overlay` (so closing the menu does not unmount the
12
- * dialog — the menu item unmounts with its dropdown);
13
- * - one Settings section: recycle bin, batch deletion and the audit trail.
14
- *
15
- * The confirmation dialog is the only place a deletion is started from the UI. It
16
- * always defaults to the safe choices: soft delete, memory checkbox OFF, and the
17
- * batch/permanent phrases are typed by the user, matching the Host's server-side
18
- * confirmation protocol in `lib/host/api.js`.
19
- */
20
- window.__ModuleLoader__.load({
21
- // Must equal the module name the bundle row mounts (`cordis.patch.yml`'s
22
- // row `name`, which is the package name). The Host's boot graph keys every
23
- // client row by that name; a bundle registering under any other id is
24
- // reported as "loaded without registering" and then executed a second time
25
- // from its one-resource URL, which throws
26
- // `duplicate factory registration for "..."` and aborts web boot.
27
- id: 'dsh-conversation-recycle-bin',
28
- factory: (require) => {
29
- const module = { exports: {} }
30
- const exports = module.exports
31
- const React = require('react')
32
- const { useCallback, useEffect, useMemo, useRef, useState } = React
33
- const h = React.createElement
34
-
35
- /*
36
- * `react-dom` is optional: without a portal the dialog still renders (in
37
- * the overlay slot), it just cannot escape the overlay layer's stacking
38
- * context. Probing instead of requiring keeps the module loadable on a
39
- * host that ships React without ReactDOM.
40
- */
41
- let createPortal = null
42
- try {
43
- const ReactDOM = require('react-dom')
44
- createPortal = ReactDOM?.createPortal ?? null
45
- } catch {
46
- createPortal = null
47
- }
48
-
49
- /** Render into `document.body` when possible (see DialogHost). */
50
- function portalIntoBody(element) {
51
- if (createPortal === null) return element
52
- if (typeof document === 'undefined' || document.body === null || document.body === undefined) return element
53
- try {
54
- return createPortal(element, document.body)
55
- } catch {
56
- return element
57
- }
58
- }
59
-
60
- /* ------------------------------------------------------------------ *
61
- * Locale
62
- * ------------------------------------------------------------------ */
63
-
64
- const NS = 'conversation-recycle-bin'
65
- const STRINGS = {
66
- en: {
67
- 'action.menuDelete': 'Delete conversation…',
68
- 'action.headerDelete': 'Delete conversation',
69
- 'action.delete': 'Delete',
70
- 'action.cancel': 'Cancel',
71
- 'action.close': 'Close',
72
- 'action.refresh': 'Refresh',
73
- 'action.restore': 'Restore',
74
- 'action.purge': 'Delete permanently',
75
- 'action.selectAll': 'Select all',
76
- 'action.clearAll': 'Clear',
77
- 'batch.selectAllDeletable': 'Select every conversation that can be deleted',
78
- 'section.label': 'Conversation Deletion',
79
- 'section.title': 'Conversation Deletion',
80
- 'section.subtitle': 'Recycle bin, batch deletion and the audit trail.',
81
- 'dialog.titleOne': 'Delete this conversation?',
82
- 'dialog.titleMany': 'Delete {count} conversations?',
83
- 'dialog.bodyOne': '“{title}” will be removed from the session list.',
84
- 'dialog.bodyMany': '{count} conversations will be removed from the session list.',
85
- 'dialog.modeSoft': 'Move to the recycle bin (recommended)',
86
- 'dialog.modeSoftHint': 'The log stays on disk and can be restored from the recycle bin.',
87
- 'dialog.modeHard': 'Delete permanently',
88
- 'dialog.modeHardHint': 'Moves the session log out of the live store. Recoverable until the recycle-bin entry is purged.',
89
- 'dialog.memoryLabel': 'Also delete this conversation’s associated memory',
90
- 'dialog.memoryHint': 'Off by default. The count is read live before you confirm.',
91
- 'dialog.memoryCount': '{count} associated memory entries will be moved to the recycle bin',
92
- 'dialog.memoryFound': '{count} associated memory entries were found',
93
- 'dialog.memoryNone': 'No associated memory entries were found',
94
- 'dialog.memoryUnsupported': 'Memory store unavailable: {reason}',
95
- 'dialog.memoryProtected': '{count} protected entries will be kept',
96
- 'dialog.memoryKeep': 'The Host is configured to keep memory entries (deleteMode = keep).',
97
- 'dialog.memoryDisabled': 'Associated-memory deletion is disabled in the Host configuration.',
98
- 'dialog.records': '{events} log records · {size}',
99
- 'dialog.batchWarning': 'Type the number {count} to confirm this batch.',
100
- 'dialog.liveStopHint': 'This conversation is open in a live agent. Permanent deletion stops it first, then removes its log.',
101
- 'dialog.phraseLabel': 'Confirmation',
102
- 'dialog.delete': 'Delete',
103
- 'dialog.working': 'Deleting…',
104
- 'dialog.stillWorking': 'Still working — confirming no other DSH instance is writing this conversation.',
105
- 'dialog.hardGraceHint': 'Permanent deletion re-checks the log for a second writer first, so a conversation used moments ago can take about 1.5s longer.',
106
- 'dialog.loading': 'Reading the plan…',
107
- 'dialog.done': 'Deleted {count} conversation(s); {memory} memory entries moved to the recycle bin.',
108
- 'dialog.partial': 'Deleted {done}, failed {failed}.',
109
- 'dialog.noSession': 'The conversation no longer exists.',
110
- 'dialog.blocked': 'This conversation cannot be deleted: {message}',
111
- 'dialog.reloadHint': 'The conversation list refreshes when you close this dialog.',
112
- 'trash.title': 'Recycle bin',
113
- 'trash.empty': 'The recycle bin is empty.',
114
- 'trash.deletedAt': 'Deleted {time}',
115
- 'trash.retention': 'Entries are purged automatically {days} days after deletion. Set trashRetentionDays to 0 to keep them until you purge them yourself.',
116
- 'trash.expiresIn': 'auto-purged in {days} d',
117
- 'trash.expiresSoon': 'auto-purged on the next check',
118
- 'trash.swept': 'The Host cleaned up {count} expired entries in the background.',
119
- 'trash.modeSoft': 'soft',
120
- 'trash.modeHard': 'permanent',
121
- 'trash.items': '{sessions} conversation(s) · {memories} memory entries',
122
- 'trash.confirmPurge': 'Type DELETE to permanently remove this entry.',
123
- 'trash.restored': 'Restored.',
124
- 'trash.purged': 'Permanently deleted.',
125
- 'trash.incompleteTitle': 'Incomplete entries',
126
- 'trash.incompleteHint': 'Entries that never committed. Discard them to free the space; an entry that still holds a conversation log must be restored or purged instead.',
127
- 'trash.restoreIncompleteHint': 'Put the conversation log back and re-attach it to its workspace.',
128
- 'trash.discard': 'Discard',
129
- 'trash.discarded': 'Entry removed.',
130
- 'trash.confirmDiscard': 'Type DELETE to discard this incomplete entry.',
131
- 'trash.status.pending': 'pending',
132
- 'trash.status.failed': 'failed',
133
- 'trash.status.restoring': 'restoring',
134
- 'trash.status.purging': 'purging',
135
- 'trash.status.restored': 'restored',
136
- 'trash.status.unreadable': 'unreadable manifest',
137
- 'batch.title': 'Batch deletion',
138
- 'batch.hint': 'Each conversation is its own transaction; a failure rolls only that conversation back.',
139
- 'batch.selected': '{count} selected',
140
- 'batch.deleteSelected': 'Delete selected…',
141
- 'batch.empty': 'No conversations.',
142
- 'batch.overLimit': 'The selection exceeds the current limit of {limit}.',
143
- 'batch.notDeletable': 'cannot be deleted',
144
- 'audit.title': 'Audit trail',
145
- 'audit.empty': 'No deletion has been recorded yet.',
146
- 'audit.denied': 'denied',
147
- 'audit.success': 'success',
148
- 'audit.rolled-back': 'rolled back',
149
- 'audit.rollback-incomplete': 'rollback incomplete',
150
- 'state.title': 'Host status',
151
- 'state.memory': 'Memory adapters',
152
- 'state.limits': 'Limits',
153
- 'state.available': 'available',
154
- 'state.unavailable': 'unavailable',
155
- 'state.notDetected': 'not detected',
156
- 'state.rate': '{used}/{limit} deletions this minute',
157
- 'state.batch': 'Effective batch limit: {limit}',
158
- 'feedback.button': 'Feedback',
159
- 'feedback.bannerTitle': 'Ran into a problem or have an idea?',
160
- 'feedback.bannerHint': 'Send one email to the maintainer. It carries your text plus the plugin version, platform and language — no conversation content.',
161
- 'feedback.title': 'Send feedback',
162
- 'feedback.intro': 'Tell the maintainer what went wrong or what you would like to see. The message is sent by email from this Host; nothing is uploaded anywhere else.',
163
- 'feedback.categoryLabel': 'Type',
164
- 'feedback.categoryBug': 'Problem / bug',
165
- 'feedback.categorySuggestion': 'Suggestion',
166
- 'feedback.categoryOther': 'Other',
167
- 'feedback.titleLabel': 'Title',
168
- 'feedback.titlePlaceholder': 'One line that summarises it',
169
- 'feedback.bodyLabel': 'Message',
170
- 'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
171
- 'feedback.emailLabel': 'Your email (optional)',
172
- 'feedback.emailPlaceholder': 'you@example.com',
173
- 'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
174
- 'feedback.emailHintAsFrom': 'Sent as the mail\'s From address. Most relays (QQ, 163, Gmail) reject this unless the mailbox matches the login account — enable only when the relay accepts arbitrary From.',
175
- 'feedback.contextLabel': 'Attached automatically',
176
- 'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
177
- 'feedback.contextVersion': 'Plugin version',
178
- 'feedback.contextPlatform': 'Platform',
179
- 'feedback.contextLocale': 'Language',
180
- 'feedback.send': 'Send',
181
- 'feedback.sending': 'Sending…',
182
- 'feedback.sent': 'Feedback sent. Thank you!',
183
- 'feedback.tooShortTitle': 'The title needs at least {min} characters.',
184
- 'feedback.tooLongTitle': 'The title may be at most {max} characters.',
185
- 'feedback.tooShortBody': 'The message needs at least {min} characters.',
186
- 'feedback.tooLongBody': 'The message may be at most {max} characters.',
187
- 'feedback.invalidEmail': 'That does not look like an email address.',
188
- 'feedback.tooLongEmail': 'The email address may be at most {max} characters.',
189
- 'feedback.unavailable': 'The feedback channel is not configured on this Host.',
190
- 'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
191
- 'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
192
- 'error.E_FEEDBACK_RATE_LIMITED': 'Several messages were sent recently; try again a little later.',
193
- 'error.E_FEEDBACK_TRANSPORT': 'The message could not be delivered. Check the Host mail configuration and retry.',
194
- 'error.E_CONFIRM_PHRASE': 'The confirmation phrase does not match.',
195
- 'error.E_CONFIRM_EXPIRED': 'The confirmation expired; the counts were reloaded.',
196
- 'error.E_CONFIRM_INVALID': 'The confirmation is stale; the plan was reloaded.',
197
- 'error.E_POLICY_LIVE': 'The conversation could not be stopped. Close it and retry.',
198
- 'error.E_POLICY_ORPHAN': 'The conversation is not owned by a registered workspace.',
199
- 'error.E_POLICY_PROTECTED': 'The conversation is protected by configuration.',
200
- 'error.E_POLICY_SUBAGENT': 'Subagent conversations cannot be deleted.',
201
- 'error.E_POLICY_ROOT': 'The conversation is outside the allowed workspace roots.',
202
- 'error.E_RATE_LIMITED': 'Too many deletions; wait a moment and retry.',
203
- 'error.E_BATCH_TOO_LARGE': 'Too many conversations selected (the current limit is {limit}).',
204
- 'error.E_TIMEOUT': 'The Host did not answer in time. It may still be working; refresh the list before retrying.',
205
- 'error.E_DISABLED': 'Conversation deletion is disabled by configuration.',
206
- 'error.E_TRANSACTION_FAILED': 'Deletion failed and was rolled back.',
207
- 'error.E_MEMORY_FAILED': 'The memory store could not be updated; nothing was deleted.',
208
- 'error.E_BAD_RESPONSE': 'The Host returned an unreadable response.',
209
- 'error.E_INTERNAL': 'An unexpected Host error occurred.',
210
- 'error.E_NOT_FOUND': 'The conversation no longer exists.',
211
- 'error.E_BAD_REQUEST': 'The Host rejected the request as invalid.',
212
- 'error.E_TRASH_NOT_FOUND': 'The recycle-bin entry no longer exists.',
213
- 'error.E_TRASH_CONFLICT': 'The entry is still in use (for example it still holds a conversation log) or its state does not allow this action.',
214
- 'error.E_CONFIRM_REQUIRED': 'Reload the dialog and confirm again.',
215
- 'error.E_FORBIDDEN_HOST': 'The Host only accepts deletion requests from loopback; set allowNonLoopback for remote access.',
216
- 'error.E_FORBIDDEN_ORIGIN': 'The Host rejected this page as an untrusted origin.',
217
- 'error.E_POLICY_FOREIGN_WRITER': 'Another DSH instance is still writing this conversation; close it there and retry.',
218
- 'error.E_LOCK_BUSY': 'Another DSH instance is using this recycle bin; retry in a moment.',
219
- 'error.E_QUEUE_TIMEOUT': 'The Host was still busy with another change, so this request was not applied. Retry in a moment.',
220
- 'error.E_ABANDONED': 'The page gave up waiting before the Host started; nothing was changed. Retry.',
221
- 'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
222
- 'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
223
- 'adapter.generic-json': 'Generic JSON memory store',
224
- 'adapter.none': 'No memory store',
225
- 'adapter.reason.no-memory-store': 'No conversation-linked memory store was detected.',
226
- 'common.bytes': '{value} {unit}',
227
- },
228
- zh: {
229
- 'action.menuDelete': '删除对话…',
230
- 'action.headerDelete': '删除对话',
231
- 'action.delete': '删除',
232
- 'action.cancel': '取消',
233
- 'action.close': '关闭',
234
- 'action.refresh': '刷新',
235
- 'action.restore': '恢复',
236
- 'action.purge': '彻底删除',
237
- 'action.selectAll': '全选',
238
- 'action.clearAll': '清除',
239
- 'batch.selectAllDeletable': '全选所有可删除的对话',
240
- 'section.label': '对话删除',
241
- 'section.title': '对话删除',
242
- 'section.subtitle': '回收站、批量删除与审计留痕。',
243
- 'dialog.titleOne': '删除这个对话?',
244
- 'dialog.titleMany': '删除 {count} 个对话?',
245
- 'dialog.bodyOne': '「{title}」将从会话列表中移除。',
246
- 'dialog.bodyMany': '{count} 个对话将从会话列表中移除。',
247
- 'dialog.modeSoft': '移入回收站(推荐)',
248
- 'dialog.modeSoftHint': '日志仍保留在磁盘上,可从回收站恢复。',
249
- 'dialog.modeHard': '彻底删除',
250
- 'dialog.modeHardHint': '把会话日志移出活动存储;在回收站条目被清理前仍可恢复。',
251
- 'dialog.memoryLabel': '同时删除该对话的关联记忆',
252
- 'dialog.memoryHint': '默认不勾选。确认前会实时统计受影响的记忆条数。',
253
- 'dialog.memoryCount': '将把 {count} 条关联记忆移入回收站',
254
- 'dialog.memoryFound': '找到 {count} 条关联记忆',
255
- 'dialog.memoryNone': '未找到关联记忆',
256
- 'dialog.memoryUnsupported': '记忆存储不可用:{reason}',
257
- 'dialog.memoryProtected': '另有 {count} 条受保护记忆将保留',
258
- 'dialog.memoryKeep': '宿主配置为保留记忆(deleteMode = keep)。',
259
- 'dialog.memoryDisabled': '宿主配置已禁用关联记忆删除。',
260
- 'dialog.records': '{events} 条日志记录 · {size}',
261
- 'dialog.batchWarning': '批量删除需输入数字 {count} 确认。',
262
- 'dialog.liveStopHint': '该对话仍在运行中;彻底删除会先停止它,再移除日志。',
263
- 'dialog.phraseLabel': '确认',
264
- 'dialog.delete': '删除',
265
- 'dialog.working': '正在删除…',
266
- 'dialog.stillWorking': '仍在执行:正在确认没有其他 DSH 实例写入该对话。',
267
- 'dialog.hardGraceHint': '彻底删除会先复查日志是否还有第二个写入方;刚使用过的对话可能多等约 1.5 秒。',
268
- 'dialog.loading': '正在读取删除计划…',
269
- 'dialog.done': '已删除 {count} 个对话;移入回收站的记忆条目 {memory} 条。',
270
- 'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
271
- 'dialog.noSession': '该对话已不存在。',
272
- 'dialog.blocked': '该对话无法删除:{message}',
273
- 'dialog.reloadHint': '关闭本对话框后会刷新会话列表。',
274
- 'trash.title': '回收站',
275
- 'trash.empty': '回收站为空。',
276
- 'trash.deletedAt': '删除于 {time}',
277
- 'trash.retention': '条目在删除 {days} 天后自动清理。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
278
- 'trash.expiresIn': '{days} 天后自动清理',
279
- 'trash.expiresSoon': '下次检查时自动清理',
280
- 'trash.swept': 'Host 已在后台自动清理 {count} 条过期条目。',
281
- 'trash.modeSoft': '软删除',
282
- 'trash.modeHard': '彻底',
283
- 'trash.items': '{sessions} 个对话 · {memories} 条记忆',
284
- 'trash.confirmPurge': '输入 DELETE 彻底删除该条目。',
285
- 'trash.restored': '已恢复。',
286
- 'trash.purged': '已彻底删除。',
287
- 'trash.incompleteTitle': '未完成条目',
288
- 'trash.incompleteHint': '这些条目没有提交成功,可直接清理以释放空间;仍保存着会话日志的条目请先恢复或彻底删除。',
289
- 'trash.restoreIncompleteHint': '把会话日志放回原位,并重新挂回它所属的工作区。',
290
- 'trash.discard': '清理',
291
- 'trash.discarded': '条目已清理。',
292
- 'trash.confirmDiscard': '输入 DELETE 清理该未完成条目。',
293
- 'trash.status.pending': '待处理',
294
- 'trash.status.failed': '失败',
295
- 'trash.status.restoring': '恢复中断',
296
- 'trash.status.purging': '清理中断',
297
- 'trash.status.restored': '已恢复',
298
- 'trash.status.unreadable': '清单不可读',
299
- 'batch.title': '批量删除',
300
- 'batch.hint': '每个对话独立事务;失败时仅回滚该对话。',
301
- 'batch.selected': '已选 {count} 个',
302
- 'batch.deleteSelected': '删除所选…',
303
- 'batch.empty': '没有对话。',
304
- 'batch.overLimit': '所选数量超过当前上限 {limit} 个。',
305
- 'batch.notDeletable': '无法删除',
306
- 'audit.title': '审计留痕',
307
- 'audit.empty': '尚无删除记录。',
308
- 'audit.denied': '被拒绝',
309
- 'audit.success': '成功',
310
- 'audit.rolled-back': '已回滚',
311
- 'audit.rollback-incomplete': '回滚不完整',
312
- 'state.title': '宿主状态',
313
- 'state.memory': '记忆适配器',
314
- 'state.limits': '限制',
315
- 'state.available': '可用',
316
- 'state.unavailable': '不可用',
317
- 'state.notDetected': '未检测到',
318
- 'state.rate': '本分钟已删除 {used}/{limit}',
319
- 'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
320
- 'feedback.button': '反馈',
321
- 'feedback.bannerTitle': '遇到问题,或有改进建议?',
322
- 'feedback.bannerHint': '写一条消息发给维护者,正文之外只附带插件版本、平台与语言,不含任何对话内容。',
323
- 'feedback.title': '发送反馈',
324
- 'feedback.intro': '把遇到的问题或想要的功能告诉维护者。消息由本机宿主通过邮件发送,不会上传到其他位置。',
325
- 'feedback.categoryLabel': '类型',
326
- 'feedback.categoryBug': '问题 / Bug',
327
- 'feedback.categorySuggestion': '建议',
328
- 'feedback.categoryOther': '其他',
329
- 'feedback.titleLabel': '标题',
330
- 'feedback.titlePlaceholder': '用一句话概括',
331
- 'feedback.bodyLabel': '正文',
332
- 'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
333
- 'feedback.emailLabel': '你的邮箱(选填)',
334
- 'feedback.emailPlaceholder': 'you@example.com',
335
- 'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
336
- 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人(From)。QQ / 163 / Gmail 一类会拒收非登录账号的 From,只有允许任意 From 的中继才生效。',
337
- 'feedback.contextLabel': '自动附带',
338
- 'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
339
- 'feedback.contextVersion': '插件版本',
340
- 'feedback.contextPlatform': '平台',
341
- 'feedback.contextLocale': '语言',
342
- 'feedback.send': '发送',
343
- 'feedback.sending': '正在发送…',
344
- 'feedback.sent': '反馈已发送,谢谢!',
345
- 'feedback.tooShortTitle': '标题至少需要 {min} 个字符。',
346
- 'feedback.tooLongTitle': '标题最多 {max} 个字符。',
347
- 'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
348
- 'feedback.tooLongBody': '正文最多 {max} 个字符。',
349
- 'feedback.invalidEmail': '这看起来不是一个邮箱地址。',
350
- 'feedback.tooLongEmail': '邮箱最多 {max} 个字符。',
351
- 'feedback.unavailable': '当前宿主未配置反馈通道。',
352
- 'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
353
- 'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
354
- 'error.E_FEEDBACK_RATE_LIMITED': '最近已发送多条反馈,请稍后再试。',
355
- 'error.E_FEEDBACK_TRANSPORT': '邮件发送失败,请检查宿主的邮件配置后重试。',
356
- 'error.E_CONFIRM_PHRASE': '确认短语不匹配。',
357
- 'error.E_CONFIRM_EXPIRED': '确认已过期;计数已重新加载。',
358
- 'error.E_CONFIRM_INVALID': '确认已失效;计划已重新加载。',
359
- 'error.E_POLICY_LIVE': '无法停止该对话,请关闭后重试。',
360
- 'error.E_POLICY_ORPHAN': '该对话不属于任何已登记工作区。',
361
- 'error.E_POLICY_PROTECTED': '该对话受配置保护。',
362
- 'error.E_POLICY_SUBAGENT': '子代理对话不能删除。',
363
- 'error.E_POLICY_ROOT': '该对话不在允许的工作区根目录内。',
364
- 'error.E_RATE_LIMITED': '删除过于频繁,请稍后重试。',
365
- 'error.E_BATCH_TOO_LARGE': '所选对话过多(当前上限 {limit} 个)。',
366
- 'error.E_TIMEOUT': '宿主未及时响应;操作可能仍在后台继续,请刷新列表确认后再重试。',
367
- 'error.E_DISABLED': '宿主配置已禁用对话删除。',
368
- 'error.E_TRANSACTION_FAILED': '删除失败,已回滚。',
369
- 'error.E_MEMORY_FAILED': '记忆存储更新失败;未删除任何内容。',
370
- 'error.E_BAD_RESPONSE': '宿主返回了无法解析的响应。',
371
- 'error.E_INTERNAL': '宿主发生未预期的错误。',
372
- 'error.E_NOT_FOUND': '该对话已不存在。',
373
- 'error.E_BAD_REQUEST': '请求参数无效。',
374
- 'error.E_TRASH_NOT_FOUND': '回收站条目已不存在。',
375
- 'error.E_TRASH_CONFLICT': '该条目仍被占用(例如仍保存着会话日志)或状态不允许此操作。',
376
- 'error.E_CONFIRM_REQUIRED': '请重新加载确认框后再试。',
377
- 'error.E_FORBIDDEN_HOST': '宿主只接受本机回环地址的删除请求;远程访问需设置 allowNonLoopback。',
378
- 'error.E_FORBIDDEN_ORIGIN': '宿主拒绝了该页面的请求(来源校验未通过)。',
379
- 'error.E_POLICY_FOREIGN_WRITER': '另一个 DSH 实例仍在写这个对话,已中止删除;请先关闭那边的对话再重试。',
380
- 'error.E_LOCK_BUSY': '另一个 DSH 实例正在使用回收站,请稍后重试。',
381
- 'error.E_QUEUE_TIMEOUT': '宿主还在处理另一个变更,这次请求没有执行,请稍后重试。',
382
- 'error.E_ABANDONED': '页面在宿主开始处理前就放弃了等待,本次没有做任何改动,请重试。',
383
- 'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
384
- 'adapter.lingshu': '灵枢认知图 (md_cg)',
385
- 'adapter.generic-json': '通用 JSON 记忆存储',
386
- 'adapter.none': '无记忆存储',
387
- 'adapter.reason.no-memory-store': '未检测到与对话关联的记忆存储。',
388
- 'common.bytes': '{value} {unit}',
389
- },
390
- }
391
-
392
- let runtimeCtx = null
393
- let localeService = null
394
-
395
- function peekLocaleService() {
396
- if (localeService !== null) return localeService
397
- if (runtimeCtx === null) return null
398
- try {
399
- localeService = runtimeCtx.locale ?? runtimeCtx.get('locale') ?? null
400
- } catch {
401
- localeService = null
402
- }
403
- return localeService
404
- }
405
-
406
- function currentLocaleId() {
407
- const service = peekLocaleService()
408
- try {
409
- const snapshot = service?.getSnapshot?.() ?? service?.getLocale?.()
410
- // `dsh-client-locale`'s snapshot is `{ active, locales, revision }`:
411
- // the ACTIVE locale id lives in `active`, and neither `locale` nor
412
- // `id` exists. Reading the wrong fields silently fell back to the
413
- // browser language, so the UI ignored the in-app language switch.
414
- const id = snapshot?.active ?? snapshot?.locale ?? snapshot?.id
415
- if (typeof id === 'string' && id !== '') return id
416
- } catch {
417
- // fall through
418
- }
419
- const language = typeof navigator !== 'undefined' && typeof navigator.language === 'string'
420
- ? navigator.language
421
- : 'zh'
422
- return language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
423
- }
424
-
425
- function isZhLocale(localeId) {
426
- return String(localeId ?? '').toLowerCase().startsWith('zh')
427
- }
428
-
429
- function t(key, vars) {
430
- const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
431
- const template = table[key] ?? STRINGS.en[key] ?? key
432
- if (vars === undefined) return template
433
- return template.replace(/\{(\w+)\}/g, (match, name) => {
434
- const value = vars[name]
435
- return value === undefined ? match : String(value)
436
- })
437
- }
438
-
439
- function errorText(error) {
440
- const code = error?.code
441
- // Same locale test as `t`: a mixed-case id (e.g. "ZH-CN") used to fall
442
- // through to the English table while every other string stayed Chinese.
443
- const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
444
- const template = code === undefined ? undefined : table[`error.${code}`]
445
- if (template === undefined) return error?.message ?? String(error)
446
- return template.replace(/\{(\w+)\}/g, (match, name) => {
447
- const value = error?.details?.[name] ?? error?.[name]
448
- return value === undefined ? '—' : String(value)
449
- })
450
- }
451
-
452
- /**
453
- * Localized adapter label. Host labels are deliberately neutral ids: the
454
- * Chinese lingshu label and the English `none` reason used to appear
455
- * untranslated in the other language's UI.
456
- */
457
- function adapterLabel(adapter) {
458
- const key = `adapter.${adapter?.id ?? ''}`
459
- const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
460
- return table[key] ?? adapter?.label ?? adapter?.id ?? '—'
461
- }
462
-
463
- /** Localize the stable reason keys; anything else is a raw Host message. */
464
- function adapterReason(reason) {
465
- if (typeof reason !== 'string' || reason === '') return ''
466
- const key = `adapter.reason.${reason}`
467
- const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
468
- return table[key] ?? reason
469
- }
470
-
471
- function useLocaleTick() {
472
- const [, force] = useState(0)
473
- useEffect(() => {
474
- const service = peekLocaleService()
475
- if (service === null || typeof service.subscribe !== 'function') return undefined
476
- try {
477
- return service.subscribe(() => force((value) => value + 1))
478
- } catch {
479
- return undefined
480
- }
481
- }, [])
482
- }
483
-
484
- /* ------------------------------------------------------------------ *
485
- * Host HTTP client
486
- * ------------------------------------------------------------------ */
487
-
488
- const API_PREFIX = '/conversation-recycle-bin/api'
489
- const REQUEST_TIMEOUT_MS = 15_000
490
- const MUTATION_TIMEOUT_MS = 180_000
491
- /**
492
- * Recycle-bin writes wait for the Host's cross-process lock, and that wait
493
- * is unbounded in wall-clock terms: the request queues behind whatever
494
- * mutation is already running (`serialize()` chains in-process first). A
495
- * 15s budget therefore aborted restore / purge / discard while the Host was
496
- * still working, so the UI reported a failure for a change that did happen.
497
- * The Host caps its own lock wait at TRASH_LOCK_BUDGET_MS (180s); this sits
498
- * just above it so the Host's own answer always wins the race.
499
- */
500
- const TRASH_MUTATION_TIMEOUT_MS = 195_000
501
- /**
502
- * How long a delete may run before the dialog says what it is waiting for.
503
- * A hard delete of a conversation written moments ago spends
504
- * `foreignWriterGraceMs` (1.5s) re-checking the log for a second writer;
505
- * without a hint the danger button looked stuck.
506
- */
507
- const SLOW_SUBMIT_HINT_MS = 1_200
508
-
509
- /**
510
- * A batch is one HTTP request that can stop several live sessions (each up
511
- * to `liveDrainMs`) and wait for the memory lock. The old flat 15s timeout
512
- * aborted the request while the Host kept deleting, leaving the user with
513
- * a timeout error, a consumed token and a half-changed list.
514
- */
515
- function mutationTimeoutMs(count) {
516
- const items = Number.isFinite(count) ? Math.max(1, count) : 1
517
- return Math.min(MUTATION_TIMEOUT_MS, 30_000 + items * 5_000)
518
- }
519
-
520
- function ApiError(code, message, details) {
521
- this.name = 'ApiError'
522
- this.code = code
523
- this.message = message
524
- this.details = details ?? null
525
- }
526
- ApiError.prototype = Object.create(Error.prototype)
527
- ApiError.prototype.constructor = ApiError
528
-
529
- /**
530
- * Resolve a plugin route against the PAGE, not against the origin.
531
- *
532
- * `dsh web` can be mounted under a path prefix (`--public-url
533
- * https://host/ui/`, stripped by a front proxy). DSH's own client stays
534
- * document-relative for exactly this reason: `dsh-client-connection` builds
535
- * its requests with `` `${channel}/${endpoint}`.slice(1) `` and resolves
536
- * them against `document.baseURI`. An absolute `/conversation-recycle-bin/…`
537
- * would replace the whole base path, drop the prefix, and 404 every call.
538
- * Keeping the same rule means one code path works for `http://127.0.0.1:<p>/`,
539
- * `https://host/<prefix>/` and the desktop shell's `dsh-app://app/`.
540
- */
541
- function apiUrl(path) {
542
- let base = 'http://127.0.0.1/'
543
- if (typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== '') {
544
- try {
545
- // Normalise to the containing directory so a base without a trailing
546
- // slash (`…/index.html`) does not swallow the last segment.
547
- base = new URL('.', document.baseURI).href
548
- } catch {
549
- base = 'http://127.0.0.1/'
550
- }
551
- }
552
- return new URL(`${API_PREFIX.replace(/^\//, '')}${path}`, base)
553
- }
554
-
555
- async function api(path, options = {}) {
556
- const method = options.method ?? 'GET'
557
- const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
558
- const controller = new AbortController()
559
- const timer = setTimeout(() => controller.abort(), timeoutMs)
560
- try {
561
- const url = apiUrl(path)
562
- const headers = { 'x-conversation-recycle-bin': '1' }
563
- if (options.body !== undefined) headers['content-type'] = 'application/json'
564
- const response = await fetch(url, {
565
- method,
566
- headers,
567
- credentials: 'same-origin',
568
- body: options.body === undefined ? undefined : JSON.stringify(options.body),
569
- signal: controller.signal,
570
- })
571
- let payload = null
572
- try {
573
- payload = await response.json()
574
- } catch {
575
- payload = null
576
- }
577
- if (payload === null || typeof payload !== 'object') {
578
- throw new ApiError('E_BAD_RESPONSE', 'The Host returned an unreadable response.')
579
- }
580
- // A batch that partially succeeded is a RESULT, not a refusal: an older
581
- // Host marked it `ok:false` without an `error`, which made the Client
582
- // throw E_INTERNAL and hide the sessions that were already deleted.
583
- const partial = Array.isArray(payload.results) && Array.isArray(payload.failures)
584
- if (response.ok !== true || (payload.ok === false && partial !== true)) {
585
- const wire = payload.error ?? {}
586
- throw new ApiError(wire.code ?? 'E_INTERNAL', wire.message ?? 'The Host refused the request.', wire.details)
587
- }
588
- return payload
589
- } catch (error) {
590
- if (error?.name === 'AbortError') throw new ApiError('E_TIMEOUT', 'The Host did not answer in time.')
591
- if (error instanceof ApiError) throw error
592
- throw new ApiError('E_INTERNAL', error instanceof Error ? error.message : String(error))
593
- } finally {
594
- clearTimeout(timer)
595
- }
596
- }
597
-
598
- /* ------------------------------------------------------------------ *
599
- * Overlay store + flash messages
600
- * ------------------------------------------------------------------ */
601
-
602
- const dialogStore = {
603
- state: null,
604
- listeners: new Set(),
605
- open(payload) {
606
- this.state = payload
607
- for (const listener of this.listeners) listener(this.state)
608
- },
609
- close() {
610
- this.state = null
611
- for (const listener of this.listeners) listener(this.state)
612
- },
613
- subscribe(listener) {
614
- this.listeners.add(listener)
615
- return () => this.listeners.delete(listener)
616
- },
617
- }
618
-
619
- const flashStore = {
620
- message: null,
621
- timer: null,
622
- listeners: new Set(),
623
- show(message) {
624
- if (this.timer !== null) clearTimeout(this.timer)
625
- this.message = message
626
- for (const listener of this.listeners) listener(this.message)
627
- this.timer = setTimeout(() => {
628
- this.timer = null
629
- if (this.message === message) {
630
- this.message = null
631
- for (const listener of this.listeners) listener(null)
632
- }
633
- }, 4_000)
634
- },
635
- subscribe(listener) {
636
- this.listeners.add(listener)
637
- return () => this.listeners.delete(listener)
638
- },
639
- }
640
-
641
- function useDialogState() {
642
- const [state, setState] = useState(dialogStore.state)
643
- useEffect(() => dialogStore.subscribe(setState), [])
644
- return state
645
- }
646
-
647
- /**
648
- * Notification channel for "a mutation changed the session list". The
649
- * dialog refreshes the shell, but the Settings section owns a second copy
650
- * of the list; without this it kept showing deleted rows until a manual
651
- * refresh (0.1.4 stopped reloading the page, which used to hide the bug).
652
- */
653
- const mutationStore = {
654
- version: 0,
655
- listeners: new Set(),
656
- bump() {
657
- this.version += 1
658
- for (const listener of this.listeners) listener(this.version)
659
- },
660
- subscribe(listener) {
661
- this.listeners.add(listener)
662
- return () => this.listeners.delete(listener)
663
- },
664
- }
665
-
666
- function useMutationVersion() {
667
- const [version, setVersion] = useState(mutationStore.version)
668
- useEffect(() => mutationStore.subscribe(setVersion), [])
669
- return version
670
- }
671
-
672
- /**
673
- * Host UI flags (`ui.showRowAction` / `ui.showHeaderAction`) used to be
674
- * dead configuration: the client registered both slots unconditionally.
675
- * The same probe carries `defaultMode`, which the dialog used to ignore
676
- * (it always started on soft).
677
- */
678
- const uiFlagsStore = {
679
- loaded: false,
680
- flags: { showRowAction: true, showHeaderAction: true, enabled: true, defaultMode: 'soft' },
681
- listeners: new Set(),
682
- set(flags) {
683
- this.flags = flags
684
- this.loaded = true
685
- for (const listener of this.listeners) listener(this.flags)
686
- },
687
- subscribe(listener) {
688
- this.listeners.add(listener)
689
- return () => this.listeners.delete(listener)
690
- },
691
- }
692
-
693
- function useUiFlags() {
694
- const [flags, setFlags] = useState(uiFlagsStore.flags)
695
- useEffect(() => uiFlagsStore.subscribe(setFlags), [])
696
- return flags
697
- }
698
-
699
- let uiFlagsLoading = false
700
- async function loadUiFlags() {
701
- if (uiFlagsStore.loaded || uiFlagsLoading || typeof fetch !== 'function') return
702
- // No real document (unit harness): there is nothing to ask the Host for.
703
- if (typeof document === 'undefined' || typeof document.baseURI !== 'string') return
704
- uiFlagsLoading = true
705
- try {
706
- const response = await api('/state')
707
- const ui = response?.state?.ui
708
- const defaultMode = response?.state?.defaultMode
709
- uiFlagsStore.set({
710
- showRowAction: ui?.showRowAction !== false,
711
- showHeaderAction: ui?.showHeaderAction !== false,
712
- enabled: response?.state?.enabled !== false,
713
- defaultMode: defaultMode === 'hard' ? 'hard' : 'soft',
714
- })
715
- } catch {
716
- // Defaults keep both entries visible; a failed probe is not fatal.
717
- } finally {
718
- uiFlagsLoading = false
719
- }
720
- }
721
-
722
- function useFlashMessage() {
723
- const [message, setMessage] = useState(flashStore.message)
724
- useEffect(() => flashStore.subscribe(setMessage), [])
725
- return message
726
- }
727
-
728
- function reloadApp(delay = 900) {
729
- if (typeof window === 'undefined' || typeof window.location?.reload !== 'function') return
730
- reloadTimer = window.setTimeout(() => {
731
- reloadTimer = null
732
- reloadPending = false
733
- window.location.reload()
734
- }, delay)
735
- }
736
-
737
- // A fallback reload is armed when the Host cannot close the deleted
738
- // conversation in place. The result dialog is the only place a partial
739
- // batch reports its per-session failures, so the reload waits for the user
740
- // to dismiss it; the timer is only a safety net for a dialog left open.
741
- const RELOAD_FALLBACK_DELAY = 5_000
742
- let reloadPending = false
743
- let reloadTimer = null
744
- // Set when the user dismisses the result dialog. `refreshAfterMutation`
745
- // settles after the dialog is already on screen, so the dismissal can
746
- // arrive first; the reload then happens at once instead of arming a timer.
747
- let reloadDialogDismissed = false
748
-
749
- function takeReloadPending() {
750
- const pending = reloadPending
751
- reloadPending = false
752
- // Cancel the safety-net timer, otherwise dismissing the dialog would
753
- // reload once now and once again when the original timer fires.
754
- if (pending && reloadTimer !== null) {
755
- if (typeof window !== 'undefined') window.clearTimeout?.(reloadTimer)
756
- reloadTimer = null
757
- }
758
- return pending
759
- }
760
-
761
- function service(name) {
762
- if (runtimeCtx === null || typeof runtimeCtx.get !== 'function') return undefined
763
- try {
764
- return runtimeCtx.get(name) ?? undefined
765
- } catch {
766
- return undefined
767
- }
768
- }
769
-
770
- function openSessionId() {
771
- const workspace = service('uiWorkspace')
772
- try {
773
- const snapshot = workspace?.selection?.getSnapshot?.()
774
- const id = snapshot?.sessionId
775
- if (typeof id === 'string' && id !== '') return id
776
- } catch {
777
- // fall through: an unknown selection simply skips the close step
778
- }
779
- return undefined
780
- }
781
-
782
- /**
783
- * Update the shell in place after a mutation instead of reloading the page.
784
- *
785
- * The Host session list has no delete push, so it is pulled explicitly; the
786
- * workspace archived/membership snapshot follows the Host stream. When the
787
- * deleted conversation is the one on screen, the shell must close it: it
788
- * would otherwise render a conversation whose log was just moved away.
789
- * `reloadApp` stays only as a fallback for hosts whose client service is
790
- * missing or fails, so a deletion can never leave a stale list behind.
791
- */
792
- async function refreshAfterMutation(options = {}) {
793
- const sessionIds = Array.isArray(options.sessionIds) ? options.sessionIds : []
794
- const closeCurrent = options.closeCurrent !== false
795
- const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : RELOAD_FALLBACK_DELAY
796
- const current = openSessionId()
797
- const sessions = service('sessions')
798
- let refreshed = false
799
- if (sessions !== undefined && typeof sessions.refresh === 'function') {
800
- try {
801
- await sessions.refresh()
802
- refreshed = true
803
- } catch (error) {
804
- console.warn('[conversation-recycle-bin] session list refresh failed', error)
805
- }
806
- }
807
- if (closeCurrent && current !== undefined && sessionIds.includes(current)) {
808
- const workspace = service('uiWorkspace')
809
- let cleared = false
810
- try {
811
- if (workspace !== undefined && typeof workspace.clearArchivedCurrent === 'function') {
812
- cleared = workspace.clearArchivedCurrent() === true
813
- }
814
- } catch {
815
- cleared = false
816
- }
817
- if (cleared !== true) {
818
- try {
819
- if (workspace !== undefined && typeof workspace.clearMain === 'function') workspace.clearMain()
820
- else {
821
- // Neither private method exists on this host (they are declared
822
- // private by dsh-client-ui-workspace). `sessions.refresh()`
823
- // succeeded, so without this the shell would keep rendering a
824
- // conversation whose log was just moved away.
825
- console.warn('[conversation-recycle-bin] the host cannot close the current conversation in place; reloading')
826
- refreshed = false
827
- }
828
- } catch {
829
- refreshed = false
830
- }
831
- }
832
- }
833
- if (!refreshed) {
834
- if (reloadDialogDismissed) reloadApp(0)
835
- else {
836
- reloadPending = true
837
- reloadApp(fallbackDelay)
838
- }
839
- }
840
- if (options.notify !== false) mutationStore.bump()
841
- return refreshed
842
- }
843
-
844
- /* ------------------------------------------------------------------ *
845
- * Small presentational pieces
846
- * ------------------------------------------------------------------ */
847
-
848
- function GlyphTrash({ size = 14 }) {
849
- return h('svg', {
850
- width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
851
- stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
852
- 'aria-hidden': 'true',
853
- },
854
- h('path', { d: 'M4 7h16' }),
855
- h('path', { d: 'M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2' }),
856
- h('path', { d: 'M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13' }),
857
- h('path', { d: 'M10 11v6M14 11v6' }))
858
- }
859
-
860
- function GlyphRefresh({ size = 14 }) {
861
- return h('svg', {
862
- width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
863
- stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
864
- 'aria-hidden': 'true',
865
- },
866
- h('path', { d: 'M20 12a8 8 0 1 1-2.3-5.7' }),
867
- h('path', { d: 'M20 4v5h-5' }))
868
- }
869
-
870
- function GlyphFeedback({ size = 14 }) {
871
- return h('svg', {
872
- width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
873
- stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
874
- 'aria-hidden': 'true',
875
- },
876
- h('path', { d: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z' }),
877
- h('path', { d: 'm3.5 7 8.5 6 8.5-6' }))
878
- }
879
-
880
- function Button({ children, onClick, variant = 'secondary', disabled = false, type = 'button', title }) {
881
- return h('button', {
882
- type,
883
- className: `dc-btn dc-btn-${variant}`,
884
- onClick,
885
- disabled,
886
- title,
887
- }, children)
888
- }
889
-
890
- function Checkbox({ checked, onChange, disabled = false, label, hint }) {
891
- return h('label', { className: `dc-check${disabled ? ' dc-disabled' : ''}` },
892
- h('input', { type: 'checkbox', checked, disabled, onChange: (event) => onChange(event.target.checked) }),
893
- h('span', { className: 'dc-check-text' },
894
- h('span', { className: 'dc-check-label' }, label),
895
- hint === undefined ? null : h('span', { className: 'dc-hint' }, hint)))
896
- }
897
-
898
- function Spinner({ label }) {
899
- return h('div', { className: 'dc-loading', role: 'status', 'aria-live': 'polite' },
900
- h('span', { className: 'dc-spinner', 'aria-hidden': 'true' }),
901
- h('span', null, label))
902
- }
903
-
904
- function ErrorBanner({ error }) {
905
- if (error === null || error === undefined) return null
906
- return h('div', { className: 'dc-error', role: 'alert' }, errorText(error))
907
- }
908
-
909
- function formatBytes(bytes) {
910
- if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
911
- const units = ['B', 'KB', 'MB', 'GB']
912
- let value = bytes
913
- let index = 0
914
- while (value >= 1024 && index < units.length - 1) {
915
- value /= 1024
916
- index += 1
917
- }
918
- return `${value < 10 && index > 0 ? value.toFixed(1) : Math.round(value)} ${units[index]}`
919
- }
920
-
921
- function formatTime(value) {
922
- if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
923
- try {
924
- return new Date(value).toLocaleString()
925
- } catch {
926
- return String(value)
927
- }
928
- }
929
-
930
- /**
931
- * The recycle-bin retention window, read from the Host payload.
932
- *
933
- * `trashRetentionDays` lives under `limits` — the same place as every other
934
- * Host limit — and the Host also sends a per-entry `daysLeft`. Reading the
935
- * top level (as 0.3.10 did) yields `undefined`, which used to switch the
936
- * whole notice off: the bin was purged with no warning at all. The countdown
937
- * prefers the Host's number so a skewed browser clock cannot promise a
938
- * different day than the sweep keeps; the local computation is only a
939
- * fallback for an older Host that does not send `daysLeft`.
940
- */
941
- function retentionInfo(state, entry = null, nowMs = Date.now()) {
942
- const raw = Number(state?.limits?.trashRetentionDays)
943
- const days = Number.isFinite(raw) && raw > 0 ? raw : 0
944
- if (days <= 0) return { days: 0, daysLeft: null }
945
- if (entry === null || entry === undefined) return { days, daysLeft: null }
946
- // Only `trashed` rows are ever swept, so a restored or mid-transaction row
947
- // must not advertise a deadline it will never reach.
948
- if (entry.status !== undefined && entry.status !== 'trashed') return { days, daysLeft: null }
949
- const hostDays = entry.daysLeft === null || entry.daysLeft === undefined ? Number.NaN : Number(entry.daysLeft)
950
- if (Number.isFinite(hostDays) && hostDays >= 0) return { days, daysLeft: hostDays }
951
- const deletedAt = Number(entry.deletedAt)
952
- if (!Number.isFinite(deletedAt) || deletedAt <= 0) return { days, daysLeft: null }
953
- const left = deletedAt + days * 86_400_000 - nowMs
954
- return { days, daysLeft: left <= 0 ? 0 : Math.ceil(left / 86_400_000) }
955
- }
956
-
957
- /* ------------------------------------------------------------------ *
958
- * Feedback form (pure helpers, shared with the Host's validation)
959
- * ------------------------------------------------------------------ */
960
-
961
- // Mirrors FEEDBACK_LIMITS in `lib/host/feedback.js`. The Host re-validates
962
- // every field; this copy only exists to disable the submit button early and
963
- // to point at the offending input.
964
- const FEEDBACK_LIMITS = Object.freeze({
965
- titleMin: 4,
966
- titleMax: 120,
967
- bodyMin: 10,
968
- bodyMax: 4_000,
969
- emailMax: 200,
970
- })
971
-
972
- /** Same loose shape the Host accepts: `a@b.cc`, no whitespace or separators. */
973
- const EMAIL_PATTERN = /^[^\s@,;:<>()[\]\\]+@[^\s@,;:<>()[\]\\]+\.[^\s@,;:<>()[\]\\]{2,}$/
974
-
975
- /**
976
- * @param {{title?: string, body?: string, email?: string}} input
977
- * @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
978
- */
979
- function validateFeedbackInput(input) {
980
- const title = String(input?.title ?? '').trim()
981
- const body = String(input?.body ?? '').trim()
982
- const email = String(input?.email ?? '').trim()
983
- if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
984
- if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
985
- if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
986
- if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
987
- // Same order as the Host (`lib/host/feedback.js:241-250`): shape first,
988
- // then length. The reverse told a 300-character address it was "not an
989
- // email" while the Host said "too long" for the identical submission.
990
- if (email !== '' && !EMAIL_PATTERN.test(email)) return { ok: false, key: 'feedback.invalidEmail', vars: {} }
991
- if (email.length > FEEDBACK_LIMITS.emailMax) return { ok: false, key: 'feedback.tooLongEmail', vars: { max: FEEDBACK_LIMITS.emailMax } }
992
- return { ok: true }
993
- }
994
-
995
- /** Context the dialog shows before sending; nothing here is user content. */
996
- function collectFeedbackContext() {
997
- const userAgent = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
998
- ? navigator.userAgent
999
- : ''
1000
- return {
1001
- platform: userAgent.slice(0, 160),
1002
- locale: currentLocaleId(),
1003
- }
1004
- }
1005
-
1006
- /* ------------------------------------------------------------------ *
1007
- * Confirmation dialog
1008
- * ------------------------------------------------------------------ */
1009
-
1010
- function DeleteDialog({ payload }) {
1011
- useLocaleTick()
1012
- const flags = useUiFlags()
1013
- const sessionIds = payload.sessionIds
1014
- const count = sessionIds.length
1015
- const fallbackTitle = payload.displayTitle ?? ''
1016
- // `config.defaultMode` was dead configuration: the dialog always started
1017
- // on soft, so the Host's documented "initial mode" never applied.
1018
- const [mode, setMode] = useState(flags.defaultMode === 'hard' ? 'hard' : 'soft')
1019
- const [includeMemory, setIncludeMemory] = useState(false)
1020
- const [preview, setPreview] = useState(null)
1021
- const [loading, setLoading] = useState(true)
1022
- const [busy, setBusy] = useState(false)
1023
- const [error, setError] = useState(null)
1024
- const [phrase, setPhrase] = useState('')
1025
- const [done, setDone] = useState(null)
1026
- // Set when a submission outlives SLOW_SUBMIT_HINT_MS, so the dialog can
1027
- // explain the wait instead of showing a frozen danger button.
1028
- const [slow, setSlow] = useState(false)
1029
-
1030
- // Dismissing the result must also complete a reload the Host armed
1031
- // because it cannot close the deleted conversation in place; otherwise
1032
- // the safety-net timer would fire later and reload under the user's feet.
1033
- function closeDone() {
1034
- reloadDialogDismissed = true
1035
- dialogStore.close()
1036
- if (takeReloadPending()) reloadApp(0)
1037
- }
1038
- const requestRef = useRef(0)
1039
-
1040
- const load = useCallback(async (nextMode, nextMemory, options = {}) => {
1041
- const serial = (requestRef.current += 1)
1042
- setLoading(true)
1043
- if (options.preserveError !== true) setError(null)
1044
- try {
1045
- const response = await api('/preview', { method: 'POST', body: { sessionIds, includeMemory: nextMemory, mode: nextMode } })
1046
- if (serial !== requestRef.current) return
1047
- setPreview(response.preview)
1048
- // A reload after a failed submit keeps the typed phrase: the plan did
1049
- // not change, and retyping a batch size on every rate-limit/expiry is
1050
- // pure friction. Switching mode or memory still clears it below.
1051
- if (options.preservePhrase !== true) setPhrase('')
1052
- } catch (caught) {
1053
- if (serial !== requestRef.current) return
1054
- if (options.preserveError !== true) {
1055
- setError(caught)
1056
- setPreview(null)
1057
- }
1058
- } finally {
1059
- if (serial === requestRef.current) setLoading(false)
1060
- }
1061
- }, [sessionIds.join('\u0000')])
1062
-
1063
- useEffect(() => {
1064
- void load(mode, includeMemory)
1065
- }, [mode, includeMemory, load])
1066
-
1067
- useEffect(() => {
1068
- function onKey(event) {
1069
- // Escape dismisses the result dialog too, so it must release the
1070
- // armed reload exactly like the Close button does.
1071
- if (event.key === 'Escape' && busy !== true) closeDone()
1072
- }
1073
- window.addEventListener('keydown', onKey)
1074
- return () => window.removeEventListener('keydown', onKey)
1075
- }, [busy])
1076
-
1077
- useEffect(() => {
1078
- if (busy !== true) {
1079
- setSlow(false)
1080
- return undefined
1081
- }
1082
- const timer = setTimeout(() => setSlow(true), SLOW_SUBMIT_HINT_MS)
1083
- return () => clearTimeout(timer)
1084
- }, [busy])
1085
-
1086
- const firstSession = preview?.sessions?.[0] ?? null
1087
- const title = count === 1
1088
- ? (firstSession?.title || fallbackTitle || sessionIds[0])
1089
- : ''
1090
- const blocked = Array.isArray(preview?.blocked) ? preview.blocked : []
1091
- const confirmation = preview?.confirmation ?? null
1092
- const expectedPhrase = confirmation?.phrase ?? ''
1093
- const phraseOk = expectedPhrase === '' || phrase.trim() === expectedPhrase
1094
- const memory = preview?.memory ?? null
1095
- const memorySupported = memory !== null && memory.enabled === true
1096
- && Array.isArray(memory.adapters) && memory.adapters.some((adapter) => adapter.available === true)
1097
- const memoryDeletable = memorySupported && preview?.memory?.deleteMode === 'trash'
1098
- const liveCount = Array.isArray(preview?.sessions) ? preview.sessions.filter((item) => item.live === true).length : 0
1099
-
1100
- const blockedMessage = blocked.length === 0
1101
- ? null
1102
- : (blocked[0].policy?.code === 'E_NOT_FOUND' ? t('dialog.noSession') : t('dialog.blocked', { message: errorText(blocked[0].policy) }))
1103
-
1104
- async function submit() {
1105
- if (confirmation === null || busy) return
1106
- // A new submission starts a new result cycle: a dismissal from the
1107
- // previous one must not force this run's reload to happen at once.
1108
- reloadDialogDismissed = false
1109
- setBusy(true)
1110
- setError(null)
1111
- try {
1112
- // The Host's own worst-case estimate for THIS plan (live drains,
1113
- // memory locks, per-item moves) replaces the client's flat guess:
1114
- // aborting while the Host keeps deleting left a consumed token and a
1115
- // half-changed list behind.
1116
- // The Host publishes the budget it actually enforces: the lock wait
1117
- // plus the queue grace period. The lock-only estimate used to abort
1118
- // 60 s before the Host gave up, so a busy bin looked like a failure
1119
- // while the deletion was still running.
1120
- const planTimeout = Number.isFinite(preview?.limits?.queueTimeoutMs)
1121
- ? Math.max(1_000, preview.limits.queueTimeoutMs)
1122
- : Number.isFinite(preview?.limits?.planTimeoutMs)
1123
- ? Math.max(1_000, preview.limits.planTimeoutMs)
1124
- : mutationTimeoutMs(sessionIds.length)
1125
- const result = await api('/delete', {
1126
- method: 'POST',
1127
- timeoutMs: planTimeout,
1128
- body: {
1129
- sessionIds,
1130
- includeMemory,
1131
- mode,
1132
- confirmToken: confirmation.token,
1133
- confirmPhrase: phrase.trim(),
1134
- },
1135
- })
1136
- const failures = Array.isArray(result.failures) ? result.failures : []
1137
- // Only successfully committed sessions may close the open view; a
1138
- // failed item in the same batch leaves its conversation on screen.
1139
- const deletedIds = (result.results ?? [])
1140
- .map((item) => item?.sessionId)
1141
- .filter((id) => typeof id === 'string')
1142
- if (failures.length > 0) {
1143
- const memoryTotal = (result.results ?? []).reduce((sum, item) => sum + (item.memory ?? 0), 0)
1144
- setDone({ count: (result.results ?? []).length, memory: memoryTotal, failed: failures.length, failures })
1145
- setBusy(false)
1146
- if (deletedIds.length > 0) void refreshAfterMutation({ sessionIds: deletedIds })
1147
- return
1148
- }
1149
- const memoryTotal = (result.results ?? []).reduce((sum, item) => sum + (item.memory ?? 0), 0)
1150
- setDone({ count: (result.results ?? []).length, memory: memoryTotal, failed: 0 })
1151
- setBusy(false)
1152
- if (deletedIds.length > 0) void refreshAfterMutation({ sessionIds: deletedIds })
1153
- } catch (caught) {
1154
- setError(caught)
1155
- setBusy(false)
1156
- // The token is single-use only after a successful phrase + rate check;
1157
- // a failed transaction leaves it usable, but counts may have changed
1158
- // on disk, so refresh the plan. Keep the failure visible: clearing the
1159
- // error inside load() made a failed delete look like a dead button.
1160
- void load(mode, includeMemory, { preserveError: true, preservePhrase: true })
1161
- }
1162
- }
1163
-
1164
- if (done !== null) {
1165
- return h('div', {
1166
- className: 'dc-backdrop',
1167
- onMouseDown: (event) => { if (event.target === event.currentTarget) closeDone() },
1168
- },
1169
- h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true' },
1170
- h('h2', { className: 'dc-dialog-title' }, t('dialog.done', { count: done.count, memory: done.memory })),
1171
- done.failed > 0 ? h('p', { className: 'dc-error-text' }, t('dialog.partial', { done: done.count, failed: done.failed })) : null,
1172
- done.failed > 0 && Array.isArray(done.failures)
1173
- ? h('ul', { className: 'dc-failures' }, ...done.failures.map((item, index) => h('li', {
1174
- key: `${item?.sessionId ?? 'item'}-${index}`,
1175
- }, `${item?.sessionId ?? '—'}: ${errorText(item?.error)}`)))
1176
- : null,
1177
- h('p', { className: 'dc-hint' }, t('dialog.reloadHint')),
1178
- h('div', { className: 'dc-actions' },
1179
- h(Button, { variant: 'primary', onClick: () => closeDone() }, t('action.close')))))
1180
- }
1181
-
1182
- return h('div', {
1183
- className: 'dc-backdrop',
1184
- onMouseDown: (event) => { if (event.target === event.currentTarget && busy !== true) dialogStore.close() },
1185
- },
1186
- h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-dialog-title' },
1187
- h('h2', { id: 'dc-dialog-title', className: 'dc-dialog-title' },
1188
- count === 1 ? t('dialog.titleOne') : t('dialog.titleMany', { count })),
1189
- h('p', { className: 'dc-dialog-body' },
1190
- count === 1 ? t('dialog.bodyOne', { title }) : t('dialog.bodyMany', { count })),
1191
-
1192
- loading ? h(Spinner, { label: t('dialog.loading') }) : null,
1193
- h(ErrorBanner, { error }),
1194
- blockedMessage !== null ? h('div', { className: 'dc-error', role: 'alert' }, blockedMessage) : null,
1195
-
1196
- !loading && preview !== null ? h('div', { className: 'dc-section' },
1197
- firstSession !== null && count === 1
1198
- ? h('div', { className: 'dc-meta' }, t('dialog.records', {
1199
- events: firstSession.events ?? '—',
1200
- size: formatBytes(firstSession.sizeBytes),
1201
- }))
1202
- : null,
1203
-
1204
- h('div', { className: 'dc-modes', role: 'radiogroup', 'aria-label': t('dialog.modeSoft') },
1205
- h('label', { className: 'dc-mode' },
1206
- h('input', {
1207
- type: 'radio', name: 'dc-mode', value: 'soft', checked: mode === 'soft',
1208
- onChange: () => setMode('soft'), disabled: busy,
1209
- }),
1210
- h('span', null,
1211
- h('span', { className: 'dc-mode-title' }, t('dialog.modeSoft')),
1212
- h('span', { className: 'dc-hint' }, t('dialog.modeSoftHint')))),
1213
- h('label', { className: 'dc-mode' },
1214
- h('input', {
1215
- type: 'radio', name: 'dc-mode', value: 'hard', checked: mode === 'hard',
1216
- onChange: () => setMode('hard'), disabled: busy,
1217
- }),
1218
- h('span', null,
1219
- h('span', { className: 'dc-mode-title' }, t('dialog.modeHard')),
1220
- h('span', { className: 'dc-hint' }, t('dialog.modeHardHint'))))),
1221
-
1222
- liveCount > 0 && mode === 'hard'
1223
- ? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.liveStopHint'))
1224
- : null,
1225
-
1226
- // Permanent deletion waits out `foreignWriterGraceMs` when the log was
1227
- // written moments ago (measured: ~1.5s once per batch). Say so before
1228
- // the click, not after the user thinks the button is stuck.
1229
- mode === 'hard' ? h('p', { className: 'dc-hint' }, t('dialog.hardGraceHint')) : null,
1230
-
1231
- h('div', { className: 'dc-memory' },
1232
- h(Checkbox, {
1233
- checked: includeMemory,
1234
- disabled: busy || memoryDeletable !== true,
1235
- onChange: (value) => setIncludeMemory(value),
1236
- label: t('dialog.memoryLabel'),
1237
- hint: memory === null
1238
- ? undefined
1239
- : memoryDeletable !== true
1240
- ? (memory.enabled !== true ? t('dialog.memoryDisabled') : t('dialog.memoryKeep'))
1241
- : t('dialog.memoryHint'),
1242
- }),
1243
- memory !== null ? h('div', { className: 'dc-memory-count' },
1244
- memorySupported
1245
- ? (memory.total > 0
1246
- ? (includeMemory
1247
- ? t('dialog.memoryCount', { count: memory.total })
1248
- : t('dialog.memoryFound', { count: memory.total }))
1249
- : t('dialog.memoryNone'))
1250
- : t('dialog.memoryUnsupported', { reason: reasonForUnavailable(memory) }),
1251
- memorySupported && memory.protected > 0
1252
- ? h('span', { className: 'dc-hint' }, ` · ${t('dialog.memoryProtected', { count: memory.protected })}`)
1253
- : null) : null),
1254
-
1255
- expectedPhrase !== ''
1256
- ? h('label', { className: 'dc-phrase' },
1257
- h('span', { className: 'dc-phrase-label' },
1258
- // A phrase exists only for a multi-item soft batch (the Host's
1259
- // `expectedPhrase` returns '' for hard mode), so the label is
1260
- // always the batch instruction.
1261
- t('dialog.batchWarning', { count })),
1262
- h('input', {
1263
- type: 'text',
1264
- value: phrase,
1265
- onChange: (event) => setPhrase(event.target.value),
1266
- placeholder: expectedPhrase,
1267
- disabled: busy,
1268
- autoFocus: true,
1269
- className: 'dc-input',
1270
- }))
1271
- : null,
1272
-
1273
- slow === true
1274
- ? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.stillWorking'))
1275
- : null,
1276
-
1277
- h('div', { className: 'dc-actions' },
1278
- h(Button, { onClick: () => { void load(mode, includeMemory) }, disabled: busy || loading, title: t('action.refresh') },
1279
- h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')),
1280
- h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
1281
- h(Button, {
1282
- variant: 'danger',
1283
- onClick: () => { void submit() },
1284
- disabled: busy || loading || blocked.length > 0 || confirmation === null || phraseOk !== true,
1285
- }, busy ? t('dialog.working') : t('dialog.delete'))))
1286
- : null))
1287
- }
1288
-
1289
- function reasonForUnavailable(memory) {
1290
- const adapters = Array.isArray(memory?.adapters) ? memory.adapters : []
1291
- const first = adapters.find((adapter) => adapter.available === false && typeof adapter.reason === 'string' && adapter.reason !== '')
1292
- if (first !== undefined) return adapterReason(first.reason)
1293
- const stores = Array.isArray(memory?.stores) ? memory.stores : []
1294
- const store = stores.find((item) => item.available === false && typeof item.reason === 'string')
1295
- // `not detected` used to be hard-coded English, so a Chinese page showed
1296
- // `记忆存储不可用:not detected`. Everything else here already routes
1297
- // through the string table.
1298
- return store === undefined ? t('state.notDetected') : adapterReason(store.reason)
1299
- }
1300
-
1301
- /* ------------------------------------------------------------------ *
1302
- * Feedback dialog
1303
- * ------------------------------------------------------------------ */
1304
-
1305
- /**
1306
- * Settings page "反馈" button.
1307
- *
1308
- * The Host performs the delivery, so the page never holds a mail
1309
- * credential. The auto-attached context is rendered BEFORE the user
1310
- * commits, so nothing leaves the machine that they did not see.
1311
- */
1312
- function FeedbackDialog({ payload }) {
1313
- useLocaleTick()
1314
- const feedback = payload?.feedback ?? null
1315
- const version = typeof payload?.version === 'string' ? payload.version : ''
1316
- const [category, setCategory] = useState('bug')
1317
- const [title, setTitle] = useState('')
1318
- const [body, setBody] = useState('')
1319
- const [email, setEmail] = useState('')
1320
- const [busy, setBusy] = useState(false)
1321
- const [error, setError] = useState(null)
1322
- const validation = validateFeedbackInput({ title, body, email })
1323
- const context = collectFeedbackContext()
1324
- // A Host without a mail transport advertises `enabled: false`; the form is
1325
- // still rendered read-only so the explanation is visible instead of a
1326
- // button that fails on click.
1327
- const unavailable = feedback?.enabled !== true
1328
- // Nothing has been typed on the first render, so the "too short" rule must
1329
- // not greet the user. It starts applying once the user edits a field OR
1330
- // leaves one: gating on `dirty` alone left the disabled submit button with
1331
- // no visible reason for anyone who only tabbed through the form.
1332
- const [dirty, setDirty] = useState(false)
1333
- const [touched, setTouched] = useState(false)
1334
- const validationVisible = (dirty === true || touched === true) && validation.ok !== true
1335
-
1336
- useEffect(() => {
1337
- function onKey(event) {
1338
- if (event.key === 'Escape' && busy !== true) dialogStore.close()
1339
- }
1340
- window.addEventListener('keydown', onKey)
1341
- return () => window.removeEventListener('keydown', onKey)
1342
- }, [busy])
1343
-
1344
- async function submit() {
1345
- if (busy || unavailable || validation.ok !== true) return
1346
- setBusy(true)
1347
- setError(null)
1348
- try {
1349
- await api('/feedback', {
1350
- method: 'POST',
1351
- timeoutMs: 30_000,
1352
- body: {
1353
- category,
1354
- title: title.trim(),
1355
- body: body.trim(),
1356
- email: email.trim(),
1357
- context: {
1358
- pluginVersion: version,
1359
- platform: context.platform,
1360
- locale: context.locale,
1361
- },
1362
- },
1363
- })
1364
- flashStore.show(t('feedback.sent'))
1365
- dialogStore.close()
1366
- } catch (caught) {
1367
- // The Host log already records the stable code; this keeps the form
1368
- // path debuggable from the browser console without a network tab.
1369
- console.error('[conversation-recycle-bin] feedback submission failed:', caught?.code ?? caught)
1370
- setError(caught)
1371
- } finally {
1372
- setBusy(false)
1373
- }
1374
- }
1375
-
1376
- return h('div', {
1377
- className: 'dc-backdrop',
1378
- onMouseDown: (event) => {
1379
- if (event.target === event.currentTarget && busy !== true) dialogStore.close()
1380
- },
1381
- },
1382
- h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-feedback-title' },
1383
- h('h2', { id: 'dc-feedback-title', className: 'dc-dialog-title' }, t('feedback.title')),
1384
- h('p', { className: 'dc-dialog-body' }, t('feedback.intro')),
1385
- unavailable ? h('div', { className: 'dc-error', role: 'alert' }, t('feedback.unavailable')) : null,
1386
- h(ErrorBanner, { error }),
1387
-
1388
- h('label', { className: 'dc-field' },
1389
- h('span', { className: 'dc-field-label' }, t('feedback.categoryLabel')),
1390
- h('select', {
1391
- className: 'dc-input',
1392
- value: category,
1393
- disabled: busy,
1394
- onBlur: () => setTouched(true),
1395
- onChange: (event) => { setDirty(true); setCategory(event.target.value) },
1396
- },
1397
- h('option', { value: 'bug' }, t('feedback.categoryBug')),
1398
- h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
1399
- h('option', { value: 'other' }, t('feedback.categoryOther')))),
1400
-
1401
- h('label', { className: 'dc-field' },
1402
- h('span', { className: 'dc-field-label' }, t('feedback.titleLabel')),
1403
- h('input', {
1404
- type: 'text',
1405
- className: 'dc-input',
1406
- value: title,
1407
- maxLength: FEEDBACK_LIMITS.titleMax,
1408
- placeholder: t('feedback.titlePlaceholder'),
1409
- disabled: busy,
1410
- autoFocus: true,
1411
- onBlur: () => setTouched(true),
1412
- onChange: (event) => { setDirty(true); setTitle(event.target.value) },
1413
- })),
1414
-
1415
- h('label', { className: 'dc-field' },
1416
- h('span', { className: 'dc-field-label' }, t('feedback.bodyLabel')),
1417
- h('textarea', {
1418
- className: 'dc-input dc-textarea',
1419
- value: body,
1420
- rows: 6,
1421
- maxLength: FEEDBACK_LIMITS.bodyMax,
1422
- placeholder: t('feedback.bodyPlaceholder'),
1423
- disabled: busy,
1424
- onBlur: () => setTouched(true),
1425
- onChange: (event) => { setDirty(true); setBody(event.target.value) },
1426
- })),
1427
-
1428
- h('label', { className: 'dc-field' },
1429
- h('span', { className: 'dc-field-label' }, t('feedback.emailLabel')),
1430
- h('input', {
1431
- type: 'email',
1432
- className: 'dc-input',
1433
- value: email,
1434
- maxLength: FEEDBACK_LIMITS.emailMax,
1435
- placeholder: t('feedback.emailPlaceholder'),
1436
- autoComplete: 'email',
1437
- disabled: busy,
1438
- onBlur: () => setTouched(true),
1439
- onChange: (event) => { setDirty(true); setEmail(event.target.value) },
1440
- }),
1441
- h('div', { className: 'dc-hint' }, t(feedback?.visitorAsFrom === true ? 'feedback.emailHintAsFrom' : 'feedback.emailHint'))),
1442
-
1443
- h('div', { className: 'dc-context' },
1444
- h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
1445
- h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
1446
- h('ul', { className: 'dc-context-list' },
1447
- h('li', null, `${t('feedback.contextVersion')}: ${version === '' ? '—' : version}`),
1448
- h('li', null, `${t('feedback.contextPlatform')}: ${context.platform === '' ? '—' : context.platform}`),
1449
- h('li', null, `${t('feedback.contextLocale')}: ${context.locale}`))),
1450
-
1451
- validationVisible ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
1452
- busy ? h(Spinner, { label: t('feedback.sending') }) : null,
1453
-
1454
- h('div', { className: 'dc-actions' },
1455
- h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
1456
- h(Button, {
1457
- variant: 'primary',
1458
- onClick: () => { void submit() },
1459
- disabled: busy || unavailable || validation.ok !== true,
1460
- }, busy ? t('feedback.sending') : t('feedback.send')))))
1461
- }
1462
-
1463
- /* ------------------------------------------------------------------ *
1464
- * Dialog host / overlay entry
1465
- * ------------------------------------------------------------------ */
1466
-
1467
- /**
1468
- * Dialog host / overlay entry.
1469
- *
1470
- * The dialog is portaled to `document.body` on purpose: the `shell.overlay`
1471
- * slot renders inside the layout's overlay layer
1472
- * (`.overlayLayer{position:absolute;z-index:20}`), which is its own
1473
- * stacking context. A `z-index:1000` backdrop inside it cannot cover the
1474
- * settings modal (also `z-index:1000`, but on `body`), so the batch-delete
1475
- * confirmation opened from Settings was invisible and its first click
1476
- * closed the settings modal instead. Portaling to the body puts the dialog
1477
- * in the same layer as every other modal; the toast follows for the same
1478
- * reason.
1479
- */
1480
- function DialogHost() {
1481
- const payload = useDialogState()
1482
- const flash = useFlashMessage()
1483
- // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
1484
- // payload without a kind keeps its old meaning.
1485
- const dialog = payload === null
1486
- ? null
1487
- : payload.kind === 'feedback'
1488
- ? h(FeedbackDialog, { payload, key: payload.key ?? 'feedback' })
1489
- : h(DeleteDialog, { payload, key: payload.key ?? (payload.sessionIds ?? []).join(',') })
1490
- const content = h(React.Fragment, null,
1491
- dialog,
1492
- flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
1493
- return portalIntoBody(content)
1494
- }
1495
-
1496
- /* ------------------------------------------------------------------ *
1497
- * Session row + header triggers
1498
- * ------------------------------------------------------------------ */
1499
-
1500
- function DeleteMenuRow(props) {
1501
- useLocaleTick()
1502
- const flags = useUiFlags()
1503
- const sessionId = props.sessionId
1504
- const displayTitle = props.displayTitle ?? ''
1505
- // Hook order must stay stable when the flag flips at runtime, so the
1506
- // menu state is read before the visibility check.
1507
- const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : null
1508
- if (flags.showRowAction !== true || flags.enabled === false) return null
1509
- const closeMenu = () => {
1510
- try {
1511
- menu?.[1]?.(false)
1512
- } catch {
1513
- // A menu that already closed is fine.
1514
- }
1515
- }
1516
- return h('button', {
1517
- type: 'button',
1518
- role: 'menuitem',
1519
- className: 'dc-menu-item dc-danger',
1520
- onClick: () => {
1521
- closeMenu()
1522
- dialogStore.open({ sessionIds: [sessionId], displayTitle, key: `${sessionId}:menu` })
1523
- },
1524
- },
1525
- h('span', { className: 'dc-menu-icon' }, h(GlyphTrash, { size: 14 })),
1526
- h('span', { className: 'dc-menu-text' }, t('action.menuDelete')))
1527
- }
1528
-
1529
- function HeaderDeleteAction(props) {
1530
- useLocaleTick()
1531
- const flags = useUiFlags()
1532
- const sessionId = props.sessionId
1533
- if (flags.showHeaderAction !== true || flags.enabled === false) return null
1534
- // The title is read from the Host preview (projection cache), not from a
1535
- // client selector: this keeps the entry safe on hosts where the standard
1536
- // `useSession` shape differs.
1537
- return h('button', {
1538
- type: 'button',
1539
- className: 'dc-header-action',
1540
- title: t('action.headerDelete'),
1541
- 'aria-label': t('action.headerDelete'),
1542
- onClick: () => dialogStore.open({ sessionIds: [sessionId], displayTitle: '', key: `${sessionId}:header` }),
1543
- }, h(GlyphTrash, { size: 15 }))
1544
- }
1545
-
1546
- /* ------------------------------------------------------------------ *
1547
- * Settings section: recycle bin, batch deletion, audit trail
1548
- * ------------------------------------------------------------------ */
1549
-
1550
- /**
1551
- * Entries that never committed. Kept as its own component so the element
1552
- * tree can be built (and smoke-tested) without a live Settings page.
1553
- *
1554
- * A restorable entry (one whose manifest still points at a moved session
1555
- * log) gets a Restore button: `performDiscard` refuses exactly those
1556
- * entries, so without this the only way out was a manual API call.
1557
- */
1558
- function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
1559
- return h('div', { className: 'dc-panel dc-incomplete' },
1560
- h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
1561
- h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
1562
- ...entries.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1563
- h('div', { className: 'dc-row-main' },
1564
- h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
1565
- h('div', { className: 'dc-hint' }, entry.status === 'unreadable' && entry.error?.message
1566
- ? `${t('trash.status.unreadable')} · ${entry.error.message}`
1567
- : `${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1568
- h('div', { className: 'dc-row-actions' },
1569
- entry.hasSessionLog === true && typeof onRestore === 'function'
1570
- ? h(Button, {
1571
- onClick: () => onRestore(entry.trashId),
1572
- disabled: restoreBusyId === entry.trashId,
1573
- title: t('trash.restoreIncompleteHint'),
1574
- }, t('action.restore'))
1575
- : null,
1576
- discardTarget === entry.trashId
1577
- ? h('span', { className: 'dc-inline-confirm' },
1578
- h('input', {
1579
- type: 'text',
1580
- value: discardPhrase,
1581
- placeholder: 'DELETE',
1582
- className: 'dc-input dc-input-small',
1583
- onChange: (event) => onPhrase(event.target.value),
1584
- }),
1585
- h(Button, {
1586
- variant: 'danger',
1587
- disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1588
- onClick: () => onDiscard(entry.trashId),
1589
- }, t('trash.discard')),
1590
- h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
1591
- : h(Button, {
1592
- onClick: () => onStart(entry.trashId),
1593
- title: t('trash.confirmDiscard'),
1594
- }, t('trash.discard'))))))
1595
- }
1596
-
1597
- /**
1598
- * One batch-picker row. A conversation the Host would refuse (subagent,
1599
- * orphan, outside the allowed roots, protected) is rendered disabled with
1600
- * the reason, instead of selectable and then failing the whole batch.
1601
- */
1602
- function BatchSessionRow({ session, checked, onToggle }) {
1603
- const notDeletable = session.deletable === false
1604
- return h('label', { key: session.id, className: `dc-row dc-row-select${notDeletable ? ' dc-disabled' : ''}` },
1605
- h('input', {
1606
- type: 'checkbox',
1607
- checked,
1608
- disabled: notDeletable,
1609
- onChange: () => onToggle(session.id),
1610
- }),
1611
- h('div', { className: 'dc-row-main' },
1612
- h('div', { className: 'dc-row-title' }, session.title || session.id),
1613
- h('div', { className: 'dc-hint' },
1614
- `${session.cwd ?? '—'}${session.archived ? ' · archived' : ''}${session.live || session.running ? ' · live' : ''}`
1615
- + (notDeletable ? ` · ${t('batch.notDeletable')}: ${errorText(session.policy)}` : ''))),
1616
- h('div', { className: 'dc-row-time' }, formatTime(session.updatedAt)))
1617
- }
1618
-
1619
- /** Ids "select all" may mark: every row the Host has not vetoed. */
1620
- function selectableSessionIds(sessions) {
1621
- return sessions.filter((session) => session.deletable !== false).map((session) => session.id)
1622
- }
1623
-
1624
- function SettingsSection() {
1625
- useLocaleTick()
1626
- const mutationVersion = useMutationVersion()
1627
- const [tab, setTab] = useState('trash')
1628
- const [state, setState] = useState(null)
1629
- const [sessions, setSessions] = useState([])
1630
- const [trash, setTrash] = useState([])
1631
- const [incomplete, setIncomplete] = useState([])
1632
- // What the background retention sweep did last time, straight from the
1633
- // Host: entries that vanished between two refreshes get an explanation
1634
- // instead of looking like a rendering bug.
1635
- const [lastSweep, setLastSweep] = useState(null)
1636
- const [audit, setAudit] = useState([])
1637
- // Which tab is fetching right now. `null` means idle: the panels stay
1638
- // mounted while a refresh runs, so the user keeps scroll position and
1639
- // never sees the whole section blink on every restore/purge.
1640
- const [tabBusy, setTabBusy] = useState(null)
1641
- const [error, setError] = useState(null)
1642
- const [selected, setSelected] = useState(() => new Set())
1643
- const [purgeTarget, setPurgeTarget] = useState(null)
1644
- const [purgePhrase, setPurgePhrase] = useState('')
1645
- const [discardTarget, setDiscardTarget] = useState(null)
1646
- const [discardPhrase, setDiscardPhrase] = useState('')
1647
- const [restoreBusyId, setRestoreBusyId] = useState(null)
1648
- const [purgeBusy, setPurgeBusy] = useState(false)
1649
- const [discardBusyId, setDiscardBusyId] = useState(null)
1650
-
1651
- // Two refreshes can overlap (mount, mutation broadcast, and the refresh a
1652
- // purge triggers), and the older response must not overwrite the newer.
1653
- const refreshSerial = useRef(0)
1654
- // The mutation version each tab's data was fetched at. `-1` = never.
1655
- const loadedRef = useRef({ trash: -1, batch: -1, audit: -1 })
1656
-
1657
- const refreshState = useCallback(async () => {
1658
- const response = await api('/state')
1659
- setState(response.state)
1660
- }, [])
1661
-
1662
- /**
1663
- * Fetch only what the visible tab renders. The section used to pull
1664
- * `/state`, `/sessions`, `/trash` and `/audit` on every mount and every
1665
- * mutation (~31 KB, 4 round trips) even when the user was looking at one
1666
- * tab. `/sessions` is the expensive one (one projection read per row),
1667
- * so opening the recycle bin must not pay for it.
1668
- */
1669
- const loadTab = useCallback(async (which, { force = false } = {}) => {
1670
- if (!force && loadedRef.current[which] === mutationVersion) return
1671
- const serial = refreshSerial.current + 1
1672
- refreshSerial.current = serial
1673
- const stale = () => serial !== refreshSerial.current
1674
- setTabBusy(which)
1675
- setError(null)
1676
- try {
1677
- if (which === 'trash') {
1678
- const response = await api('/trash')
1679
- if (stale()) return
1680
- setTrash(Array.isArray(response.entries) ? response.entries : [])
1681
- setIncomplete(Array.isArray(response.incomplete) ? response.incomplete : [])
1682
- setLastSweep(response.lastSweep ?? null)
1683
- } else if (which === 'batch') {
1684
- const response = await api('/sessions')
1685
- if (stale()) return
1686
- const nextSessions = Array.isArray(response.sessions) ? response.sessions : []
1687
- setSessions(nextSessions)
1688
- // Drop selections whose conversation no longer exists.
1689
- setSelected((current) => {
1690
- const alive = new Set(nextSessions.map((session) => session.id))
1691
- const next = new Set([...current].filter((id) => alive.has(id)))
1692
- return next.size === current.size ? current : next
1693
- })
1694
- } else {
1695
- const response = await api('/audit?limit=50')
1696
- if (stale()) return
1697
- setAudit(Array.isArray(response.records) ? response.records : [])
1698
- }
1699
- loadedRef.current[which] = mutationVersion
1700
- } catch (caught) {
1701
- if (!stale()) setError(caught)
1702
- } finally {
1703
- if (!stale()) setTabBusy(null)
1704
- }
1705
- }, [mutationVersion])
1706
-
1707
- // `/state` drives the capability block and the limits; refetch it on
1708
- // mount and whenever a mutation happened, not on every tab switch.
1709
- useEffect(() => {
1710
- refreshState().catch((caught) => setError(caught))
1711
- }, [mutationVersion, refreshState])
1712
-
1713
- // The active tab's own slice, on mount, on tab switch, and after a
1714
- // mutation (which changes `loadTab`'s identity through the version).
1715
- useEffect(() => {
1716
- void loadTab(tab)
1717
- }, [tab, loadTab])
1718
-
1719
- /** Header "refresh" button: re-read state and the visible tab. */
1720
- const refresh = useCallback(async () => {
1721
- await Promise.all([
1722
- refreshState().catch((caught) => setError(caught)),
1723
- loadTab(tab, { force: true }),
1724
- ])
1725
- }, [tab, refreshState, loadTab])
1726
-
1727
- /**
1728
- * Mark a slice stale without fetching it. Recycle-bin actions call
1729
- * `refreshAfterMutation({ notify: false })`, so no version bump happens:
1730
- * the visible tab is re-read by `refresh()`, and any other tab must be
1731
- * re-read the next time the user opens it instead of showing rows that
1732
- * were deleted a moment ago.
1733
- */
1734
- const markStale = useCallback((...which) => {
1735
- for (const name of which) loadedRef.current[name] = -1
1736
- }, [])
1737
-
1738
- async function restore(trashId) {
1739
- setError(null)
1740
- const entry = trash.find((item) => item.trashId === trashId)
1741
- try {
1742
- await api('/trash/restore', {
1743
- method: 'POST',
1744
- body: { trashId },
1745
- timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1746
- })
1747
- flashStore.show(t('trash.restored'))
1748
- markStale('trash', 'batch')
1749
- await refresh()
1750
- await refreshAfterMutation({
1751
- sessionIds: typeof entry?.sessionId === 'string' ? [entry.sessionId] : [],
1752
- closeCurrent: false,
1753
- notify: false,
1754
- })
1755
- } catch (caught) {
1756
- setError(caught)
1757
- }
1758
- }
1759
-
1760
- /**
1761
- * Restore an entry that never committed (pending / failed / interrupted).
1762
- * The same endpoint as the recycle-bin restore: `performRestore` accepts
1763
- * those statuses, and this is the action `performDiscard` tells the user
1764
- * to take when the entry still holds a session log.
1765
- */
1766
- async function restoreIncomplete(trashId) {
1767
- setError(null)
1768
- setRestoreBusyId(trashId)
1769
- try {
1770
- await api('/trash/restore', {
1771
- method: 'POST',
1772
- body: { trashId },
1773
- timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1774
- })
1775
- flashStore.show(t('trash.restored'))
1776
- markStale('trash', 'batch')
1777
- await refresh()
1778
- await refreshAfterMutation({
1779
- sessionIds: incomplete.find((item) => item.trashId === trashId)?.sessionId
1780
- ? [incomplete.find((item) => item.trashId === trashId).sessionId]
1781
- : [],
1782
- closeCurrent: false,
1783
- notify: false,
1784
- })
1785
- } catch (caught) {
1786
- setError(caught)
1787
- } finally {
1788
- setRestoreBusyId(null)
1789
- }
1790
- }
1791
-
1792
- async function purge(trashId, phrase) {
1793
- if (purgeBusy === true) return
1794
- setPurgeBusy(true)
1795
- setError(null)
1796
- const entry = trash.find((item) => item.trashId === trashId)
1797
- try {
1798
- await api('/trash/purge', {
1799
- method: 'POST',
1800
- body: { trashId, confirmPhrase: phrase },
1801
- timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1802
- })
1803
- flashStore.show(t('trash.purged'))
1804
- setPurgeTarget(null)
1805
- setPurgePhrase('')
1806
- markStale('trash', 'batch')
1807
- await refresh()
1808
- // Purging a soft entry detaches the session from its workspace.
1809
- await refreshAfterMutation({
1810
- sessionIds: typeof entry?.sessionId === 'string' ? [entry.sessionId] : [],
1811
- closeCurrent: false,
1812
- notify: false,
1813
- })
1814
- } catch (caught) {
1815
- setError(caught)
1816
- } finally {
1817
- setPurgeBusy(false)
1818
- }
1819
- }
1820
-
1821
- async function discard(trashId, phrase) {
1822
- if (discardBusyId !== null) return
1823
- setDiscardBusyId(trashId)
1824
- setError(null)
1825
- try {
1826
- await api('/trash/discard', {
1827
- method: 'POST',
1828
- body: { trashId, confirmPhrase: phrase },
1829
- timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1830
- })
1831
- flashStore.show(t('trash.discarded'))
1832
- setDiscardTarget(null)
1833
- setDiscardPhrase('')
1834
- markStale('trash')
1835
- await refresh()
1836
- } catch (caught) {
1837
- setError(caught)
1838
- } finally {
1839
- setDiscardBusyId(null)
1840
- }
1841
- }
1842
-
1843
- function toggle(id) {
1844
- setSelected((current) => {
1845
- const next = new Set(current)
1846
- if (next.has(id)) next.delete(id)
1847
- else next.add(id)
1848
- return next
1849
- })
1850
- }
1851
-
1852
- const selectedIds = useMemo(() => [...selected], [selected])
1853
- const effectiveBatch = Number.isFinite(state?.limits?.effectiveBatch)
1854
- ? state.limits.effectiveBatch
1855
- : (state?.limits?.maxBatchSessions ?? 0)
1856
- const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
1857
- // The Host purges entries on its own after `trashRetentionDays`; both the
1858
- // banner and the per-row hint come from `retentionInfo`, which reads the
1859
- // field where the Host actually sends it (`limits`).
1860
- const retentionDays = retentionInfo(state).days
1861
- const expiryHint = (entry) => {
1862
- const left = retentionInfo(state, entry).daysLeft
1863
- if (left === null) return ''
1864
- return ` · ${left === 0 ? t('trash.expiresSoon') : t('trash.expiresIn', { days: left })}`
1865
- }
1866
-
1867
- const openFeedback = () => dialogStore.open({
1868
- kind: 'feedback',
1869
- key: 'feedback',
1870
- version: typeof state?.version === 'string' ? state.version : '',
1871
- feedback: state?.feedback ?? null,
1872
- })
1873
-
1874
- return h('div', { className: 'dc-root' },
1875
- // Top-of-section call to action, above the title: it is the first thing
1876
- // a user unsure about the plugin should see. Rendered only when the Host
1877
- // reports a configured transport, so a profile without mail settings
1878
- // never shows a button that cannot work (the dialog would otherwise
1879
- // explain a failure to send).
1880
- state?.feedback?.enabled === true
1881
- ? h('div', { className: 'dc-feedback-bar' },
1882
- h('div', { className: 'dc-feedback-bar-text' },
1883
- h('div', { className: 'dc-feedback-bar-title' }, t('feedback.bannerTitle')),
1884
- h('div', { className: 'dc-feedback-bar-hint' }, t('feedback.bannerHint'))),
1885
- h(Button, { variant: 'primary', title: t('feedback.button'), onClick: openFeedback },
1886
- h(GlyphFeedback, { size: 14 }), ' ', t('feedback.button')))
1887
- : null,
1888
- h('div', { className: 'dc-header' },
1889
- h('div', null,
1890
- h('h1', { className: 'dc-title' }, t('section.title')),
1891
- h('p', { className: 'dc-hint' }, t('section.subtitle'))),
1892
- h('div', { className: 'dc-header-actions' },
1893
- h(Button, { onClick: () => { void refresh() }, disabled: tabBusy !== null, title: t('action.refresh') },
1894
- h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
1895
-
1896
- h(ErrorBanner, { error }),
1897
-
1898
- state !== null ? h('div', { className: 'dc-state' },
1899
- h('div', { className: 'dc-state-block' },
1900
- h('div', { className: 'dc-state-title' }, t('state.memory')),
1901
- ...(state.memory?.adapters ?? []).map((adapter) => h('div', {
1902
- key: adapter.id,
1903
- className: 'dc-state-row',
1904
- },
1905
- h('span', { className: adapter.available ? 'dc-ok' : 'dc-off' }, adapter.available ? '●' : '○'),
1906
- h('span', null, ` ${adapterLabel(adapter)} `),
1907
- h('span', { className: 'dc-hint' }, adapter.available ? t('state.available') : `${t('state.unavailable')} — ${adapterReason(adapter.reason)}`)))),
1908
- h('div', { className: 'dc-state-block' },
1909
- h('div', { className: 'dc-state-title' }, t('state.limits')),
1910
- h('div', { className: 'dc-hint' },
1911
- t('state.rate', {
1912
- used: state.limits?.rate?.used ?? 0,
1913
- limit: state.limits?.rate?.limit ?? state.limits?.maxDeletesPerMinute ?? 0,
1914
- })),
1915
- h('div', { className: 'dc-hint' }, t('state.batch', { limit: effectiveBatch > 0 ? effectiveBatch : '—' })))) : null,
1916
-
1917
- h('div', { className: 'dc-tabs', role: 'tablist' },
1918
- h('button', { id: 'dc-tab-trash', type: 'button', role: 'tab', 'aria-selected': tab === 'trash', 'aria-controls': 'dc-panel-trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
1919
- h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1920
- h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1921
-
1922
- // First paint only: once anything has been rendered, a refresh keeps the
1923
- // panels mounted and shows the inline indicator below instead.
1924
- state === null && tabBusy !== null ? h(Spinner, { label: t('dialog.loading') }) : null,
1925
- state !== null && tabBusy !== null
1926
- ? h('div', { className: 'dc-loading' }, h('span', { className: 'dc-spinner' }), ' ', t('dialog.loading'))
1927
- : null,
1928
-
1929
- tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1930
- retentionDays > 0 ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.retention', { days: retentionDays })) : null,
1931
- Number(lastSweep?.purged) > 0
1932
- ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.swept', { count: Number(lastSweep.purged) }))
1933
- : null,
1934
- trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
1935
- ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1936
- h('div', { className: 'dc-row-main' },
1937
- h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId),
1938
- h('div', { className: 'dc-hint' },
1939
- `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${expiryHint(entry)}`)),
1940
- h('div', { className: 'dc-row-actions' },
1941
- // The Host already knows whether this entry can be restored;
1942
- // the panel used to offer the button and then show the refusal.
1943
- h(Button, {
1944
- disabled: entry.capabilities?.canRestore === false,
1945
- title: entry.capabilities?.canRestore === false ? t('error.E_TRASH_CONFLICT') : t('action.restore'),
1946
- onClick: () => { void restore(entry.trashId) },
1947
- }, t('action.restore')),
1948
- purgeTarget === entry.trashId
1949
- ? h('span', { className: 'dc-inline-confirm' },
1950
- h('input', {
1951
- type: 'text', value: purgePhrase, placeholder: 'DELETE', className: 'dc-input dc-input-small',
1952
- onChange: (event) => setPurgePhrase(event.target.value),
1953
- }),
1954
- h(Button, {
1955
- variant: 'danger',
1956
- disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
1957
- onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1958
- }, t('action.purge')),
1959
- h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1960
- : h(Button, {
1961
- variant: 'danger',
1962
- disabled: entry.capabilities?.canPurge === false,
1963
- title: entry.capabilities?.canPurge === false ? t('error.E_TRASH_CONFLICT') : t('action.purge'),
1964
- onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') },
1965
- }, t('action.purge')))))) : null,
1966
-
1967
- tab === 'trash' && incomplete.length > 0 ? h(IncompleteEntries, {
1968
- entries: incomplete,
1969
- discardTarget,
1970
- discardPhrase,
1971
- onPhrase: setDiscardPhrase,
1972
- onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase('') },
1973
- onCancel: () => { setDiscardTarget(null); setDiscardPhrase('') },
1974
- onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim()) },
1975
- onRestore: (trashId) => { void restoreIncomplete(trashId) },
1976
- restoreBusyId,
1977
- discardBusyId,
1978
- }) : null,
1979
-
1980
- tab === 'batch' ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
1981
- h('div', { className: 'dc-batch-bar' },
1982
- h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
1983
- // "Select all" only marks deletable rows: a batch containing one
1984
- // subagent/orphan/out-of-root conversation is refused as a whole,
1985
- // so selecting it guaranteed a dead button with no hint why.
1986
- h(Button, {
1987
- onClick: () => setSelected(new Set(selectableSessionIds(sessions))),
1988
- title: t('batch.selectAllDeletable'),
1989
- }, t('action.selectAll')),
1990
- h(Button, { onClick: () => setSelected(new Set()) }, t('action.clearAll')),
1991
- h(Button, {
1992
- variant: 'danger',
1993
- disabled: selectedIds.length === 0 || overLimit,
1994
- onClick: () => dialogStore.open({ sessionIds: selectedIds, displayTitle: '', key: `batch:${selectedIds.join(',')}` }),
1995
- }, t('batch.deleteSelected'))),
1996
- h('p', { className: 'dc-hint' }, t('batch.hint')),
1997
- overLimit ? h('p', { className: 'dc-error-text' }, t('batch.overLimit', { limit: effectiveBatch })) : null,
1998
- sessions.length === 0 ? h('div', { className: 'dc-empty' }, t('batch.empty')) : null,
1999
- ...sessions.map((session) => h(BatchSessionRow, {
2000
- key: session.id,
2001
- session,
2002
- checked: selected.has(session.id),
2003
- onToggle: toggle,
2004
- }))) : null,
2005
-
2006
- tab === 'audit' ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
2007
- audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
2008
- ...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
2009
- h('div', { className: 'dc-row-main' },
2010
- h('div', { className: 'dc-row-title' },
2011
- `${record.action ?? ''} · ${record.sessionId ?? (Array.isArray(record.sessionIds) ? record.sessionIds.join(', ') : '')}`),
2012
- h('div', { className: 'dc-hint' },
2013
- `${formatTime(record.at)} · ${t(`audit.${record.result ?? 'success'}`) ?? record.result ?? ''}`
2014
- + (record.mode ? ` · ${record.mode}` : '')
2015
- + (record.includeMemory ? ' · memory' : '')
2016
- // Automatic clean-up writes the same shape as a manual purge;
2017
- // without the source the two are indistinguishable in the UI.
2018
- + (typeof record.source === 'string' && record.source !== 'web-ui' ? ` · ${record.source}` : '')
2019
- + (record.error?.message ? ` · ${record.error.message}` : ''))),
2020
- h('div', { className: 'dc-row-time' }, record.actor ?? '')))) : null)
2021
- }
2022
-
2023
- /* ------------------------------------------------------------------ *
2024
- * Stylesheet (one element owned by this plugin's fiber)
2025
- * ------------------------------------------------------------------ */
2026
-
2027
- const CSS = `
2028
- /* 1300 clears the settings modal (1000) and the host panels that also use
2029
- 1100, since this backlog shares document.body with them. */
2030
- .dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
2031
- .dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
2032
- .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
2033
- .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
2034
- .dc-section{margin-top:10px}
2035
- .dc-meta{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin-bottom:10px}
2036
- .dc-modes{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
2037
- .dc-mode{display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;cursor:pointer;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2038
- .dc-live-warning{margin-top:10px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-warn-label,#b26a00);background:var(--dsw-alias-state-warn-tertiary,#fff7e6)}
2039
- .dc-mode input{margin-top:3px}
2040
- .dc-mode-title{display:block;font-size:13px;font-weight:600}
2041
- .dc-hint{display:block;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
2042
- .dc-memory{margin:12px 0;padding:10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px}
2043
- .dc-memory-count{font-size:12px;margin-top:6px;color:var(--dsw-alias-label-primary,#1f2329)}
2044
- .dc-check{display:flex;gap:8px;align-items:flex-start;cursor:pointer}
2045
- .dc-check.dc-disabled{opacity:.6;cursor:not-allowed}
2046
- .dc-check input{margin-top:3px}
2047
- .dc-check-text{min-width:0}
2048
- .dc-check-label{display:block;font-size:13px;font-weight:500}
2049
- .dc-phrase{display:block;margin:12px 0}
2050
- .dc-phrase-label{display:block;font-size:12px;color:var(--dsw-alias-state-warn-primary,#d46b08);margin-bottom:5px;line-height:1.5}
2051
- .dc-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);border-radius:7px;background:var(--dsw-alias-bg-base,#fff);color:inherit;font:inherit;font-size:13px}
2052
- .dc-input-small{width:90px;height:28px}
2053
- .dc-header-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
2054
- .dc-field{display:flex;flex-direction:column;gap:5px;margin:10px 0}
2055
- .dc-field-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}
2056
- .dc-textarea{height:auto;min-height:96px;padding:8px 9px;line-height:1.55;resize:vertical}
2057
- .dc-context{margin:12px 0 4px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2058
- .dc-context-title{font-size:12px;font-weight:600;margin-bottom:2px}
2059
- .dc-context-list{margin:6px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--dsw-alias-label-secondary,#646a73);word-break:break-all}
2060
- .dc-actions{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:14px;flex-wrap:wrap}
2061
- .dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
2062
- .dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
2063
- .dc-btn:disabled{opacity:.5;cursor:not-allowed}
2064
- /* --dsw-alias-brand-primary is a FILL that flips with the theme: near-black
2065
- (#0f1115) in light, near-white (#f9fafb) in dark. Pairing it with a hard-coded
2066
- white label made the primary button's text invisible on dark (contrast
2067
- ~1.04:1) — that is the "确认按钮消失" report. The design system's counterpart
2068
- for text on a primary fill is --dsw-alias-label-primary-inverted: white in
2069
- light, neutral-800 (#353638) in dark. */
2070
- .dc-btn-primary{background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4d6bfe));border-color:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4d6bfe));color:var(--dsw-alias-label-primary-inverted,#fff)}
2071
- .dc-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#4d6bfe))}
2072
- .dc-btn-danger{color:var(--dsw-alias-state-error-primary,#d93026);border-color:var(--dsw-alias-state-error-primary,#d93026);background:transparent}
2073
- .dc-btn-danger:hover:not(:disabled){background:rgba(217,48,38,.08)}
2074
- .dc-error{font-size:12px;line-height:1.55;padding:8px 10px;border-radius:8px;margin:8px 0;color:var(--dsw-alias-state-error-primary,#d93026);background:rgba(217,48,38,.08);border:1px solid rgba(217,48,38,.28)}
2075
- .dc-error-text{font-size:12px;color:var(--dsw-alias-state-error-primary,#d93026);margin:6px 0}
2076
- .dc-failures{margin:6px 0 0;padding-left:18px;font-size:12px;line-height:1.6;color:var(--dsw-alias-state-error-primary,#d93026)}
2077
- .dc-incomplete{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#e5e6eb)}
2078
- .dc-loading{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);padding:8px 0}
2079
- .dc-spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--dsw-alias-border-l2,#c9cdd4);border-top-color:var(--dsw-alias-brand-primary,#4d6bfe);animation:dc-spin .8s linear infinite;display:inline-block}
2080
- @keyframes dc-spin{to{transform:rotate(360deg)}}
2081
- .dc-menu-item{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 10px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;text-align:left;cursor:pointer}
2082
- .dc-menu-item:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2)}
2083
- .dc-menu-item.dc-danger{color:var(--dsw-alias-state-error-primary,#d93026)}
2084
- .dc-menu-item.dc-danger:hover{background:rgba(217,48,38,.1)}
2085
- .dc-menu-icon{display:inline-flex;align-items:center}
2086
- .dc-header-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer}
2087
- .dc-header-action:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2);color:var(--dsw-alias-state-error-primary,#d93026)}
2088
- .dc-root{padding:18px 22px 32px;max-width:900px;font:inherit;color:var(--dsw-alias-label-primary,#1f2329)}
2089
- .dc-feedback-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 14px;margin-bottom:16px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2090
- .dc-feedback-bar-text{min-width:220px;flex:1 1 auto}
2091
- .dc-feedback-bar-title{font-size:13px;font-weight:600;line-height:1.5}
2092
- .dc-feedback-bar-hint{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
2093
- .dc-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:14px}
2094
- .dc-title{font-size:17px;font-weight:600;margin:0}
2095
- .dc-state{display:flex;gap:24px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);margin-bottom:14px}
2096
- .dc-state-block{min-width:220px}
2097
- .dc-state-title{font-size:12px;font-weight:600;margin-bottom:4px}
2098
- .dc-state-row{font-size:12px;line-height:1.7}
2099
- .dc-ok{color:var(--dsw-alias-state-success-primary,#00a870)}
2100
- .dc-off{color:var(--dsw-alias-state-idle-primary,#8f959e)}
2101
- .dc-tabs{display:flex;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l1,#e5e6eb);margin-bottom:12px}
2102
- .dc-tab{border:0;background:transparent;padding:8px 12px;font:inherit;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
2103
- .dc-tab-active{color:var(--dsw-alias-label-primary,#1f2329);border-bottom-color:var(--dsw-alias-brand-primary,#4d6bfe);font-weight:600}
2104
- .dc-panel{display:flex;flex-direction:column;gap:8px}
2105
- .dc-empty{padding:18px;text-align:center;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
2106
- .dc-row{display:flex;gap:12px;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2107
- .dc-row-select{cursor:pointer}
2108
- .dc-row-select.dc-disabled{opacity:.55;cursor:not-allowed}
2109
- .dc-row-main{min-width:0;flex:1}
2110
- .dc-row-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2111
- .dc-row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
2112
- .dc-row-time{font-size:11px;color:var(--dsw-alias-label-secondary,#646a73);white-space:nowrap}
2113
- .dc-inline-confirm{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
2114
- .dc-batch-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
2115
- .dc-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;box-shadow:0 10px 30px rgba(0,0,0,.22);padding:9px 14px;font-size:12px;max-width:80vw}
2116
- `
2117
-
2118
- /* ------------------------------------------------------------------ *
2119
- * Plugin entry
2120
- * ------------------------------------------------------------------ */
2121
-
2122
- function apply(ctx) {
2123
- runtimeCtx = ctx
2124
- const slots = ctx.slots ?? (typeof ctx.get === 'function' ? ctx.get('slots') : undefined)
2125
- if (slots === undefined || slots === null || typeof slots.inject !== 'function') {
2126
- console.error('[conversation-recycle-bin] slots service unavailable; UI not registered')
2127
- return
2128
- }
2129
-
2130
- ctx.effect(() => {
2131
- const style = document.createElement('style')
2132
- style.setAttribute('data-dsh-plugin', NS)
2133
- style.textContent = CSS
2134
- document.head.appendChild(style)
2135
- return () => {
2136
- if (style.parentNode !== null) style.parentNode.removeChild(style)
2137
- }
2138
- })
2139
-
2140
- // `ui.showRowAction` / `ui.showHeaderAction` are real switches now; the
2141
- // entries stay visible until the probe answers, so a failed /state call
2142
- // never removes the feature.
2143
- void loadUiFlags()
2144
-
2145
- if (typeof ctx.inject === 'function') {
2146
- try {
2147
- ctx.inject(['locale'], (scope) => {
2148
- localeService = scope.locale ?? null
2149
- })
2150
- } catch {
2151
- // Pre-locale hosts keep the built-in dictionary.
2152
- }
2153
- }
2154
-
2155
- // The dialog lives in the overlay, not inside the menu item: the menu
2156
- // item unmounts with its dropdown, which would take the dialog with it.
2157
- ctx.effect(() => slots.inject('shell.overlay', () => slots.register({
2158
- name: 'shell.overlay',
2159
- id: 'conversation-recycle-bin-dialog',
2160
- order: 50,
2161
- }, () => h(DialogHost))))
2162
-
2163
- ctx.effect(() => slots.inject('sidebar.workspaces.session.menu.item', () => slots.register({
2164
- name: 'sidebar.workspaces.session.menu.item',
2165
- id: 'conversation-recycle-bin',
2166
- order: 500,
2167
- }, (props) => h(DeleteMenuRow, props))))
2168
-
2169
- ctx.effect(() => slots.inject('conversation.session.header.actions', () => slots.register({
2170
- name: 'conversation.session.header.actions',
2171
- id: 'conversation-recycle-bin',
2172
- order: 100,
2173
- }, (props) => h(HeaderDeleteAction, props))))
2174
-
2175
- const hasLocaleService = peekLocaleService() !== null
2176
- ctx.effect(() => slots.inject('settings.section', () => slots.register({
2177
- name: 'settings.section',
2178
- id: 'conversation-recycle-bin',
2179
- order: 1300,
2180
- // A thunk label is re-read on locale switches, but pre-locale shells
2181
- // render the label string directly and would crash on a function.
2182
- label: hasLocaleService ? () => t('section.label') : t('section.label'),
2183
- }, () => h(SettingsSection))))
2184
- }
2185
-
2186
- exports.name = 'conversation-recycle-bin'
2187
- // Without this Cordis may run apply() before the slot service exists, and
2188
- // the registration would silently never happen (no retry).
2189
- exports.inject = ['slots']
2190
- exports.apply = apply
2191
- exports.__t = t
2192
- exports.__errorText = errorText
2193
- exports.__store = { dialogStore, flashStore, mutationStore, uiFlagsStore }
2194
- exports.__refreshAfterMutation = refreshAfterMutation
2195
- exports.__mutationTimeoutMs = mutationTimeoutMs
2196
- // Exposed so the harness can assert the client budget stays ABOVE the
2197
- // Host's own TRASH_LOCK_BUDGET_MS: whichever side gives up first decides
2198
- // whether the user sees "failed" for work that actually completed.
2199
- exports.__requestBudgets = {
2200
- request: REQUEST_TIMEOUT_MS,
2201
- mutation: MUTATION_TIMEOUT_MS,
2202
- trashMutation: TRASH_MUTATION_TIMEOUT_MS,
2203
- }
2204
- exports.__apiUrl = apiUrl
2205
- exports.__api = api
2206
- // Direct component handles keep the hook-level behavior testable without a
2207
- // DOM renderer (the registered wrapper alone only builds an element).
2208
- exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow, FeedbackDialog }
2209
- exports.__validateFeedbackInput = validateFeedbackInput
2210
- exports.__feedbackLimits = FEEDBACK_LIMITS
2211
- exports.__selectableSessionIds = selectableSessionIds
2212
- // Exposed because the retention notice lives inside a hook-heavy component
2213
- // the harness cannot render: this is the piece that decides whether the user
2214
- // is told at all, so it must be assertable against a real Host payload.
2215
- exports.__retentionInfo = retentionInfo
2216
- exports.__adapterLabel = adapterLabel
2217
- exports.__adapterReason = adapterReason
2218
- exports.__loadUiFlags = loadUiFlags
2219
- return module.exports
2220
- },
2221
- })
1
+ /**
2
+ * dsh-conversation-recycle-bin — Client half.
3
+ *
4
+ * Hand-authored in the Web shell's module-loader format (`window.__ModuleLoader__.load`
5
+ * + `require`), so the package needs no build step. The Client owns every pixel; all
6
+ * authority and filesystem work stays on the Host behind the same-origin route.
7
+ *
8
+ * Surface:
9
+ * - one row in the Session "..." menu (`sidebar.workspaces.session.menu.item`);
10
+ * - one action in the conversation header (`conversation.session.header.actions`);
11
+ * - one dialog host in `shell.overlay` (so closing the menu does not unmount the
12
+ * dialog — the menu item unmounts with its dropdown);
13
+ * - one Settings section: recycle bin, batch deletion and the audit trail.
14
+ *
15
+ * The confirmation dialog is the only place a deletion is started from the UI. It
16
+ * always defaults to the safe choices: soft delete, memory checkbox OFF, and the
17
+ * batch/permanent phrases are typed by the user, matching the Host's server-side
18
+ * confirmation protocol in `lib/host/api.js`.
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ // Must equal the module name the bundle row mounts (`cordis.patch.yml`'s
22
+ // row `name`, which is the package name). The Host's boot graph keys every
23
+ // client row by that name; a bundle registering under any other id is
24
+ // reported as "loaded without registering" and then executed a second time
25
+ // from its one-resource URL, which throws
26
+ // `duplicate factory registration for "..."` and aborts web boot.
27
+ id: 'dsh-conversation-recycle-bin',
28
+ factory: (require) => {
29
+ const module = { exports: {} }
30
+ const exports = module.exports
31
+ const React = require('react')
32
+ const { useCallback, useEffect, useMemo, useRef, useState } = React
33
+ const h = React.createElement
34
+
35
+ /*
36
+ * `react-dom` is optional: without a portal the dialog still renders (in
37
+ * the overlay slot), it just cannot escape the overlay layer's stacking
38
+ * context. Probing instead of requiring keeps the module loadable on a
39
+ * host that ships React without ReactDOM.
40
+ */
41
+ let createPortal = null
42
+ try {
43
+ const ReactDOM = require('react-dom')
44
+ createPortal = ReactDOM?.createPortal ?? null
45
+ } catch {
46
+ createPortal = null
47
+ }
48
+
49
+ /** Render into `document.body` when possible (see DialogHost). */
50
+ function portalIntoBody(element) {
51
+ if (createPortal === null) return element
52
+ if (typeof document === 'undefined' || document.body === null || document.body === undefined) return element
53
+ try {
54
+ return createPortal(element, document.body)
55
+ } catch {
56
+ return element
57
+ }
58
+ }
59
+
60
+ /* ------------------------------------------------------------------ *
61
+ * Locale
62
+ * ------------------------------------------------------------------ */
63
+
64
+ const NS = 'conversation-recycle-bin'
65
+ const STRINGS = {
66
+ en: {
67
+ 'action.menuDelete': 'Delete conversation…',
68
+ 'action.headerDelete': 'Delete conversation',
69
+ 'action.delete': 'Delete',
70
+ 'action.cancel': 'Cancel',
71
+ 'action.close': 'Close',
72
+ 'action.refresh': 'Refresh',
73
+ 'action.restore': 'Restore',
74
+ 'action.purge': 'Delete permanently',
75
+ 'action.selectAll': 'Select all',
76
+ 'action.clearAll': 'Clear',
77
+ 'batch.selectAllDeletable': 'Select every conversation that can be deleted',
78
+ 'section.label': 'Conversation Deletion',
79
+ 'section.title': 'Conversation Deletion',
80
+ 'section.subtitle': 'Recycle bin, batch deletion and the audit trail.',
81
+ 'dialog.titleOne': 'Delete this conversation?',
82
+ 'dialog.titleMany': 'Delete {count} conversations?',
83
+ 'dialog.bodyOne': '“{title}” will be removed from the session list.',
84
+ 'dialog.bodyMany': '{count} conversations will be removed from the session list.',
85
+ 'dialog.modeSoft': 'Move to the recycle bin (recommended)',
86
+ 'dialog.modeSoftHint': 'The log stays on disk and can be restored from the recycle bin.',
87
+ 'dialog.modeHard': 'Delete permanently',
88
+ 'dialog.modeHardHint': 'Moves the session log out of the live store. Recoverable until the recycle-bin entry is purged.',
89
+ 'dialog.memoryLabel': 'Also delete this conversation’s associated memory',
90
+ 'dialog.memoryHint': 'Off by default. The count is read live before you confirm.',
91
+ 'dialog.memoryCount': '{count} associated memory entries will be moved to the recycle bin',
92
+ 'dialog.memoryFound': '{count} associated memory entries were found',
93
+ 'dialog.memoryNone': 'No associated memory entries were found',
94
+ 'dialog.memoryUnsupported': 'Memory store unavailable: {reason}',
95
+ 'dialog.memoryProtected': '{count} protected entries will be kept',
96
+ 'dialog.memoryKeep': 'The Host is configured to keep memory entries (deleteMode = keep).',
97
+ 'dialog.memoryDisabled': 'Associated-memory deletion is disabled in the Host configuration.',
98
+ 'dialog.records': '{events} log records · {size}',
99
+ 'dialog.batchWarning': 'Type the number {count} to confirm this batch.',
100
+ 'dialog.liveStopHint': 'This conversation is open in a live agent. Permanent deletion stops it first, then removes its log.',
101
+ 'dialog.phraseLabel': 'Confirmation',
102
+ 'dialog.delete': 'Delete',
103
+ 'dialog.working': 'Deleting…',
104
+ 'dialog.stillWorking': 'Still working — confirming no other DSH instance is writing this conversation.',
105
+ 'dialog.hardGraceHint': 'Permanent deletion re-checks the log for a second writer first, so a conversation used moments ago can take about 1.5s longer.',
106
+ 'dialog.loading': 'Reading the plan…',
107
+ 'dialog.done': 'Deleted {count} conversation(s); {memory} memory entries moved to the recycle bin.',
108
+ 'dialog.partial': 'Deleted {done}, failed {failed}.',
109
+ 'dialog.noSession': 'The conversation no longer exists.',
110
+ 'dialog.blocked': 'This conversation cannot be deleted: {message}',
111
+ 'dialog.reloadHint': 'The conversation list refreshes when you close this dialog.',
112
+ 'trash.title': 'Recycle bin',
113
+ 'trash.empty': 'The recycle bin is empty.',
114
+ 'trash.deletedAt': 'Deleted {time}',
115
+ 'trash.retention': 'Entries are purged automatically {days} days after deletion. Set trashRetentionDays to 0 to keep them until you purge them yourself.',
116
+ 'trash.expiresIn': 'auto-purged in {days} d',
117
+ 'trash.expiresSoon': 'auto-purged on the next check',
118
+ 'trash.swept': 'The Host cleaned up {count} expired entries in the background.',
119
+ 'trash.modeSoft': 'soft',
120
+ 'trash.modeHard': 'permanent',
121
+ 'trash.items': '{sessions} conversation(s) · {memories} memory entries',
122
+ 'trash.confirmPurge': 'Type DELETE to permanently remove this entry.',
123
+ 'trash.restored': 'Restored.',
124
+ 'trash.purged': 'Permanently deleted.',
125
+ 'trash.incompleteTitle': 'Incomplete entries',
126
+ 'trash.incompleteHint': 'Entries that never committed. Discard them to free the space; an entry that still holds a conversation log must be restored or purged instead.',
127
+ 'trash.restoreIncompleteHint': 'Put the conversation log back and re-attach it to its workspace.',
128
+ 'trash.discard': 'Discard',
129
+ 'trash.discarded': 'Entry removed.',
130
+ 'trash.confirmDiscard': 'Type DELETE to discard this incomplete entry.',
131
+ 'trash.status.pending': 'pending',
132
+ 'trash.status.failed': 'failed',
133
+ 'trash.status.restoring': 'restoring',
134
+ 'trash.status.purging': 'purging',
135
+ 'trash.status.restored': 'restored',
136
+ 'trash.status.unreadable': 'unreadable manifest',
137
+ 'batch.title': 'Batch deletion',
138
+ 'batch.hint': 'Each conversation is its own transaction; a failure rolls only that conversation back.',
139
+ 'batch.selected': '{count} selected',
140
+ 'batch.deleteSelected': 'Delete selected…',
141
+ 'batch.empty': 'No conversations.',
142
+ 'batch.overLimit': 'The selection exceeds the current limit of {limit}.',
143
+ 'batch.notDeletable': 'cannot be deleted',
144
+ 'audit.title': 'Audit trail',
145
+ 'audit.empty': 'No deletion has been recorded yet.',
146
+ 'audit.denied': 'denied',
147
+ 'audit.success': 'success',
148
+ 'audit.rolled-back': 'rolled back',
149
+ 'audit.rollback-incomplete': 'rollback incomplete',
150
+ 'state.title': 'Host status',
151
+ 'state.memory': 'Memory adapters',
152
+ 'state.limits': 'Limits',
153
+ 'state.available': 'available',
154
+ 'state.unavailable': 'unavailable',
155
+ 'state.notDetected': 'not detected',
156
+ 'state.rate': '{used}/{limit} deletions this minute',
157
+ 'state.batch': 'Effective batch limit: {limit}',
158
+ 'feedback.button': 'Feedback',
159
+ 'feedback.bannerTitle': 'Ran into a problem or have an idea?',
160
+ 'feedback.bannerHint': 'Send one email to the maintainer. It carries your text plus the plugin version, platform and language — no conversation content.',
161
+ 'feedback.title': 'Send feedback',
162
+ 'feedback.intro': 'Tell the maintainer what went wrong or what you would like to see. The message is sent by email from this Host; nothing is uploaded anywhere else.',
163
+ 'feedback.categoryLabel': 'Type',
164
+ 'feedback.categoryBug': 'Problem / bug',
165
+ 'feedback.categorySuggestion': 'Suggestion',
166
+ 'feedback.categoryOther': 'Other',
167
+ 'feedback.titleLabel': 'Title',
168
+ 'feedback.titlePlaceholder': 'One line that summarises it',
169
+ 'feedback.bodyLabel': 'Message',
170
+ 'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
171
+ 'feedback.emailLabel': 'Your email (optional)',
172
+ 'feedback.emailPlaceholder': 'you@example.com',
173
+ 'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
174
+ 'feedback.emailHintAsFrom': 'Sent as the mail\'s From address. Most relays (QQ, 163, Gmail) reject this unless the mailbox matches the login account — enable only when the relay accepts arbitrary From.',
175
+ 'feedback.contextLabel': 'Attached automatically',
176
+ 'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
177
+ 'feedback.contextVersion': 'Plugin version',
178
+ 'feedback.contextPlatform': 'Platform',
179
+ 'feedback.contextLocale': 'Language',
180
+ 'feedback.send': 'Send',
181
+ 'feedback.sending': 'Sending…',
182
+ 'feedback.sent': 'Feedback sent. Thank you!',
183
+ 'feedback.tooShortTitle': 'The title needs at least {min} characters.',
184
+ 'feedback.tooLongTitle': 'The title may be at most {max} characters.',
185
+ 'feedback.tooShortBody': 'The message needs at least {min} characters.',
186
+ 'feedback.tooLongBody': 'The message may be at most {max} characters.',
187
+ 'feedback.invalidEmail': 'That does not look like an email address.',
188
+ 'feedback.tooLongEmail': 'The email address may be at most {max} characters.',
189
+ 'feedback.unavailable': 'The feedback channel is not configured on this Host.',
190
+ 'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
191
+ 'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
192
+ 'error.E_FEEDBACK_RATE_LIMITED': 'Several messages were sent recently; try again a little later.',
193
+ 'error.E_FEEDBACK_TRANSPORT': 'The message could not be delivered. Check the Host mail configuration and retry.',
194
+ 'error.E_CONFIRM_PHRASE': 'The confirmation phrase does not match.',
195
+ 'error.E_CONFIRM_EXPIRED': 'The confirmation expired; the counts were reloaded.',
196
+ 'error.E_CONFIRM_INVALID': 'The confirmation is stale; the plan was reloaded.',
197
+ 'error.E_POLICY_LIVE': 'The conversation could not be stopped. Close it and retry.',
198
+ 'error.E_POLICY_ORPHAN': 'The conversation is not owned by a registered workspace.',
199
+ 'error.E_POLICY_PROTECTED': 'The conversation is protected by configuration.',
200
+ 'error.E_POLICY_SUBAGENT': 'Subagent conversations cannot be deleted.',
201
+ 'error.E_POLICY_ROOT': 'The conversation is outside the allowed workspace roots.',
202
+ 'error.E_RATE_LIMITED': 'Too many deletions; wait a moment and retry.',
203
+ 'error.E_BATCH_TOO_LARGE': 'Too many conversations selected (the current limit is {limit}).',
204
+ 'error.E_TIMEOUT': 'The Host did not answer in time. It may still be working; refresh the list before retrying.',
205
+ 'error.E_DISABLED': 'Conversation deletion is disabled by configuration.',
206
+ 'error.E_TRANSACTION_FAILED': 'Deletion failed and was rolled back.',
207
+ 'error.E_MEMORY_FAILED': 'The memory store could not be updated; nothing was deleted.',
208
+ 'error.E_BAD_RESPONSE': 'The Host returned an unreadable response.',
209
+ 'error.E_INTERNAL': 'An unexpected Host error occurred.',
210
+ 'error.E_NOT_FOUND': 'The conversation no longer exists.',
211
+ 'error.E_BAD_REQUEST': 'The Host rejected the request as invalid.',
212
+ 'error.E_TRASH_NOT_FOUND': 'The recycle-bin entry no longer exists.',
213
+ 'error.E_TRASH_CONFLICT': 'The entry is still in use (for example it still holds a conversation log) or its state does not allow this action.',
214
+ 'error.E_CONFIRM_REQUIRED': 'Reload the dialog and confirm again.',
215
+ 'error.E_FORBIDDEN_HOST': 'The Host only accepts deletion requests from loopback; set allowNonLoopback for remote access.',
216
+ 'error.E_FORBIDDEN_ORIGIN': 'The Host rejected this page as an untrusted origin.',
217
+ 'error.E_POLICY_FOREIGN_WRITER': 'Another DSH instance is still writing this conversation; close it there and retry.',
218
+ 'error.E_LOCK_BUSY': 'Another DSH instance is using this recycle bin; retry in a moment.',
219
+ 'error.E_QUEUE_TIMEOUT': 'The Host was still busy with another change, so this request was not applied. Retry in a moment.',
220
+ 'error.E_ABANDONED': 'The page gave up waiting before the Host started; nothing was changed. Retry.',
221
+ 'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
222
+ 'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
223
+ 'adapter.generic-json': 'Generic JSON memory store',
224
+ 'adapter.none': 'No memory store',
225
+ 'adapter.reason.no-memory-store': 'No conversation-linked memory store was detected.',
226
+ 'common.bytes': '{value} {unit}',
227
+ },
228
+ zh: {
229
+ 'action.menuDelete': '删除对话…',
230
+ 'action.headerDelete': '删除对话',
231
+ 'action.delete': '删除',
232
+ 'action.cancel': '取消',
233
+ 'action.close': '关闭',
234
+ 'action.refresh': '刷新',
235
+ 'action.restore': '恢复',
236
+ 'action.purge': '彻底删除',
237
+ 'action.selectAll': '全选',
238
+ 'action.clearAll': '清除',
239
+ 'batch.selectAllDeletable': '全选所有可删除的对话',
240
+ 'section.label': '对话删除',
241
+ 'section.title': '对话删除',
242
+ 'section.subtitle': '回收站、批量删除与审计留痕。',
243
+ 'dialog.titleOne': '删除这个对话?',
244
+ 'dialog.titleMany': '删除 {count} 个对话?',
245
+ 'dialog.bodyOne': '「{title}」将从会话列表中移除。',
246
+ 'dialog.bodyMany': '{count} 个对话将从会话列表中移除。',
247
+ 'dialog.modeSoft': '移入回收站(推荐)',
248
+ 'dialog.modeSoftHint': '日志仍保留在磁盘上,可从回收站恢复。',
249
+ 'dialog.modeHard': '彻底删除',
250
+ 'dialog.modeHardHint': '把会话日志移出活动存储;在回收站条目被清理前仍可恢复。',
251
+ 'dialog.memoryLabel': '同时删除该对话的关联记忆',
252
+ 'dialog.memoryHint': '默认不勾选。确认前会实时统计受影响的记忆条数。',
253
+ 'dialog.memoryCount': '将把 {count} 条关联记忆移入回收站',
254
+ 'dialog.memoryFound': '找到 {count} 条关联记忆',
255
+ 'dialog.memoryNone': '未找到关联记忆',
256
+ 'dialog.memoryUnsupported': '记忆存储不可用:{reason}',
257
+ 'dialog.memoryProtected': '另有 {count} 条受保护记忆将保留',
258
+ 'dialog.memoryKeep': '宿主配置为保留记忆(deleteMode = keep)。',
259
+ 'dialog.memoryDisabled': '宿主配置已禁用关联记忆删除。',
260
+ 'dialog.records': '{events} 条日志记录 · {size}',
261
+ 'dialog.batchWarning': '批量删除需输入数字 {count} 确认。',
262
+ 'dialog.liveStopHint': '该对话仍在运行中;彻底删除会先停止它,再移除日志。',
263
+ 'dialog.phraseLabel': '确认',
264
+ 'dialog.delete': '删除',
265
+ 'dialog.working': '正在删除…',
266
+ 'dialog.stillWorking': '仍在执行:正在确认没有其他 DSH 实例写入该对话。',
267
+ 'dialog.hardGraceHint': '彻底删除会先复查日志是否还有第二个写入方;刚使用过的对话可能多等约 1.5 秒。',
268
+ 'dialog.loading': '正在读取删除计划…',
269
+ 'dialog.done': '已删除 {count} 个对话;移入回收站的记忆条目 {memory} 条。',
270
+ 'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
271
+ 'dialog.noSession': '该对话已不存在。',
272
+ 'dialog.blocked': '该对话无法删除:{message}',
273
+ 'dialog.reloadHint': '关闭本对话框后会刷新会话列表。',
274
+ 'trash.title': '回收站',
275
+ 'trash.empty': '回收站为空。',
276
+ 'trash.deletedAt': '删除于 {time}',
277
+ 'trash.retention': '条目在删除 {days} 天后自动清理。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
278
+ 'trash.expiresIn': '{days} 天后自动清理',
279
+ 'trash.expiresSoon': '下次检查时自动清理',
280
+ 'trash.swept': 'Host 已在后台自动清理 {count} 条过期条目。',
281
+ 'trash.modeSoft': '软删除',
282
+ 'trash.modeHard': '彻底',
283
+ 'trash.items': '{sessions} 个对话 · {memories} 条记忆',
284
+ 'trash.confirmPurge': '输入 DELETE 彻底删除该条目。',
285
+ 'trash.restored': '已恢复。',
286
+ 'trash.purged': '已彻底删除。',
287
+ 'trash.incompleteTitle': '未完成条目',
288
+ 'trash.incompleteHint': '这些条目没有提交成功,可直接清理以释放空间;仍保存着会话日志的条目请先恢复或彻底删除。',
289
+ 'trash.restoreIncompleteHint': '把会话日志放回原位,并重新挂回它所属的工作区。',
290
+ 'trash.discard': '清理',
291
+ 'trash.discarded': '条目已清理。',
292
+ 'trash.confirmDiscard': '输入 DELETE 清理该未完成条目。',
293
+ 'trash.status.pending': '待处理',
294
+ 'trash.status.failed': '失败',
295
+ 'trash.status.restoring': '恢复中断',
296
+ 'trash.status.purging': '清理中断',
297
+ 'trash.status.restored': '已恢复',
298
+ 'trash.status.unreadable': '清单不可读',
299
+ 'batch.title': '批量删除',
300
+ 'batch.hint': '每个对话独立事务;失败时仅回滚该对话。',
301
+ 'batch.selected': '已选 {count} 个',
302
+ 'batch.deleteSelected': '删除所选…',
303
+ 'batch.empty': '没有对话。',
304
+ 'batch.overLimit': '所选数量超过当前上限 {limit} 个。',
305
+ 'batch.notDeletable': '无法删除',
306
+ 'audit.title': '审计留痕',
307
+ 'audit.empty': '尚无删除记录。',
308
+ 'audit.denied': '被拒绝',
309
+ 'audit.success': '成功',
310
+ 'audit.rolled-back': '已回滚',
311
+ 'audit.rollback-incomplete': '回滚不完整',
312
+ 'state.title': '宿主状态',
313
+ 'state.memory': '记忆适配器',
314
+ 'state.limits': '限制',
315
+ 'state.available': '可用',
316
+ 'state.unavailable': '不可用',
317
+ 'state.notDetected': '未检测到',
318
+ 'state.rate': '本分钟已删除 {used}/{limit}',
319
+ 'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
320
+ 'feedback.button': '反馈',
321
+ 'feedback.bannerTitle': '遇到问题,或有改进建议?',
322
+ 'feedback.bannerHint': '写一条消息发给维护者,正文之外只附带插件版本、平台与语言,不含任何对话内容。',
323
+ 'feedback.title': '发送反馈',
324
+ 'feedback.intro': '把遇到的问题或想要的功能告诉维护者。消息由本机宿主通过邮件发送,不会上传到其他位置。',
325
+ 'feedback.categoryLabel': '类型',
326
+ 'feedback.categoryBug': '问题 / Bug',
327
+ 'feedback.categorySuggestion': '建议',
328
+ 'feedback.categoryOther': '其他',
329
+ 'feedback.titleLabel': '标题',
330
+ 'feedback.titlePlaceholder': '用一句话概括',
331
+ 'feedback.bodyLabel': '正文',
332
+ 'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
333
+ 'feedback.emailLabel': '你的邮箱(选填)',
334
+ 'feedback.emailPlaceholder': 'you@example.com',
335
+ 'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
336
+ 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人(From)。QQ / 163 / Gmail 一类会拒收非登录账号的 From,只有允许任意 From 的中继才生效。',
337
+ 'feedback.contextLabel': '自动附带',
338
+ 'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
339
+ 'feedback.contextVersion': '插件版本',
340
+ 'feedback.contextPlatform': '平台',
341
+ 'feedback.contextLocale': '语言',
342
+ 'feedback.send': '发送',
343
+ 'feedback.sending': '正在发送…',
344
+ 'feedback.sent': '反馈已发送,谢谢!',
345
+ 'feedback.tooShortTitle': '标题至少需要 {min} 个字符。',
346
+ 'feedback.tooLongTitle': '标题最多 {max} 个字符。',
347
+ 'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
348
+ 'feedback.tooLongBody': '正文最多 {max} 个字符。',
349
+ 'feedback.invalidEmail': '这看起来不是一个邮箱地址。',
350
+ 'feedback.tooLongEmail': '邮箱最多 {max} 个字符。',
351
+ 'feedback.unavailable': '当前宿主未配置反馈通道。',
352
+ 'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
353
+ 'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
354
+ 'error.E_FEEDBACK_RATE_LIMITED': '最近已发送多条反馈,请稍后再试。',
355
+ 'error.E_FEEDBACK_TRANSPORT': '邮件发送失败,请检查宿主的邮件配置后重试。',
356
+ 'error.E_CONFIRM_PHRASE': '确认短语不匹配。',
357
+ 'error.E_CONFIRM_EXPIRED': '确认已过期;计数已重新加载。',
358
+ 'error.E_CONFIRM_INVALID': '确认已失效;计划已重新加载。',
359
+ 'error.E_POLICY_LIVE': '无法停止该对话,请关闭后重试。',
360
+ 'error.E_POLICY_ORPHAN': '该对话不属于任何已登记工作区。',
361
+ 'error.E_POLICY_PROTECTED': '该对话受配置保护。',
362
+ 'error.E_POLICY_SUBAGENT': '子代理对话不能删除。',
363
+ 'error.E_POLICY_ROOT': '该对话不在允许的工作区根目录内。',
364
+ 'error.E_RATE_LIMITED': '删除过于频繁,请稍后重试。',
365
+ 'error.E_BATCH_TOO_LARGE': '所选对话过多(当前上限 {limit} 个)。',
366
+ 'error.E_TIMEOUT': '宿主未及时响应;操作可能仍在后台继续,请刷新列表确认后再重试。',
367
+ 'error.E_DISABLED': '宿主配置已禁用对话删除。',
368
+ 'error.E_TRANSACTION_FAILED': '删除失败,已回滚。',
369
+ 'error.E_MEMORY_FAILED': '记忆存储更新失败;未删除任何内容。',
370
+ 'error.E_BAD_RESPONSE': '宿主返回了无法解析的响应。',
371
+ 'error.E_INTERNAL': '宿主发生未预期的错误。',
372
+ 'error.E_NOT_FOUND': '该对话已不存在。',
373
+ 'error.E_BAD_REQUEST': '请求参数无效。',
374
+ 'error.E_TRASH_NOT_FOUND': '回收站条目已不存在。',
375
+ 'error.E_TRASH_CONFLICT': '该条目仍被占用(例如仍保存着会话日志)或状态不允许此操作。',
376
+ 'error.E_CONFIRM_REQUIRED': '请重新加载确认框后再试。',
377
+ 'error.E_FORBIDDEN_HOST': '宿主只接受本机回环地址的删除请求;远程访问需设置 allowNonLoopback。',
378
+ 'error.E_FORBIDDEN_ORIGIN': '宿主拒绝了该页面的请求(来源校验未通过)。',
379
+ 'error.E_POLICY_FOREIGN_WRITER': '另一个 DSH 实例仍在写这个对话,已中止删除;请先关闭那边的对话再重试。',
380
+ 'error.E_LOCK_BUSY': '另一个 DSH 实例正在使用回收站,请稍后重试。',
381
+ 'error.E_QUEUE_TIMEOUT': '宿主还在处理另一个变更,这次请求没有执行,请稍后重试。',
382
+ 'error.E_ABANDONED': '页面在宿主开始处理前就放弃了等待,本次没有做任何改动,请重试。',
383
+ 'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
384
+ 'adapter.lingshu': '灵枢认知图 (md_cg)',
385
+ 'adapter.generic-json': '通用 JSON 记忆存储',
386
+ 'adapter.none': '无记忆存储',
387
+ 'adapter.reason.no-memory-store': '未检测到与对话关联的记忆存储。',
388
+ 'common.bytes': '{value} {unit}',
389
+ },
390
+ }
391
+
392
+ let runtimeCtx = null
393
+ let localeService = null
394
+
395
+ function peekLocaleService() {
396
+ if (localeService !== null) return localeService
397
+ if (runtimeCtx === null) return null
398
+ try {
399
+ localeService = runtimeCtx.locale ?? runtimeCtx.get('locale') ?? null
400
+ } catch {
401
+ localeService = null
402
+ }
403
+ return localeService
404
+ }
405
+
406
+ function currentLocaleId() {
407
+ const service = peekLocaleService()
408
+ try {
409
+ const snapshot = service?.getSnapshot?.() ?? service?.getLocale?.()
410
+ // `dsh-client-locale`'s snapshot is `{ active, locales, revision }`:
411
+ // the ACTIVE locale id lives in `active`, and neither `locale` nor
412
+ // `id` exists. Reading the wrong fields silently fell back to the
413
+ // browser language, so the UI ignored the in-app language switch.
414
+ const id = snapshot?.active ?? snapshot?.locale ?? snapshot?.id
415
+ if (typeof id === 'string' && id !== '') return id
416
+ } catch {
417
+ // fall through
418
+ }
419
+ const language = typeof navigator !== 'undefined' && typeof navigator.language === 'string'
420
+ ? navigator.language
421
+ : 'zh'
422
+ return language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
423
+ }
424
+
425
+ function isZhLocale(localeId) {
426
+ return String(localeId ?? '').toLowerCase().startsWith('zh')
427
+ }
428
+
429
+ function t(key, vars) {
430
+ const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
431
+ const template = table[key] ?? STRINGS.en[key] ?? key
432
+ if (vars === undefined) return template
433
+ return template.replace(/\{(\w+)\}/g, (match, name) => {
434
+ const value = vars[name]
435
+ return value === undefined ? match : String(value)
436
+ })
437
+ }
438
+
439
+ function errorText(error) {
440
+ const code = error?.code
441
+ // Same locale test as `t`: a mixed-case id (e.g. "ZH-CN") used to fall
442
+ // through to the English table while every other string stayed Chinese.
443
+ const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
444
+ const template = code === undefined ? undefined : table[`error.${code}`]
445
+ if (template === undefined) return error?.message ?? String(error)
446
+ return template.replace(/\{(\w+)\}/g, (match, name) => {
447
+ const value = error?.details?.[name] ?? error?.[name]
448
+ return value === undefined ? '—' : String(value)
449
+ })
450
+ }
451
+
452
+ /**
453
+ * Localized adapter label. Host labels are deliberately neutral ids: the
454
+ * Chinese lingshu label and the English `none` reason used to appear
455
+ * untranslated in the other language's UI.
456
+ */
457
+ function adapterLabel(adapter) {
458
+ const key = `adapter.${adapter?.id ?? ''}`
459
+ const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
460
+ return table[key] ?? adapter?.label ?? adapter?.id ?? '—'
461
+ }
462
+
463
+ /** Localize the stable reason keys; anything else is a raw Host message. */
464
+ function adapterReason(reason) {
465
+ if (typeof reason !== 'string' || reason === '') return ''
466
+ const key = `adapter.reason.${reason}`
467
+ const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
468
+ return table[key] ?? reason
469
+ }
470
+
471
+ function useLocaleTick() {
472
+ const [, force] = useState(0)
473
+ useEffect(() => {
474
+ const service = peekLocaleService()
475
+ if (service === null || typeof service.subscribe !== 'function') return undefined
476
+ try {
477
+ return service.subscribe(() => force((value) => value + 1))
478
+ } catch {
479
+ return undefined
480
+ }
481
+ }, [])
482
+ }
483
+
484
+ /* ------------------------------------------------------------------ *
485
+ * Host HTTP client
486
+ * ------------------------------------------------------------------ */
487
+
488
+ const API_PREFIX = '/conversation-recycle-bin/api'
489
+ const REQUEST_TIMEOUT_MS = 15_000
490
+ const MUTATION_TIMEOUT_MS = 180_000
491
+ /**
492
+ * Recycle-bin writes wait for the Host's cross-process lock, and that wait
493
+ * is unbounded in wall-clock terms: the request queues behind whatever
494
+ * mutation is already running (`serialize()` chains in-process first). A
495
+ * 15s budget therefore aborted restore / purge / discard while the Host was
496
+ * still working, so the UI reported a failure for a change that did happen.
497
+ * The Host caps its own lock wait at TRASH_LOCK_BUDGET_MS (180s); this sits
498
+ * just above it so the Host's own answer always wins the race.
499
+ */
500
+ const TRASH_MUTATION_TIMEOUT_MS = 195_000
501
+ /**
502
+ * How long a delete may run before the dialog says what it is waiting for.
503
+ * A hard delete of a conversation written moments ago spends
504
+ * `foreignWriterGraceMs` (1.5s) re-checking the log for a second writer;
505
+ * without a hint the danger button looked stuck.
506
+ */
507
+ const SLOW_SUBMIT_HINT_MS = 1_200
508
+
509
+ /**
510
+ * A batch is one HTTP request that can stop several live sessions (each up
511
+ * to `liveDrainMs`) and wait for the memory lock. The old flat 15s timeout
512
+ * aborted the request while the Host kept deleting, leaving the user with
513
+ * a timeout error, a consumed token and a half-changed list.
514
+ */
515
+ function mutationTimeoutMs(count) {
516
+ const items = Number.isFinite(count) ? Math.max(1, count) : 1
517
+ return Math.min(MUTATION_TIMEOUT_MS, 30_000 + items * 5_000)
518
+ }
519
+
520
+ function ApiError(code, message, details) {
521
+ this.name = 'ApiError'
522
+ this.code = code
523
+ this.message = message
524
+ this.details = details ?? null
525
+ }
526
+ ApiError.prototype = Object.create(Error.prototype)
527
+ ApiError.prototype.constructor = ApiError
528
+
529
+ /**
530
+ * Resolve a plugin route against the PAGE, not against the origin.
531
+ *
532
+ * `dsh web` can be mounted under a path prefix (`--public-url
533
+ * https://host/ui/`, stripped by a front proxy). DSH's own client stays
534
+ * document-relative for exactly this reason: `dsh-client-connection` builds
535
+ * its requests with `` `${channel}/${endpoint}`.slice(1) `` and resolves
536
+ * them against `document.baseURI`. An absolute `/conversation-recycle-bin/…`
537
+ * would replace the whole base path, drop the prefix, and 404 every call.
538
+ * Keeping the same rule means one code path works for `http://127.0.0.1:<p>/`,
539
+ * `https://host/<prefix>/` and the desktop shell's `dsh-app://app/`.
540
+ */
541
+ function apiUrl(path) {
542
+ let base = 'http://127.0.0.1/'
543
+ if (typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== '') {
544
+ try {
545
+ // Normalise to the containing directory so a base without a trailing
546
+ // slash (`…/index.html`) does not swallow the last segment.
547
+ base = new URL('.', document.baseURI).href
548
+ } catch {
549
+ base = 'http://127.0.0.1/'
550
+ }
551
+ }
552
+ return new URL(`${API_PREFIX.replace(/^\//, '')}${path}`, base)
553
+ }
554
+
555
+ async function api(path, options = {}) {
556
+ const method = options.method ?? 'GET'
557
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
558
+ const controller = new AbortController()
559
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
560
+ try {
561
+ const url = apiUrl(path)
562
+ const headers = { 'x-conversation-recycle-bin': '1' }
563
+ if (options.body !== undefined) headers['content-type'] = 'application/json'
564
+ const response = await fetch(url, {
565
+ method,
566
+ headers,
567
+ credentials: 'same-origin',
568
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
569
+ signal: controller.signal,
570
+ })
571
+ let payload = null
572
+ try {
573
+ payload = await response.json()
574
+ } catch {
575
+ payload = null
576
+ }
577
+ if (payload === null || typeof payload !== 'object') {
578
+ throw new ApiError('E_BAD_RESPONSE', 'The Host returned an unreadable response.')
579
+ }
580
+ // A batch that partially succeeded is a RESULT, not a refusal: an older
581
+ // Host marked it `ok:false` without an `error`, which made the Client
582
+ // throw E_INTERNAL and hide the sessions that were already deleted.
583
+ const partial = Array.isArray(payload.results) && Array.isArray(payload.failures)
584
+ if (response.ok !== true || (payload.ok === false && partial !== true)) {
585
+ const wire = payload.error ?? {}
586
+ throw new ApiError(wire.code ?? 'E_INTERNAL', wire.message ?? 'The Host refused the request.', wire.details)
587
+ }
588
+ return payload
589
+ } catch (error) {
590
+ if (error?.name === 'AbortError') throw new ApiError('E_TIMEOUT', 'The Host did not answer in time.')
591
+ if (error instanceof ApiError) throw error
592
+ throw new ApiError('E_INTERNAL', error instanceof Error ? error.message : String(error))
593
+ } finally {
594
+ clearTimeout(timer)
595
+ }
596
+ }
597
+
598
+ /* ------------------------------------------------------------------ *
599
+ * Overlay store + flash messages
600
+ * ------------------------------------------------------------------ */
601
+
602
+ const dialogStore = {
603
+ state: null,
604
+ listeners: new Set(),
605
+ open(payload) {
606
+ this.state = payload
607
+ for (const listener of this.listeners) listener(this.state)
608
+ },
609
+ close() {
610
+ this.state = null
611
+ for (const listener of this.listeners) listener(this.state)
612
+ },
613
+ subscribe(listener) {
614
+ this.listeners.add(listener)
615
+ return () => this.listeners.delete(listener)
616
+ },
617
+ }
618
+
619
+ const flashStore = {
620
+ message: null,
621
+ timer: null,
622
+ listeners: new Set(),
623
+ show(message) {
624
+ if (this.timer !== null) clearTimeout(this.timer)
625
+ this.message = message
626
+ for (const listener of this.listeners) listener(this.message)
627
+ this.timer = setTimeout(() => {
628
+ this.timer = null
629
+ if (this.message === message) {
630
+ this.message = null
631
+ for (const listener of this.listeners) listener(null)
632
+ }
633
+ }, 4_000)
634
+ },
635
+ subscribe(listener) {
636
+ this.listeners.add(listener)
637
+ return () => this.listeners.delete(listener)
638
+ },
639
+ }
640
+
641
+ function useDialogState() {
642
+ const [state, setState] = useState(dialogStore.state)
643
+ useEffect(() => dialogStore.subscribe(setState), [])
644
+ return state
645
+ }
646
+
647
+ /**
648
+ * Notification channel for "a mutation changed the session list". The
649
+ * dialog refreshes the shell, but the Settings section owns a second copy
650
+ * of the list; without this it kept showing deleted rows until a manual
651
+ * refresh (0.1.4 stopped reloading the page, which used to hide the bug).
652
+ */
653
+ const mutationStore = {
654
+ version: 0,
655
+ listeners: new Set(),
656
+ bump() {
657
+ this.version += 1
658
+ for (const listener of this.listeners) listener(this.version)
659
+ },
660
+ subscribe(listener) {
661
+ this.listeners.add(listener)
662
+ return () => this.listeners.delete(listener)
663
+ },
664
+ }
665
+
666
+ function useMutationVersion() {
667
+ const [version, setVersion] = useState(mutationStore.version)
668
+ useEffect(() => mutationStore.subscribe(setVersion), [])
669
+ return version
670
+ }
671
+
672
+ /**
673
+ * Host UI flags (`ui.showRowAction` / `ui.showHeaderAction`) used to be
674
+ * dead configuration: the client registered both slots unconditionally.
675
+ * The same probe carries `defaultMode`, which the dialog used to ignore
676
+ * (it always started on soft).
677
+ */
678
+ const uiFlagsStore = {
679
+ loaded: false,
680
+ flags: { showRowAction: true, showHeaderAction: true, enabled: true, defaultMode: 'soft' },
681
+ listeners: new Set(),
682
+ set(flags) {
683
+ this.flags = flags
684
+ this.loaded = true
685
+ for (const listener of this.listeners) listener(this.flags)
686
+ },
687
+ subscribe(listener) {
688
+ this.listeners.add(listener)
689
+ return () => this.listeners.delete(listener)
690
+ },
691
+ }
692
+
693
+ function useUiFlags() {
694
+ const [flags, setFlags] = useState(uiFlagsStore.flags)
695
+ useEffect(() => uiFlagsStore.subscribe(setFlags), [])
696
+ return flags
697
+ }
698
+
699
+ let uiFlagsLoading = false
700
+ async function loadUiFlags() {
701
+ if (uiFlagsStore.loaded || uiFlagsLoading || typeof fetch !== 'function') return
702
+ // No real document (unit harness): there is nothing to ask the Host for.
703
+ if (typeof document === 'undefined' || typeof document.baseURI !== 'string') return
704
+ uiFlagsLoading = true
705
+ try {
706
+ const response = await api('/state')
707
+ const ui = response?.state?.ui
708
+ const defaultMode = response?.state?.defaultMode
709
+ uiFlagsStore.set({
710
+ showRowAction: ui?.showRowAction !== false,
711
+ showHeaderAction: ui?.showHeaderAction !== false,
712
+ enabled: response?.state?.enabled !== false,
713
+ defaultMode: defaultMode === 'hard' ? 'hard' : 'soft',
714
+ })
715
+ } catch {
716
+ // Defaults keep both entries visible; a failed probe is not fatal.
717
+ } finally {
718
+ uiFlagsLoading = false
719
+ }
720
+ }
721
+
722
+ function useFlashMessage() {
723
+ const [message, setMessage] = useState(flashStore.message)
724
+ useEffect(() => flashStore.subscribe(setMessage), [])
725
+ return message
726
+ }
727
+
728
+ function reloadApp(delay = 900) {
729
+ if (typeof window === 'undefined' || typeof window.location?.reload !== 'function') return
730
+ reloadTimer = window.setTimeout(() => {
731
+ reloadTimer = null
732
+ reloadPending = false
733
+ window.location.reload()
734
+ }, delay)
735
+ }
736
+
737
+ // A fallback reload is armed when the Host cannot close the deleted
738
+ // conversation in place. The result dialog is the only place a partial
739
+ // batch reports its per-session failures, so the reload waits for the user
740
+ // to dismiss it; the timer is only a safety net for a dialog left open.
741
+ const RELOAD_FALLBACK_DELAY = 5_000
742
+ let reloadPending = false
743
+ let reloadTimer = null
744
+ // Set when the user dismisses the result dialog. `refreshAfterMutation`
745
+ // settles after the dialog is already on screen, so the dismissal can
746
+ // arrive first; the reload then happens at once instead of arming a timer.
747
+ let reloadDialogDismissed = false
748
+
749
+ function takeReloadPending() {
750
+ const pending = reloadPending
751
+ reloadPending = false
752
+ // Cancel the safety-net timer, otherwise dismissing the dialog would
753
+ // reload once now and once again when the original timer fires.
754
+ if (pending && reloadTimer !== null) {
755
+ if (typeof window !== 'undefined') window.clearTimeout?.(reloadTimer)
756
+ reloadTimer = null
757
+ }
758
+ return pending
759
+ }
760
+
761
+ function service(name) {
762
+ if (runtimeCtx === null || typeof runtimeCtx.get !== 'function') return undefined
763
+ try {
764
+ return runtimeCtx.get(name) ?? undefined
765
+ } catch {
766
+ return undefined
767
+ }
768
+ }
769
+
770
+ function openSessionId() {
771
+ const workspace = service('uiWorkspace')
772
+ try {
773
+ const snapshot = workspace?.selection?.getSnapshot?.()
774
+ const id = snapshot?.sessionId
775
+ if (typeof id === 'string' && id !== '') return id
776
+ } catch {
777
+ // fall through: an unknown selection simply skips the close step
778
+ }
779
+ return undefined
780
+ }
781
+
782
+ /**
783
+ * Update the shell in place after a mutation instead of reloading the page.
784
+ *
785
+ * The Host session list has no delete push, so it is pulled explicitly; the
786
+ * workspace archived/membership snapshot follows the Host stream. When the
787
+ * deleted conversation is the one on screen, the shell must close it: it
788
+ * would otherwise render a conversation whose log was just moved away.
789
+ * `reloadApp` stays only as a fallback for hosts whose client service is
790
+ * missing or fails, so a deletion can never leave a stale list behind.
791
+ */
792
+ async function refreshAfterMutation(options = {}) {
793
+ const sessionIds = Array.isArray(options.sessionIds) ? options.sessionIds : []
794
+ const closeCurrent = options.closeCurrent !== false
795
+ const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : RELOAD_FALLBACK_DELAY
796
+ const current = openSessionId()
797
+ const sessions = service('sessions')
798
+ let refreshed = false
799
+ if (sessions !== undefined && typeof sessions.refresh === 'function') {
800
+ try {
801
+ await sessions.refresh()
802
+ refreshed = true
803
+ } catch (error) {
804
+ console.warn('[conversation-recycle-bin] session list refresh failed', error)
805
+ }
806
+ }
807
+ if (closeCurrent && current !== undefined && sessionIds.includes(current)) {
808
+ const workspace = service('uiWorkspace')
809
+ let cleared = false
810
+ try {
811
+ if (workspace !== undefined && typeof workspace.clearArchivedCurrent === 'function') {
812
+ cleared = workspace.clearArchivedCurrent() === true
813
+ }
814
+ } catch {
815
+ cleared = false
816
+ }
817
+ if (cleared !== true) {
818
+ try {
819
+ if (workspace !== undefined && typeof workspace.clearMain === 'function') workspace.clearMain()
820
+ else {
821
+ // Neither private method exists on this host (they are declared
822
+ // private by dsh-client-ui-workspace). `sessions.refresh()`
823
+ // succeeded, so without this the shell would keep rendering a
824
+ // conversation whose log was just moved away.
825
+ console.warn('[conversation-recycle-bin] the host cannot close the current conversation in place; reloading')
826
+ refreshed = false
827
+ }
828
+ } catch {
829
+ refreshed = false
830
+ }
831
+ }
832
+ }
833
+ if (!refreshed) {
834
+ if (reloadDialogDismissed) reloadApp(0)
835
+ else {
836
+ reloadPending = true
837
+ reloadApp(fallbackDelay)
838
+ }
839
+ }
840
+ if (options.notify !== false) mutationStore.bump()
841
+ return refreshed
842
+ }
843
+
844
+ /* ------------------------------------------------------------------ *
845
+ * Small presentational pieces
846
+ * ------------------------------------------------------------------ */
847
+
848
+ function GlyphTrash({ size = 14 }) {
849
+ return h('svg', {
850
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
851
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
852
+ 'aria-hidden': 'true',
853
+ },
854
+ h('path', { d: 'M4 7h16' }),
855
+ h('path', { d: 'M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2' }),
856
+ h('path', { d: 'M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13' }),
857
+ h('path', { d: 'M10 11v6M14 11v6' }))
858
+ }
859
+
860
+ function GlyphRefresh({ size = 14 }) {
861
+ return h('svg', {
862
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
863
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
864
+ 'aria-hidden': 'true',
865
+ },
866
+ h('path', { d: 'M20 12a8 8 0 1 1-2.3-5.7' }),
867
+ h('path', { d: 'M20 4v5h-5' }))
868
+ }
869
+
870
+ function GlyphFeedback({ size = 14 }) {
871
+ return h('svg', {
872
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
873
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
874
+ 'aria-hidden': 'true',
875
+ },
876
+ h('path', { d: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z' }),
877
+ h('path', { d: 'm3.5 7 8.5 6 8.5-6' }))
878
+ }
879
+
880
+ function Button({ children, onClick, variant = 'secondary', disabled = false, type = 'button', title }) {
881
+ return h('button', {
882
+ type,
883
+ className: `dc-btn dc-btn-${variant}`,
884
+ onClick,
885
+ disabled,
886
+ title,
887
+ }, children)
888
+ }
889
+
890
+ function Checkbox({ checked, onChange, disabled = false, label, hint }) {
891
+ return h('label', { className: `dc-check${disabled ? ' dc-disabled' : ''}` },
892
+ h('input', { type: 'checkbox', checked, disabled, onChange: (event) => onChange(event.target.checked) }),
893
+ h('span', { className: 'dc-check-text' },
894
+ h('span', { className: 'dc-check-label' }, label),
895
+ hint === undefined ? null : h('span', { className: 'dc-hint' }, hint)))
896
+ }
897
+
898
+ function Spinner({ label }) {
899
+ return h('div', { className: 'dc-loading', role: 'status', 'aria-live': 'polite' },
900
+ h('span', { className: 'dc-spinner', 'aria-hidden': 'true' }),
901
+ h('span', null, label))
902
+ }
903
+
904
+ function ErrorBanner({ error }) {
905
+ if (error === null || error === undefined) return null
906
+ return h('div', { className: 'dc-error', role: 'alert' }, errorText(error))
907
+ }
908
+
909
+ function formatBytes(bytes) {
910
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
911
+ const units = ['B', 'KB', 'MB', 'GB']
912
+ let value = bytes
913
+ let index = 0
914
+ while (value >= 1024 && index < units.length - 1) {
915
+ value /= 1024
916
+ index += 1
917
+ }
918
+ return `${value < 10 && index > 0 ? value.toFixed(1) : Math.round(value)} ${units[index]}`
919
+ }
920
+
921
+ function formatTime(value) {
922
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
923
+ try {
924
+ return new Date(value).toLocaleString()
925
+ } catch {
926
+ return String(value)
927
+ }
928
+ }
929
+
930
+ /**
931
+ * The recycle-bin retention window, read from the Host payload.
932
+ *
933
+ * `trashRetentionDays` lives under `limits` — the same place as every other
934
+ * Host limit — and the Host also sends a per-entry `daysLeft`. Reading the
935
+ * top level (as 0.3.10 did) yields `undefined`, which used to switch the
936
+ * whole notice off: the bin was purged with no warning at all. The countdown
937
+ * prefers the Host's number so a skewed browser clock cannot promise a
938
+ * different day than the sweep keeps; the local computation is only a
939
+ * fallback for an older Host that does not send `daysLeft`.
940
+ */
941
+ function retentionInfo(state, entry = null, nowMs = Date.now()) {
942
+ const raw = Number(state?.limits?.trashRetentionDays)
943
+ const days = Number.isFinite(raw) && raw > 0 ? raw : 0
944
+ if (days <= 0) return { days: 0, daysLeft: null }
945
+ if (entry === null || entry === undefined) return { days, daysLeft: null }
946
+ // Only `trashed` rows are ever swept, so a restored or mid-transaction row
947
+ // must not advertise a deadline it will never reach.
948
+ if (entry.status !== undefined && entry.status !== 'trashed') return { days, daysLeft: null }
949
+ const hostDays = entry.daysLeft === null || entry.daysLeft === undefined ? Number.NaN : Number(entry.daysLeft)
950
+ if (Number.isFinite(hostDays) && hostDays >= 0) return { days, daysLeft: hostDays }
951
+ const deletedAt = Number(entry.deletedAt)
952
+ if (!Number.isFinite(deletedAt) || deletedAt <= 0) return { days, daysLeft: null }
953
+ const left = deletedAt + days * 86_400_000 - nowMs
954
+ return { days, daysLeft: left <= 0 ? 0 : Math.ceil(left / 86_400_000) }
955
+ }
956
+
957
+ /* ------------------------------------------------------------------ *
958
+ * Feedback form (pure helpers, shared with the Host's validation)
959
+ * ------------------------------------------------------------------ */
960
+
961
+ // Mirrors FEEDBACK_LIMITS in `lib/host/feedback.js`. The Host re-validates
962
+ // every field; this copy only exists to disable the submit button early and
963
+ // to point at the offending input.
964
+ const FEEDBACK_LIMITS = Object.freeze({
965
+ titleMin: 4,
966
+ titleMax: 120,
967
+ bodyMin: 10,
968
+ bodyMax: 4_000,
969
+ emailMax: 200,
970
+ })
971
+
972
+ /** Same loose shape the Host accepts: `a@b.cc`, no whitespace or separators. */
973
+ const EMAIL_PATTERN = /^[^\s@,;:<>()[\]\\]+@[^\s@,;:<>()[\]\\]+\.[^\s@,;:<>()[\]\\]{2,}$/
974
+
975
+ /**
976
+ * @param {{title?: string, body?: string, email?: string}} input
977
+ * @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
978
+ */
979
+ function validateFeedbackInput(input) {
980
+ const title = String(input?.title ?? '').trim()
981
+ const body = String(input?.body ?? '').trim()
982
+ const email = String(input?.email ?? '').trim()
983
+ if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
984
+ if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
985
+ if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
986
+ if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
987
+ // Same order as the Host (`lib/host/feedback.js:241-250`): shape first,
988
+ // then length. The reverse told a 300-character address it was "not an
989
+ // email" while the Host said "too long" for the identical submission.
990
+ if (email !== '' && !EMAIL_PATTERN.test(email)) return { ok: false, key: 'feedback.invalidEmail', vars: {} }
991
+ if (email.length > FEEDBACK_LIMITS.emailMax) return { ok: false, key: 'feedback.tooLongEmail', vars: { max: FEEDBACK_LIMITS.emailMax } }
992
+ return { ok: true }
993
+ }
994
+
995
+ /** Context the dialog shows before sending; nothing here is user content. */
996
+ function collectFeedbackContext() {
997
+ const userAgent = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
998
+ ? navigator.userAgent
999
+ : ''
1000
+ return {
1001
+ platform: userAgent.slice(0, 160),
1002
+ locale: currentLocaleId(),
1003
+ }
1004
+ }
1005
+
1006
+ /* ------------------------------------------------------------------ *
1007
+ * Confirmation dialog
1008
+ * ------------------------------------------------------------------ */
1009
+
1010
+ function DeleteDialog({ payload }) {
1011
+ useLocaleTick()
1012
+ const flags = useUiFlags()
1013
+ const sessionIds = payload.sessionIds
1014
+ const count = sessionIds.length
1015
+ const fallbackTitle = payload.displayTitle ?? ''
1016
+ // `config.defaultMode` was dead configuration: the dialog always started
1017
+ // on soft, so the Host's documented "initial mode" never applied.
1018
+ const [mode, setMode] = useState(flags.defaultMode === 'hard' ? 'hard' : 'soft')
1019
+ const [includeMemory, setIncludeMemory] = useState(false)
1020
+ const [preview, setPreview] = useState(null)
1021
+ const [loading, setLoading] = useState(true)
1022
+ const [busy, setBusy] = useState(false)
1023
+ const [error, setError] = useState(null)
1024
+ const [phrase, setPhrase] = useState('')
1025
+ const [done, setDone] = useState(null)
1026
+ // Set when a submission outlives SLOW_SUBMIT_HINT_MS, so the dialog can
1027
+ // explain the wait instead of showing a frozen danger button.
1028
+ const [slow, setSlow] = useState(false)
1029
+
1030
+ // Dismissing the result must also complete a reload the Host armed
1031
+ // because it cannot close the deleted conversation in place; otherwise
1032
+ // the safety-net timer would fire later and reload under the user's feet.
1033
+ function closeDone() {
1034
+ reloadDialogDismissed = true
1035
+ dialogStore.close()
1036
+ if (takeReloadPending()) reloadApp(0)
1037
+ }
1038
+ const requestRef = useRef(0)
1039
+
1040
+ const load = useCallback(async (nextMode, nextMemory, options = {}) => {
1041
+ const serial = (requestRef.current += 1)
1042
+ setLoading(true)
1043
+ if (options.preserveError !== true) setError(null)
1044
+ try {
1045
+ const response = await api('/preview', { method: 'POST', body: { sessionIds, includeMemory: nextMemory, mode: nextMode } })
1046
+ if (serial !== requestRef.current) return
1047
+ setPreview(response.preview)
1048
+ // A reload after a failed submit keeps the typed phrase: the plan did
1049
+ // not change, and retyping a batch size on every rate-limit/expiry is
1050
+ // pure friction. Switching mode or memory still clears it below.
1051
+ if (options.preservePhrase !== true) setPhrase('')
1052
+ } catch (caught) {
1053
+ if (serial !== requestRef.current) return
1054
+ if (options.preserveError !== true) {
1055
+ setError(caught)
1056
+ setPreview(null)
1057
+ }
1058
+ } finally {
1059
+ if (serial === requestRef.current) setLoading(false)
1060
+ }
1061
+ }, [sessionIds.join('\u0000')])
1062
+
1063
+ useEffect(() => {
1064
+ void load(mode, includeMemory)
1065
+ }, [mode, includeMemory, load])
1066
+
1067
+ useEffect(() => {
1068
+ function onKey(event) {
1069
+ // Escape dismisses the result dialog too, so it must release the
1070
+ // armed reload exactly like the Close button does.
1071
+ if (event.key === 'Escape' && busy !== true) closeDone()
1072
+ }
1073
+ window.addEventListener('keydown', onKey)
1074
+ return () => window.removeEventListener('keydown', onKey)
1075
+ }, [busy])
1076
+
1077
+ useEffect(() => {
1078
+ if (busy !== true) {
1079
+ setSlow(false)
1080
+ return undefined
1081
+ }
1082
+ const timer = setTimeout(() => setSlow(true), SLOW_SUBMIT_HINT_MS)
1083
+ return () => clearTimeout(timer)
1084
+ }, [busy])
1085
+
1086
+ const firstSession = preview?.sessions?.[0] ?? null
1087
+ const title = count === 1
1088
+ ? (firstSession?.title || fallbackTitle || sessionIds[0])
1089
+ : ''
1090
+ const blocked = Array.isArray(preview?.blocked) ? preview.blocked : []
1091
+ const confirmation = preview?.confirmation ?? null
1092
+ const expectedPhrase = confirmation?.phrase ?? ''
1093
+ const phraseOk = expectedPhrase === '' || phrase.trim() === expectedPhrase
1094
+ const memory = preview?.memory ?? null
1095
+ const memorySupported = memory !== null && memory.enabled === true
1096
+ && Array.isArray(memory.adapters) && memory.adapters.some((adapter) => adapter.available === true)
1097
+ const memoryDeletable = memorySupported && preview?.memory?.deleteMode === 'trash'
1098
+ const liveCount = Array.isArray(preview?.sessions) ? preview.sessions.filter((item) => item.live === true).length : 0
1099
+
1100
+ const blockedMessage = blocked.length === 0
1101
+ ? null
1102
+ : (blocked[0].policy?.code === 'E_NOT_FOUND' ? t('dialog.noSession') : t('dialog.blocked', { message: errorText(blocked[0].policy) }))
1103
+
1104
+ async function submit() {
1105
+ if (confirmation === null || busy) return
1106
+ // A new submission starts a new result cycle: a dismissal from the
1107
+ // previous one must not force this run's reload to happen at once.
1108
+ reloadDialogDismissed = false
1109
+ setBusy(true)
1110
+ setError(null)
1111
+ try {
1112
+ // The Host's own worst-case estimate for THIS plan (live drains,
1113
+ // memory locks, per-item moves) replaces the client's flat guess:
1114
+ // aborting while the Host keeps deleting left a consumed token and a
1115
+ // half-changed list behind.
1116
+ // The Host publishes the budget it actually enforces: the lock wait
1117
+ // plus the queue grace period. The lock-only estimate used to abort
1118
+ // 60 s before the Host gave up, so a busy bin looked like a failure
1119
+ // while the deletion was still running.
1120
+ const planTimeout = Number.isFinite(preview?.limits?.queueTimeoutMs)
1121
+ ? Math.max(1_000, preview.limits.queueTimeoutMs)
1122
+ : Number.isFinite(preview?.limits?.planTimeoutMs)
1123
+ ? Math.max(1_000, preview.limits.planTimeoutMs)
1124
+ : mutationTimeoutMs(sessionIds.length)
1125
+ const result = await api('/delete', {
1126
+ method: 'POST',
1127
+ timeoutMs: planTimeout,
1128
+ body: {
1129
+ sessionIds,
1130
+ includeMemory,
1131
+ mode,
1132
+ confirmToken: confirmation.token,
1133
+ confirmPhrase: phrase.trim(),
1134
+ },
1135
+ })
1136
+ const failures = Array.isArray(result.failures) ? result.failures : []
1137
+ // Only successfully committed sessions may close the open view; a
1138
+ // failed item in the same batch leaves its conversation on screen.
1139
+ const deletedIds = (result.results ?? [])
1140
+ .map((item) => item?.sessionId)
1141
+ .filter((id) => typeof id === 'string')
1142
+ if (failures.length > 0) {
1143
+ const memoryTotal = (result.results ?? []).reduce((sum, item) => sum + (item.memory ?? 0), 0)
1144
+ setDone({ count: (result.results ?? []).length, memory: memoryTotal, failed: failures.length, failures })
1145
+ setBusy(false)
1146
+ if (deletedIds.length > 0) void refreshAfterMutation({ sessionIds: deletedIds })
1147
+ return
1148
+ }
1149
+ const memoryTotal = (result.results ?? []).reduce((sum, item) => sum + (item.memory ?? 0), 0)
1150
+ setDone({ count: (result.results ?? []).length, memory: memoryTotal, failed: 0 })
1151
+ setBusy(false)
1152
+ if (deletedIds.length > 0) void refreshAfterMutation({ sessionIds: deletedIds })
1153
+ } catch (caught) {
1154
+ setError(caught)
1155
+ setBusy(false)
1156
+ // The token is single-use only after a successful phrase + rate check;
1157
+ // a failed transaction leaves it usable, but counts may have changed
1158
+ // on disk, so refresh the plan. Keep the failure visible: clearing the
1159
+ // error inside load() made a failed delete look like a dead button.
1160
+ void load(mode, includeMemory, { preserveError: true, preservePhrase: true })
1161
+ }
1162
+ }
1163
+
1164
+ if (done !== null) {
1165
+ return h('div', {
1166
+ className: 'dc-backdrop',
1167
+ onMouseDown: (event) => { if (event.target === event.currentTarget) closeDone() },
1168
+ },
1169
+ h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true' },
1170
+ h('h2', { className: 'dc-dialog-title' }, t('dialog.done', { count: done.count, memory: done.memory })),
1171
+ done.failed > 0 ? h('p', { className: 'dc-error-text' }, t('dialog.partial', { done: done.count, failed: done.failed })) : null,
1172
+ done.failed > 0 && Array.isArray(done.failures)
1173
+ ? h('ul', { className: 'dc-failures' }, ...done.failures.map((item, index) => h('li', {
1174
+ key: `${item?.sessionId ?? 'item'}-${index}`,
1175
+ }, `${item?.sessionId ?? '—'}: ${errorText(item?.error)}`)))
1176
+ : null,
1177
+ h('p', { className: 'dc-hint' }, t('dialog.reloadHint')),
1178
+ h('div', { className: 'dc-actions' },
1179
+ h(Button, { variant: 'primary', onClick: () => closeDone() }, t('action.close')))))
1180
+ }
1181
+
1182
+ return h('div', {
1183
+ className: 'dc-backdrop',
1184
+ onMouseDown: (event) => { if (event.target === event.currentTarget && busy !== true) dialogStore.close() },
1185
+ },
1186
+ h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-dialog-title' },
1187
+ h('h2', { id: 'dc-dialog-title', className: 'dc-dialog-title' },
1188
+ count === 1 ? t('dialog.titleOne') : t('dialog.titleMany', { count })),
1189
+ h('p', { className: 'dc-dialog-body' },
1190
+ count === 1 ? t('dialog.bodyOne', { title }) : t('dialog.bodyMany', { count })),
1191
+
1192
+ loading ? h(Spinner, { label: t('dialog.loading') }) : null,
1193
+ h(ErrorBanner, { error }),
1194
+ blockedMessage !== null ? h('div', { className: 'dc-error', role: 'alert' }, blockedMessage) : null,
1195
+
1196
+ !loading && preview !== null ? h('div', { className: 'dc-section' },
1197
+ firstSession !== null && count === 1
1198
+ ? h('div', { className: 'dc-meta' }, t('dialog.records', {
1199
+ events: firstSession.events ?? '—',
1200
+ size: formatBytes(firstSession.sizeBytes),
1201
+ }))
1202
+ : null,
1203
+
1204
+ h('div', { className: 'dc-modes', role: 'radiogroup', 'aria-label': t('dialog.modeSoft') },
1205
+ h('label', { className: 'dc-mode' },
1206
+ h('input', {
1207
+ type: 'radio', name: 'dc-mode', value: 'soft', checked: mode === 'soft',
1208
+ onChange: () => setMode('soft'), disabled: busy,
1209
+ }),
1210
+ h('span', null,
1211
+ h('span', { className: 'dc-mode-title' }, t('dialog.modeSoft')),
1212
+ h('span', { className: 'dc-hint' }, t('dialog.modeSoftHint')))),
1213
+ h('label', { className: 'dc-mode' },
1214
+ h('input', {
1215
+ type: 'radio', name: 'dc-mode', value: 'hard', checked: mode === 'hard',
1216
+ onChange: () => setMode('hard'), disabled: busy,
1217
+ }),
1218
+ h('span', null,
1219
+ h('span', { className: 'dc-mode-title' }, t('dialog.modeHard')),
1220
+ h('span', { className: 'dc-hint' }, t('dialog.modeHardHint'))))),
1221
+
1222
+ liveCount > 0 && mode === 'hard'
1223
+ ? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.liveStopHint'))
1224
+ : null,
1225
+
1226
+ // Permanent deletion waits out `foreignWriterGraceMs` when the log was
1227
+ // written moments ago (measured: ~1.5s once per batch). Say so before
1228
+ // the click, not after the user thinks the button is stuck.
1229
+ mode === 'hard' ? h('p', { className: 'dc-hint' }, t('dialog.hardGraceHint')) : null,
1230
+
1231
+ h('div', { className: 'dc-memory' },
1232
+ h(Checkbox, {
1233
+ checked: includeMemory,
1234
+ disabled: busy || memoryDeletable !== true,
1235
+ onChange: (value) => setIncludeMemory(value),
1236
+ label: t('dialog.memoryLabel'),
1237
+ hint: memory === null
1238
+ ? undefined
1239
+ : memoryDeletable !== true
1240
+ ? (memory.enabled !== true ? t('dialog.memoryDisabled') : t('dialog.memoryKeep'))
1241
+ : t('dialog.memoryHint'),
1242
+ }),
1243
+ memory !== null ? h('div', { className: 'dc-memory-count' },
1244
+ memorySupported
1245
+ ? (memory.total > 0
1246
+ ? (includeMemory
1247
+ ? t('dialog.memoryCount', { count: memory.total })
1248
+ : t('dialog.memoryFound', { count: memory.total }))
1249
+ : t('dialog.memoryNone'))
1250
+ : t('dialog.memoryUnsupported', { reason: reasonForUnavailable(memory) }),
1251
+ memorySupported && memory.protected > 0
1252
+ ? h('span', { className: 'dc-hint' }, ` · ${t('dialog.memoryProtected', { count: memory.protected })}`)
1253
+ : null) : null),
1254
+
1255
+ expectedPhrase !== ''
1256
+ ? h('label', { className: 'dc-phrase' },
1257
+ h('span', { className: 'dc-phrase-label' },
1258
+ // A phrase exists only for a multi-item soft batch (the Host's
1259
+ // `expectedPhrase` returns '' for hard mode), so the label is
1260
+ // always the batch instruction.
1261
+ t('dialog.batchWarning', { count })),
1262
+ h('input', {
1263
+ type: 'text',
1264
+ value: phrase,
1265
+ onChange: (event) => setPhrase(event.target.value),
1266
+ placeholder: expectedPhrase,
1267
+ disabled: busy,
1268
+ autoFocus: true,
1269
+ className: 'dc-input',
1270
+ }))
1271
+ : null,
1272
+
1273
+ slow === true
1274
+ ? h('div', { className: 'dc-live-warning', role: 'status' }, t('dialog.stillWorking'))
1275
+ : null,
1276
+
1277
+ h('div', { className: 'dc-actions' },
1278
+ h(Button, { onClick: () => { void load(mode, includeMemory) }, disabled: busy || loading, title: t('action.refresh') },
1279
+ h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')),
1280
+ h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
1281
+ h(Button, {
1282
+ variant: 'danger',
1283
+ onClick: () => { void submit() },
1284
+ disabled: busy || loading || blocked.length > 0 || confirmation === null || phraseOk !== true,
1285
+ }, busy ? t('dialog.working') : t('dialog.delete'))))
1286
+ : null))
1287
+ }
1288
+
1289
+ function reasonForUnavailable(memory) {
1290
+ const adapters = Array.isArray(memory?.adapters) ? memory.adapters : []
1291
+ const first = adapters.find((adapter) => adapter.available === false && typeof adapter.reason === 'string' && adapter.reason !== '')
1292
+ if (first !== undefined) return adapterReason(first.reason)
1293
+ const stores = Array.isArray(memory?.stores) ? memory.stores : []
1294
+ const store = stores.find((item) => item.available === false && typeof item.reason === 'string')
1295
+ // `not detected` used to be hard-coded English, so a Chinese page showed
1296
+ // `记忆存储不可用:not detected`. Everything else here already routes
1297
+ // through the string table.
1298
+ return store === undefined ? t('state.notDetected') : adapterReason(store.reason)
1299
+ }
1300
+
1301
+ /* ------------------------------------------------------------------ *
1302
+ * Feedback dialog
1303
+ * ------------------------------------------------------------------ */
1304
+
1305
+ /**
1306
+ * Settings page "反馈" button.
1307
+ *
1308
+ * The Host performs the delivery, so the page never holds a mail
1309
+ * credential. The auto-attached context is rendered BEFORE the user
1310
+ * commits, so nothing leaves the machine that they did not see.
1311
+ */
1312
+ function FeedbackDialog({ payload }) {
1313
+ useLocaleTick()
1314
+ const feedback = payload?.feedback ?? null
1315
+ const version = typeof payload?.version === 'string' ? payload.version : ''
1316
+ const [category, setCategory] = useState('bug')
1317
+ const [title, setTitle] = useState('')
1318
+ const [body, setBody] = useState('')
1319
+ const [email, setEmail] = useState('')
1320
+ const [busy, setBusy] = useState(false)
1321
+ const [error, setError] = useState(null)
1322
+ const validation = validateFeedbackInput({ title, body, email })
1323
+ const context = collectFeedbackContext()
1324
+ // A Host without a mail transport advertises `enabled: false`; the form is
1325
+ // still rendered read-only so the explanation is visible instead of a
1326
+ // button that fails on click.
1327
+ const unavailable = feedback?.enabled !== true
1328
+ // Nothing has been typed on the first render, so the "too short" rule must
1329
+ // not greet the user. It starts applying once the user edits a field OR
1330
+ // leaves one: gating on `dirty` alone left the disabled submit button with
1331
+ // no visible reason for anyone who only tabbed through the form.
1332
+ const [dirty, setDirty] = useState(false)
1333
+ const [touched, setTouched] = useState(false)
1334
+ const validationVisible = (dirty === true || touched === true) && validation.ok !== true
1335
+
1336
+ useEffect(() => {
1337
+ function onKey(event) {
1338
+ if (event.key === 'Escape' && busy !== true) dialogStore.close()
1339
+ }
1340
+ window.addEventListener('keydown', onKey)
1341
+ return () => window.removeEventListener('keydown', onKey)
1342
+ }, [busy])
1343
+
1344
+ async function submit() {
1345
+ if (busy || unavailable || validation.ok !== true) return
1346
+ setBusy(true)
1347
+ setError(null)
1348
+ try {
1349
+ await api('/feedback', {
1350
+ method: 'POST',
1351
+ timeoutMs: 30_000,
1352
+ body: {
1353
+ category,
1354
+ title: title.trim(),
1355
+ body: body.trim(),
1356
+ email: email.trim(),
1357
+ context: {
1358
+ pluginVersion: version,
1359
+ platform: context.platform,
1360
+ locale: context.locale,
1361
+ },
1362
+ },
1363
+ })
1364
+ flashStore.show(t('feedback.sent'))
1365
+ dialogStore.close()
1366
+ } catch (caught) {
1367
+ // The Host log already records the stable code; this keeps the form
1368
+ // path debuggable from the browser console without a network tab.
1369
+ console.error('[conversation-recycle-bin] feedback submission failed:', caught?.code ?? caught)
1370
+ setError(caught)
1371
+ } finally {
1372
+ setBusy(false)
1373
+ }
1374
+ }
1375
+
1376
+ return h('div', {
1377
+ className: 'dc-backdrop',
1378
+ onMouseDown: (event) => {
1379
+ if (event.target === event.currentTarget && busy !== true) dialogStore.close()
1380
+ },
1381
+ },
1382
+ h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-feedback-title' },
1383
+ h('h2', { id: 'dc-feedback-title', className: 'dc-dialog-title' }, t('feedback.title')),
1384
+ h('p', { className: 'dc-dialog-body' }, t('feedback.intro')),
1385
+ unavailable ? h('div', { className: 'dc-error', role: 'alert' }, t('feedback.unavailable')) : null,
1386
+ h(ErrorBanner, { error }),
1387
+
1388
+ h('label', { className: 'dc-field' },
1389
+ h('span', { className: 'dc-field-label' }, t('feedback.categoryLabel')),
1390
+ h('select', {
1391
+ className: 'dc-input',
1392
+ value: category,
1393
+ disabled: busy,
1394
+ onBlur: () => setTouched(true),
1395
+ onChange: (event) => { setDirty(true); setCategory(event.target.value) },
1396
+ },
1397
+ h('option', { value: 'bug' }, t('feedback.categoryBug')),
1398
+ h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
1399
+ h('option', { value: 'other' }, t('feedback.categoryOther')))),
1400
+
1401
+ h('label', { className: 'dc-field' },
1402
+ h('span', { className: 'dc-field-label' }, t('feedback.titleLabel')),
1403
+ h('input', {
1404
+ type: 'text',
1405
+ className: 'dc-input',
1406
+ value: title,
1407
+ maxLength: FEEDBACK_LIMITS.titleMax,
1408
+ placeholder: t('feedback.titlePlaceholder'),
1409
+ disabled: busy,
1410
+ autoFocus: true,
1411
+ onBlur: () => setTouched(true),
1412
+ onChange: (event) => { setDirty(true); setTitle(event.target.value) },
1413
+ })),
1414
+
1415
+ h('label', { className: 'dc-field' },
1416
+ h('span', { className: 'dc-field-label' }, t('feedback.bodyLabel')),
1417
+ h('textarea', {
1418
+ className: 'dc-input dc-textarea',
1419
+ value: body,
1420
+ rows: 6,
1421
+ maxLength: FEEDBACK_LIMITS.bodyMax,
1422
+ placeholder: t('feedback.bodyPlaceholder'),
1423
+ disabled: busy,
1424
+ onBlur: () => setTouched(true),
1425
+ onChange: (event) => { setDirty(true); setBody(event.target.value) },
1426
+ })),
1427
+
1428
+ h('label', { className: 'dc-field' },
1429
+ h('span', { className: 'dc-field-label' }, t('feedback.emailLabel')),
1430
+ h('input', {
1431
+ type: 'email',
1432
+ className: 'dc-input',
1433
+ value: email,
1434
+ maxLength: FEEDBACK_LIMITS.emailMax,
1435
+ placeholder: t('feedback.emailPlaceholder'),
1436
+ autoComplete: 'email',
1437
+ disabled: busy,
1438
+ onBlur: () => setTouched(true),
1439
+ onChange: (event) => { setDirty(true); setEmail(event.target.value) },
1440
+ }),
1441
+ h('div', { className: 'dc-hint' }, t(feedback?.visitorAsFrom === true ? 'feedback.emailHintAsFrom' : 'feedback.emailHint'))),
1442
+
1443
+ h('div', { className: 'dc-context' },
1444
+ h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
1445
+ h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
1446
+ h('ul', { className: 'dc-context-list' },
1447
+ h('li', null, `${t('feedback.contextVersion')}: ${version === '' ? '—' : version}`),
1448
+ h('li', null, `${t('feedback.contextPlatform')}: ${context.platform === '' ? '—' : context.platform}`),
1449
+ h('li', null, `${t('feedback.contextLocale')}: ${context.locale}`))),
1450
+
1451
+ validationVisible ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
1452
+ busy ? h(Spinner, { label: t('feedback.sending') }) : null,
1453
+
1454
+ h('div', { className: 'dc-actions' },
1455
+ h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
1456
+ h(Button, {
1457
+ variant: 'primary',
1458
+ onClick: () => { void submit() },
1459
+ disabled: busy || unavailable || validation.ok !== true,
1460
+ }, busy ? t('feedback.sending') : t('feedback.send')))))
1461
+ }
1462
+
1463
+ /* ------------------------------------------------------------------ *
1464
+ * Dialog host / overlay entry
1465
+ * ------------------------------------------------------------------ */
1466
+
1467
+ /**
1468
+ * Dialog host / overlay entry.
1469
+ *
1470
+ * The dialog is portaled to `document.body` on purpose: the `shell.overlay`
1471
+ * slot renders inside the layout's overlay layer
1472
+ * (`.overlayLayer{position:absolute;z-index:20}`), which is its own
1473
+ * stacking context. A `z-index:1000` backdrop inside it cannot cover the
1474
+ * settings modal (also `z-index:1000`, but on `body`), so the batch-delete
1475
+ * confirmation opened from Settings was invisible and its first click
1476
+ * closed the settings modal instead. Portaling to the body puts the dialog
1477
+ * in the same layer as every other modal; the toast follows for the same
1478
+ * reason.
1479
+ */
1480
+ function DialogHost() {
1481
+ const payload = useDialogState()
1482
+ const flash = useFlashMessage()
1483
+ // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
1484
+ // payload without a kind keeps its old meaning.
1485
+ const dialog = payload === null
1486
+ ? null
1487
+ : payload.kind === 'feedback'
1488
+ ? h(FeedbackDialog, { payload, key: payload.key ?? 'feedback' })
1489
+ : h(DeleteDialog, { payload, key: payload.key ?? (payload.sessionIds ?? []).join(',') })
1490
+ const content = h(React.Fragment, null,
1491
+ dialog,
1492
+ flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
1493
+ return portalIntoBody(content)
1494
+ }
1495
+
1496
+ /* ------------------------------------------------------------------ *
1497
+ * Session row + header triggers
1498
+ * ------------------------------------------------------------------ */
1499
+
1500
+ function DeleteMenuRow(props) {
1501
+ useLocaleTick()
1502
+ const flags = useUiFlags()
1503
+ const sessionId = props.sessionId
1504
+ const displayTitle = props.displayTitle ?? ''
1505
+ // Hook order must stay stable when the flag flips at runtime, so the
1506
+ // menu state is read before the visibility check.
1507
+ const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : null
1508
+ if (flags.showRowAction !== true || flags.enabled === false) return null
1509
+ const closeMenu = () => {
1510
+ try {
1511
+ menu?.[1]?.(false)
1512
+ } catch {
1513
+ // A menu that already closed is fine.
1514
+ }
1515
+ }
1516
+ return h('button', {
1517
+ type: 'button',
1518
+ role: 'menuitem',
1519
+ className: 'dc-menu-item dc-danger',
1520
+ onClick: () => {
1521
+ closeMenu()
1522
+ dialogStore.open({ sessionIds: [sessionId], displayTitle, key: `${sessionId}:menu` })
1523
+ },
1524
+ },
1525
+ h('span', { className: 'dc-menu-icon' }, h(GlyphTrash, { size: 14 })),
1526
+ h('span', { className: 'dc-menu-text' }, t('action.menuDelete')))
1527
+ }
1528
+
1529
+ function HeaderDeleteAction(props) {
1530
+ useLocaleTick()
1531
+ const flags = useUiFlags()
1532
+ const sessionId = props.sessionId
1533
+ if (flags.showHeaderAction !== true || flags.enabled === false) return null
1534
+ // The title is read from the Host preview (projection cache), not from a
1535
+ // client selector: this keeps the entry safe on hosts where the standard
1536
+ // `useSession` shape differs.
1537
+ return h('button', {
1538
+ type: 'button',
1539
+ className: 'dc-header-action',
1540
+ title: t('action.headerDelete'),
1541
+ 'aria-label': t('action.headerDelete'),
1542
+ onClick: () => dialogStore.open({ sessionIds: [sessionId], displayTitle: '', key: `${sessionId}:header` }),
1543
+ }, h(GlyphTrash, { size: 15 }))
1544
+ }
1545
+
1546
+ /* ------------------------------------------------------------------ *
1547
+ * Settings section: recycle bin, batch deletion, audit trail
1548
+ * ------------------------------------------------------------------ */
1549
+
1550
+ /**
1551
+ * Entries that never committed. Kept as its own component so the element
1552
+ * tree can be built (and smoke-tested) without a live Settings page.
1553
+ *
1554
+ * A restorable entry (one whose manifest still points at a moved session
1555
+ * log) gets a Restore button: `performDiscard` refuses exactly those
1556
+ * entries, so without this the only way out was a manual API call.
1557
+ */
1558
+ function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
1559
+ return h('div', { className: 'dc-panel dc-incomplete' },
1560
+ h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
1561
+ h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
1562
+ ...entries.map((entry) => {
1563
+ // The Host knows what is actually recoverable (a moved log, a memory
1564
+ // bundle, or nothing at all). Falling back to `hasSessionLog` keeps
1565
+ // older Hosts working, but a memory-only entry used to show Discard
1566
+ // only — the route refuses that while memory files remain — so the
1567
+ // one action the Host offered was its API, not the UI.
1568
+ const canRestore = entry.capabilities === undefined
1569
+ ? entry.hasSessionLog === true
1570
+ : entry.capabilities?.canRestore === true
1571
+ const canDiscard = entry.capabilities === undefined
1572
+ ? true
1573
+ : entry.capabilities?.canDiscard !== false
1574
+ return h('div', { key: entry.trashId, className: 'dc-row' },
1575
+ h('div', { className: 'dc-row-main' },
1576
+ h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId || entry.trashId),
1577
+ h('div', { className: 'dc-hint' }, entry.status === 'unreadable' && entry.error?.message
1578
+ ? `${t('trash.status.unreadable')} · ${entry.error.message}`
1579
+ : `${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1580
+ h('div', { className: 'dc-row-actions' },
1581
+ canRestore === true && typeof onRestore === 'function'
1582
+ ? h(Button, {
1583
+ onClick: () => onRestore(entry.trashId),
1584
+ disabled: restoreBusyId === entry.trashId,
1585
+ title: t('trash.restoreIncompleteHint'),
1586
+ }, t('action.restore'))
1587
+ : null,
1588
+ canDiscard !== true
1589
+ ? null
1590
+ : discardTarget === entry.trashId
1591
+ ? h('span', { className: 'dc-inline-confirm' },
1592
+ h('input', {
1593
+ type: 'text',
1594
+ value: discardPhrase,
1595
+ placeholder: 'DELETE',
1596
+ className: 'dc-input dc-input-small',
1597
+ onChange: (event) => onPhrase(event.target.value),
1598
+ }),
1599
+ h(Button, {
1600
+ variant: 'danger',
1601
+ disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1602
+ onClick: () => onDiscard(entry.trashId),
1603
+ }, t('trash.discard')),
1604
+ h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
1605
+ : h(Button, {
1606
+ onClick: () => onStart(entry.trashId),
1607
+ title: t('trash.confirmDiscard'),
1608
+ }, t('trash.discard')))
1609
+ )
1610
+ }))
1611
+ }
1612
+
1613
+ /**
1614
+ * One batch-picker row. A conversation the Host would refuse (subagent,
1615
+ * orphan, outside the allowed roots, protected) is rendered disabled with
1616
+ * the reason, instead of selectable and then failing the whole batch.
1617
+ */
1618
+ function BatchSessionRow({ session, checked, onToggle }) {
1619
+ const notDeletable = session.deletable === false
1620
+ return h('label', { key: session.id, className: `dc-row dc-row-select${notDeletable ? ' dc-disabled' : ''}` },
1621
+ h('input', {
1622
+ type: 'checkbox',
1623
+ checked,
1624
+ disabled: notDeletable,
1625
+ onChange: () => onToggle(session.id),
1626
+ }),
1627
+ h('div', { className: 'dc-row-main' },
1628
+ h('div', { className: 'dc-row-title' }, session.title || session.id),
1629
+ h('div', { className: 'dc-hint' },
1630
+ `${session.cwd ?? '—'}${session.archived ? ' · archived' : ''}${session.live || session.running ? ' · live' : ''}`
1631
+ + (notDeletable ? ` · ${t('batch.notDeletable')}: ${errorText(session.policy)}` : ''))),
1632
+ h('div', { className: 'dc-row-time' }, formatTime(session.updatedAt)))
1633
+ }
1634
+
1635
+ /** Ids "select all" may mark: every row the Host has not vetoed. */
1636
+ function selectableSessionIds(sessions) {
1637
+ return sessions.filter((session) => session.deletable !== false).map((session) => session.id)
1638
+ }
1639
+
1640
+ function SettingsSection() {
1641
+ useLocaleTick()
1642
+ const mutationVersion = useMutationVersion()
1643
+ const [tab, setTab] = useState('trash')
1644
+ const [state, setState] = useState(null)
1645
+ const [sessions, setSessions] = useState([])
1646
+ const [trash, setTrash] = useState([])
1647
+ const [incomplete, setIncomplete] = useState([])
1648
+ // What the background retention sweep did last time, straight from the
1649
+ // Host: entries that vanished between two refreshes get an explanation
1650
+ // instead of looking like a rendering bug.
1651
+ const [lastSweep, setLastSweep] = useState(null)
1652
+ const [audit, setAudit] = useState([])
1653
+ // Which tab is fetching right now. `null` means idle: the panels stay
1654
+ // mounted while a refresh runs, so the user keeps scroll position and
1655
+ // never sees the whole section blink on every restore/purge.
1656
+ const [tabBusy, setTabBusy] = useState(null)
1657
+ const [error, setError] = useState(null)
1658
+ const [selected, setSelected] = useState(() => new Set())
1659
+ const [purgeTarget, setPurgeTarget] = useState(null)
1660
+ const [purgePhrase, setPurgePhrase] = useState('')
1661
+ const [discardTarget, setDiscardTarget] = useState(null)
1662
+ const [discardPhrase, setDiscardPhrase] = useState('')
1663
+ const [restoreBusyId, setRestoreBusyId] = useState(null)
1664
+ const [purgeBusy, setPurgeBusy] = useState(false)
1665
+ const [discardBusyId, setDiscardBusyId] = useState(null)
1666
+
1667
+ // Two refreshes can overlap (mount, mutation broadcast, and the refresh a
1668
+ // purge triggers), and the older response must not overwrite the newer.
1669
+ const refreshSerial = useRef(0)
1670
+ // The mutation version each tab's data was fetched at. `-1` = never.
1671
+ const loadedRef = useRef({ trash: -1, batch: -1, audit: -1 })
1672
+
1673
+ const refreshState = useCallback(async () => {
1674
+ const response = await api('/state')
1675
+ setState(response.state)
1676
+ }, [])
1677
+
1678
+ /**
1679
+ * Fetch only what the visible tab renders. The section used to pull
1680
+ * `/state`, `/sessions`, `/trash` and `/audit` on every mount and every
1681
+ * mutation (~31 KB, 4 round trips) even when the user was looking at one
1682
+ * tab. `/sessions` is the expensive one (one projection read per row),
1683
+ * so opening the recycle bin must not pay for it.
1684
+ */
1685
+ const loadTab = useCallback(async (which, { force = false } = {}) => {
1686
+ if (!force && loadedRef.current[which] === mutationVersion) return
1687
+ const serial = refreshSerial.current + 1
1688
+ refreshSerial.current = serial
1689
+ const stale = () => serial !== refreshSerial.current
1690
+ setTabBusy(which)
1691
+ setError(null)
1692
+ try {
1693
+ if (which === 'trash') {
1694
+ const response = await api('/trash')
1695
+ if (stale()) return
1696
+ setTrash(Array.isArray(response.entries) ? response.entries : [])
1697
+ setIncomplete(Array.isArray(response.incomplete) ? response.incomplete : [])
1698
+ setLastSweep(response.lastSweep ?? null)
1699
+ } else if (which === 'batch') {
1700
+ const response = await api('/sessions')
1701
+ if (stale()) return
1702
+ const nextSessions = Array.isArray(response.sessions) ? response.sessions : []
1703
+ setSessions(nextSessions)
1704
+ // Drop selections whose conversation no longer exists.
1705
+ setSelected((current) => {
1706
+ const alive = new Set(nextSessions.map((session) => session.id))
1707
+ const next = new Set([...current].filter((id) => alive.has(id)))
1708
+ return next.size === current.size ? current : next
1709
+ })
1710
+ } else {
1711
+ const response = await api('/audit?limit=50')
1712
+ if (stale()) return
1713
+ setAudit(Array.isArray(response.records) ? response.records : [])
1714
+ }
1715
+ loadedRef.current[which] = mutationVersion
1716
+ } catch (caught) {
1717
+ if (!stale()) setError(caught)
1718
+ } finally {
1719
+ if (!stale()) setTabBusy(null)
1720
+ }
1721
+ }, [mutationVersion])
1722
+
1723
+ // `/state` drives the capability block and the limits; refetch it on
1724
+ // mount and whenever a mutation happened, not on every tab switch.
1725
+ useEffect(() => {
1726
+ refreshState().catch((caught) => setError(caught))
1727
+ }, [mutationVersion, refreshState])
1728
+
1729
+ // The active tab's own slice, on mount, on tab switch, and after a
1730
+ // mutation (which changes `loadTab`'s identity through the version).
1731
+ useEffect(() => {
1732
+ void loadTab(tab)
1733
+ }, [tab, loadTab])
1734
+
1735
+ /** Header "refresh" button: re-read state and the visible tab. */
1736
+ const refresh = useCallback(async () => {
1737
+ await Promise.all([
1738
+ refreshState().catch((caught) => setError(caught)),
1739
+ loadTab(tab, { force: true }),
1740
+ ])
1741
+ }, [tab, refreshState, loadTab])
1742
+
1743
+ /**
1744
+ * Mark a slice stale without fetching it. Recycle-bin actions call
1745
+ * `refreshAfterMutation({ notify: false })`, so no version bump happens:
1746
+ * the visible tab is re-read by `refresh()`, and any other tab must be
1747
+ * re-read the next time the user opens it instead of showing rows that
1748
+ * were deleted a moment ago.
1749
+ */
1750
+ const markStale = useCallback((...which) => {
1751
+ for (const name of which) loadedRef.current[name] = -1
1752
+ }, [])
1753
+
1754
+ async function restore(trashId) {
1755
+ setError(null)
1756
+ const entry = trash.find((item) => item.trashId === trashId)
1757
+ try {
1758
+ await api('/trash/restore', {
1759
+ method: 'POST',
1760
+ body: { trashId },
1761
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1762
+ })
1763
+ flashStore.show(t('trash.restored'))
1764
+ markStale('trash', 'batch')
1765
+ await refresh()
1766
+ await refreshAfterMutation({
1767
+ sessionIds: typeof entry?.sessionId === 'string' ? [entry.sessionId] : [],
1768
+ closeCurrent: false,
1769
+ notify: false,
1770
+ })
1771
+ } catch (caught) {
1772
+ setError(caught)
1773
+ }
1774
+ }
1775
+
1776
+ /**
1777
+ * Restore an entry that never committed (pending / failed / interrupted).
1778
+ * The same endpoint as the recycle-bin restore: `performRestore` accepts
1779
+ * those statuses, and this is the action `performDiscard` tells the user
1780
+ * to take when the entry still holds a session log.
1781
+ */
1782
+ async function restoreIncomplete(trashId) {
1783
+ setError(null)
1784
+ setRestoreBusyId(trashId)
1785
+ try {
1786
+ await api('/trash/restore', {
1787
+ method: 'POST',
1788
+ body: { trashId },
1789
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1790
+ })
1791
+ flashStore.show(t('trash.restored'))
1792
+ markStale('trash', 'batch')
1793
+ await refresh()
1794
+ await refreshAfterMutation({
1795
+ sessionIds: incomplete.find((item) => item.trashId === trashId)?.sessionId
1796
+ ? [incomplete.find((item) => item.trashId === trashId).sessionId]
1797
+ : [],
1798
+ closeCurrent: false,
1799
+ notify: false,
1800
+ })
1801
+ } catch (caught) {
1802
+ setError(caught)
1803
+ } finally {
1804
+ setRestoreBusyId(null)
1805
+ }
1806
+ }
1807
+
1808
+ async function purge(trashId, phrase) {
1809
+ if (purgeBusy === true) return
1810
+ setPurgeBusy(true)
1811
+ setError(null)
1812
+ const entry = trash.find((item) => item.trashId === trashId)
1813
+ try {
1814
+ await api('/trash/purge', {
1815
+ method: 'POST',
1816
+ body: { trashId, confirmPhrase: phrase },
1817
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1818
+ })
1819
+ flashStore.show(t('trash.purged'))
1820
+ setPurgeTarget(null)
1821
+ setPurgePhrase('')
1822
+ markStale('trash', 'batch')
1823
+ await refresh()
1824
+ // Purging a soft entry detaches the session from its workspace.
1825
+ await refreshAfterMutation({
1826
+ sessionIds: typeof entry?.sessionId === 'string' ? [entry.sessionId] : [],
1827
+ closeCurrent: false,
1828
+ notify: false,
1829
+ })
1830
+ } catch (caught) {
1831
+ setError(caught)
1832
+ } finally {
1833
+ setPurgeBusy(false)
1834
+ }
1835
+ }
1836
+
1837
+ async function discard(trashId, phrase) {
1838
+ if (discardBusyId !== null) return
1839
+ setDiscardBusyId(trashId)
1840
+ setError(null)
1841
+ try {
1842
+ await api('/trash/discard', {
1843
+ method: 'POST',
1844
+ body: { trashId, confirmPhrase: phrase },
1845
+ timeoutMs: TRASH_MUTATION_TIMEOUT_MS,
1846
+ })
1847
+ flashStore.show(t('trash.discarded'))
1848
+ setDiscardTarget(null)
1849
+ setDiscardPhrase('')
1850
+ markStale('trash')
1851
+ await refresh()
1852
+ } catch (caught) {
1853
+ setError(caught)
1854
+ } finally {
1855
+ setDiscardBusyId(null)
1856
+ }
1857
+ }
1858
+
1859
+ function toggle(id) {
1860
+ setSelected((current) => {
1861
+ const next = new Set(current)
1862
+ if (next.has(id)) next.delete(id)
1863
+ else next.add(id)
1864
+ return next
1865
+ })
1866
+ }
1867
+
1868
+ const selectedIds = useMemo(() => [...selected], [selected])
1869
+ const effectiveBatch = Number.isFinite(state?.limits?.effectiveBatch)
1870
+ ? state.limits.effectiveBatch
1871
+ : (state?.limits?.maxBatchSessions ?? 0)
1872
+ const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
1873
+ // The Host purges entries on its own after `trashRetentionDays`; both the
1874
+ // banner and the per-row hint come from `retentionInfo`, which reads the
1875
+ // field where the Host actually sends it (`limits`).
1876
+ const retentionDays = retentionInfo(state).days
1877
+ const expiryHint = (entry) => {
1878
+ const left = retentionInfo(state, entry).daysLeft
1879
+ if (left === null) return ''
1880
+ return ` · ${left === 0 ? t('trash.expiresSoon') : t('trash.expiresIn', { days: left })}`
1881
+ }
1882
+
1883
+ const openFeedback = () => dialogStore.open({
1884
+ kind: 'feedback',
1885
+ key: 'feedback',
1886
+ version: typeof state?.version === 'string' ? state.version : '',
1887
+ feedback: state?.feedback ?? null,
1888
+ })
1889
+
1890
+ return h('div', { className: 'dc-root' },
1891
+ // Top-of-section call to action, above the title: it is the first thing
1892
+ // a user unsure about the plugin should see. Rendered only when the Host
1893
+ // reports a configured transport, so a profile without mail settings
1894
+ // never shows a button that cannot work (the dialog would otherwise
1895
+ // explain a failure to send).
1896
+ state?.feedback?.enabled === true
1897
+ ? h('div', { className: 'dc-feedback-bar' },
1898
+ h('div', { className: 'dc-feedback-bar-text' },
1899
+ h('div', { className: 'dc-feedback-bar-title' }, t('feedback.bannerTitle')),
1900
+ h('div', { className: 'dc-feedback-bar-hint' }, t('feedback.bannerHint'))),
1901
+ h(Button, { variant: 'primary', title: t('feedback.button'), onClick: openFeedback },
1902
+ h(GlyphFeedback, { size: 14 }), ' ', t('feedback.button')))
1903
+ : null,
1904
+ h('div', { className: 'dc-header' },
1905
+ h('div', null,
1906
+ h('h1', { className: 'dc-title' }, t('section.title')),
1907
+ h('p', { className: 'dc-hint' }, t('section.subtitle'))),
1908
+ h('div', { className: 'dc-header-actions' },
1909
+ h(Button, { onClick: () => { void refresh() }, disabled: tabBusy !== null, title: t('action.refresh') },
1910
+ h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
1911
+
1912
+ h(ErrorBanner, { error }),
1913
+
1914
+ state !== null ? h('div', { className: 'dc-state' },
1915
+ h('div', { className: 'dc-state-block' },
1916
+ h('div', { className: 'dc-state-title' }, t('state.memory')),
1917
+ ...(state.memory?.adapters ?? []).map((adapter) => h('div', {
1918
+ key: adapter.id,
1919
+ className: 'dc-state-row',
1920
+ },
1921
+ h('span', { className: adapter.available ? 'dc-ok' : 'dc-off' }, adapter.available ? '●' : '○'),
1922
+ h('span', null, ` ${adapterLabel(adapter)} `),
1923
+ h('span', { className: 'dc-hint' }, adapter.available ? t('state.available') : `${t('state.unavailable')} — ${adapterReason(adapter.reason)}`)))),
1924
+ h('div', { className: 'dc-state-block' },
1925
+ h('div', { className: 'dc-state-title' }, t('state.limits')),
1926
+ h('div', { className: 'dc-hint' },
1927
+ t('state.rate', {
1928
+ used: state.limits?.rate?.used ?? 0,
1929
+ limit: state.limits?.rate?.limit ?? state.limits?.maxDeletesPerMinute ?? 0,
1930
+ })),
1931
+ h('div', { className: 'dc-hint' }, t('state.batch', { limit: effectiveBatch > 0 ? effectiveBatch : '—' })))) : null,
1932
+
1933
+ h('div', { className: 'dc-tabs', role: 'tablist' },
1934
+ h('button', { id: 'dc-tab-trash', type: 'button', role: 'tab', 'aria-selected': tab === 'trash', 'aria-controls': 'dc-panel-trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
1935
+ h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1936
+ h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1937
+
1938
+ // First paint only: once anything has been rendered, a refresh keeps the
1939
+ // panels mounted and shows the inline indicator below instead.
1940
+ state === null && tabBusy !== null ? h(Spinner, { label: t('dialog.loading') }) : null,
1941
+ state !== null && tabBusy !== null
1942
+ ? h('div', { className: 'dc-loading' }, h('span', { className: 'dc-spinner' }), ' ', t('dialog.loading'))
1943
+ : null,
1944
+
1945
+ tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1946
+ retentionDays > 0 ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.retention', { days: retentionDays })) : null,
1947
+ Number(lastSweep?.purged) > 0
1948
+ ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.swept', { count: Number(lastSweep.purged) }))
1949
+ : null,
1950
+ trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
1951
+ ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1952
+ h('div', { className: 'dc-row-main' },
1953
+ h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId),
1954
+ h('div', { className: 'dc-hint' },
1955
+ `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${expiryHint(entry)}`)),
1956
+ h('div', { className: 'dc-row-actions' },
1957
+ // The Host already knows whether this entry can be restored;
1958
+ // the panel used to offer the button and then show the refusal.
1959
+ h(Button, {
1960
+ disabled: entry.capabilities?.canRestore === false,
1961
+ title: entry.capabilities?.canRestore === false ? t('error.E_TRASH_CONFLICT') : t('action.restore'),
1962
+ onClick: () => { void restore(entry.trashId) },
1963
+ }, t('action.restore')),
1964
+ purgeTarget === entry.trashId
1965
+ ? h('span', { className: 'dc-inline-confirm' },
1966
+ h('input', {
1967
+ type: 'text', value: purgePhrase, placeholder: 'DELETE', className: 'dc-input dc-input-small',
1968
+ onChange: (event) => setPurgePhrase(event.target.value),
1969
+ }),
1970
+ h(Button, {
1971
+ variant: 'danger',
1972
+ disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
1973
+ onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1974
+ }, t('action.purge')),
1975
+ h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1976
+ : h(Button, {
1977
+ variant: 'danger',
1978
+ disabled: entry.capabilities?.canPurge === false,
1979
+ title: entry.capabilities?.canPurge === false ? t('error.E_TRASH_CONFLICT') : t('action.purge'),
1980
+ onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') },
1981
+ }, t('action.purge')))))) : null,
1982
+
1983
+ tab === 'trash' && incomplete.length > 0 ? h(IncompleteEntries, {
1984
+ entries: incomplete,
1985
+ discardTarget,
1986
+ discardPhrase,
1987
+ onPhrase: setDiscardPhrase,
1988
+ onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase('') },
1989
+ onCancel: () => { setDiscardTarget(null); setDiscardPhrase('') },
1990
+ onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim()) },
1991
+ onRestore: (trashId) => { void restoreIncomplete(trashId) },
1992
+ restoreBusyId,
1993
+ discardBusyId,
1994
+ }) : null,
1995
+
1996
+ tab === 'batch' ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
1997
+ h('div', { className: 'dc-batch-bar' },
1998
+ h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
1999
+ // "Select all" only marks deletable rows: a batch containing one
2000
+ // subagent/orphan/out-of-root conversation is refused as a whole,
2001
+ // so selecting it guaranteed a dead button with no hint why.
2002
+ h(Button, {
2003
+ onClick: () => setSelected(new Set(selectableSessionIds(sessions))),
2004
+ title: t('batch.selectAllDeletable'),
2005
+ }, t('action.selectAll')),
2006
+ h(Button, { onClick: () => setSelected(new Set()) }, t('action.clearAll')),
2007
+ h(Button, {
2008
+ variant: 'danger',
2009
+ disabled: selectedIds.length === 0 || overLimit,
2010
+ onClick: () => dialogStore.open({ sessionIds: selectedIds, displayTitle: '', key: `batch:${selectedIds.join(',')}` }),
2011
+ }, t('batch.deleteSelected'))),
2012
+ h('p', { className: 'dc-hint' }, t('batch.hint')),
2013
+ overLimit ? h('p', { className: 'dc-error-text' }, t('batch.overLimit', { limit: effectiveBatch })) : null,
2014
+ sessions.length === 0 ? h('div', { className: 'dc-empty' }, t('batch.empty')) : null,
2015
+ ...sessions.map((session) => h(BatchSessionRow, {
2016
+ key: session.id,
2017
+ session,
2018
+ checked: selected.has(session.id),
2019
+ onToggle: toggle,
2020
+ }))) : null,
2021
+
2022
+ tab === 'audit' ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
2023
+ audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
2024
+ ...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
2025
+ h('div', { className: 'dc-row-main' },
2026
+ h('div', { className: 'dc-row-title' },
2027
+ `${record.action ?? ''} · ${record.sessionId ?? (Array.isArray(record.sessionIds) ? record.sessionIds.join(', ') : '')}`),
2028
+ h('div', { className: 'dc-hint' },
2029
+ `${formatTime(record.at)} · ${t(`audit.${record.result ?? 'success'}`) ?? record.result ?? ''}`
2030
+ + (record.mode ? ` · ${record.mode}` : '')
2031
+ + (record.includeMemory ? ' · memory' : '')
2032
+ // Automatic clean-up writes the same shape as a manual purge;
2033
+ // without the source the two are indistinguishable in the UI.
2034
+ + (typeof record.source === 'string' && record.source !== 'web-ui' ? ` · ${record.source}` : '')
2035
+ + (record.error?.message ? ` · ${record.error.message}` : ''))),
2036
+ h('div', { className: 'dc-row-time' }, record.actor ?? '')))) : null)
2037
+ }
2038
+
2039
+ /* ------------------------------------------------------------------ *
2040
+ * Stylesheet (one element owned by this plugin's fiber)
2041
+ * ------------------------------------------------------------------ */
2042
+
2043
+ const CSS = `
2044
+ /* 1300 clears the settings modal (1000) and the host panels that also use
2045
+ 1100, since this backlog shares document.body with them. */
2046
+ .dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
2047
+ .dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
2048
+ .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
2049
+ .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
2050
+ .dc-section{margin-top:10px}
2051
+ .dc-meta{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin-bottom:10px}
2052
+ .dc-modes{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
2053
+ .dc-mode{display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;cursor:pointer;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2054
+ .dc-live-warning{margin-top:10px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-warn-label,#b26a00);background:var(--dsw-alias-state-warn-tertiary,#fff7e6)}
2055
+ .dc-mode input{margin-top:3px}
2056
+ .dc-mode-title{display:block;font-size:13px;font-weight:600}
2057
+ .dc-hint{display:block;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
2058
+ .dc-memory{margin:12px 0;padding:10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px}
2059
+ .dc-memory-count{font-size:12px;margin-top:6px;color:var(--dsw-alias-label-primary,#1f2329)}
2060
+ .dc-check{display:flex;gap:8px;align-items:flex-start;cursor:pointer}
2061
+ .dc-check.dc-disabled{opacity:.6;cursor:not-allowed}
2062
+ .dc-check input{margin-top:3px}
2063
+ .dc-check-text{min-width:0}
2064
+ .dc-check-label{display:block;font-size:13px;font-weight:500}
2065
+ .dc-phrase{display:block;margin:12px 0}
2066
+ .dc-phrase-label{display:block;font-size:12px;color:var(--dsw-alias-state-warn-primary,#d46b08);margin-bottom:5px;line-height:1.5}
2067
+ .dc-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);border-radius:7px;background:var(--dsw-alias-bg-base,#fff);color:inherit;font:inherit;font-size:13px}
2068
+ .dc-input-small{width:90px;height:28px}
2069
+ .dc-header-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
2070
+ .dc-field{display:flex;flex-direction:column;gap:5px;margin:10px 0}
2071
+ .dc-field-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}
2072
+ .dc-textarea{height:auto;min-height:96px;padding:8px 9px;line-height:1.55;resize:vertical}
2073
+ .dc-context{margin:12px 0 4px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2074
+ .dc-context-title{font-size:12px;font-weight:600;margin-bottom:2px}
2075
+ .dc-context-list{margin:6px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--dsw-alias-label-secondary,#646a73);word-break:break-all}
2076
+ .dc-actions{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:14px;flex-wrap:wrap}
2077
+ .dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
2078
+ .dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
2079
+ .dc-btn:disabled{opacity:.5;cursor:not-allowed}
2080
+ /* --dsw-alias-brand-primary is a FILL that flips with the theme: near-black
2081
+ (#0f1115) in light, near-white (#f9fafb) in dark. Pairing it with a hard-coded
2082
+ white label made the primary button's text invisible on dark (contrast
2083
+ ~1.04:1) — that is the "确认按钮消失" report. The design system's counterpart
2084
+ for text on a primary fill is --dsw-alias-label-primary-inverted: white in
2085
+ light, neutral-800 (#353638) in dark. */
2086
+ .dc-btn-primary{background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4d6bfe));border-color:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4d6bfe));color:var(--dsw-alias-label-primary-inverted,#fff)}
2087
+ .dc-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#4d6bfe))}
2088
+ .dc-btn-danger{color:var(--dsw-alias-state-error-primary,#d93026);border-color:var(--dsw-alias-state-error-primary,#d93026);background:transparent}
2089
+ .dc-btn-danger:hover:not(:disabled){background:rgba(217,48,38,.08)}
2090
+ .dc-error{font-size:12px;line-height:1.55;padding:8px 10px;border-radius:8px;margin:8px 0;color:var(--dsw-alias-state-error-primary,#d93026);background:rgba(217,48,38,.08);border:1px solid rgba(217,48,38,.28)}
2091
+ .dc-error-text{font-size:12px;color:var(--dsw-alias-state-error-primary,#d93026);margin:6px 0}
2092
+ .dc-failures{margin:6px 0 0;padding-left:18px;font-size:12px;line-height:1.6;color:var(--dsw-alias-state-error-primary,#d93026)}
2093
+ .dc-incomplete{margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#e5e6eb)}
2094
+ .dc-loading{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);padding:8px 0}
2095
+ .dc-spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--dsw-alias-border-l2,#c9cdd4);border-top-color:var(--dsw-alias-brand-primary,#4d6bfe);animation:dc-spin .8s linear infinite;display:inline-block}
2096
+ @keyframes dc-spin{to{transform:rotate(360deg)}}
2097
+ .dc-menu-item{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 10px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;text-align:left;cursor:pointer}
2098
+ .dc-menu-item:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2)}
2099
+ .dc-menu-item.dc-danger{color:var(--dsw-alias-state-error-primary,#d93026)}
2100
+ .dc-menu-item.dc-danger:hover{background:rgba(217,48,38,.1)}
2101
+ .dc-menu-icon{display:inline-flex;align-items:center}
2102
+ .dc-header-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer}
2103
+ .dc-header-action:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2);color:var(--dsw-alias-state-error-primary,#d93026)}
2104
+ .dc-root{padding:18px 22px 32px;max-width:900px;font:inherit;color:var(--dsw-alias-label-primary,#1f2329)}
2105
+ .dc-feedback-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 14px;margin-bottom:16px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2106
+ .dc-feedback-bar-text{min-width:220px;flex:1 1 auto}
2107
+ .dc-feedback-bar-title{font-size:13px;font-weight:600;line-height:1.5}
2108
+ .dc-feedback-bar-hint{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
2109
+ .dc-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:14px}
2110
+ .dc-title{font-size:17px;font-weight:600;margin:0}
2111
+ .dc-state{display:flex;gap:24px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);margin-bottom:14px}
2112
+ .dc-state-block{min-width:220px}
2113
+ .dc-state-title{font-size:12px;font-weight:600;margin-bottom:4px}
2114
+ .dc-state-row{font-size:12px;line-height:1.7}
2115
+ .dc-ok{color:var(--dsw-alias-state-success-primary,#00a870)}
2116
+ .dc-off{color:var(--dsw-alias-state-idle-primary,#8f959e)}
2117
+ .dc-tabs{display:flex;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l1,#e5e6eb);margin-bottom:12px}
2118
+ .dc-tab{border:0;background:transparent;padding:8px 12px;font:inherit;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
2119
+ .dc-tab-active{color:var(--dsw-alias-label-primary,#1f2329);border-bottom-color:var(--dsw-alias-brand-primary,#4d6bfe);font-weight:600}
2120
+ .dc-panel{display:flex;flex-direction:column;gap:8px}
2121
+ .dc-empty{padding:18px;text-align:center;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
2122
+ .dc-row{display:flex;gap:12px;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2123
+ .dc-row-select{cursor:pointer}
2124
+ .dc-row-select.dc-disabled{opacity:.55;cursor:not-allowed}
2125
+ .dc-row-main{min-width:0;flex:1}
2126
+ .dc-row-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2127
+ .dc-row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
2128
+ .dc-row-time{font-size:11px;color:var(--dsw-alias-label-secondary,#646a73);white-space:nowrap}
2129
+ .dc-inline-confirm{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
2130
+ .dc-batch-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
2131
+ .dc-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;box-shadow:0 10px 30px rgba(0,0,0,.22);padding:9px 14px;font-size:12px;max-width:80vw}
2132
+ `
2133
+
2134
+ /* ------------------------------------------------------------------ *
2135
+ * Plugin entry
2136
+ * ------------------------------------------------------------------ */
2137
+
2138
+ function apply(ctx) {
2139
+ runtimeCtx = ctx
2140
+ const slots = ctx.slots ?? (typeof ctx.get === 'function' ? ctx.get('slots') : undefined)
2141
+ if (slots === undefined || slots === null || typeof slots.inject !== 'function') {
2142
+ console.error('[conversation-recycle-bin] slots service unavailable; UI not registered')
2143
+ return
2144
+ }
2145
+
2146
+ ctx.effect(() => {
2147
+ const style = document.createElement('style')
2148
+ style.setAttribute('data-dsh-plugin', NS)
2149
+ style.textContent = CSS
2150
+ document.head.appendChild(style)
2151
+ return () => {
2152
+ if (style.parentNode !== null) style.parentNode.removeChild(style)
2153
+ }
2154
+ })
2155
+
2156
+ // `ui.showRowAction` / `ui.showHeaderAction` are real switches now; the
2157
+ // entries stay visible until the probe answers, so a failed /state call
2158
+ // never removes the feature.
2159
+ void loadUiFlags()
2160
+
2161
+ if (typeof ctx.inject === 'function') {
2162
+ try {
2163
+ ctx.inject(['locale'], (scope) => {
2164
+ localeService = scope.locale ?? null
2165
+ })
2166
+ } catch {
2167
+ // Pre-locale hosts keep the built-in dictionary.
2168
+ }
2169
+ }
2170
+
2171
+ // The dialog lives in the overlay, not inside the menu item: the menu
2172
+ // item unmounts with its dropdown, which would take the dialog with it.
2173
+ ctx.effect(() => slots.inject('shell.overlay', () => slots.register({
2174
+ name: 'shell.overlay',
2175
+ id: 'conversation-recycle-bin-dialog',
2176
+ order: 50,
2177
+ }, () => h(DialogHost))))
2178
+
2179
+ ctx.effect(() => slots.inject('sidebar.workspaces.session.menu.item', () => slots.register({
2180
+ name: 'sidebar.workspaces.session.menu.item',
2181
+ id: 'conversation-recycle-bin',
2182
+ order: 500,
2183
+ }, (props) => h(DeleteMenuRow, props))))
2184
+
2185
+ ctx.effect(() => slots.inject('conversation.session.header.actions', () => slots.register({
2186
+ name: 'conversation.session.header.actions',
2187
+ id: 'conversation-recycle-bin',
2188
+ order: 100,
2189
+ }, (props) => h(HeaderDeleteAction, props))))
2190
+
2191
+ const hasLocaleService = peekLocaleService() !== null
2192
+ ctx.effect(() => slots.inject('settings.section', () => slots.register({
2193
+ name: 'settings.section',
2194
+ id: 'conversation-recycle-bin',
2195
+ order: 1300,
2196
+ // A thunk label is re-read on locale switches, but pre-locale shells
2197
+ // render the label string directly and would crash on a function.
2198
+ label: hasLocaleService ? () => t('section.label') : t('section.label'),
2199
+ }, () => h(SettingsSection))))
2200
+ }
2201
+
2202
+ exports.name = 'conversation-recycle-bin'
2203
+ // Without this Cordis may run apply() before the slot service exists, and
2204
+ // the registration would silently never happen (no retry).
2205
+ exports.inject = ['slots']
2206
+ exports.apply = apply
2207
+ exports.__t = t
2208
+ exports.__errorText = errorText
2209
+ exports.__store = { dialogStore, flashStore, mutationStore, uiFlagsStore }
2210
+ exports.__refreshAfterMutation = refreshAfterMutation
2211
+ exports.__mutationTimeoutMs = mutationTimeoutMs
2212
+ // Exposed so the harness can assert the client budget stays ABOVE the
2213
+ // Host's own TRASH_LOCK_BUDGET_MS: whichever side gives up first decides
2214
+ // whether the user sees "failed" for work that actually completed.
2215
+ exports.__requestBudgets = {
2216
+ request: REQUEST_TIMEOUT_MS,
2217
+ mutation: MUTATION_TIMEOUT_MS,
2218
+ trashMutation: TRASH_MUTATION_TIMEOUT_MS,
2219
+ }
2220
+ exports.__apiUrl = apiUrl
2221
+ exports.__api = api
2222
+ // Direct component handles keep the hook-level behavior testable without a
2223
+ // DOM renderer (the registered wrapper alone only builds an element).
2224
+ exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow, FeedbackDialog }
2225
+ exports.__validateFeedbackInput = validateFeedbackInput
2226
+ exports.__feedbackLimits = FEEDBACK_LIMITS
2227
+ exports.__selectableSessionIds = selectableSessionIds
2228
+ // Exposed because the retention notice lives inside a hook-heavy component
2229
+ // the harness cannot render: this is the piece that decides whether the user
2230
+ // is told at all, so it must be assertable against a real Host payload.
2231
+ exports.__retentionInfo = retentionInfo
2232
+ exports.__adapterLabel = adapterLabel
2233
+ exports.__adapterReason = adapterReason
2234
+ exports.__loadUiFlags = loadUiFlags
2235
+ return module.exports
2236
+ },
2237
+ })