better-dsh-session-deletetool 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1466 @@
1
+ /**
2
+ * better-dsh-session-deletetool — Client half.
3
+ *
4
+ * Adds one row, "删除会话", to a Session's "⋯" menu at order 500 (after the
5
+ * shipped pin 100 / rename 200 / fork 300 / archive 400), plus the confirmation
6
+ * dialog that row opens. Confirming POSTs to the Host half and waits: the row
7
+ * leaves the sidebar when the Host's `api-session/removed` event arrives, the
8
+ * same event the shipped Session controller emits for a disposed Session.
9
+ *
10
+ * Opening the dialog first asks the Host what the Session's state actually is,
11
+ * because the page cannot see it: behind a row that is not the one on screen
12
+ * there can still be a held-open Session, or a running turn, subagent,
13
+ * background job or reminder. The dialog says so in words instead of refusing
14
+ * blindly — being open never blocks the delete, and running work is stopped on
15
+ * the user's confirmation.
16
+ *
17
+ * The package imports no Harness Client package: the menu row and the dialog
18
+ * are local markup styled from theme tokens, matching the shipped rows' shape
19
+ * (`role="menuitem"` button inside the list, errors in
20
+ * `--dsw-alias-state-error-primary`) so the menu's own keyboard walk and focus
21
+ * return keep working over the added row.
22
+ */
23
+ window.__ModuleLoader__.load({
24
+ id: 'better-dsh-session-deletetool',
25
+ factory(require) {
26
+ const React = require('react')
27
+ const h = React.createElement
28
+ const { useCallback, useEffect, useRef, useState, useSyncExternalStore } = React
29
+
30
+ const NS = 'better-dsh-session-deletetool'
31
+ const DELETE_PATH = '/better-dsh-session-deletetool/delete'
32
+ const INSPECT_PATH = '/better-dsh-session-deletetool/inspect'
33
+ const CATALOG_PATH = '/better-dsh-session-deletetool/catalog'
34
+ const BATCH_PATH = '/better-dsh-session-deletetool/delete-batch'
35
+
36
+ const DICT = {
37
+ zh: {
38
+ 'menu.delete': '删除会话',
39
+ 'icon.batch': '批量删除会话',
40
+ 'batch.title': '批量删除会话',
41
+ 'batch.desc': '按工作区分组列出每个会话的家族树:一行下面是它的子智能体与派生对话,可以逐层展开。勾选一行会连同它下面的一切一起删除;只勾某个子智能体,就只删它。删除不可撤销。',
42
+ 'batch.loading': '正在读取会话列表…',
43
+ 'batch.failed': '读取失败',
44
+ 'batch.empty': '没有读到任何会话。',
45
+ 'batch.cancel': '取消',
46
+ 'batch.expandAll': '展开全部',
47
+ 'batch.collapseAll': '收起全部',
48
+ 'batch.partial': '已选 {m} / {n} 个会话',
49
+ 'batch.selectAll': '全选({n} 个会话)',
50
+ 'batch.workspaceCount': '{n} 个会话',
51
+ 'batch.untitled': '未命名会话',
52
+ 'batch.ungrouped': '未归类',
53
+ 'batch.region': '会话列表',
54
+ 'batch.workspaces': '工作区列表',
55
+ 'batch.confirmNone': '请选择要删除的会话',
56
+ 'batch.confirm': '删除 {n} 个会话',
57
+ 'batch.confirmStop': '停止并删除 {n} 个会话',
58
+ 'batch.workspaceAll': '选中这个工作区的全部会话',
59
+ 'batch.deleting': '正在删除…',
60
+ 'batch.deletePartial': '{n} 个删除失败,其余已删除。',
61
+ 'batch.stoppingNote': '确认后会先停掉这些会话里未结束的工作,再删除。',
62
+ 'batch.unknown': '读不到整个会话列表,仍可直接删除。',
63
+ 'dialog.title': '删除会话',
64
+ 'dialog.desc': '将永久删除“{title}”:会话日志、工程记录与投影缓存都会被移除,无法撤销。',
65
+ 'dialog.untitled': '未命名会话',
66
+ 'dialog.cancel': '取消',
67
+ 'dialog.checking': '检查中…',
68
+ 'dialog.deleting': '正在删除…',
69
+ 'dialog.failed': '删除失败',
70
+ 'dialog.running': '这个会话还有未结束的工作:',
71
+ 'dialog.runningHint': '确认后会先停掉这些工作,再删除这个会话。',
72
+ 'dialog.open': '它当前仍被 Harness 占用(可能开在某个窗口或标签里)。删除后它会立刻从列表消失。',
73
+ 'dialog.unknown': '读不到它的当前状态,仍可直接删除。',
74
+ 'dialog.descendants': '会连同它一起删除:{list}。',
75
+ 'dialog.descendants.subagents': '{n} 个子智能体会话',
76
+ 'dialog.descendants.derived': '{n} 个由它派生(fork)出来的对话',
77
+ 'dialog.listSeparator': '、',
78
+ 'dialog.group.subagents': '子智能体',
79
+ 'dialog.group.derived': '派生对话',
80
+ 'dialog.group.subagentsCount': '子智能体({n})',
81
+ 'dialog.group.derivedCount': '派生对话({n})',
82
+ 'dialog.header.subagents': '子智能体',
83
+ 'dialog.mainRow': '本对话:{title}',
84
+ 'dialog.selectAll': '删除全部(共 {n} 个)',
85
+ 'dialog.selectPartial': '已选 {m} / {n} 个',
86
+ 'dialog.expandHint': '点分组标题可折叠',
87
+ 'dialog.truncated': '共 {n} 个,下面只列出前 {shown} 个。',
88
+ 'dialog.maxDeletable': '一次最多删除 {max} 个,请分批选择。',
89
+ 'dialog.itemRunning': '运行中',
90
+ 'dialog.itemActive': '有未结束的工作',
91
+ 'dialog.itemOpen': '已打开',
92
+ 'dialog.warnings': '下面这些线索没读到,家族可能不完整:',
93
+ 'dialog.untitledItem': '未命名',
94
+ 'dialog.confirmCount': '删除 {n} 个会话',
95
+ 'dialog.confirmStopCount': '停止并删除 {n} 个会话',
96
+ },
97
+ en: {
98
+ 'menu.delete': 'Delete conversation',
99
+ 'icon.batch': 'Delete conversations in bulk',
100
+ 'batch.title': 'Delete conversations in bulk',
101
+ 'batch.desc': 'Every conversation, grouped by Workspace. Under each row sit its subagents and its forked conversations, expandable level by level. Ticking a row takes everything below it; ticking a subagent alone deletes only that subagent. This cannot be undone.',
102
+ 'batch.loading': 'Reading the conversation list…',
103
+ 'batch.failed': 'Could not read the list',
104
+ 'batch.empty': 'No conversation was found.',
105
+ 'batch.cancel': 'Cancel',
106
+ 'batch.expandAll': 'Expand all',
107
+ 'batch.collapseAll': 'Collapse all',
108
+ 'batch.partial': '{m} of {n} selected',
109
+ 'batch.selectAll': 'Select all ({n})',
110
+ 'batch.workspaceCount': '{n} conversation(s)',
111
+ 'batch.untitled': 'Untitled conversation',
112
+ 'batch.ungrouped': 'Ungrouped',
113
+ 'batch.region': 'Conversation list',
114
+ 'batch.workspaces': 'Workspaces',
115
+ 'batch.confirmNone': 'Select the conversations to delete',
116
+ 'batch.confirm': 'Delete {n} conversation(s)',
117
+ 'batch.confirmStop': 'Stop and delete {n} conversation(s)',
118
+ 'batch.workspaceAll': 'Select every conversation in this Workspace',
119
+ 'batch.deleting': 'Deleting…',
120
+ 'batch.deletePartial': '{n} failed; the rest were deleted.',
121
+ 'batch.stoppingNote': 'Confirming stops the unfinished work in these conversations first, then deletes them.',
122
+ 'batch.unknown': 'The whole conversation list could not be read; deleting is still possible.',
123
+ 'dialog.title': 'Delete conversation',
124
+ 'dialog.desc': '“{title}” will be deleted permanently: its session log, workspace account and projection cache are removed. This cannot be undone.',
125
+ 'dialog.untitled': 'Untitled conversation',
126
+ 'dialog.cancel': 'Cancel',
127
+ 'dialog.checking': 'Checking…',
128
+ 'dialog.deleting': 'Deleting…',
129
+ 'dialog.failed': 'Delete failed',
130
+ 'dialog.running': 'This conversation still has unfinished work:',
131
+ 'dialog.runningHint': 'Confirming stops that work first, then deletes the conversation.',
132
+ 'dialog.open': 'The Harness still holds it open (it may be showing in a window or tab). Deleting removes it from the list at once.',
133
+ 'dialog.unknown': 'Its current state could not be read; deleting is still possible.',
134
+ 'dialog.descendants': 'Deleted together with it: {list}.',
135
+ 'dialog.descendants.subagents': '{n} subagent conversation(s)',
136
+ 'dialog.descendants.derived': '{n} conversation(s) forked off it',
137
+ 'dialog.listSeparator': ', ',
138
+ 'dialog.group.subagents': 'Subagents',
139
+ 'dialog.group.derived': 'Forked conversations',
140
+ 'dialog.group.subagentsCount': 'Subagents ({n})',
141
+ 'dialog.group.derivedCount': 'Forked conversations ({n})',
142
+ 'dialog.header.subagents': 'Subagents',
143
+ 'dialog.mainRow': 'This conversation: {title}',
144
+ 'dialog.selectAll': 'Delete all ({n})',
145
+ 'dialog.selectPartial': '{m} of {n} selected',
146
+ 'dialog.expandHint': 'Click a group title to collapse it',
147
+ 'dialog.truncated': 'There are {n}; only the first {shown} are listed.',
148
+ 'dialog.maxDeletable': 'At most {max} can be deleted at once — select in batches.',
149
+ 'dialog.itemRunning': 'running',
150
+ 'dialog.itemActive': 'has unfinished work',
151
+ 'dialog.itemOpen': 'open',
152
+ 'dialog.warnings': 'These leads could not be read, so the family may be incomplete:',
153
+ 'dialog.untitledItem': 'Untitled',
154
+ 'dialog.confirmCount': 'Delete {n} conversations',
155
+ 'dialog.confirmStopCount': 'Stop and delete {n} conversations',
156
+ },
157
+ }
158
+
159
+ /** Fill one `{name}` placeholder set in a dictionary string. */
160
+ function interpolate(text, params) {
161
+ if (params === undefined) return text
162
+ return text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
163
+ }
164
+
165
+ /** Translate through the registered locale when there is one, else the zh table. */
166
+ let translate = null
167
+ function t(key, params) {
168
+ if (translate !== null) {
169
+ try {
170
+ const value = translate(key, params)
171
+ if (typeof value === 'string' && value !== '') return value
172
+ } catch {
173
+ // A missing key falls through to the local table below.
174
+ }
175
+ }
176
+ const fallback = DICT.zh[key]
177
+ return typeof fallback === 'string' ? interpolate(fallback, params) : key
178
+ }
179
+
180
+ // ---- the pending confirmation, shared by the row and the dialog ----------
181
+ let pending = null
182
+ const listeners = new Set()
183
+ function subscribe(listener) {
184
+ listeners.add(listener)
185
+ return () => listeners.delete(listener)
186
+ }
187
+ function getPending() {
188
+ return pending
189
+ }
190
+ function openConfirm(request) {
191
+ pending = request
192
+ for (const listener of [...listeners]) listener()
193
+ }
194
+ function settle() {
195
+ pending = null
196
+ for (const listener of [...listeners]) listener()
197
+ }
198
+
199
+ // ---- the bulk view, opened from the icon beside the workspace search ----
200
+ let bulkOpen = false
201
+ const bulkListeners = new Set()
202
+ function subscribeBulk(listener) {
203
+ bulkListeners.add(listener)
204
+ return () => bulkListeners.delete(listener)
205
+ }
206
+ function getBulkOpen() {
207
+ return bulkOpen
208
+ }
209
+ function setBulkOpen(next) {
210
+ if (bulkOpen === next) return
211
+ bulkOpen = next
212
+ for (const listener of [...bulkListeners]) listener()
213
+ }
214
+
215
+ /** One failed Host answer, carrying its stable code and details. */
216
+ function failureOf(payload, response) {
217
+ const error = new Error(payload?.message ?? `HTTP ${response.status}`)
218
+ error.code = payload?.code
219
+ error.activity = Array.isArray(payload?.activity) ? payload.activity : null
220
+ return error
221
+ }
222
+
223
+ /** Ask the Host what this Session's state is before committing to anything. */
224
+ async function inspectSession(sessionId) {
225
+ const response = await fetch(`${INSPECT_PATH}?sessionId=${encodeURIComponent(sessionId)}`, { cache: 'no-store' })
226
+ const payload = await response.json().catch(() => null)
227
+ if (!response.ok || payload?.ok !== true) throw failureOf(payload, response)
228
+ return payload
229
+ }
230
+
231
+ /**
232
+ * Ask the Host half to delete one Session.
233
+ * @param sessionId - the Session the user acted on.
234
+ * @param stop - whether running work may be stopped first.
235
+ * @param descendants - the descendant ids the user ticked; always sent, so
236
+ * the Host deletes exactly what the dialog showed.
237
+ */
238
+ async function deleteSession(sessionId, stop, descendants) {
239
+ const response = await fetch(DELETE_PATH, {
240
+ method: 'POST',
241
+ headers: { 'content-type': 'application/json' },
242
+ body: JSON.stringify({ sessionId, stop: stop === true, descendants }),
243
+ })
244
+ const payload = await response.json().catch(() => null)
245
+ if (!response.ok || payload?.ok !== true) throw failureOf(payload, response)
246
+ return payload
247
+ }
248
+
249
+ /** Read the whole corpus, grouped by Workspace, for the bulk dialog. */
250
+ async function fetchCatalog() {
251
+ const response = await fetch(CATALOG_PATH, { cache: 'no-store' })
252
+ const payload = await response.json().catch(() => null)
253
+ if (!response.ok || payload?.ok !== true) throw failureOf(payload, response)
254
+ return payload
255
+ }
256
+
257
+ /**
258
+ * Ask the Host half to delete one root and the descendants it was left with.
259
+ * @param roots - the ticked roots, each with its own descendant selection.
260
+ * @param stop - whether running work may be stopped first.
261
+ */
262
+ async function deleteBatch(roots, stop) {
263
+ const response = await fetch(BATCH_PATH, {
264
+ method: 'POST',
265
+ headers: { 'content-type': 'application/json' },
266
+ body: JSON.stringify({ roots, stop: stop === true }),
267
+ })
268
+ const payload = await response.json().catch(() => null)
269
+ if (!response.ok || payload?.ok !== true) throw failureOf(payload, response)
270
+ return payload
271
+ }
272
+
273
+ /** The activity families reported for one Session, or an empty list. */
274
+ function activityOf(entity) {
275
+ return Array.isArray(entity?.activity) ? entity.activity : []
276
+ }
277
+
278
+ /** The tail of an id, so two same-titled Sessions stay distinguishable. */
279
+ function shortId(id) {
280
+ return typeof id === 'string' && id.length > 6 ? `…${id.slice(-6)}` : id
281
+ }
282
+
283
+ /**
284
+ * Fold the per-Session activity a refusal reported back into the state the
285
+ * dialog is showing, so the rows that are busy say so on the next render.
286
+ */
287
+ function mergeActivity(info, activeSessions) {
288
+ if (info === null || info === undefined) return info
289
+ const byId = new Map()
290
+ for (const entry of activeSessions) {
291
+ if (typeof entry?.sessionId !== 'string') continue
292
+ byId.set(entry.sessionId, Array.isArray(entry.activity) ? entry.activity : [])
293
+ }
294
+ const descendants = info.descendants === undefined
295
+ ? undefined
296
+ : {
297
+ ...info.descendants,
298
+ items: Array.isArray(info.descendants.items)
299
+ ? info.descendants.items.map((item) => (byId.has(item.id) ? { ...item, activity: byId.get(item.id) } : item))
300
+ : info.descendants.items,
301
+ }
302
+ return {
303
+ ...info,
304
+ activity: byId.get(info.sessionId) ?? activityOf(info),
305
+ ...(descendants === undefined ? {} : { descendants }),
306
+ }
307
+ }
308
+
309
+ // ---- the menu row -------------------------------------------------------
310
+ /** One "⋯" menu row (order 500): ask to delete this Session. */
311
+ function DeleteSessionMenuItem(props) {
312
+ const { sessionId, displayTitle, useMenuOpenState } = props
313
+ const menuOpenState = typeof useMenuOpenState === 'function' ? useMenuOpenState : null
314
+ const setMenuOpen = menuOpenState === null ? null : menuOpenState()[1]
315
+ const onSelect = useCallback(() => {
316
+ if (setMenuOpen !== null) setMenuOpen(false)
317
+ openConfirm({ sessionId, title: displayTitle })
318
+ }, [sessionId, displayTitle, setMenuOpen])
319
+ return h('button', {
320
+ type: 'button',
321
+ role: 'menuitem',
322
+ className: 'dsd-item dsd-danger',
323
+ onClick: onSelect,
324
+ children: [
325
+ h('span', { className: 'dsd-itemIcon', key: 'icon', children: trashIcon() }),
326
+ h('span', { className: 'dsd-itemLabel', key: 'label', children: t('menu.delete') }),
327
+ ],
328
+ })
329
+ }
330
+
331
+ /** One 14px trash glyph, drawn in the menu icon's colour. */
332
+ function trashIcon() {
333
+ return h(
334
+ 'svg',
335
+ { viewBox: '0 0 16 16', width: 14, height: 14, fill: 'none', 'aria-hidden': true },
336
+ h('path', {
337
+ d: 'M3 4.5h10M6.5 4.5V3.25A.75.75 0 0 1 7.25 2.5h1.5a.75.75 0 0 1 .75.75V4.5M4.5 4.5l.6 8.1a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9l.6-8.1',
338
+ stroke: 'currentColor',
339
+ strokeWidth: 1.2,
340
+ strokeLinecap: 'round',
341
+ strokeLinejoin: 'round',
342
+ }),
343
+ )
344
+ }
345
+
346
+ /**
347
+ * The bulk-delete drawing, in the source SVG's own coordinates.
348
+ *
349
+ * The paths are identity transforms, so mapping the viewBox onto the icon box
350
+ * needs no geometry change. The drawing is authored as a filled glyph, but
351
+ * the shipped product icon set is outline-only — `fill: none`,
352
+ * `stroke: currentColor`, `strokeWidth: 1` on a 16px box — so it is stroked
353
+ * here to match the search and view-option icons it sits beside. At a 16px
354
+ * box the 32-unit viewBox halves every coordinate, so the shipped 1px stroke
355
+ * is `2` in these units.
356
+ */
357
+ const BULK_ICON_SHAPES = [
358
+ 'M20,29H12a5,5,0,0,1-5-5V12a1,1,0,0,1,2,0V24a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V12a1,1,0,0,1,2,0V24A5,5,0,0,1,20,29Z',
359
+ 'M26,9H6A1,1,0,0,1,6,7H26a1,1,0,0,1,0,2Z',
360
+ 'M20,9H12a1,1,0,0,1-1-1V6a3,3,0,0,1,3-3h4a3,3,0,0,1,3,3V8A1,1,0,0,1,20,9ZM13,7h6V6a1,1,0,0,0-1-1H14a1,1,0,0,0-1,1Z',
361
+ 'M14,23a1,1,0,0,1-1-1V15a1,1,0,0,1,2,0v7A1,1,0,0,1,14,23Z',
362
+ 'M18,23a1,1,0,0,1-1-1V15a1,1,0,0,1,2,0v7A1,1,0,0,1,18,23Z',
363
+ ]
364
+
365
+ /**
366
+ * The bulk-delete glyph, drawn in the icon set's own outline convention.
367
+ * @param {number} size - the rendered box, in CSS pixels.
368
+ * @returns {object} the SVG element.
369
+ */
370
+ function bulkIcon(size) {
371
+ return h(
372
+ 'svg',
373
+ {
374
+ viewBox: '0 0 32 32',
375
+ width: size,
376
+ height: size,
377
+ fill: 'none',
378
+ stroke: 'currentColor',
379
+ strokeWidth: 2,
380
+ strokeLinecap: 'round',
381
+ strokeLinejoin: 'round',
382
+ xmlns: 'http://www.w3.org/2000/svg',
383
+ 'aria-hidden': 'true',
384
+ focusable: 'false',
385
+ style: { display: 'block' },
386
+ },
387
+ BULK_ICON_SHAPES.map((d, index) => h('path', { key: index, d })),
388
+ )
389
+ }
390
+
391
+ /**
392
+ * The same glyph as markup, for the plain DOM button the anchor mounts.
393
+ *
394
+ * The button lives outside React, so the drawing is serialised here instead
395
+ * of rendered: one source of truth for the geometry (`BULK_ICON_SHAPES`), two
396
+ * ways of emitting it. Nothing but this module's own constants reaches the
397
+ * string.
398
+ * @param {number} size - the rendered box, in CSS pixels.
399
+ * @returns {string} the SVG markup.
400
+ */
401
+ function bulkIconMarkup(size) {
402
+ const paths = BULK_ICON_SHAPES.map((d) => `<path d="${d}"/>`).join('')
403
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="${size}" height="${size}"`
404
+ + ' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
405
+ + ` aria-hidden="true" focusable="false" style="display:block">${paths}</svg>`
406
+ }
407
+
408
+ /**
409
+ * The bulk-delete entry point, placed to the left of the workspace search icon.
410
+ *
411
+ * The browsing region is a `single` slot, so a second registrant would shadow
412
+ * the shipped WorkspaceBrowser rather than sit beside it. This entry therefore
413
+ * registers in the sidebar footer, renders only a hidden placeholder, and
414
+ * mounts one element into the region's own search slot. The insertion is
415
+ * additive and reversible: a mutation observer re-creates the element when
416
+ * React re-renders the header, and the element and the observer both go away
417
+ * with this registration.
418
+ */
419
+ function BulkDeleteAnchor() {
420
+ useEffect(() => {
421
+ const node = document.createElement('button')
422
+ node.type = 'button'
423
+ node.className = 'dsd-anchor'
424
+ node.setAttribute('data-dsh-plugin', 'better-dsh-session-deletetool')
425
+ node.title = t('icon.batch')
426
+ node.setAttribute('aria-label', t('icon.batch'))
427
+ // The button is a plain DOM node, so the glyph goes in as markup; it is
428
+ // written from the same path set the React side draws, and carries no
429
+ // user text, so this is not a sink for anything but our own constant.
430
+ node.innerHTML = bulkIconMarkup(16)
431
+ const onClick = (event) => {
432
+ event.preventDefault()
433
+ event.stopPropagation()
434
+ setBulkOpen(true)
435
+ }
436
+ node.addEventListener('click', onClick)
437
+
438
+ /**
439
+ * Put the button where the search control's own flex row starts.
440
+ *
441
+ * The class names are the shipped build's CSS-module output and the
442
+ * `aria-label` is its localized search label, so each lookup carries a
443
+ * second signal; with neither present the button stays unmounted instead
444
+ * of landing somewhere arbitrary.
445
+ */
446
+ const mount = () => {
447
+ const slot = document.querySelector('[class*="_searchSlot"]')
448
+ if (slot !== null) {
449
+ if (node.parentElement !== slot) slot.insertBefore(node, slot.firstChild)
450
+ return true
451
+ }
452
+ if (!node.isConnected) {
453
+ const search = document.querySelector('[class*="_searchButton"]')
454
+ if (search !== null && search.parentElement !== null) {
455
+ search.parentElement.insertBefore(node, search)
456
+ return true
457
+ }
458
+ }
459
+ return false
460
+ }
461
+
462
+ // The region is mounted by its own entry, which may come after this one.
463
+ const observer = new MutationObserver(() => {
464
+ if (!node.isConnected) mount()
465
+ })
466
+ observer.observe(document.body, { childList: true, subtree: true })
467
+ mount()
468
+
469
+ return () => {
470
+ observer.disconnect()
471
+ node.removeEventListener('click', onClick)
472
+ node.remove()
473
+ }
474
+ }, [])
475
+
476
+ // Nothing is rendered in the footer itself: the control lives in the search
477
+ // slot, and an empty list entry leaves the shipped footer row untouched.
478
+ return null
479
+ }
480
+
481
+ // ---- the confirmation dialog -------------------------------------------
482
+ const IDLE = { phase: 'idle', info: null, error: null, stop: false, busy: false }
483
+
484
+ /** The `shell.overlay` seat: one confirmation while a delete is pending. */
485
+ function DeleteSessionDialog() {
486
+ const request = useSyncExternalStore(subscribe, getPending)
487
+ const [state, setState] = useState(IDLE)
488
+ const [selected, setSelected] = useState(null)
489
+ const [collapsed, setCollapsed] = useState({})
490
+ const dialog = useRef(null)
491
+
492
+ const close = useCallback(() => {
493
+ setState((previous) => {
494
+ if (previous.busy) return previous
495
+ settle()
496
+ return IDLE
497
+ })
498
+ setSelected(null)
499
+ }, [])
500
+
501
+ // Every open re-reads the Host's state: the answer is only true for the
502
+ // moment the dialog opened, and a Session can start working in between.
503
+ // Nobody is selected until that answer arrives, so a Session whose family
504
+ // is still being walked can never be deleted by accident.
505
+ useEffect(() => {
506
+ if (request === null) {
507
+ setState(IDLE)
508
+ setSelected(null)
509
+ return undefined
510
+ }
511
+ let cancelled = false
512
+ setState({ phase: 'checking', info: null, error: null, stop: false, busy: false })
513
+ setSelected(null)
514
+ inspectSession(request.sessionId).then(
515
+ (info) => {
516
+ if (cancelled) return
517
+ const items = Array.isArray(info?.descendants?.items) ? info.descendants.items : []
518
+ setSelected(new Set(items.map((item) => item.id)))
519
+ setState({
520
+ phase: 'ready',
521
+ info,
522
+ error: null,
523
+ // Default to stopping work, but only when something is running.
524
+ stop: activityOf(info).length > 0 || items.some((item) => activityOf(item).length > 0),
525
+ busy: false,
526
+ })
527
+ },
528
+ () => {
529
+ if (!cancelled) setState({ phase: 'ready', info: null, error: null, stop: false, busy: false })
530
+ },
531
+ )
532
+ return () => {
533
+ cancelled = true
534
+ }
535
+ }, [request])
536
+
537
+ useEffect(() => {
538
+ if (request === null) return undefined
539
+ const onKeyDown = (event) => {
540
+ if (event.key === 'Escape') close()
541
+ }
542
+ window.addEventListener('keydown', onKeyDown, true)
543
+ return () => window.removeEventListener('keydown', onKeyDown, true)
544
+ }, [request, close])
545
+
546
+ useEffect(() => {
547
+ if (request !== null) dialog.current?.focus()
548
+ }, [request])
549
+
550
+ if (request === null) return null
551
+
552
+ const info = state.info
553
+ const items = Array.isArray(info?.descendants?.items) ? info.descendants.items : []
554
+ const picked = selected ?? new Set(items.map((item) => item.id))
555
+ const selectedCount = items.filter((item) => picked.has(item.id)).length
556
+
557
+ const toggleMany = (ids, on) => {
558
+ setSelected(() => {
559
+ const next = new Set(picked)
560
+ for (const id of ids) {
561
+ if (on) next.add(id)
562
+ else next.delete(id)
563
+ }
564
+ return next
565
+ })
566
+ }
567
+
568
+ const confirm = () => {
569
+ if (state.busy || state.phase !== 'ready') return
570
+ const chosen = items.filter((item) => picked.has(item.id)).map((item) => item.id)
571
+ setState((previous) => ({ ...previous, busy: true, error: null }))
572
+ deleteSession(request.sessionId, state.stop, chosen).then(
573
+ () => {
574
+ settle()
575
+ setSelected(null)
576
+ setState(IDLE)
577
+ },
578
+ (reason) => {
579
+ // A Session that started working between the check and the write
580
+ // answers with its activity, so the next press stops it and the rows
581
+ // that are busy say so.
582
+ const active = Array.isArray(reason?.activeSessions) ? reason.activeSessions : null
583
+ setState((previous) => ({
584
+ ...previous,
585
+ busy: false,
586
+ error: reason instanceof Error ? reason.message : String(reason),
587
+ stop: previous.stop || reason?.code === 'session-active' || active !== null,
588
+ info: active === null ? previous.info : mergeActivity(previous.info, active),
589
+ }))
590
+ },
591
+ )
592
+ }
593
+
594
+ const title = request.title === undefined || request.title === '' ? t('dialog.untitled') : request.title
595
+ const activity = activityOf(info)
596
+ const checking = state.phase === 'checking'
597
+ const deletedCount = selectedCount + 1
598
+ const confirmLabel = state.busy || checking
599
+ ? (checking ? t('dialog.checking') : t('dialog.deleting'))
600
+ : state.stop
601
+ ? t('dialog.confirmStopCount', { n: deletedCount })
602
+ : t('dialog.confirmCount', { n: deletedCount })
603
+
604
+ return h('div', {
605
+ className: 'dsd-overlay',
606
+ role: 'presentation',
607
+ onKeyDownCapture: (event) => {
608
+ if (event.key === 'Escape') {
609
+ event.stopPropagation()
610
+ close()
611
+ }
612
+ },
613
+ }, [
614
+ h('div', { className: 'dsd-mask', key: 'mask', 'aria-hidden': true, onClick: close }),
615
+ h('div', {
616
+ className: 'dsd-dialog',
617
+ key: 'dialog',
618
+ ref: dialog,
619
+ tabIndex: -1,
620
+ role: 'dialog',
621
+ 'aria-modal': 'true',
622
+ 'aria-label': t('dialog.title'),
623
+ }, [
624
+ h('h2', { className: 'dsd-title', key: 'title', children: t('dialog.title') }),
625
+ h('p', { className: 'dsd-desc', key: 'desc', children: t('dialog.desc', { title }) }),
626
+ activity.length === 0
627
+ ? null
628
+ : h('div', { className: 'dsd-note dsd-warn', key: 'running' }, [
629
+ h('p', { className: 'dsd-noteTitle', key: 'head', children: t('dialog.running') }),
630
+ h('ul', { className: 'dsd-list', key: 'list' }, activity.map((entry, index) => {
631
+ const names = Array.isArray(entry.items)
632
+ ? entry.items.map((item) => (item.label === '' ? item.id : item.label)).filter((name) => name !== '')
633
+ : []
634
+ return h('li', { key: `${entry.kind}-${index}` }, names.length === 0 ? entry.label : `${entry.label}:${names.join('、')}`)
635
+ })),
636
+ h('p', { className: 'dsd-noteHint', key: 'hint', children: t('dialog.runningHint') }),
637
+ ]),
638
+ info !== null && info.open === true && activity.length === 0
639
+ ? h('p', { className: 'dsd-note', key: 'open', children: t('dialog.open') })
640
+ : null,
641
+ state.phase === 'ready' && info === null
642
+ ? h('p', { className: 'dsd-note', key: 'unknown', children: t('dialog.unknown') })
643
+ : null,
644
+ h(FamilyTree, {
645
+ key: 'tree',
646
+ items,
647
+ implicitParent: request.sessionId,
648
+ main: title,
649
+ selected: picked,
650
+ onToggle: toggleMany,
651
+ collapsed,
652
+ setCollapsed,
653
+ disabled: state.busy,
654
+ leafName: t('dialog.untitledItem'),
655
+ }),
656
+ info?.descendants?.truncated === true
657
+ ? h('p', {
658
+ className: 'dsd-note dsd-hint',
659
+ key: 'truncated',
660
+ children: t('dialog.truncated', { n: info.descendants.count, shown: items.length }),
661
+ })
662
+ : null,
663
+ typeof info?.descendants?.maxDeletable === 'number' && info.descendants.count > info.descendants.maxDeletable
664
+ ? h('p', {
665
+ className: 'dsd-note dsd-hint',
666
+ key: 'cap',
667
+ children: t('dialog.maxDeletable', { max: info.descendants.maxDeletable }),
668
+ })
669
+ : null,
670
+ // A source that failed leaves rows missing, which no row can say for
671
+ // itself: this is where the family's own report of that lands.
672
+ ...(Array.isArray(info?.warnings) && info.warnings.length > 0
673
+ ? [h('div', { className: 'dsd-note dsd-warn', key: 'warnings' }, [
674
+ h('p', { className: 'dsd-noteTitle', key: 'head', children: t('dialog.warnings') }),
675
+ h('ul', { className: 'dsd-list', key: 'list' }, info.warnings.map((warning, index) => h('li', { key: index }, String(warning)))),
676
+ ])]
677
+ : []),
678
+ state.error === null
679
+ ? null
680
+ : h('p', { className: 'dsd-error', key: 'error', children: `${t('dialog.failed')}:${state.error}` }),
681
+ h('div', { className: 'dsd-footer', key: 'footer' }, [
682
+ h('button', {
683
+ type: 'button',
684
+ className: 'dsd-button dsd-outline',
685
+ key: 'cancel',
686
+ disabled: state.busy,
687
+ onClick: close,
688
+ children: t('dialog.cancel'),
689
+ }),
690
+ h('button', {
691
+ type: 'button',
692
+ className: 'dsd-button dsd-outline dsd-dangerText',
693
+ key: 'confirm',
694
+ disabled: state.busy || checking,
695
+ onClick: confirm,
696
+ children: confirmLabel,
697
+ }),
698
+ ]),
699
+ ]),
700
+ ])
701
+ }
702
+
703
+ /** The indent one tree level adds, in CSS pixels. */
704
+ const INDENT = 18
705
+
706
+ /**
707
+ * The 16px outline glyphs the family tree draws, each as [tag, attributes]
708
+ * pairs in the shipped icon set's own convention: fill none, stroke
709
+ * currentColor, round joins, on a 16px box.
710
+ */
711
+ const TREE_GLYPHS = {
712
+ // The conversation itself: a document with a folded corner.
713
+ session: [
714
+ ['path', { d: 'M3.5 2.5H9L12.5 6v7.5h-9z' }],
715
+ ['path', { d: 'M9 2.5V6h3.5' }],
716
+ ['path', { d: 'M5.75 8.75h4.5' }],
717
+ ['path', { d: 'M5.75 11.25h2.75' }],
718
+ ],
719
+ // A spawned Session: a robot head. Marks the 子智能体 block.
720
+ subagent: [
721
+ ['path', { d: 'M4.5 6.5h7A1.5 1.5 0 0 1 13 8v3.5a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 11.5V8a1.5 1.5 0 0 1 1.5-1.5z' }],
722
+ ['path', { d: 'M8 6.5V3.75' }],
723
+ ['path', { d: 'M6.5 3.75h3' }],
724
+ ['circle', { cx: 6.35, cy: 9.3, r: 0.7 }],
725
+ ['circle', { cx: 9.65, cy: 9.3, r: 0.7 }],
726
+ ],
727
+ // One subagent Session's own row.
728
+ member: [
729
+ ['path', { d: 'M8 7.75A2.125 2.125 0 1 0 8 3.5a2.125 2.125 0 0 0 0 4.25z' }],
730
+ ['path', { d: 'M3.75 13.5c0-2.35 1.9-3.75 4.25-3.75s4.25 1.4 4.25 3.75' }],
731
+ ],
732
+ // A derived conversation: a chat bubble. Marks the 派生对话 block.
733
+ chat: [
734
+ ['path', { d: 'M14 10a1.33 1.33 0 0 1-1.33 1.33H4.67L2 14V3.33A1.33 1.33 0 0 1 3.33 2h9.34A1.33 1.33 0 0 1 14 3.33z' }],
735
+ ],
736
+ // The relation a fork is: two links.
737
+ linked: [
738
+ ['path', { d: 'M6.67 8.67a3.33 3.33 0 0 0 5.03.36l2-2a3.33 3.33 0 0 0-4.71-4.71l-1.15 1.14' }],
739
+ ['path', { d: 'M9.33 7.33a3.33 3.33 0 0 0-5.03-.36l-2 2a3.33 3.33 0 0 0 4.71 4.71l1.14-1.14' }],
740
+ ],
741
+ }
742
+
743
+ /** One outline glyph, drawn at the given box size. */
744
+ function treeGlyph(name, size) {
745
+ return h('svg', {
746
+ viewBox: '0 0 16 16',
747
+ width: size,
748
+ height: size,
749
+ fill: 'none',
750
+ stroke: 'currentColor',
751
+ strokeWidth: 1.2,
752
+ strokeLinecap: 'round',
753
+ strokeLinejoin: 'round',
754
+ xmlns: 'http://www.w3.org/2000/svg',
755
+ 'aria-hidden': true,
756
+ focusable: 'false',
757
+ style: { display: 'block' },
758
+ }, TREE_GLYPHS[name].map((entry, index) => h(entry[0], { key: index, ...entry[1] })))
759
+ }
760
+
761
+ /**
762
+ * The family as a forest: one node per Session, its children hanging under it.
763
+ *
764
+ * A row whose parentId names another listed row hangs under that row. A row
765
+ * whose parent is not listed — because it is the Session the single dialog is
766
+ * about, because it belongs to another Workspace, or because its parent simply
767
+ * is not in this list — is a root. A hand-edited header cycle is cut rather
768
+ * than followed, so the drawing can never recurse forever.
769
+ * @param {object[]} entries - the family rows, in the Host's order.
770
+ * @param {string|null} implicitParent - the id whose children are the roots.
771
+ * @returns {object[]} the roots, each { id, entry, children }.
772
+ */
773
+ function buildForest(entries, implicitParent) {
774
+ const nodes = new Map()
775
+ for (const entry of entries) {
776
+ const id = entry === null || typeof entry !== 'object' ? null : entry.id
777
+ if (typeof id !== 'string' || id === '' || nodes.has(id)) continue
778
+ nodes.set(id, { id, entry, children: [] })
779
+ }
780
+ const parentOf = (node) => {
781
+ const parentId = typeof node.entry.parentId === 'string' ? node.entry.parentId : null
782
+ if (parentId === null || parentId === node.id) return undefined
783
+ return nodes.get(parentId)
784
+ }
785
+ /** Whether hanging one node under another would close a loop. */
786
+ const loops = (node, candidate) => {
787
+ const guard = new Set([node.id])
788
+ let current = candidate
789
+ while (current !== undefined) {
790
+ if (guard.has(current.id)) return true
791
+ guard.add(current.id)
792
+ current = parentOf(current)
793
+ }
794
+ return false
795
+ }
796
+ const roots = []
797
+ for (const node of nodes.values()) {
798
+ const parentId = typeof node.entry.parentId === 'string' ? node.entry.parentId : null
799
+ const parent = parentId === implicitParent ? undefined : parentOf(node)
800
+ if (parent === undefined || loops(node, parent)) roots.push(node)
801
+ else parent.children.push(node)
802
+ }
803
+ return roots
804
+ }
805
+
806
+ /**
807
+ * One node's whole subtree, itself first.
808
+ * @param {object} node - a forest node.
809
+ * @returns {string[]} the ids.
810
+ */
811
+ function subtreeIds(node) {
812
+ const ids = []
813
+ const walk = (current) => {
814
+ ids.push(current.id)
815
+ for (const child of current.children) walk(child)
816
+ }
817
+ walk(node)
818
+ return ids
819
+ }
820
+
821
+ /**
822
+ * The family tree both dialogs draw.
823
+ *
824
+ * One row per Session, and under every row two collapsible blocks: its direct
825
+ * 子智能体, then its direct 派生对话. A derived conversation therefore holds
826
+ * its own subagents, and its own derived conversations, inside its own block
827
+ * one step further in, instead of in a second list that only shares a heading
828
+ * with it. The nesting is expressed by the indentation alone: no connectors,
829
+ * no guide lines, so a row's place is read from its indent.
830
+ *
831
+ * A row's checkbox covers that row's whole subtree, and a block's covers every
832
+ * row the block lists. Both report the same two states: ticked when everything
833
+ * below them is ticked, mixed when only part of it is.
834
+ * @param {object} props - the family, the selection, and the drawing callbacks.
835
+ */
836
+ function FamilyTree(props) {
837
+ const { items, implicitParent, main, selected, onToggle, collapsed, setCollapsed, disabled, leafName } = props
838
+ const forest = buildForest(items, implicitParent === undefined ? null : implicitParent)
839
+
840
+ /** A set of ids read as a checkbox state. */
841
+ const stateOf = (ids) => {
842
+ let on = ids.length > 0
843
+ let some = false
844
+ for (const id of ids) {
845
+ if (selected.has(id)) some = true
846
+ else on = false
847
+ }
848
+ return { on, partial: some && !on }
849
+ }
850
+
851
+ /** One checkbox: ticked when every id it covers is ticked, mixed when only some are. */
852
+ const box = (ids) => {
853
+ const state = stateOf(ids)
854
+ return h('input', {
855
+ type: 'checkbox',
856
+ key: 'box',
857
+ className: 'dsd-check',
858
+ checked: state.on,
859
+ disabled: disabled === true,
860
+ ref: (input) => {
861
+ if (input !== null) input.indeterminate = state.partial
862
+ },
863
+ onChange: () => onToggle(ids, !state.on),
864
+ })
865
+ }
866
+
867
+ /** The caret of one row or block, or the empty column a leaf keeps. */
868
+ const caret = (collapseKey, hasChildren, isCollapsed) => (hasChildren
869
+ ? h('button', {
870
+ type: 'button',
871
+ key: 'caret',
872
+ className: 'dsd-caret',
873
+ 'aria-expanded': !isCollapsed,
874
+ onClick: () => setCollapsed((previous) => ({ ...previous, [collapseKey]: !isCollapsed })),
875
+ children: isCollapsed ? '▸' : '▾',
876
+ })
877
+ : h('span', { className: 'dsd-caret dsd-caretLeaf', key: 'caret', 'aria-hidden': true }))
878
+
879
+ /** The badges one row carries: what that Session is doing right now. */
880
+ const badgesOf = (entry) => {
881
+ const busy = activityOf(entry)
882
+ return [
883
+ ...busy.map((item) => item.label),
884
+ ...(busy.length === 0 && entry.running === true ? [t('dialog.itemRunning')] : []),
885
+ ...(entry.open === true ? [t('dialog.itemOpen')] : []),
886
+ ]
887
+ }
888
+
889
+ /**
890
+ * One collapsible block: the direct children of one row that share one kind.
891
+ * @param {string} collapseKey - unique to this parent and kind.
892
+ * @param {string} label - the block's text, already counted.
893
+ * @param {string} glyphName - the glyph the heading carries.
894
+ * @param {object[]} members - the child nodes the block lists.
895
+ * @param {number} depth - the indent the heading sits at; members sit one deeper.
896
+ */
897
+ const drawBlock = (collapseKey, label, glyphName, members, depth) => {
898
+ const isCollapsed = collapsed[collapseKey] === true
899
+ const ids = members.flatMap((member) => subtreeIds(member))
900
+ return h('div', { className: 'dsd-block', key: collapseKey }, [
901
+ h('div', {
902
+ className: 'dsd-node dsd-nodeBranch',
903
+ key: 'head',
904
+ style: { paddingInlineStart: (depth * INDENT) + 'px' },
905
+ }, [
906
+ caret(collapseKey, true, isCollapsed),
907
+ h('label', { className: 'dsd-nodeLabel', key: 'label' }, [
908
+ box(ids),
909
+ h('span', { className: 'dsd-rowIcon', key: 'glyph', children: treeGlyph(glyphName, 14) }),
910
+ h('span', { className: 'dsd-rowText', key: 'text' }, [
911
+ h('span', { className: 'dsd-blockTitle', key: 'name', children: label }),
912
+ ]),
913
+ ]),
914
+ ]),
915
+ isCollapsed
916
+ ? null
917
+ : h('div', { className: 'dsd-children', key: 'rows' }, members.map((member) => drawNode(member, depth + 1, member.id))),
918
+ ])
919
+ }
920
+
921
+ /** One Session row, then the blocks holding its own direct children. */
922
+ const drawNode = (node, depth, key) => {
923
+ const entry = node.entry
924
+ const isCollapsed = collapsed[node.id] === true
925
+ const subagents = node.children.filter((child) => child.entry.kind === 'subagent')
926
+ const forks = node.children.filter((child) => child.entry.kind !== 'subagent')
927
+ const hints = badgesOf(entry)
928
+ const glyphName = entry.kind === 'subagent' ? 'member' : entry.kind === 'root' ? 'session' : 'chat'
929
+ return h('div', { className: 'dsd-branch', key }, [
930
+ h('div', {
931
+ className: 'dsd-node',
932
+ key: 'row',
933
+ style: { paddingInlineStart: (depth * INDENT) + 'px' },
934
+ }, [
935
+ caret(node.id, node.children.length > 0, isCollapsed),
936
+ h('label', { className: 'dsd-nodeLabel', key: 'label' }, [
937
+ box(subtreeIds(node)),
938
+ h('span', { className: 'dsd-rowIcon', key: 'glyph', children: treeGlyph(glyphName, 14) }),
939
+ h('span', { className: 'dsd-rowText', key: 'text' }, [
940
+ h('span', {
941
+ className: 'dsd-rowTitle',
942
+ key: 'name',
943
+ children: entry.title === undefined || entry.title === '' ? leafName : entry.title,
944
+ }),
945
+ h('span', { className: 'dsd-rowId', key: 'id', children: shortId(entry.id) }),
946
+ ]),
947
+ ]),
948
+ hints.length === 0
949
+ ? null
950
+ : h('span', { className: 'dsd-badge', key: 'badge', children: hints.join(' · ') }),
951
+ ]),
952
+ isCollapsed || node.children.length === 0
953
+ ? null
954
+ : h('div', { className: 'dsd-children', key: 'children' }, [
955
+ subagents.length === 0
956
+ ? null
957
+ : drawBlock('sub:' + node.id, t('dialog.group.subagentsCount', { n: subagents.length }), 'subagent', subagents, depth + 1),
958
+ forks.length === 0
959
+ ? null
960
+ : drawBlock('fork:' + node.id, t('dialog.group.derivedCount', { n: forks.length }), 'linked', forks, depth + 1),
961
+ ]),
962
+ ])
963
+ }
964
+
965
+ const mainIds = items.map((entry) => entry.id)
966
+ const hasMain = main !== null && main !== undefined
967
+ const mainCollapsed = collapsed['__main__'] === true
968
+ return h('div', { className: 'dsd-tree' }, [
969
+ hasMain
970
+ ? h('div', { className: 'dsd-node dsd-nodeMain', key: 'main' }, [
971
+ caret('__main__', forest.length > 0, mainCollapsed),
972
+ h('label', { className: 'dsd-nodeLabel', key: 'label' }, [
973
+ box(mainIds),
974
+ h('span', { className: 'dsd-rowIcon', key: 'glyph', children: treeGlyph('session', 14) }),
975
+ h('span', { className: 'dsd-rowText', key: 'text' }, [
976
+ h('span', {
977
+ className: 'dsd-rowTitle dsd-mainTitle',
978
+ key: 'name',
979
+ children: t('dialog.mainRow', { title: main }),
980
+ }),
981
+ ]),
982
+ ]),
983
+ ])
984
+ : null,
985
+ hasMain && mainCollapsed
986
+ ? null
987
+ : forest.map((node) => drawNode(node, hasMain ? 1 : 0, node.id)),
988
+ ])
989
+ }
990
+
991
+ /**
992
+ * The `shell.overlay` seat: the bulk dialog, opened from the search-adjacent icon.
993
+ *
994
+ * Rows are grouped by Workspace and listed flat inside each group, the way the
995
+ * sidebar shows them, with parents before their children. The nesting is read
996
+ * from the indentation alone: a row sits one step deeper for each ancestor
997
+ * between it and the top of its family, so a mixed list still shows which row
998
+ * is a child and which is a parent. Ticking a parent takes its whole family
999
+ * with it, and a child can be taken back off that family, which is the same
1000
+ * choice the single Session dialog offers.
1001
+ */
1002
+ function BulkDeleteDialog() {
1003
+ const open = useSyncExternalStore(subscribeBulk, getBulkOpen)
1004
+ const [state, setState] = useState({ phase: 'idle', catalog: null, error: null, busy: false })
1005
+ const [selected, setSelected] = useState(null)
1006
+ const [collapsed, setCollapsed] = useState({})
1007
+ const [stop, setStop] = useState(true)
1008
+ const dialog = useRef(null)
1009
+
1010
+ const close = useCallback(() => {
1011
+ setState((previous) => (previous.busy
1012
+ ? previous
1013
+ : { phase: 'idle', catalog: null, error: null, busy: false }))
1014
+ setSelected(null)
1015
+ setBulkOpen(false)
1016
+ }, [])
1017
+
1018
+ // The list is re-read on every open: a batch view is only true for the
1019
+ // moment it was read, and the sidebar may have changed in between.
1020
+ useEffect(() => {
1021
+ if (!open) return undefined
1022
+ let cancelled = false
1023
+ setState({ phase: 'loading', catalog: null, error: null, busy: false })
1024
+ setSelected(new Set())
1025
+ setCollapsed({})
1026
+ setStop(true)
1027
+ fetchCatalog().then(
1028
+ (catalog) => {
1029
+ if (cancelled) return
1030
+ const next = {}
1031
+ for (const group of catalog?.workspaces ?? []) next[group.key] = false
1032
+ setState({ phase: 'ready', catalog, error: null, busy: false })
1033
+ setCollapsed(next)
1034
+ },
1035
+ (reason) => {
1036
+ if (cancelled) return
1037
+ setState({
1038
+ phase: 'ready',
1039
+ catalog: null,
1040
+ error: reason instanceof Error ? reason.message : String(reason),
1041
+ busy: false,
1042
+ })
1043
+ },
1044
+ )
1045
+ return () => {
1046
+ cancelled = true
1047
+ }
1048
+ }, [open])
1049
+
1050
+ useEffect(() => {
1051
+ if (!open) return undefined
1052
+ const onKeyDown = (event) => {
1053
+ if (event.key === 'Escape') close()
1054
+ }
1055
+ window.addEventListener('keydown', onKeyDown, true)
1056
+ return () => window.removeEventListener('keydown', onKeyDown, true)
1057
+ }, [open, close])
1058
+
1059
+ useEffect(() => {
1060
+ if (open) dialog.current?.focus()
1061
+ }, [open])
1062
+
1063
+ if (!open) return null
1064
+
1065
+ const catalog = state.catalog
1066
+ const groups = (catalog?.workspaces ?? []).map((group) => ({
1067
+ ...group,
1068
+ items: Array.isArray(group.sessions) ? group.sessions : [],
1069
+ }))
1070
+ const allIds = groups.flatMap((group) => group.items.map((entry) => entry.id))
1071
+ const picked = selected ?? new Set()
1072
+ const busy = state.busy
1073
+ const chosenCount = allIds.filter((id) => picked.has(id)).length
1074
+ const allChosen = allIds.length > 0 && chosenCount === allIds.length
1075
+
1076
+ const toggle = (ids, on) => {
1077
+ setSelected(() => {
1078
+ const next = new Set(picked)
1079
+ for (const id of ids) {
1080
+ if (on) next.add(id)
1081
+ else next.delete(id)
1082
+ }
1083
+ return next
1084
+ })
1085
+ }
1086
+
1087
+ /**
1088
+ * The batch to send: every ticked row whose parent is not ticked becomes a
1089
+ * delete root of its own, carrying the ticked rows below it. Ticking a
1090
+ * subagent on its own therefore deletes just that subagent, and ticking a
1091
+ * parent takes its selected family with it — the same choice the single
1092
+ * dialog offers, with no second "exclusion" state to keep in step.
1093
+ */
1094
+ const confirm = () => {
1095
+ if (busy || state.phase !== 'ready') return
1096
+ const roots = []
1097
+ const collect = (node, underChosen) => {
1098
+ const on = picked.has(node.id)
1099
+ if (on && !underChosen) {
1100
+ roots.push({
1101
+ sessionId: node.id,
1102
+ descendants: subtreeIds(node).filter((id) => id !== node.id && picked.has(id)),
1103
+ })
1104
+ }
1105
+ for (const child of node.children) collect(child, underChosen || on)
1106
+ }
1107
+ for (const group of groups) for (const root of buildForest(group.items, null)) collect(root, false)
1108
+ if (roots.length === 0) return
1109
+ setState((previous) => ({ ...previous, busy: true, error: null }))
1110
+ deleteBatch(roots, stop).then(
1111
+ (payload) => {
1112
+ const failed = Array.isArray(payload?.failed) ? payload.failed : []
1113
+ if (failed.length === 0) {
1114
+ setSelected(null)
1115
+ setState({ phase: 'idle', catalog: null, error: null, busy: false })
1116
+ setBulkOpen(false)
1117
+ return
1118
+ }
1119
+ // A partial batch stays open with the survivors ticked: what was
1120
+ // deleted is gone, and the rest can be retried by hand.
1121
+ const failedIds = new Set(failed.map((entry) => entry.sessionId))
1122
+ setSelected(() => new Set(allIds.filter((id) => failedIds.has(id))))
1123
+ setState((previous) => ({
1124
+ ...previous,
1125
+ busy: false,
1126
+ error: t('batch.deletePartial', { n: failed.length }) + ' ' + failed.map((entry) => entry.message).join(' / '),
1127
+ }))
1128
+ },
1129
+ (reason) => {
1130
+ setState((previous) => ({
1131
+ ...previous,
1132
+ busy: false,
1133
+ error: reason instanceof Error ? reason.message : String(reason),
1134
+ }))
1135
+ },
1136
+ )
1137
+ }
1138
+
1139
+ return h('div', {
1140
+ className: 'dsd-overlay',
1141
+ role: 'presentation',
1142
+ onKeyDownCapture: (event) => {
1143
+ if (event.key === 'Escape') {
1144
+ event.stopPropagation()
1145
+ close()
1146
+ }
1147
+ },
1148
+ }, [
1149
+ h('div', { className: 'dsd-mask', key: 'mask', 'aria-hidden': true, onClick: close }),
1150
+ h('div', {
1151
+ className: 'dsd-dialog dsd-dialogWide',
1152
+ key: 'dialog',
1153
+ ref: dialog,
1154
+ tabIndex: -1,
1155
+ role: 'dialog',
1156
+ 'aria-modal': 'true',
1157
+ 'aria-label': t('batch.title'),
1158
+ }, [
1159
+ h('h2', { className: 'dsd-title', key: 'title', children: t('batch.title') }),
1160
+ h('p', { className: 'dsd-desc', key: 'desc', children: t('batch.desc') }),
1161
+ state.phase === 'loading'
1162
+ ? h('p', { className: 'dsd-note', key: 'loading', children: t('batch.loading') })
1163
+ : null,
1164
+ state.error === null
1165
+ ? null
1166
+ : h('p', { className: 'dsd-error', key: 'error', children: `${t('batch.failed')}:${state.error}` }),
1167
+ state.phase === 'ready' && catalog === null && state.error === null
1168
+ ? h('p', { className: 'dsd-note', key: 'unknown', children: t('batch.unknown') })
1169
+ : null,
1170
+ state.phase === 'ready' && groups.length === 0
1171
+ ? h('p', { className: 'dsd-note', key: 'empty', children: t('batch.empty') })
1172
+ : h('div', {
1173
+ className: 'dsd-scroll',
1174
+ key: 'list',
1175
+ role: 'group',
1176
+ 'aria-label': t('batch.region'),
1177
+ }, [
1178
+ h('div', { className: 'dsd-toolbar', key: 'toolbar' }, [
1179
+ h('label', { className: 'dsd-row dsd-toolbarCell', key: 'master' }, [
1180
+ h('input', {
1181
+ type: 'checkbox',
1182
+ key: 'box',
1183
+ className: 'dsd-check',
1184
+ checked: allChosen,
1185
+ disabled: busy,
1186
+ ref: (node) => {
1187
+ if (node !== null) node.indeterminate = chosenCount > 0 && !allChosen
1188
+ },
1189
+ onChange: () => toggle(allIds, !allChosen),
1190
+ }),
1191
+ h('span', {
1192
+ className: 'dsd-rowText',
1193
+ key: 'text',
1194
+ children: allChosen
1195
+ ? t('batch.selectAll', { n: chosenCount })
1196
+ : t('batch.partial', { m: chosenCount, n: allIds.length }),
1197
+ }),
1198
+ ]),
1199
+ h('button', {
1200
+ type: 'button',
1201
+ key: 'expand',
1202
+ className: 'dsd-button dsd-link',
1203
+ onClick: () => setCollapsed({}),
1204
+ children: t('batch.expandAll'),
1205
+ }),
1206
+ h('button', {
1207
+ type: 'button',
1208
+ key: 'collapse',
1209
+ className: 'dsd-button dsd-link',
1210
+ onClick: () => {
1211
+ const next = {}
1212
+ for (const group of groups) next[group.key] = true
1213
+ setCollapsed(next)
1214
+ },
1215
+ children: t('batch.collapseAll'),
1216
+ }),
1217
+ ]),
1218
+ ...groups.map((group) => {
1219
+ const groupIds = group.items.map((entry) => entry.id)
1220
+ const on = groupIds.length > 0 && groupIds.every((id) => picked.has(id))
1221
+ const isCollapsed = collapsed[group.key] === true
1222
+ return h('section', { className: 'dsd-group', key: group.key }, [
1223
+ h('div', { className: 'dsd-groupHead', key: 'head' }, [
1224
+ h('button', {
1225
+ type: 'button',
1226
+ key: 'caret',
1227
+ className: 'dsd-caret',
1228
+ 'aria-expanded': !isCollapsed,
1229
+ onClick: () => setCollapsed((previous) => ({ ...previous, [group.key]: !isCollapsed })),
1230
+ children: isCollapsed ? '▸' : '▾',
1231
+ }),
1232
+ h('label', { className: 'dsd-row dsd-groupRow', key: 'label' }, [
1233
+ h('input', {
1234
+ type: 'checkbox',
1235
+ key: 'box',
1236
+ className: 'dsd-check',
1237
+ checked: on,
1238
+ disabled: busy,
1239
+ title: t('batch.workspaceAll'),
1240
+ ref: (node) => {
1241
+ if (node !== null) node.indeterminate = !on && groupIds.some((id) => picked.has(id))
1242
+ },
1243
+ onChange: () => toggle(groupIds, !on),
1244
+ }),
1245
+ h('span', { className: 'dsd-rowText', key: 'text' }, [
1246
+ h('span', {
1247
+ className: 'dsd-wsTitle',
1248
+ key: 'title',
1249
+ children: group.title === '' ? t('batch.ungrouped') : group.title,
1250
+ }),
1251
+ h('span', {
1252
+ className: 'dsd-wsCount',
1253
+ key: 'count',
1254
+ children: t('batch.workspaceCount', { n: group.items.length }),
1255
+ }),
1256
+ ]),
1257
+ ]),
1258
+ ]),
1259
+ isCollapsed ? null : h('div', { className: 'dsd-rows dsd-groupRows', key: 'rows' }, [
1260
+ h(FamilyTree, {
1261
+ key: 'tree',
1262
+ items: group.items,
1263
+ implicitParent: null,
1264
+ main: null,
1265
+ selected: picked,
1266
+ onToggle: toggle,
1267
+ collapsed,
1268
+ setCollapsed,
1269
+ disabled: busy,
1270
+ leafName: t('batch.untitled'),
1271
+ }),
1272
+ ]),
1273
+ ])
1274
+ }),
1275
+ ]),
1276
+ h('div', { className: 'dsd-footer', key: 'footer' }, [
1277
+ h('label', { className: 'dsd-row dsd-stopRow', key: 'stop' }, [
1278
+ h('input', {
1279
+ type: 'checkbox',
1280
+ key: 'box',
1281
+ className: 'dsd-check',
1282
+ checked: stop,
1283
+ disabled: busy,
1284
+ onChange: () => setStop((previous) => !previous),
1285
+ }),
1286
+ h('span', { className: 'dsd-rowText', key: 'text', children: t('batch.stoppingNote') }),
1287
+ ]),
1288
+ h('button', {
1289
+ type: 'button',
1290
+ className: 'dsd-button dsd-outline',
1291
+ key: 'cancel',
1292
+ disabled: busy,
1293
+ onClick: close,
1294
+ children: t('batch.cancel'),
1295
+ }),
1296
+ h('button', {
1297
+ type: 'button',
1298
+ className: 'dsd-button dsd-outline dsd-dangerText',
1299
+ key: 'confirm',
1300
+ disabled: busy || chosenCount === 0,
1301
+ onClick: confirm,
1302
+ children: busy
1303
+ ? t('batch.deleting')
1304
+ : chosenCount === 0
1305
+ ? t('batch.confirmNone')
1306
+ : stop
1307
+ ? t('batch.confirmStop', { n: chosenCount })
1308
+ : t('batch.confirm', { n: chosenCount }),
1309
+ }),
1310
+ ]),
1311
+ ]),
1312
+ ])
1313
+ }
1314
+
1315
+ /** The plugin's own styles: host tokens only, so light and dark both read. */
1316
+ const CSS = `
1317
+ .dsd-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:var(--dsw-radius-md,8px);background:transparent;cursor:pointer;font:inherit;font-size:13px;line-height:20px;text-align:left;color:var(--dsw-alias-state-error-primary,#e5484d)}
1318
+ .dsd-item:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(229,72,77,.12))}
1319
+ .dsd-item:focus-visible{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(229,72,77,.12));outline:none}
1320
+ .dsd-itemIcon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center}
1321
+ .dsd-itemLabel{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1322
+ .dsd-overlay{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:auto}
1323
+ .dsd-mask{position:absolute;inset:0;background:rgba(0,0,0,.45)}
1324
+ .dsd-dialog{position:relative;z-index:1;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;width:min(420px,100%);max-height:100%;overflow:auto;padding:22px 24px 20px;border-radius:var(--dsw-radius-panel,12px);background:var(--dsw-alias-bg-layer-2,#1b1d21);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.34))}
1325
+ .dsd-dialog:focus{outline:none}
1326
+ .dsd-title{margin:0;font-size:16px;line-height:24px;font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6)}
1327
+ .dsd-desc{margin:0;font-size:14px;line-height:22px;color:var(--dsw-alias-label-secondary,#a8adb7);word-break:break-word}
1328
+ .dsd-note{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary,#a8adb7);word-break:break-word}
1329
+ .dsd-warn{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:var(--dsw-radius-md,8px);border:.5px solid var(--dsw-alias-state-warn-primary,#d9a03a);color:var(--dsw-alias-label-primary,#e6e6e6)}
1330
+ .dsd-noteTitle{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-state-warn-primary,#d9a03a)}
1331
+ .dsd-noteHint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#a8adb7)}
1332
+ .dsd-list{margin:0;padding-inline-start:18px;font-size:13px;line-height:20px}
1333
+ .dsd-tree{display:flex;flex-direction:column;gap:2px;padding:6px 0;border-block:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.2))}
1334
+ .dsd-row{display:flex;align-items:center;gap:8px;padding:4px 6px;border-radius:var(--dsw-radius-sm,6px);cursor:pointer}
1335
+ .dsd-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1336
+ .dsd-check{flex:none;width:14px;height:14px;margin:0;accent-color:var(--dsw-alias-state-error-primary,#e5484d);cursor:pointer}
1337
+ .dsd-rowText{flex:1;min-width:0;display:flex;align-items:baseline;gap:6px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}
1338
+ .dsd-rowTitle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1339
+ .dsd-rowId{flex:none;font-size:11px;color:var(--dsw-alias-label-secondary,#a8adb7)}
1340
+ .dsd-badge{flex:none;font-size:11px;line-height:16px;padding:1px 6px;border-radius:999px;border:.5px solid var(--dsw-alias-state-warn-primary,#d9a03a);color:var(--dsw-alias-state-warn-primary,#d9a03a)}
1341
+ .dsd-master{font-weight:500}
1342
+ .dsd-group{display:flex;flex-direction:column}
1343
+ .dsd-groupHead{display:flex;align-items:center;gap:2px}
1344
+ .dsd-groupRow{flex:1;min-width:0;font-weight:500}
1345
+ .dsd-caret{flex:none;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-secondary,#a8adb7);cursor:pointer;font:inherit;font-size:11px;line-height:1}
1346
+ .dsd-caret:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1347
+ .dsd-rows{display:flex;flex-direction:column}
1348
+ .dsd-groupRows{padding-inline-start:12px}
1349
+ /* The family tree: one row per Session, its children in collapsible blocks under
1350
+ it. The nesting is the indentation alone — no connectors, no guide lines. */
1351
+ .dsd-branch{display:flex;flex-direction:column}
1352
+ .dsd-node{position:relative;box-sizing:border-box;display:flex;align-items:center;gap:2px;min-height:28px}
1353
+ .dsd-nodeLabel{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:3px 6px;border-radius:var(--dsw-radius-sm,6px);cursor:pointer}
1354
+ .dsd-nodeLabel:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1355
+ .dsd-nodeMain>.dsd-nodeLabel{font-weight:500}
1356
+ .dsd-nodeBranch>.dsd-nodeLabel{color:var(--dsw-alias-label-secondary,#a8adb7);font-weight:500}
1357
+ .dsd-rowIcon{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--dsw-alias-label-tertiary,#8b909a)}
1358
+ .dsd-nodeMain .dsd-rowIcon{color:var(--dsw-alias-label-secondary,#a8adb7)}
1359
+ .dsd-mainTitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1360
+ .dsd-blockTitle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1361
+ .dsd-caretLeaf{cursor:default}
1362
+ .dsd-caretLeaf:hover{background:transparent}
1363
+ .dsd-children{display:flex;flex-direction:column}
1364
+ .dsd-hint{margin:4px 0 0;font-size:12px;line-height:18px}
1365
+ .dsd-descendants{padding:8px 12px;border-radius:var(--dsw-radius-md,8px);border:.5px solid var(--dsw-alias-state-warn-primary,#d9a03a);color:var(--dsw-alias-label-primary,#e6e6e6)}
1366
+ .dsd-error{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary,#e5484d);word-break:break-word}
1367
+ .dsd-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:8px}
1368
+ .dsd-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:36px;padding:0 14px;border-radius:var(--dsw-radius-md,8px);cursor:pointer;font:inherit;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary,#e6e6e6);background:transparent}
1369
+ .dsd-button:disabled{cursor:not-allowed;opacity:.4}
1370
+ .dsd-outline{border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.32))}
1371
+ .dsd-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1372
+ .dsd-dangerText{color:var(--dsw-alias-state-error-primary,#e5484d)}
1373
+ /* The bulk entry point, mounted into the workspace header's own search slot: a
1374
+ 28px control in the search icon's cell, ahead of that icon. */
1375
+ .dsd-anchor{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-secondary,#a8adb7);cursor:pointer}
1376
+ .dsd-anchor:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,#e6e6e6)}
1377
+ .dsd-anchor:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4a8cff));outline-offset:-2px}
1378
+ /* The shipped search cell is exactly one icon wide; the bulk control joins its
1379
+ flex row, so the cell is widened by that one control and the icon slides
1380
+ right. The shipped rules are CSS-module hashes, hence the attribute match and
1381
+ the !important that keeps the two stylesheets' order irrelevant. */
1382
+ [class*="_searchSlot"]:has(> .dsd-anchor){max-width:56px !important}
1383
+ /* The bulk dialog is wider than the single-Session one: it lists every Workspace. */
1384
+ .dsd-dialogWide{width:min(660px,100%)}
1385
+ .dsd-scroll{display:flex;flex-direction:column;gap:2px;padding:6px 0;max-height:min(58vh,520px);overflow:auto;border-block:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.2))}
1386
+ .dsd-toolbar{display:flex;align-items:center;gap:8px;padding:2px 4px 6px;position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-2,#1b1d21)}
1387
+ .dsd-toolbarCell{flex:1;min-width:0}
1388
+ .dsd-link{height:26px;padding:0 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-state-business-primary,#4a8cff)}
1389
+ .dsd-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1390
+
1391
+ .dsd-wsTitle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1392
+ .dsd-wsCount{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary,#8b909a)}
1393
+ .dsd-stopRow{flex:1;min-width:0;margin-right:4px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary,#a8adb7)}
1394
+ .dsd-stopRow .dsd-rowText{font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary,#a8adb7)}
1395
+ `
1396
+
1397
+ /** Mount the plugin's stylesheet with its components. */
1398
+ function Styles() {
1399
+ return h('style', { 'data-dsh-plugin': 'better-dsh-session-deletetool', children: CSS })
1400
+ }
1401
+
1402
+ /** The menu row and its dialog, wrapped so the stylesheet travels with them. */
1403
+ function WithStyles(Component) {
1404
+ function StyledComponent(props) {
1405
+ return h(React.Fragment, null, h(Styles, { key: 'style' }), h(Component, { ...props, key: 'body' }))
1406
+ }
1407
+ StyledComponent.displayName = `BetterSessionDeletetool(${Component.name})`
1408
+ return StyledComponent
1409
+ }
1410
+
1411
+ return {
1412
+ inject: ['slots'],
1413
+ apply(ctx) {
1414
+ const locale = ctx.get('locale')
1415
+ if (locale !== undefined && typeof locale.register === 'function' && typeof locale.bind === 'function') {
1416
+ ctx.effect(() => locale.register(NS, { zh: DICT.zh, en: DICT.en }), 'better-dsh-session-deletetool: locale')
1417
+ translate = locale.bind(NS)
1418
+ }
1419
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () =>
1420
+ ctx.slots.register(
1421
+ { name: 'sidebar.workspaces.session.menu.item', id: 'better-dsh-session-deletetool', order: 500, locale: NS },
1422
+ WithStyles(DeleteSessionMenuItem),
1423
+ ),
1424
+ )
1425
+ ctx.slots.inject('shell.overlay', () =>
1426
+ ctx.slots.register(
1427
+ { name: 'shell.overlay', id: 'better-dsh-session-deletetool-dialog', order: 60, locale: NS },
1428
+ WithStyles(DeleteSessionDialog),
1429
+ ),
1430
+ )
1431
+ // The bulk entry lives in the sidebar foot: it renders only a hidden
1432
+ // placeholder and mounts the real control into the browsing region's own
1433
+ // search slot, which is what puts it beside the search icon without
1434
+ // shadowing the shipped WorkspaceBrowser that owns that `single` slot.
1435
+ ctx.slots.inject('sidebar.footer.action', () =>
1436
+ ctx.slots.register(
1437
+ { name: 'sidebar.footer.action', id: 'better-dsh-session-deletetool-bulk', order: 40, locale: NS },
1438
+ WithStyles(BulkDeleteAnchor),
1439
+ ),
1440
+ )
1441
+ ctx.slots.inject('shell.overlay', () =>
1442
+ ctx.slots.register(
1443
+ { name: 'shell.overlay', id: 'better-dsh-session-deletetool-bulk-dialog', order: 61, locale: NS },
1444
+ WithStyles(BulkDeleteDialog),
1445
+ ),
1446
+ )
1447
+ },
1448
+ /**
1449
+ * The pure halves of the bulk dialog, reachable only from a test run.
1450
+ *
1451
+ * The harness loads this file in the page; nothing else can see these
1452
+ * functions, and the property costs one boolean check at load.
1453
+ */
1454
+ ...(globalThis.__DSD_TEST__ === true
1455
+ ? {
1456
+ __test: {
1457
+ buildForest,
1458
+ subtreeIds,
1459
+ setBulkOpen,
1460
+ failureOf,
1461
+ },
1462
+ }
1463
+ : {}),
1464
+ }
1465
+ },
1466
+ })