dsh-conversation-recycle-bin 0.2.0 → 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/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
- const id = snapshot?.locale ?? snapshot?.id
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
- const [mode, setMode] = useState('soft')
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: mutationTimeoutMs(sessionIds.length),
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
- mode === 'hard' && count > 1
884
- ? t('dialog.batchWarning', { count })
885
- : mode === 'hard' ? t('dialog.hardWarning') : t('dialog.batchWarning', { count })),
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?.reason ?? 'not detected'
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
- return h(React.Fragment, null,
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.label ?? adapter.id} `),
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.map((session) => session.id))),
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('label', { key: session.id, className: 'dc-row dc-row-select' },
1223
- h('input', { type: 'checkbox', checked: selected.has(session.id), onChange: () => toggle(session.id) }),
1224
- h('div', { className: 'dc-row-main' },
1225
- h('div', { className: 'dc-row-title' }, session.title || session.id),
1226
- h('div', { className: 'dc-hint' },
1227
- `${session.cwd ?? '—'}${session.archived ? ' · archived' : ''}${session.live || session.running ? ' · live' : ''}`)),
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
- .dc-backdrop{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
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-text-warning,#b26a00);background:var(--dsw-alias-fill-warning,#fff7e6)}
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
  })
@@ -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/Sec-Fetch-Site is not the serving Web GUI." },
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'." },