dsh-conversation-recycle-bin 0.2.1 → 0.2.2
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/AUDIT-2026-10-17-desktop-web.md +333 -0
- package/CHANGELOG.md +51 -0
- package/INSTALL.md +2 -1
- package/README.md +16 -12
- package/client.js +197 -26
- package/config.schema.json +1 -1
- package/lib/core/cascade.js +53 -19
- package/lib/core/config.js +4 -1
- package/lib/core/errors.js +5 -2
- package/lib/core/file-lock.js +151 -0
- package/lib/core/memory/generic-json.js +2 -2
- package/lib/core/memory/json-index-adapter.js +182 -87
- package/lib/core/memory/lingshu.js +10 -4
- package/lib/core/memory/none.js +4 -2
- package/lib/core/memory/registry.js +9 -6
- package/lib/core/policy.js +4 -4
- package/lib/core/redact.js +66 -0
- package/lib/host/api.js +252 -55
- package/lib/host/catalog.js +101 -36
- package/lib/host/gateway.js +19 -5
- package/lib/host/paths.js +13 -2
- package/lib/version.js +1 -1
- package/package.json +2 -3
- package/locale/en.json +0 -63
- package/locale/zh.json +0 -63
package/client.js
CHANGED
|
@@ -32,6 +32,31 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
33
33
|
const h = React.createElement
|
|
34
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
|
+
|
|
35
60
|
/* ------------------------------------------------------------------ *
|
|
36
61
|
* Locale
|
|
37
62
|
* ------------------------------------------------------------------ */
|
|
@@ -49,6 +74,7 @@ window.__ModuleLoader__.load({
|
|
|
49
74
|
'action.purge': 'Delete permanently',
|
|
50
75
|
'action.selectAll': 'Select all',
|
|
51
76
|
'action.clearAll': 'Clear',
|
|
77
|
+
'batch.selectAllDeletable': 'Select every conversation that can be deleted',
|
|
52
78
|
'section.label': 'Conversation Deletion',
|
|
53
79
|
'section.title': 'Conversation Deletion',
|
|
54
80
|
'section.subtitle': 'Recycle bin, batch deletion and the audit trail.',
|
|
@@ -70,7 +96,6 @@ window.__ModuleLoader__.load({
|
|
|
70
96
|
'dialog.memoryKeep': 'The Host is configured to keep memory entries (deleteMode = keep).',
|
|
71
97
|
'dialog.memoryDisabled': 'Associated-memory deletion is disabled in the Host configuration.',
|
|
72
98
|
'dialog.records': '{events} log records · {size}',
|
|
73
|
-
'dialog.hardWarning': 'Type DELETE to confirm permanent deletion.',
|
|
74
99
|
'dialog.batchWarning': 'Type the number {count} to confirm this batch.',
|
|
75
100
|
'dialog.liveStopHint': 'This conversation is open in a live agent. Permanent deletion stops it first, then removes its log.',
|
|
76
101
|
'dialog.phraseLabel': 'Confirmation',
|
|
@@ -93,6 +118,7 @@ window.__ModuleLoader__.load({
|
|
|
93
118
|
'trash.purged': 'Permanently deleted.',
|
|
94
119
|
'trash.incompleteTitle': 'Incomplete entries',
|
|
95
120
|
'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.',
|
|
121
|
+
'trash.restoreIncompleteHint': 'Put the conversation log back and re-attach it to its workspace.',
|
|
96
122
|
'trash.discard': 'Discard',
|
|
97
123
|
'trash.discarded': 'Entry removed.',
|
|
98
124
|
'trash.confirmDiscard': 'Type DELETE to discard this incomplete entry.',
|
|
@@ -107,6 +133,7 @@ window.__ModuleLoader__.load({
|
|
|
107
133
|
'batch.deleteSelected': 'Delete selected…',
|
|
108
134
|
'batch.empty': 'No conversations.',
|
|
109
135
|
'batch.overLimit': 'The selection exceeds the current limit of {limit}.',
|
|
136
|
+
'batch.notDeletable': 'cannot be deleted',
|
|
110
137
|
'audit.title': 'Audit trail',
|
|
111
138
|
'audit.empty': 'No deletion has been recorded yet.',
|
|
112
139
|
'audit.denied': 'denied',
|
|
@@ -144,6 +171,12 @@ window.__ModuleLoader__.load({
|
|
|
144
171
|
'error.E_FORBIDDEN_HOST': 'The Host only accepts deletion requests from loopback; set allowNonLoopback for remote access.',
|
|
145
172
|
'error.E_FORBIDDEN_ORIGIN': 'The Host rejected this page as an untrusted origin.',
|
|
146
173
|
'error.E_POLICY_FOREIGN_WRITER': 'Another DSH instance is still writing this conversation; close it there and retry.',
|
|
174
|
+
'error.E_LOCK_BUSY': 'Another DSH instance is using this recycle bin; retry in a moment.',
|
|
175
|
+
'error.E_UNAUTHORIZED': 'This request is not authenticated with the Host.',
|
|
176
|
+
'adapter.lingshu': 'Lingshu cognitive graph (md_cg)',
|
|
177
|
+
'adapter.generic-json': 'Generic JSON memory store',
|
|
178
|
+
'adapter.none': 'No memory store',
|
|
179
|
+
'adapter.reason.no-memory-store': 'No conversation-linked memory store was detected.',
|
|
147
180
|
'common.bytes': '{value} {unit}',
|
|
148
181
|
},
|
|
149
182
|
zh: {
|
|
@@ -157,6 +190,7 @@ window.__ModuleLoader__.load({
|
|
|
157
190
|
'action.purge': '彻底删除',
|
|
158
191
|
'action.selectAll': '全选',
|
|
159
192
|
'action.clearAll': '清除',
|
|
193
|
+
'batch.selectAllDeletable': '全选所有可删除的对话',
|
|
160
194
|
'section.label': '对话删除',
|
|
161
195
|
'section.title': '对话删除',
|
|
162
196
|
'section.subtitle': '回收站、批量删除与审计留痕。',
|
|
@@ -178,7 +212,6 @@ window.__ModuleLoader__.load({
|
|
|
178
212
|
'dialog.memoryKeep': '宿主配置为保留记忆(deleteMode = keep)。',
|
|
179
213
|
'dialog.memoryDisabled': '宿主配置已禁用关联记忆删除。',
|
|
180
214
|
'dialog.records': '{events} 条日志记录 · {size}',
|
|
181
|
-
'dialog.hardWarning': '彻底删除需输入 DELETE 确认。',
|
|
182
215
|
'dialog.batchWarning': '批量删除需输入数字 {count} 确认。',
|
|
183
216
|
'dialog.liveStopHint': '该对话仍在运行中;彻底删除会先停止它,再移除日志。',
|
|
184
217
|
'dialog.phraseLabel': '确认',
|
|
@@ -201,6 +234,7 @@ window.__ModuleLoader__.load({
|
|
|
201
234
|
'trash.purged': '已彻底删除。',
|
|
202
235
|
'trash.incompleteTitle': '未完成条目',
|
|
203
236
|
'trash.incompleteHint': '这些条目没有提交成功,可直接清理以释放空间;仍保存着会话日志的条目请先恢复或彻底删除。',
|
|
237
|
+
'trash.restoreIncompleteHint': '把会话日志放回原位,并重新挂回它所属的工作区。',
|
|
204
238
|
'trash.discard': '清理',
|
|
205
239
|
'trash.discarded': '条目已清理。',
|
|
206
240
|
'trash.confirmDiscard': '输入 DELETE 清理该未完成条目。',
|
|
@@ -215,6 +249,7 @@ window.__ModuleLoader__.load({
|
|
|
215
249
|
'batch.deleteSelected': '删除所选…',
|
|
216
250
|
'batch.empty': '没有对话。',
|
|
217
251
|
'batch.overLimit': '所选数量超过当前上限 {limit} 个。',
|
|
252
|
+
'batch.notDeletable': '无法删除',
|
|
218
253
|
'audit.title': '审计留痕',
|
|
219
254
|
'audit.empty': '尚无删除记录。',
|
|
220
255
|
'audit.denied': '被拒绝',
|
|
@@ -252,6 +287,12 @@ window.__ModuleLoader__.load({
|
|
|
252
287
|
'error.E_FORBIDDEN_HOST': '宿主只接受本机回环地址的删除请求;远程访问需设置 allowNonLoopback。',
|
|
253
288
|
'error.E_FORBIDDEN_ORIGIN': '宿主拒绝了该页面的请求(来源校验未通过)。',
|
|
254
289
|
'error.E_POLICY_FOREIGN_WRITER': '另一个 DSH 实例仍在写这个对话,已中止删除;请先关闭那边的对话再重试。',
|
|
290
|
+
'error.E_LOCK_BUSY': '另一个 DSH 实例正在使用回收站,请稍后重试。',
|
|
291
|
+
'error.E_UNAUTHORIZED': '该请求未通过宿主的鉴权。',
|
|
292
|
+
'adapter.lingshu': '灵枢认知图 (md_cg)',
|
|
293
|
+
'adapter.generic-json': '通用 JSON 记忆存储',
|
|
294
|
+
'adapter.none': '无记忆存储',
|
|
295
|
+
'adapter.reason.no-memory-store': '未检测到与对话关联的记忆存储。',
|
|
255
296
|
'common.bytes': '{value} {unit}',
|
|
256
297
|
},
|
|
257
298
|
}
|
|
@@ -274,7 +315,11 @@ window.__ModuleLoader__.load({
|
|
|
274
315
|
const service = peekLocaleService()
|
|
275
316
|
try {
|
|
276
317
|
const snapshot = service?.getSnapshot?.() ?? service?.getLocale?.()
|
|
277
|
-
|
|
318
|
+
// `dsh-client-locale`'s snapshot is `{ active, locales, revision }`:
|
|
319
|
+
// the ACTIVE locale id lives in `active`, and neither `locale` nor
|
|
320
|
+
// `id` exists. Reading the wrong fields silently fell back to the
|
|
321
|
+
// browser language, so the UI ignored the in-app language switch.
|
|
322
|
+
const id = snapshot?.active ?? snapshot?.locale ?? snapshot?.id
|
|
278
323
|
if (typeof id === 'string' && id !== '') return id
|
|
279
324
|
} catch {
|
|
280
325
|
// fall through
|
|
@@ -312,6 +357,25 @@ window.__ModuleLoader__.load({
|
|
|
312
357
|
})
|
|
313
358
|
}
|
|
314
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Localized adapter label. Host labels are deliberately neutral ids: the
|
|
362
|
+
* Chinese lingshu label and the English `none` reason used to appear
|
|
363
|
+
* untranslated in the other language's UI.
|
|
364
|
+
*/
|
|
365
|
+
function adapterLabel(adapter) {
|
|
366
|
+
const key = `adapter.${adapter?.id ?? ''}`
|
|
367
|
+
const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
|
|
368
|
+
return table[key] ?? adapter?.label ?? adapter?.id ?? '—'
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Localize the stable reason keys; anything else is a raw Host message. */
|
|
372
|
+
function adapterReason(reason) {
|
|
373
|
+
if (typeof reason !== 'string' || reason === '') return ''
|
|
374
|
+
const key = `adapter.reason.${reason}`
|
|
375
|
+
const table = isZhLocale(currentLocaleId()) ? STRINGS.zh : STRINGS.en
|
|
376
|
+
return table[key] ?? reason
|
|
377
|
+
}
|
|
378
|
+
|
|
315
379
|
function useLocaleTick() {
|
|
316
380
|
const [, force] = useState(0)
|
|
317
381
|
useEffect(() => {
|
|
@@ -471,10 +535,12 @@ window.__ModuleLoader__.load({
|
|
|
471
535
|
/**
|
|
472
536
|
* Host UI flags (`ui.showRowAction` / `ui.showHeaderAction`) used to be
|
|
473
537
|
* dead configuration: the client registered both slots unconditionally.
|
|
538
|
+
* The same probe carries `defaultMode`, which the dialog used to ignore
|
|
539
|
+
* (it always started on soft).
|
|
474
540
|
*/
|
|
475
541
|
const uiFlagsStore = {
|
|
476
542
|
loaded: false,
|
|
477
|
-
flags: { showRowAction: true, showHeaderAction: true, enabled: true },
|
|
543
|
+
flags: { showRowAction: true, showHeaderAction: true, enabled: true, defaultMode: 'soft' },
|
|
478
544
|
listeners: new Set(),
|
|
479
545
|
set(flags) {
|
|
480
546
|
this.flags = flags
|
|
@@ -502,10 +568,12 @@ window.__ModuleLoader__.load({
|
|
|
502
568
|
try {
|
|
503
569
|
const response = await api('/state')
|
|
504
570
|
const ui = response?.state?.ui
|
|
571
|
+
const defaultMode = response?.state?.defaultMode
|
|
505
572
|
uiFlagsStore.set({
|
|
506
573
|
showRowAction: ui?.showRowAction !== false,
|
|
507
574
|
showHeaderAction: ui?.showHeaderAction !== false,
|
|
508
575
|
enabled: response?.state?.enabled !== false,
|
|
576
|
+
defaultMode: defaultMode === 'hard' ? 'hard' : 'soft',
|
|
509
577
|
})
|
|
510
578
|
} catch {
|
|
511
579
|
// Defaults keep both entries visible; a failed probe is not fatal.
|
|
@@ -684,10 +752,13 @@ window.__ModuleLoader__.load({
|
|
|
684
752
|
|
|
685
753
|
function DeleteDialog({ payload }) {
|
|
686
754
|
useLocaleTick()
|
|
755
|
+
const flags = useUiFlags()
|
|
687
756
|
const sessionIds = payload.sessionIds
|
|
688
757
|
const count = sessionIds.length
|
|
689
758
|
const fallbackTitle = payload.displayTitle ?? ''
|
|
690
|
-
|
|
759
|
+
// `config.defaultMode` was dead configuration: the dialog always started
|
|
760
|
+
// on soft, so the Host's documented "initial mode" never applied.
|
|
761
|
+
const [mode, setMode] = useState(flags.defaultMode === 'hard' ? 'hard' : 'soft')
|
|
691
762
|
const [includeMemory, setIncludeMemory] = useState(false)
|
|
692
763
|
const [preview, setPreview] = useState(null)
|
|
693
764
|
const [loading, setLoading] = useState(true)
|
|
@@ -755,9 +826,16 @@ window.__ModuleLoader__.load({
|
|
|
755
826
|
setBusy(true)
|
|
756
827
|
setError(null)
|
|
757
828
|
try {
|
|
829
|
+
// The Host's own worst-case estimate for THIS plan (live drains,
|
|
830
|
+
// memory locks, per-item moves) replaces the client's flat guess:
|
|
831
|
+
// aborting while the Host keeps deleting left a consumed token and a
|
|
832
|
+
// half-changed list behind.
|
|
833
|
+
const planTimeout = Number.isFinite(preview?.limits?.planTimeoutMs)
|
|
834
|
+
? Math.max(1_000, preview.limits.planTimeoutMs)
|
|
835
|
+
: mutationTimeoutMs(sessionIds.length)
|
|
758
836
|
const result = await api('/delete', {
|
|
759
837
|
method: 'POST',
|
|
760
|
-
timeoutMs:
|
|
838
|
+
timeoutMs: planTimeout,
|
|
761
839
|
body: {
|
|
762
840
|
sessionIds,
|
|
763
841
|
includeMemory,
|
|
@@ -880,9 +958,10 @@ window.__ModuleLoader__.load({
|
|
|
880
958
|
expectedPhrase !== ''
|
|
881
959
|
? h('label', { className: 'dc-phrase' },
|
|
882
960
|
h('span', { className: 'dc-phrase-label' },
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
961
|
+
// A phrase exists only for a multi-item soft batch (the Host's
|
|
962
|
+
// `expectedPhrase` returns '' for hard mode), so the label is
|
|
963
|
+
// always the batch instruction.
|
|
964
|
+
t('dialog.batchWarning', { count })),
|
|
886
965
|
h('input', {
|
|
887
966
|
type: 'text',
|
|
888
967
|
value: phrase,
|
|
@@ -909,22 +988,36 @@ window.__ModuleLoader__.load({
|
|
|
909
988
|
function reasonForUnavailable(memory) {
|
|
910
989
|
const adapters = Array.isArray(memory?.adapters) ? memory.adapters : []
|
|
911
990
|
const first = adapters.find((adapter) => adapter.available === false && typeof adapter.reason === 'string' && adapter.reason !== '')
|
|
912
|
-
if (first !== undefined) return first.reason
|
|
991
|
+
if (first !== undefined) return adapterReason(first.reason)
|
|
913
992
|
const stores = Array.isArray(memory?.stores) ? memory.stores : []
|
|
914
993
|
const store = stores.find((item) => item.available === false && typeof item.reason === 'string')
|
|
915
|
-
return store
|
|
994
|
+
return store === undefined ? 'not detected' : adapterReason(store.reason)
|
|
916
995
|
}
|
|
917
996
|
|
|
918
997
|
/* ------------------------------------------------------------------ *
|
|
919
998
|
* Dialog host / overlay entry
|
|
920
999
|
* ------------------------------------------------------------------ */
|
|
921
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Dialog host / overlay entry.
|
|
1003
|
+
*
|
|
1004
|
+
* The dialog is portaled to `document.body` on purpose: the `shell.overlay`
|
|
1005
|
+
* slot renders inside the layout's overlay layer
|
|
1006
|
+
* (`.overlayLayer{position:absolute;z-index:20}`), which is its own
|
|
1007
|
+
* stacking context. A `z-index:1000` backdrop inside it cannot cover the
|
|
1008
|
+
* settings modal (also `z-index:1000`, but on `body`), so the batch-delete
|
|
1009
|
+
* confirmation opened from Settings was invisible and its first click
|
|
1010
|
+
* closed the settings modal instead. Portaling to the body puts the dialog
|
|
1011
|
+
* in the same layer as every other modal; the toast follows for the same
|
|
1012
|
+
* reason.
|
|
1013
|
+
*/
|
|
922
1014
|
function DialogHost() {
|
|
923
1015
|
const payload = useDialogState()
|
|
924
1016
|
const flash = useFlashMessage()
|
|
925
|
-
|
|
1017
|
+
const content = h(React.Fragment, null,
|
|
926
1018
|
payload === null ? null : h(DeleteDialog, { payload, key: payload.key ?? payload.sessionIds.join(',') }),
|
|
927
1019
|
flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
|
|
1020
|
+
return portalIntoBody(content)
|
|
928
1021
|
}
|
|
929
1022
|
|
|
930
1023
|
/* ------------------------------------------------------------------ *
|
|
@@ -984,8 +1077,12 @@ window.__ModuleLoader__.load({
|
|
|
984
1077
|
/**
|
|
985
1078
|
* Entries that never committed. Kept as its own component so the element
|
|
986
1079
|
* tree can be built (and smoke-tested) without a live Settings page.
|
|
1080
|
+
*
|
|
1081
|
+
* A restorable entry (one whose manifest still points at a moved session
|
|
1082
|
+
* log) gets a Restore button: `performDiscard` refuses exactly those
|
|
1083
|
+
* entries, so without this the only way out was a manual API call.
|
|
987
1084
|
*/
|
|
988
|
-
function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard }) {
|
|
1085
|
+
function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId }) {
|
|
989
1086
|
return h('div', { className: 'dc-panel dc-incomplete' },
|
|
990
1087
|
h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
|
|
991
1088
|
h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
|
|
@@ -995,6 +1092,13 @@ window.__ModuleLoader__.load({
|
|
|
995
1092
|
h('div', { className: 'dc-hint' },
|
|
996
1093
|
`${t(`trash.status.${entry.status}`)} · ${formatTime(entry.updatedAt ?? entry.deletedAt)} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
|
|
997
1094
|
h('div', { className: 'dc-row-actions' },
|
|
1095
|
+
entry.hasSessionLog === true && typeof onRestore === 'function'
|
|
1096
|
+
? h(Button, {
|
|
1097
|
+
onClick: () => onRestore(entry.trashId),
|
|
1098
|
+
disabled: restoreBusyId === entry.trashId,
|
|
1099
|
+
title: t('trash.restoreIncompleteHint'),
|
|
1100
|
+
}, t('action.restore'))
|
|
1101
|
+
: null,
|
|
998
1102
|
discardTarget === entry.trashId
|
|
999
1103
|
? h('span', { className: 'dc-inline-confirm' },
|
|
1000
1104
|
h('input', {
|
|
@@ -1016,6 +1120,33 @@ window.__ModuleLoader__.load({
|
|
|
1016
1120
|
}, t('trash.discard'))))))
|
|
1017
1121
|
}
|
|
1018
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* One batch-picker row. A conversation the Host would refuse (subagent,
|
|
1125
|
+
* orphan, outside the allowed roots, protected) is rendered disabled with
|
|
1126
|
+
* the reason, instead of selectable and then failing the whole batch.
|
|
1127
|
+
*/
|
|
1128
|
+
function BatchSessionRow({ session, checked, onToggle }) {
|
|
1129
|
+
const notDeletable = session.deletable === false
|
|
1130
|
+
return h('label', { key: session.id, className: `dc-row dc-row-select${notDeletable ? ' dc-disabled' : ''}` },
|
|
1131
|
+
h('input', {
|
|
1132
|
+
type: 'checkbox',
|
|
1133
|
+
checked,
|
|
1134
|
+
disabled: notDeletable,
|
|
1135
|
+
onChange: () => onToggle(session.id),
|
|
1136
|
+
}),
|
|
1137
|
+
h('div', { className: 'dc-row-main' },
|
|
1138
|
+
h('div', { className: 'dc-row-title' }, session.title || session.id),
|
|
1139
|
+
h('div', { className: 'dc-hint' },
|
|
1140
|
+
`${session.cwd ?? '—'}${session.archived ? ' · archived' : ''}${session.live || session.running ? ' · live' : ''}`
|
|
1141
|
+
+ (notDeletable ? ` · ${t('batch.notDeletable')}: ${errorText(session.policy)}` : ''))),
|
|
1142
|
+
h('div', { className: 'dc-row-time' }, formatTime(session.updatedAt)))
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Ids "select all" may mark: every row the Host has not vetoed. */
|
|
1146
|
+
function selectableSessionIds(sessions) {
|
|
1147
|
+
return sessions.filter((session) => session.deletable !== false).map((session) => session.id)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1019
1150
|
function SettingsSection() {
|
|
1020
1151
|
useLocaleTick()
|
|
1021
1152
|
const mutationVersion = useMutationVersion()
|
|
@@ -1032,6 +1163,7 @@ window.__ModuleLoader__.load({
|
|
|
1032
1163
|
const [purgePhrase, setPurgePhrase] = useState('')
|
|
1033
1164
|
const [discardTarget, setDiscardTarget] = useState(null)
|
|
1034
1165
|
const [discardPhrase, setDiscardPhrase] = useState('')
|
|
1166
|
+
const [restoreBusyId, setRestoreBusyId] = useState(null)
|
|
1035
1167
|
|
|
1036
1168
|
const refresh = useCallback(async () => {
|
|
1037
1169
|
setLoading(true)
|
|
@@ -1087,6 +1219,33 @@ window.__ModuleLoader__.load({
|
|
|
1087
1219
|
}
|
|
1088
1220
|
}
|
|
1089
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* Restore an entry that never committed (pending / failed / interrupted).
|
|
1224
|
+
* The same endpoint as the recycle-bin restore: `performRestore` accepts
|
|
1225
|
+
* those statuses, and this is the action `performDiscard` tells the user
|
|
1226
|
+
* to take when the entry still holds a session log.
|
|
1227
|
+
*/
|
|
1228
|
+
async function restoreIncomplete(trashId) {
|
|
1229
|
+
setError(null)
|
|
1230
|
+
setRestoreBusyId(trashId)
|
|
1231
|
+
try {
|
|
1232
|
+
await api('/trash/restore', { method: 'POST', body: { trashId } })
|
|
1233
|
+
flashStore.show(t('trash.restored'))
|
|
1234
|
+
await refresh()
|
|
1235
|
+
await refreshAfterMutation({
|
|
1236
|
+
sessionIds: incomplete.find((item) => item.trashId === trashId)?.sessionId
|
|
1237
|
+
? [incomplete.find((item) => item.trashId === trashId).sessionId]
|
|
1238
|
+
: [],
|
|
1239
|
+
closeCurrent: false,
|
|
1240
|
+
notify: false,
|
|
1241
|
+
})
|
|
1242
|
+
} catch (caught) {
|
|
1243
|
+
setError(caught)
|
|
1244
|
+
} finally {
|
|
1245
|
+
setRestoreBusyId(null)
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1090
1249
|
async function purge(trashId, phrase) {
|
|
1091
1250
|
setError(null)
|
|
1092
1251
|
const entry = trash.find((item) => item.trashId === trashId)
|
|
@@ -1153,8 +1312,8 @@ window.__ModuleLoader__.load({
|
|
|
1153
1312
|
className: 'dc-state-row',
|
|
1154
1313
|
},
|
|
1155
1314
|
h('span', { className: adapter.available ? 'dc-ok' : 'dc-off' }, adapter.available ? '●' : '○'),
|
|
1156
|
-
h('span', null, ` ${adapter
|
|
1157
|
-
h('span', { className: 'dc-hint' }, adapter.available ? t('state.available') : `${t('state.unavailable')} — ${adapter.reason
|
|
1315
|
+
h('span', null, ` ${adapterLabel(adapter)} `),
|
|
1316
|
+
h('span', { className: 'dc-hint' }, adapter.available ? t('state.available') : `${t('state.unavailable')} — ${adapterReason(adapter.reason)}`)))),
|
|
1158
1317
|
h('div', { className: 'dc-state-block' },
|
|
1159
1318
|
h('div', { className: 'dc-state-title' }, t('state.limits')),
|
|
1160
1319
|
h('div', { className: 'dc-hint' },
|
|
@@ -1202,13 +1361,19 @@ window.__ModuleLoader__.load({
|
|
|
1202
1361
|
onStart: (trashId) => { setDiscardTarget(trashId); setDiscardPhrase('') },
|
|
1203
1362
|
onCancel: () => { setDiscardTarget(null); setDiscardPhrase('') },
|
|
1204
1363
|
onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim()) },
|
|
1364
|
+
onRestore: (trashId) => { void restoreIncomplete(trashId) },
|
|
1365
|
+
restoreBusyId,
|
|
1205
1366
|
}) : null,
|
|
1206
1367
|
|
|
1207
1368
|
tab === 'batch' && !loading ? h('div', { className: 'dc-panel' },
|
|
1208
1369
|
h('div', { className: 'dc-batch-bar' },
|
|
1209
1370
|
h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
|
|
1371
|
+
// "Select all" only marks deletable rows: a batch containing one
|
|
1372
|
+
// subagent/orphan/out-of-root conversation is refused as a whole,
|
|
1373
|
+
// so selecting it guaranteed a dead button with no hint why.
|
|
1210
1374
|
h(Button, {
|
|
1211
|
-
onClick: () => setSelected(new Set(sessions
|
|
1375
|
+
onClick: () => setSelected(new Set(selectableSessionIds(sessions))),
|
|
1376
|
+
title: t('batch.selectAllDeletable'),
|
|
1212
1377
|
}, t('action.selectAll')),
|
|
1213
1378
|
h(Button, { onClick: () => setSelected(new Set()) }, t('action.clearAll')),
|
|
1214
1379
|
h(Button, {
|
|
@@ -1219,13 +1384,12 @@ window.__ModuleLoader__.load({
|
|
|
1219
1384
|
h('p', { className: 'dc-hint' }, t('batch.hint')),
|
|
1220
1385
|
overLimit ? h('p', { className: 'dc-error-text' }, t('batch.overLimit', { limit: effectiveBatch })) : null,
|
|
1221
1386
|
sessions.length === 0 ? h('div', { className: 'dc-empty' }, t('batch.empty')) : null,
|
|
1222
|
-
...sessions.map((session) => h(
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
h('div', { className: 'dc-row-time' }, formatTime(session.updatedAt))))) : null,
|
|
1387
|
+
...sessions.map((session) => h(BatchSessionRow, {
|
|
1388
|
+
key: session.id,
|
|
1389
|
+
session,
|
|
1390
|
+
checked: selected.has(session.id),
|
|
1391
|
+
onToggle: toggle,
|
|
1392
|
+
}))) : null,
|
|
1229
1393
|
|
|
1230
1394
|
tab === 'audit' && !loading ? h('div', { className: 'dc-panel' },
|
|
1231
1395
|
audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
|
|
@@ -1246,7 +1410,9 @@ window.__ModuleLoader__.load({
|
|
|
1246
1410
|
* ------------------------------------------------------------------ */
|
|
1247
1411
|
|
|
1248
1412
|
const CSS = `
|
|
1249
|
-
|
|
1413
|
+
/* 1100 sits above the settings modal (z-index 1000 on body) now that the
|
|
1414
|
+
dialog is portaled into the same layer. */
|
|
1415
|
+
.dc-backdrop{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
|
|
1250
1416
|
.dc-dialog{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}
|
|
1251
1417
|
.dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
|
|
1252
1418
|
.dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
|
|
@@ -1255,7 +1421,7 @@ window.__ModuleLoader__.load({
|
|
|
1255
1421
|
.dc-modes{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
|
|
1256
1422
|
.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)}
|
|
1257
1423
|
.dc-mode-preferred{border-color:var(--dsw-alias-brand-primary,#4d6bfe)}
|
|
1258
|
-
.dc-live-warning{margin-top:10px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-
|
|
1424
|
+
.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)}
|
|
1259
1425
|
.dc-mode input{margin-top:3px}
|
|
1260
1426
|
.dc-mode-title{display:block;font-size:13px;font-weight:600}
|
|
1261
1427
|
.dc-hint{display:block;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
|
|
@@ -1308,6 +1474,7 @@ window.__ModuleLoader__.load({
|
|
|
1308
1474
|
.dc-empty{padding:18px;text-align:center;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
|
|
1309
1475
|
.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)}
|
|
1310
1476
|
.dc-row-select{cursor:pointer}
|
|
1477
|
+
.dc-row-select.dc-disabled{opacity:.55;cursor:not-allowed}
|
|
1311
1478
|
.dc-row-main{min-width:0;flex:1}
|
|
1312
1479
|
.dc-row-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1313
1480
|
.dc-row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
|
|
@@ -1398,7 +1565,11 @@ window.__ModuleLoader__.load({
|
|
|
1398
1565
|
exports.__api = api
|
|
1399
1566
|
// Direct component handles keep the hook-level behavior testable without a
|
|
1400
1567
|
// DOM renderer (the registered wrapper alone only builds an element).
|
|
1401
|
-
exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries }
|
|
1568
|
+
exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow }
|
|
1569
|
+
exports.__selectableSessionIds = selectableSessionIds
|
|
1570
|
+
exports.__adapterLabel = adapterLabel
|
|
1571
|
+
exports.__adapterReason = adapterReason
|
|
1572
|
+
exports.__loadUiFlags = loadUiFlags
|
|
1402
1573
|
return module.exports
|
|
1403
1574
|
},
|
|
1404
1575
|
})
|
package/config.schema.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
9
|
"enabled": { "type": "boolean", "default": true, "description": "Master switch. When false the route answers 503 and the UI hides its entries." },
|
|
10
|
-
"requireSameOrigin": { "type": "boolean", "default": true, "description": "Reject requests whose Origin
|
|
10
|
+
"requireSameOrigin": { "type": "boolean", "default": true, "description": "Reject requests whose Origin does not match the request's full authority (hostname AND effective port; a missing port defaults by the Origin's own scheme). The desktop shell source dsh-app://app is trusted, matching DSH's own fence." },
|
|
11
11
|
"allowNonLoopback": { "type": "boolean", "default": false, "description": "Allow requests when the Web server is bound beyond loopback." },
|
|
12
12
|
"allowOrphanSessions": { "type": "boolean", "default": false, "description": "Allow deleting sessions that no registered workspace owns." },
|
|
13
13
|
"allowSubagentSessions": { "type": "boolean", "default": false, "description": "Allow deleting sessions whose header origin is 'subagent'." },
|