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/CHANGELOG.md +299 -0
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/README.zh-CN.md +171 -0
- package/client.js +1466 -0
- package/cordis.patch.yml +4 -0
- package/host.js +1431 -0
- package/icon.svg +6 -0
- package/package.json +62 -0
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
|
+
})
|