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/CHANGELOG.md +454 -437
- package/README.md +8 -8
- package/client.js +2237 -2221
- package/lib/core/cascade.js +682 -658
- package/lib/host/api.js +1508 -1435
- package/lib/host/catalog.js +9 -2
- package/lib/host/paths.js +136 -1
- package/lib/version.js +8 -8
- package/package.json +81 -81
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) =>
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
entry.hasSessionLog === true
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
})
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
const
|
|
1643
|
-
const [
|
|
1644
|
-
const [
|
|
1645
|
-
const [
|
|
1646
|
-
const [
|
|
1647
|
-
const [
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
//
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
const
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
const
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
h('
|
|
1907
|
-
h('
|
|
1908
|
-
h('div', { className: 'dc-
|
|
1909
|
-
h(
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
trash
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
:
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
.
|
|
2031
|
-
.
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
.
|
|
2035
|
-
.
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
.
|
|
2046
|
-
.dc-
|
|
2047
|
-
.dc-
|
|
2048
|
-
.dc-
|
|
2049
|
-
.dc-
|
|
2050
|
-
.dc-
|
|
2051
|
-
.dc-
|
|
2052
|
-
.dc-
|
|
2053
|
-
.dc-
|
|
2054
|
-
.dc-
|
|
2055
|
-
.dc-
|
|
2056
|
-
.dc-
|
|
2057
|
-
.dc-
|
|
2058
|
-
.dc-
|
|
2059
|
-
.dc-
|
|
2060
|
-
.dc-
|
|
2061
|
-
.dc-
|
|
2062
|
-
.dc-
|
|
2063
|
-
.dc-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
.dc-
|
|
2071
|
-
.dc-
|
|
2072
|
-
.dc-
|
|
2073
|
-
.dc-
|
|
2074
|
-
.dc-
|
|
2075
|
-
.dc-
|
|
2076
|
-
.dc-
|
|
2077
|
-
.dc-
|
|
2078
|
-
.dc-
|
|
2079
|
-
.dc-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
.
|
|
2084
|
-
|
|
2085
|
-
.
|
|
2086
|
-
.dc-
|
|
2087
|
-
.dc-
|
|
2088
|
-
.dc-
|
|
2089
|
-
.dc-
|
|
2090
|
-
.dc-
|
|
2091
|
-
.dc-
|
|
2092
|
-
.dc-
|
|
2093
|
-
.dc-
|
|
2094
|
-
.dc-
|
|
2095
|
-
.dc-
|
|
2096
|
-
|
|
2097
|
-
.dc-
|
|
2098
|
-
.dc-
|
|
2099
|
-
.dc-
|
|
2100
|
-
.dc-
|
|
2101
|
-
.dc-
|
|
2102
|
-
.dc-
|
|
2103
|
-
.dc-
|
|
2104
|
-
.dc-
|
|
2105
|
-
.dc-
|
|
2106
|
-
.dc-
|
|
2107
|
-
.dc-
|
|
2108
|
-
.dc-
|
|
2109
|
-
.dc-
|
|
2110
|
-
.dc-
|
|
2111
|
-
.dc-
|
|
2112
|
-
.dc-
|
|
2113
|
-
.dc-
|
|
2114
|
-
.dc-
|
|
2115
|
-
.dc-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
//
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
}, () => h(
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
exports.
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
exports.
|
|
2209
|
-
exports.
|
|
2210
|
-
exports.
|
|
2211
|
-
exports.
|
|
2212
|
-
// Exposed
|
|
2213
|
-
//
|
|
2214
|
-
//
|
|
2215
|
-
exports.
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
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
|
+
})
|