@zfdx123/dsh-session-cleaner 1.0.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/LICENSE +21 -0
- package/README.md +172 -0
- package/client.js +1147 -0
- package/cordis.patch.yml +6 -0
- package/lib/index.js +414 -0
- package/package.json +80 -0
package/client.js
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
// dsh-session-cleaner — client half.
|
|
2
|
+
//
|
|
3
|
+
// Two additive entries:
|
|
4
|
+
// 1. a "删除会话" item in the sidebar session row ⋮ menu, inserted directly
|
|
5
|
+
// BELOW "归档会话";
|
|
6
|
+
// 2. a "会话清理" Settings page listing every session the store knows —
|
|
7
|
+
// workspace members, ungrouped strays, archived and legacy bare-uuid
|
|
8
|
+
// sessions alike — with per-row Delete (plus Unarchive on archived rows).
|
|
9
|
+
//
|
|
10
|
+
// The row ⋮ menu has no public slot, so entry 1 augments the opened menu in the
|
|
11
|
+
// DOM. Three deliberate choices keep that from being guesswork:
|
|
12
|
+
// - the menu is recognized SEMANTICALLY: a subtree carrying both the archive
|
|
13
|
+
// and the fork/rename labels is the session row menu, and the insertion
|
|
14
|
+
// point is the smallest element containing both;
|
|
15
|
+
// - the row comes from the ⋮ trigger the user just pressed, never from a time
|
|
16
|
+
// window or a rectangle distance;
|
|
17
|
+
// - the SESSION ID comes from the row's React fiber props (the row component
|
|
18
|
+
// receives the session node), so no title matching and no catalog fetch is
|
|
19
|
+
// required; the title catalog remains a fallback and supplies the running
|
|
20
|
+
// flag.
|
|
21
|
+
// Every step reports to `/api-ext/session.cleaner.diag`, so a menu item that
|
|
22
|
+
// fails to appear can be diagnosed from outside the browser.
|
|
23
|
+
//
|
|
24
|
+
// Elements are built with React.createElement, and the shell's own UI kit is
|
|
25
|
+
// required behind a guard: without it the confirmation falls back to the
|
|
26
|
+
// browser's, and every glyph to the hand-drawn SVG beside it.
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: '@zfdx123/dsh-session-cleaner',
|
|
29
|
+
factory: (require) => {
|
|
30
|
+
var module = { exports: {} }
|
|
31
|
+
var exports = module.exports
|
|
32
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
33
|
+
const React = require('react')
|
|
34
|
+
|
|
35
|
+
const NS = 'sessionCleaner'
|
|
36
|
+
const zh = {
|
|
37
|
+
nav: '会话清理',
|
|
38
|
+
search: '搜索会话',
|
|
39
|
+
loading: '正在读取会话…',
|
|
40
|
+
empty: '没有匹配的会话。',
|
|
41
|
+
ungrouped: '未分组',
|
|
42
|
+
count: '{n} 个会话',
|
|
43
|
+
countOf: '{n}/{total} 个',
|
|
44
|
+
archived: '已归档',
|
|
45
|
+
running: '运行中',
|
|
46
|
+
legacy: '旧格式',
|
|
47
|
+
del: '删除',
|
|
48
|
+
delNamed: '删除会话 {title}',
|
|
49
|
+
confirm: '确定要永久删除会话「{title}」吗?此操作不可恢复。',
|
|
50
|
+
refused: '该会话正被打开(有 agent 附着),请先关闭它再删除。',
|
|
51
|
+
failed: '删除失败:{message}',
|
|
52
|
+
unarchive: '取消归档',
|
|
53
|
+
cancel: '取消',
|
|
54
|
+
close: '关闭',
|
|
55
|
+
pending: '正在删除…',
|
|
56
|
+
menuDelete: '删除会话',
|
|
57
|
+
menuDeleteDisabled: '会话正在运行,无法删除',
|
|
58
|
+
now: '刚刚',
|
|
59
|
+
minutes: '{n}分钟',
|
|
60
|
+
hours: '{n}小时',
|
|
61
|
+
days: '{n}天',
|
|
62
|
+
months: '{n}个月',
|
|
63
|
+
years: '{n}年',
|
|
64
|
+
}
|
|
65
|
+
const en = {
|
|
66
|
+
nav: 'Session cleaner',
|
|
67
|
+
search: 'Search sessions',
|
|
68
|
+
loading: 'Reading sessions…',
|
|
69
|
+
empty: 'No matching sessions.',
|
|
70
|
+
ungrouped: 'Ungrouped',
|
|
71
|
+
count: '{n} sessions',
|
|
72
|
+
countOf: '{n}/{total}',
|
|
73
|
+
archived: 'Archived',
|
|
74
|
+
running: 'Running',
|
|
75
|
+
legacy: 'Legacy',
|
|
76
|
+
del: 'Delete',
|
|
77
|
+
delNamed: 'Delete session {title}',
|
|
78
|
+
confirm: 'Permanently delete session “{title}”? This cannot be undone.',
|
|
79
|
+
refused: 'This session is open (an agent is attached); close it before deleting.',
|
|
80
|
+
failed: 'Delete failed: {message}',
|
|
81
|
+
unarchive: 'Unarchive',
|
|
82
|
+
cancel: 'Cancel',
|
|
83
|
+
close: 'Close',
|
|
84
|
+
pending: 'Deleting…',
|
|
85
|
+
menuDelete: 'Delete session',
|
|
86
|
+
menuDeleteDisabled: 'Session is running and cannot be deleted',
|
|
87
|
+
now: 'now',
|
|
88
|
+
minutes: '{n}min',
|
|
89
|
+
hours: '{n}h',
|
|
90
|
+
days: '{n}d',
|
|
91
|
+
months: '{n}mo',
|
|
92
|
+
years: '{n}y',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const inject = ['slots', 'locale', 'sessions', 'uiWorkspace']
|
|
96
|
+
|
|
97
|
+
const DELETE_PATH = '/api-ext/session.delete'
|
|
98
|
+
const DIAG_PATH = '/api-ext/session.cleaner.diag'
|
|
99
|
+
/** The shell hands every client bundle a fixed module set; all three are in it. */
|
|
100
|
+
const PRIMITIVES = '@deepseek-ai/dsh-client-ui-primitives'
|
|
101
|
+
const REACT_DOM = 'react-dom/client'
|
|
102
|
+
/** `react-dom` proper, for the one export that renders a kit glyph now. */
|
|
103
|
+
const REACT_DOM_SYNC = 'react-dom'
|
|
104
|
+
|
|
105
|
+
// ------------------------------------------------------------------ shared
|
|
106
|
+
|
|
107
|
+
/** Fire-and-forget diagnostic report to the host's bounded log. */
|
|
108
|
+
function report(event, detail) {
|
|
109
|
+
try {
|
|
110
|
+
fetch(DIAG_PATH, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
credentials: 'same-origin',
|
|
114
|
+
body: JSON.stringify({ report: { event, detail: detail === undefined ? null : detail } }),
|
|
115
|
+
}).catch(() => {})
|
|
116
|
+
} catch {
|
|
117
|
+
/* diagnostics must never break the feature */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** POST the delete route; throws with the server's message on refusal. */
|
|
122
|
+
async function requestDelete(sessionId) {
|
|
123
|
+
const response = await fetch(DELETE_PATH, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
credentials: 'same-origin',
|
|
127
|
+
body: JSON.stringify({ sessionId }),
|
|
128
|
+
})
|
|
129
|
+
const body = await response.json().catch(() => ({}))
|
|
130
|
+
if (body?.ok !== true) {
|
|
131
|
+
const failure = new Error(body?.error?.message ?? `HTTP ${response.status}`)
|
|
132
|
+
failure.code = body?.error?.code ?? 'internal'
|
|
133
|
+
throw failure
|
|
134
|
+
}
|
|
135
|
+
return body.value
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The UI primitives, or null when the shell did not hand them over — the
|
|
140
|
+
* module-table miss this guard exists for, or a kit that only partly
|
|
141
|
+
* arrived: every member this plugin renders has to be there, icons
|
|
142
|
+
* included, because a kit without them would silently change the page's
|
|
143
|
+
* look. Null means the delete still works through the browser's own
|
|
144
|
+
* confirmation, and every glyph falls back to the hand-drawn SVG.
|
|
145
|
+
*/
|
|
146
|
+
function loadPrimitives(require) {
|
|
147
|
+
try {
|
|
148
|
+
const primitives = require(PRIMITIVES)
|
|
149
|
+
if (
|
|
150
|
+
typeof primitives?.Modal === 'function' &&
|
|
151
|
+
typeof primitives?.Button === 'function' &&
|
|
152
|
+
typeof primitives?.IconSearchOutline16 === 'function' &&
|
|
153
|
+
typeof primitives?.IconChevronDownOutline14 === 'function' &&
|
|
154
|
+
typeof primitives?.IconTrashOutline16 === 'function'
|
|
155
|
+
)
|
|
156
|
+
return primitives
|
|
157
|
+
} catch (error) {
|
|
158
|
+
report('primitives-missing', { message: String(error?.message ?? error) })
|
|
159
|
+
}
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What to show for a failed delete: a refusal reads differently from a fault. */
|
|
164
|
+
function failureText(error, labels) {
|
|
165
|
+
const message = error?.message ?? String(error)
|
|
166
|
+
const refused = error?.code === 'refused' || /open|attached|running/i.test(message)
|
|
167
|
+
return refused ? labels.refused : labels.failed.replace('{message}', message)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The copy every delete surface shares. */
|
|
171
|
+
function deleteLabels(t) {
|
|
172
|
+
return {
|
|
173
|
+
title: t('menuDelete'),
|
|
174
|
+
description: (name) => t('confirm', { title: name }),
|
|
175
|
+
confirm: t('del'),
|
|
176
|
+
cancel: t('cancel'),
|
|
177
|
+
close: t('close'),
|
|
178
|
+
pending: t('pending'),
|
|
179
|
+
refused: t('refused'),
|
|
180
|
+
failed: t('failed', { message: '{message}' }),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Drop a just-deleted session from the CLIENT session list.
|
|
186
|
+
*
|
|
187
|
+
* The host's only removal notification is `session/disposed`, and the
|
|
188
|
+
* session store emits it solely for an ANNOUNCED live entry — one an agent
|
|
189
|
+
* opened, which is exactly the kind of session this plugin refuses to
|
|
190
|
+
* delete. A deleted cold session holds no live entry at all, so nothing
|
|
191
|
+
* ever reaches this page and the row would sit in the sidebar and in both
|
|
192
|
+
* Settings pages forever. `handleSessionRemoved` is the very method the
|
|
193
|
+
* `api-session/removed` relay calls, so the row leaves on the store's own
|
|
194
|
+
* path (list, grouping, per-session scope and bindings together); the
|
|
195
|
+
* follow-up `refresh()` reconciles with the host baseline, which is built
|
|
196
|
+
* from the persisted logs and therefore no longer lists the session.
|
|
197
|
+
* @param ctx - client plugin context (needs the sessions service).
|
|
198
|
+
* @param sessionId - the session that no longer exists.
|
|
199
|
+
* @returns completion of the baseline reconcile.
|
|
200
|
+
*/
|
|
201
|
+
function forgetDeleted(ctx, sessionId) {
|
|
202
|
+
const sessions = ctx?.sessions
|
|
203
|
+
try {
|
|
204
|
+
sessions?.handleSessionRemoved?.(sessionId)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
report('forget-failed', { id: sessionId, message: String(error?.message ?? error) })
|
|
207
|
+
}
|
|
208
|
+
return Promise.resolve(sessions?.refresh?.()).catch(() => {})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Localized compact relative time. */
|
|
212
|
+
function timeLabel(updatedAt, now, t) {
|
|
213
|
+
const minutes = Math.floor((now - updatedAt) / 6e4)
|
|
214
|
+
if (minutes < 1) return t('now')
|
|
215
|
+
const hours = Math.floor(minutes / 60)
|
|
216
|
+
if (hours < 1) return t('minutes', { n: minutes })
|
|
217
|
+
const days = Math.floor(hours / 24)
|
|
218
|
+
if (days < 1) return t('hours', { n: hours })
|
|
219
|
+
const months = Math.floor(days / 30)
|
|
220
|
+
if (months < 1) return t('days', { n: days })
|
|
221
|
+
const years = Math.floor(months / 12)
|
|
222
|
+
if (years < 1) return t('months', { n: months })
|
|
223
|
+
return t('years', { n: years })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const S = {
|
|
227
|
+
section: {
|
|
228
|
+
width: '100%',
|
|
229
|
+
maxWidth: 760,
|
|
230
|
+
display: 'flex',
|
|
231
|
+
flexDirection: 'column',
|
|
232
|
+
gap: 12,
|
|
233
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
234
|
+
font: 'inherit',
|
|
235
|
+
},
|
|
236
|
+
status: { color: 'var(--dsw-alias-label-tertiary)', margin: 0, fontSize: 13, lineHeight: '20px' },
|
|
237
|
+
search: { position: 'relative', display: 'flex', alignItems: 'center', width: '100%' },
|
|
238
|
+
/** The box the search glyph rides: a kit icon drops `style`, the box keeps it. */
|
|
239
|
+
searchGlyph: {
|
|
240
|
+
position: 'absolute',
|
|
241
|
+
left: 12,
|
|
242
|
+
display: 'flex',
|
|
243
|
+
pointerEvents: 'none',
|
|
244
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
245
|
+
},
|
|
246
|
+
input: {
|
|
247
|
+
width: '100%',
|
|
248
|
+
height: 32,
|
|
249
|
+
boxSizing: 'border-box',
|
|
250
|
+
borderRadius: 8,
|
|
251
|
+
border: '.5px solid var(--dsw-alias-border-l2)',
|
|
252
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
253
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
254
|
+
padding: '0 12px 0 36px',
|
|
255
|
+
font: 'inherit',
|
|
256
|
+
},
|
|
257
|
+
groups: { display: 'flex', flexDirection: 'column', gap: 10 },
|
|
258
|
+
group: { display: 'flex', flexDirection: 'column', gap: 2 },
|
|
259
|
+
groupHeader: {
|
|
260
|
+
display: 'flex',
|
|
261
|
+
alignItems: 'center',
|
|
262
|
+
gap: 6,
|
|
263
|
+
width: '100%',
|
|
264
|
+
margin: 0,
|
|
265
|
+
padding: '2px 8px',
|
|
266
|
+
border: 'none',
|
|
267
|
+
background: 'transparent',
|
|
268
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
269
|
+
font: 'inherit',
|
|
270
|
+
fontSize: 12,
|
|
271
|
+
lineHeight: '18px',
|
|
272
|
+
textAlign: 'left',
|
|
273
|
+
cursor: 'pointer',
|
|
274
|
+
},
|
|
275
|
+
groupTitle: { fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
276
|
+
chevron: { display: 'flex', flexShrink: 0, transform: 'none' },
|
|
277
|
+
chevronFolded: { transform: 'rotate(-90deg)' },
|
|
278
|
+
groupList: {
|
|
279
|
+
listStyle: 'none',
|
|
280
|
+
margin: 0,
|
|
281
|
+
padding: 0,
|
|
282
|
+
paddingLeft: 8,
|
|
283
|
+
display: 'flex',
|
|
284
|
+
flexDirection: 'column',
|
|
285
|
+
gap: 2,
|
|
286
|
+
},
|
|
287
|
+
row: { display: 'flex', alignItems: 'center', gap: 12, padding: '8px 10px', borderRadius: 8 },
|
|
288
|
+
identity: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 },
|
|
289
|
+
titleRow: { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 },
|
|
290
|
+
title: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13, lineHeight: '20px' },
|
|
291
|
+
meta: {
|
|
292
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
293
|
+
fontSize: 12,
|
|
294
|
+
lineHeight: '18px',
|
|
295
|
+
overflow: 'hidden',
|
|
296
|
+
textOverflow: 'ellipsis',
|
|
297
|
+
whiteSpace: 'nowrap',
|
|
298
|
+
},
|
|
299
|
+
badge: {
|
|
300
|
+
flexShrink: 0,
|
|
301
|
+
fontSize: 11,
|
|
302
|
+
lineHeight: '16px',
|
|
303
|
+
padding: '0 6px',
|
|
304
|
+
borderRadius: 4,
|
|
305
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
306
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
307
|
+
},
|
|
308
|
+
button: {
|
|
309
|
+
font: 'inherit',
|
|
310
|
+
fontSize: 13,
|
|
311
|
+
lineHeight: '20px',
|
|
312
|
+
borderRadius: 8,
|
|
313
|
+
border: '.5px solid var(--dsw-alias-border-l2)',
|
|
314
|
+
background: 'transparent',
|
|
315
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
316
|
+
padding: '2px 12px',
|
|
317
|
+
cursor: 'pointer',
|
|
318
|
+
},
|
|
319
|
+
dialogStatus: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12, lineHeight: '18px' },
|
|
320
|
+
dialogError: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, lineHeight: '18px', margin: 0 },
|
|
321
|
+
danger: {
|
|
322
|
+
color: 'var(--dsw-alias-state-error-primary, #d64545)',
|
|
323
|
+
borderColor: 'var(--dsw-alias-state-error-primary, #d64545)',
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ------------------------------------------------------------------ glyphs
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The shell's own icon component, or null when the kit did not deliver that
|
|
331
|
+
* member. Kit icons are `{size, className} -> svg`: they forward nothing
|
|
332
|
+
* else — no `style`, no `aria-hidden` — so placement belongs to the box a
|
|
333
|
+
* glyph sits in, never to the glyph.
|
|
334
|
+
*/
|
|
335
|
+
function kitIcon(primitives, name) {
|
|
336
|
+
const icon = primitives?.[name]
|
|
337
|
+
return typeof icon === 'function' ? icon : null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The search field's leading glyph, hand-drawn: the path a kit-less shell takes. */
|
|
341
|
+
function SearchIcon(props) {
|
|
342
|
+
return React.createElement(
|
|
343
|
+
'svg',
|
|
344
|
+
Object.assign({ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none' }, props),
|
|
345
|
+
React.createElement('circle', { cx: 6.5, cy: 6.5, r: 4.5, stroke: 'currentColor', strokeWidth: 1.4 }),
|
|
346
|
+
React.createElement('path', {
|
|
347
|
+
d: 'M10 10 L14 14',
|
|
348
|
+
stroke: 'currentColor',
|
|
349
|
+
strokeWidth: 1.4,
|
|
350
|
+
strokeLinecap: 'round',
|
|
351
|
+
}),
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The group header's fold glyph, hand-drawn: the path a kit-less shell takes. */
|
|
356
|
+
function ChevronIcon(props) {
|
|
357
|
+
return React.createElement(
|
|
358
|
+
'svg',
|
|
359
|
+
Object.assign({ width: 12, height: 12, viewBox: '0 0 16 16' }, props),
|
|
360
|
+
React.createElement('path', {
|
|
361
|
+
d: 'M4 6.5 L8 10.5 L12 6.5',
|
|
362
|
+
fill: 'none',
|
|
363
|
+
stroke: 'currentColor',
|
|
364
|
+
strokeWidth: 1.6,
|
|
365
|
+
strokeLinecap: 'round',
|
|
366
|
+
}),
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* One glyph inside its placement box: the kit's icon when the loader kept
|
|
372
|
+
* the kit, the hand-drawn SVG otherwise. Both paths ride the same box, so a
|
|
373
|
+
* missing kit cannot move anything on the page.
|
|
374
|
+
* @param props.primitives - the kit, or null.
|
|
375
|
+
* @param props.name - the kit icon export for this spot.
|
|
376
|
+
* @param props.size - the kit glyph's square edge in px.
|
|
377
|
+
* @param props.box - the box's style: placement, colour, transform.
|
|
378
|
+
* @param props.fallback - the hand-drawn SVG for this spot.
|
|
379
|
+
*/
|
|
380
|
+
function Glyph(props) {
|
|
381
|
+
const { primitives, name, size, box, fallback } = props
|
|
382
|
+
const Icon = kitIcon(primitives, name)
|
|
383
|
+
const glyph = Icon === null ? React.createElement(fallback) : React.createElement(Icon, { size })
|
|
384
|
+
return React.createElement('span', { 'aria-hidden': true, style: box }, glyph)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --------------------------------------------------------- delete dialog
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The one delete confirmation: the app's own modal, with progress and
|
|
391
|
+
* failure shown inside it. It owns the whole flow — ask, run, report — so
|
|
392
|
+
* both surfaces that can delete share one behaviour, and nothing here ever
|
|
393
|
+
* opens a browser alert.
|
|
394
|
+
*
|
|
395
|
+
* `title` doubles as the open flag: null means "no target, render nothing".
|
|
396
|
+
* @param props.primitives - `{Modal, Button}` from the shell.
|
|
397
|
+
* @param props.labels - copy from {@link deleteLabels}.
|
|
398
|
+
* @param props.title - the session title to delete, or null.
|
|
399
|
+
* @param props.run - the delete itself; rejecting keeps the dialog open.
|
|
400
|
+
* @param props.onClose - called with whether the delete went through.
|
|
401
|
+
*/
|
|
402
|
+
function DeleteDialog(props) {
|
|
403
|
+
const { primitives, labels, title, run, onClose } = props
|
|
404
|
+
const { Modal, Button } = primitives
|
|
405
|
+
const [pending, setPending] = React.useState(false)
|
|
406
|
+
const [error, setError] = React.useState(null)
|
|
407
|
+
|
|
408
|
+
const confirm = async () => {
|
|
409
|
+
if (pending) return
|
|
410
|
+
setPending(true)
|
|
411
|
+
setError(null)
|
|
412
|
+
try {
|
|
413
|
+
await run()
|
|
414
|
+
onClose(true)
|
|
415
|
+
} catch (failure) {
|
|
416
|
+
report('delete-failed', { message: String(failure?.message ?? failure) })
|
|
417
|
+
setError(failureText(failure, labels))
|
|
418
|
+
setPending(false)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return React.createElement(
|
|
423
|
+
Modal,
|
|
424
|
+
{
|
|
425
|
+
open: title !== null,
|
|
426
|
+
onClose: () => onClose(false),
|
|
427
|
+
closeLabel: labels.close,
|
|
428
|
+
title: labels.title,
|
|
429
|
+
...(title === null ? {} : { description: labels.description(title) }),
|
|
430
|
+
footer: [
|
|
431
|
+
React.createElement(
|
|
432
|
+
Button,
|
|
433
|
+
{
|
|
434
|
+
key: 'cancel',
|
|
435
|
+
variant: 'outline',
|
|
436
|
+
disabled: pending,
|
|
437
|
+
onClick: () => onClose(false),
|
|
438
|
+
},
|
|
439
|
+
labels.cancel,
|
|
440
|
+
),
|
|
441
|
+
React.createElement(
|
|
442
|
+
Button,
|
|
443
|
+
{
|
|
444
|
+
key: 'confirm',
|
|
445
|
+
variant: 'outline',
|
|
446
|
+
disabled: pending,
|
|
447
|
+
// The native destructive action is an outline button with the
|
|
448
|
+
// error colour, but only while it can actually be pressed.
|
|
449
|
+
...(pending ? {} : { style: { color: 'var(--dsw-alias-state-error-primary)' } }),
|
|
450
|
+
onClick: confirm,
|
|
451
|
+
},
|
|
452
|
+
labels.confirm,
|
|
453
|
+
),
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
[
|
|
457
|
+
pending
|
|
458
|
+
? React.createElement('div', { key: 'pending', role: 'status', style: S.dialogStatus }, labels.pending)
|
|
459
|
+
: null,
|
|
460
|
+
error === null
|
|
461
|
+
? null
|
|
462
|
+
: React.createElement('div', { key: 'error', role: 'alert', style: S.dialogError }, error),
|
|
463
|
+
],
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Run a delete behind the dialog for a caller that has no React tree of its
|
|
469
|
+
* own — the row ⋮ menu is imperative DOM, so the dialog needs its own root.
|
|
470
|
+
* The root and its container are torn down on either answer.
|
|
471
|
+
* @returns whether the session was actually deleted.
|
|
472
|
+
*/
|
|
473
|
+
function deleteWithDialog(options) {
|
|
474
|
+
const { require, labels, title, run } = options
|
|
475
|
+
const primitives = loadPrimitives(require)
|
|
476
|
+
if (primitives === null) {
|
|
477
|
+
if (!window.confirm(labels.description(title))) return Promise.resolve(false)
|
|
478
|
+
return Promise.resolve(run()).then(
|
|
479
|
+
() => true,
|
|
480
|
+
(failure) => {
|
|
481
|
+
report('delete-failed', { message: String(failure?.message ?? failure) })
|
|
482
|
+
return false
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
const container = document.createElement('div')
|
|
487
|
+
document.body.append(container)
|
|
488
|
+
const root = require(REACT_DOM).createRoot(container)
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
const close = (deleted) => {
|
|
491
|
+
root.unmount()
|
|
492
|
+
container.remove()
|
|
493
|
+
resolve(deleted)
|
|
494
|
+
}
|
|
495
|
+
root.render(
|
|
496
|
+
React.createElement(DeleteDialog, {
|
|
497
|
+
primitives,
|
|
498
|
+
labels,
|
|
499
|
+
title,
|
|
500
|
+
run,
|
|
501
|
+
onClose: close,
|
|
502
|
+
}),
|
|
503
|
+
)
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ------------------------------------------------------------ Settings page
|
|
508
|
+
|
|
509
|
+
/** Group key of every session no workspace accounts for. */
|
|
510
|
+
const UNGROUPED = ''
|
|
511
|
+
|
|
512
|
+
function SessionCleanerSection(props) {
|
|
513
|
+
// `uiWorkspace` and `forget` arrive through the registration's own
|
|
514
|
+
// `inject`: the section slot composes only the standard hooks, so a
|
|
515
|
+
// service read straight off these props would be undefined.
|
|
516
|
+
const { t, useSessions, useWorkspaces, uiWorkspace, forget, primitives } = props
|
|
517
|
+
const sessionsState = useSessions((state) => state)
|
|
518
|
+
const workspaces = useWorkspaces((state) => state.items)
|
|
519
|
+
const archivedIds = useWorkspaces((state) => state.archivedSessionIds)
|
|
520
|
+
const [query, setQuery] = React.useState('')
|
|
521
|
+
const [busy, setBusy] = React.useState('')
|
|
522
|
+
/** Group keys the user folded. A search overrides them. */
|
|
523
|
+
const [folded, setFolded] = React.useState([])
|
|
524
|
+
/** The row awaiting confirmation. */
|
|
525
|
+
const [target, setTarget] = React.useState(null)
|
|
526
|
+
/** Last failure, shown inline when no dialog is available to hold it. */
|
|
527
|
+
const [failure, setFailure] = React.useState(null)
|
|
528
|
+
|
|
529
|
+
const summaries = sessionsState.byId
|
|
530
|
+
// One group per owning workspace in HOST order, then the strays — the
|
|
531
|
+
// same order the sidebar uses, so both surfaces agree about where a
|
|
532
|
+
// session lives. Archiving is a property of a row, not a place, so an
|
|
533
|
+
// archived session keeps its workspace group and only carries a badge.
|
|
534
|
+
const groups = React.useMemo(() => {
|
|
535
|
+
const owner = new Map()
|
|
536
|
+
const order = workspaces.map((workspace) => {
|
|
537
|
+
const key = String(workspace.workspaceId)
|
|
538
|
+
for (const id of workspace.sessionIds) owner.set(id, key)
|
|
539
|
+
return { key, title: workspace.title, rows: [] }
|
|
540
|
+
})
|
|
541
|
+
const byKey = new Map(order.map((group) => [group.key, group]))
|
|
542
|
+
const archived = new Set(archivedIds)
|
|
543
|
+
// Every listed session — workspace members AND ungrouped or legacy
|
|
544
|
+
// ones — plus archived ids, which the list may no longer carry.
|
|
545
|
+
const ids = [...new Set([...Object.keys(summaries), ...archived])]
|
|
546
|
+
const strays = []
|
|
547
|
+
for (const id of ids) {
|
|
548
|
+
const summary = summaries[id]
|
|
549
|
+
const row = {
|
|
550
|
+
id,
|
|
551
|
+
title: summary === undefined ? id : summary.displayTitle,
|
|
552
|
+
archived: archived.has(id),
|
|
553
|
+
legacy: id.indexOf('session-') !== 0,
|
|
554
|
+
updatedAt: summary === undefined ? 0 : summary.updatedAt,
|
|
555
|
+
running: summary === undefined ? false : summary.running,
|
|
556
|
+
}
|
|
557
|
+
;(byKey.get(owner.get(id))?.rows ?? strays).push(row)
|
|
558
|
+
}
|
|
559
|
+
const result = order.filter((group) => group.rows.length > 0)
|
|
560
|
+
if (strays.length > 0) result.push({ key: UNGROUPED, title: t('ungrouped'), rows: strays })
|
|
561
|
+
for (const group of result) group.rows.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
562
|
+
return result
|
|
563
|
+
}, [workspaces, archivedIds, summaries, t])
|
|
564
|
+
|
|
565
|
+
if (sessionsState.phase !== undefined && sessionsState.phase !== 'ready') {
|
|
566
|
+
return React.createElement('p', { style: S.status }, t('loading'))
|
|
567
|
+
}
|
|
568
|
+
const now = Date.now()
|
|
569
|
+
const normalized = query.trim().toLowerCase()
|
|
570
|
+
const searching = normalized.length > 0
|
|
571
|
+
// A search both filters and expands: a hit hidden inside a folded group
|
|
572
|
+
// would look exactly like no hit at all.
|
|
573
|
+
const visible = groups.flatMap((group) => {
|
|
574
|
+
const named = group.title.toLowerCase().includes(normalized)
|
|
575
|
+
const rows =
|
|
576
|
+
!searching || named ? group.rows : group.rows.filter((row) => row.title.toLowerCase().includes(normalized))
|
|
577
|
+
if (rows.length === 0) return []
|
|
578
|
+
return [{ key: group.key, title: group.title, total: group.rows.length, rows }]
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
const toggle = (key) => {
|
|
582
|
+
setFolded((current) =>
|
|
583
|
+
current.includes(key) ? current.filter((candidate) => candidate !== key) : [...current, key],
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const labels = deleteLabels(t)
|
|
588
|
+
|
|
589
|
+
/** The delete itself; rejecting leaves the dialog open on the reason. */
|
|
590
|
+
const runDelete = async (row) => {
|
|
591
|
+
setBusy(row.id)
|
|
592
|
+
try {
|
|
593
|
+
await requestDelete(row.id)
|
|
594
|
+
report('page-delete', { id: row.id, ok: true })
|
|
595
|
+
await forget(row.id)
|
|
596
|
+
} catch (error) {
|
|
597
|
+
report('page-delete', { id: row.id, ok: false, message: String(error?.message ?? error) })
|
|
598
|
+
throw error
|
|
599
|
+
} finally {
|
|
600
|
+
setBusy('')
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Ask first — in the app's dialog, or the browser's if it is missing. */
|
|
605
|
+
const ask = (row) => {
|
|
606
|
+
if (busy !== '') return
|
|
607
|
+
setFailure(null)
|
|
608
|
+
if (primitives !== null) {
|
|
609
|
+
setTarget(row)
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
if (!window.confirm(labels.description(row.title))) return
|
|
613
|
+
runDelete(row).catch((error) => setFailure(failureText(error, labels)))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const rowNodes = (rows) =>
|
|
617
|
+
rows.map((row) =>
|
|
618
|
+
React.createElement('li', { key: row.id, 'data-session-row': row.id, style: S.row }, [
|
|
619
|
+
React.createElement('div', { key: 'id', style: S.identity }, [
|
|
620
|
+
React.createElement('div', { key: 'title', style: S.titleRow }, [
|
|
621
|
+
React.createElement('span', { key: 't', style: S.title }, row.title),
|
|
622
|
+
row.archived ? React.createElement('span', { key: 'a', style: S.badge }, t('archived')) : null,
|
|
623
|
+
row.legacy ? React.createElement('span', { key: 'l', style: S.badge }, t('legacy')) : null,
|
|
624
|
+
row.running
|
|
625
|
+
? React.createElement(
|
|
626
|
+
'span',
|
|
627
|
+
{
|
|
628
|
+
key: 'r',
|
|
629
|
+
style: Object.assign({}, S.badge, { color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }),
|
|
630
|
+
},
|
|
631
|
+
t('running'),
|
|
632
|
+
)
|
|
633
|
+
: null,
|
|
634
|
+
]),
|
|
635
|
+
React.createElement(
|
|
636
|
+
'span',
|
|
637
|
+
{ key: 'm', style: S.meta },
|
|
638
|
+
row.updatedAt > 0 ? timeLabel(row.updatedAt, now, t) : row.id,
|
|
639
|
+
),
|
|
640
|
+
]),
|
|
641
|
+
row.archived
|
|
642
|
+
? React.createElement(
|
|
643
|
+
'button',
|
|
644
|
+
{
|
|
645
|
+
key: 'un',
|
|
646
|
+
type: 'button',
|
|
647
|
+
style: Object.assign({}, S.button, { marginRight: 6 }),
|
|
648
|
+
onClick: () => {
|
|
649
|
+
uiWorkspace.unarchiveSession(row.id).catch(() => {})
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
t('unarchive'),
|
|
653
|
+
)
|
|
654
|
+
: null,
|
|
655
|
+
React.createElement(
|
|
656
|
+
'button',
|
|
657
|
+
{
|
|
658
|
+
key: 'del',
|
|
659
|
+
type: 'button',
|
|
660
|
+
'aria-label': t('delNamed', { title: row.title }),
|
|
661
|
+
disabled: busy === row.id,
|
|
662
|
+
style: Object.assign({}, S.button, row.archived ? S.danger : {}),
|
|
663
|
+
onClick: () => ask(row),
|
|
664
|
+
},
|
|
665
|
+
busy === row.id ? '…' : t('del'),
|
|
666
|
+
),
|
|
667
|
+
]),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
const groupNodes = visible.map((group) => {
|
|
671
|
+
const collapsed = searching ? false : folded.includes(group.key)
|
|
672
|
+
return React.createElement('section', { key: group.key, 'data-session-group': group.key, style: S.group }, [
|
|
673
|
+
React.createElement(
|
|
674
|
+
'button',
|
|
675
|
+
{
|
|
676
|
+
key: 'header',
|
|
677
|
+
type: 'button',
|
|
678
|
+
'data-session-group-header': group.key,
|
|
679
|
+
'aria-expanded': !collapsed,
|
|
680
|
+
onClick: () => toggle(group.key),
|
|
681
|
+
style: S.groupHeader,
|
|
682
|
+
},
|
|
683
|
+
[
|
|
684
|
+
React.createElement(Glyph, {
|
|
685
|
+
key: 'chevron',
|
|
686
|
+
primitives,
|
|
687
|
+
name: 'IconChevronDownOutline14',
|
|
688
|
+
size: 12,
|
|
689
|
+
box: Object.assign({}, S.chevron, collapsed ? S.chevronFolded : {}),
|
|
690
|
+
fallback: ChevronIcon,
|
|
691
|
+
}),
|
|
692
|
+
React.createElement('span', { key: 't', style: S.groupTitle }, group.title),
|
|
693
|
+
React.createElement(
|
|
694
|
+
'span',
|
|
695
|
+
{ key: 'n', style: S.badge },
|
|
696
|
+
searching ? t('countOf', { n: group.rows.length, total: group.total }) : t('count', { n: group.total }),
|
|
697
|
+
),
|
|
698
|
+
],
|
|
699
|
+
),
|
|
700
|
+
collapsed ? null : React.createElement('ul', { key: 'rows', style: S.groupList }, rowNodes(group.rows)),
|
|
701
|
+
])
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
return React.createElement('div', { style: S.section }, [
|
|
705
|
+
React.createElement('div', { key: 'search', style: S.search }, [
|
|
706
|
+
React.createElement(Glyph, {
|
|
707
|
+
key: 'i',
|
|
708
|
+
primitives,
|
|
709
|
+
name: 'IconSearchOutline16',
|
|
710
|
+
size: 16,
|
|
711
|
+
box: S.searchGlyph,
|
|
712
|
+
fallback: SearchIcon,
|
|
713
|
+
}),
|
|
714
|
+
React.createElement('input', {
|
|
715
|
+
key: 'q',
|
|
716
|
+
type: 'search',
|
|
717
|
+
value: query,
|
|
718
|
+
placeholder: t('search'),
|
|
719
|
+
'aria-label': t('search'),
|
|
720
|
+
onChange: (event) => setQuery(event.currentTarget.value),
|
|
721
|
+
style: S.input,
|
|
722
|
+
}),
|
|
723
|
+
]),
|
|
724
|
+
visible.length === 0
|
|
725
|
+
? React.createElement('p', { key: 'empty', style: S.status }, t('empty'))
|
|
726
|
+
: React.createElement('div', { key: 'groups', style: S.groups }, groupNodes),
|
|
727
|
+
// Only reachable when the shell gave no primitives and the browser
|
|
728
|
+
// asked instead; the dialog carries its own failure text.
|
|
729
|
+
target === null && failure !== null
|
|
730
|
+
? React.createElement('p', { key: 'failure', role: 'alert', style: S.dialogError }, failure)
|
|
731
|
+
: null,
|
|
732
|
+
primitives === null
|
|
733
|
+
? null
|
|
734
|
+
: React.createElement(DeleteDialog, {
|
|
735
|
+
key: 'dialog',
|
|
736
|
+
primitives,
|
|
737
|
+
labels,
|
|
738
|
+
title: target === null ? null : target.title,
|
|
739
|
+
run: () => runDelete(target),
|
|
740
|
+
onClose: () => setTarget(null),
|
|
741
|
+
}),
|
|
742
|
+
])
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// --------------------------------------------------------- row ⋮ menu item
|
|
746
|
+
|
|
747
|
+
const ITEM_ATTR = 'data-session-cleaner-item'
|
|
748
|
+
/** The session row menu is the only surface carrying BOTH of these. */
|
|
749
|
+
const ARCHIVE_LABELS = ['归档会话', 'Archive session']
|
|
750
|
+
const OTHER_MENU_LABELS = ['分叉会话', 'Fork session', '重命名', 'Rename']
|
|
751
|
+
/** A ⋮ trigger's accessible name starts like this, in either language. */
|
|
752
|
+
const TRIGGER_PREFIXES = ['会话“', 'Session actions for ']
|
|
753
|
+
const TRIGGER_WINDOW_MS = 3000
|
|
754
|
+
|
|
755
|
+
/** Innermost elements whose trimmed text is exactly one of `labels`. */
|
|
756
|
+
function leafByText(root, labels) {
|
|
757
|
+
const hits = []
|
|
758
|
+
const consider = (element) => {
|
|
759
|
+
if (element === null || element === undefined || element.nodeType !== 1) return
|
|
760
|
+
const text = (element.textContent ?? '').trim()
|
|
761
|
+
if (!labels.includes(text)) return
|
|
762
|
+
const nested = [...element.children].some((child) => labels.includes((child.textContent ?? '').trim()))
|
|
763
|
+
if (nested) return
|
|
764
|
+
hits.push(element)
|
|
765
|
+
}
|
|
766
|
+
consider(root)
|
|
767
|
+
if (typeof root.querySelectorAll === 'function')
|
|
768
|
+
for (const element of root.querySelectorAll('*')) consider(element)
|
|
769
|
+
return hits
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Smallest element containing both nodes. */
|
|
773
|
+
function commonAncestor(a, b) {
|
|
774
|
+
let node = a
|
|
775
|
+
while (node !== null && !node.contains(b)) node = node.parentElement
|
|
776
|
+
return node
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Recognize the session row menu and its insertion anchor. Examines the
|
|
781
|
+
* added subtree and, failing that, climbs from it — so the menu is found
|
|
782
|
+
* whichever order its items mount in.
|
|
783
|
+
*/
|
|
784
|
+
function findSessionMenu(added) {
|
|
785
|
+
const archives = leafByText(added, ARCHIVE_LABELS)
|
|
786
|
+
const others = leafByText(added, OTHER_MENU_LABELS)
|
|
787
|
+
if (archives.length > 0 && others.length > 0) {
|
|
788
|
+
return { menu: commonAncestor(archives[0], others[0]), anchor: archives[0] }
|
|
789
|
+
}
|
|
790
|
+
let node = added
|
|
791
|
+
for (let depth = 0; depth < 6 && node !== null; depth++) {
|
|
792
|
+
node = node.parentElement
|
|
793
|
+
if (node === null || node === document.body) break
|
|
794
|
+
const upArchives = leafByText(node, ARCHIVE_LABELS)
|
|
795
|
+
const upOthers = leafByText(node, OTHER_MENU_LABELS)
|
|
796
|
+
if (upArchives.length > 0 && upOthers.length > 0) {
|
|
797
|
+
return { menu: commonAncestor(upArchives[0], upOthers[0]), anchor: upArchives[0] }
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return undefined
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** The menu's direct child that holds `element`. */
|
|
804
|
+
function itemContainer(element, menu) {
|
|
805
|
+
let node = element
|
|
806
|
+
while (node.parentElement !== null && node.parentElement !== menu) node = node.parentElement
|
|
807
|
+
return node.parentElement === menu ? node : undefined
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** The row's visible title. */
|
|
811
|
+
function sessionTitle(rowEl) {
|
|
812
|
+
const span = rowEl.querySelector('span[class*="title"]')
|
|
813
|
+
return span?.textContent?.trim() ?? ''
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The row's session id, read from React's own bookkeeping: the row
|
|
818
|
+
* component renders with the session node as a prop, and React attaches
|
|
819
|
+
* its fiber to the host element.
|
|
820
|
+
*/
|
|
821
|
+
function sessionIdFromReact(rowEl) {
|
|
822
|
+
try {
|
|
823
|
+
const key = Object.keys(rowEl).find(
|
|
824
|
+
(name) => name.startsWith('__reactFiber$') || name.startsWith('__reactInternalInstance$'),
|
|
825
|
+
)
|
|
826
|
+
if (key === undefined) return undefined
|
|
827
|
+
let fiber = rowEl[key]
|
|
828
|
+
for (let depth = 0; depth < 10 && fiber !== null && fiber !== undefined; depth++) {
|
|
829
|
+
const props = fiber.memoizedProps
|
|
830
|
+
if (props !== null && typeof props === 'object') {
|
|
831
|
+
const candidate = props.node?.id ?? props.sessionId ?? props.node?.sessionId
|
|
832
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate
|
|
833
|
+
}
|
|
834
|
+
fiber = fiber.return
|
|
835
|
+
}
|
|
836
|
+
} catch {
|
|
837
|
+
/* internals changed — fall back to the catalog */
|
|
838
|
+
}
|
|
839
|
+
return undefined
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Resolve {id, running, source} for a row, or undefined when unknown. */
|
|
843
|
+
function resolveSession(rowEl, catalog) {
|
|
844
|
+
const fromReact = sessionIdFromReact(rowEl)
|
|
845
|
+
if (fromReact !== undefined) {
|
|
846
|
+
const known = catalog?.byId?.get(fromReact)
|
|
847
|
+
return { id: fromReact, running: known?.running === true, source: 'fiber' }
|
|
848
|
+
}
|
|
849
|
+
if (catalog === null || catalog === undefined) return undefined
|
|
850
|
+
for (const span of rowEl.querySelectorAll('span')) {
|
|
851
|
+
const text = (span.textContent ?? '').trim()
|
|
852
|
+
if (text === '') continue
|
|
853
|
+
const entries = catalog.byTitle.get(text)
|
|
854
|
+
if (entries !== undefined && entries.length === 1) {
|
|
855
|
+
return { id: entries[0].id, running: entries[0].running === true, source: 'title' }
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return undefined
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Title -> entries and id -> entry for the visible sessions. A convenience
|
|
863
|
+
* only: id resolution prefers React's props, so a failed fetch costs the
|
|
864
|
+
* running flag, not the feature.
|
|
865
|
+
*
|
|
866
|
+
* `/api/session.list` is the single transport. The `remote` service is the
|
|
867
|
+
* transport owner, not a namespace holder — a browser remote namespace is
|
|
868
|
+
* its own `remote.<namespace>` service (DSH 0.1.6-alpha.2
|
|
869
|
+
* api-gateway `client.js`), so `ctx.get('remote').session` never resolves,
|
|
870
|
+
* and reading it reflectively would make the catalog depend on a service
|
|
871
|
+
* this plugin does not inject.
|
|
872
|
+
*/
|
|
873
|
+
async function fetchCatalog() {
|
|
874
|
+
const build = (items) => {
|
|
875
|
+
const byTitle = new Map()
|
|
876
|
+
const byId = new Map()
|
|
877
|
+
for (const item of items ?? []) {
|
|
878
|
+
if (item?.blank === true || item?.origin === 'subagent') continue
|
|
879
|
+
const entry = { id: item.sessionId, running: item.running === true }
|
|
880
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0) continue
|
|
881
|
+
byId.set(entry.id, entry)
|
|
882
|
+
const title = item?.projections?.values?.title ?? item?.title
|
|
883
|
+
if (typeof title !== 'string' || title === '') continue
|
|
884
|
+
if (!byTitle.has(title)) byTitle.set(title, [])
|
|
885
|
+
byTitle.get(title).push(entry)
|
|
886
|
+
}
|
|
887
|
+
return { byTitle, byId }
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const response = await fetch('/api/session.list', {
|
|
891
|
+
method: 'POST',
|
|
892
|
+
headers: { 'content-type': 'application/json' },
|
|
893
|
+
credentials: 'same-origin',
|
|
894
|
+
body: JSON.stringify({
|
|
895
|
+
type: 'client-request',
|
|
896
|
+
rpcId: 'session-cleaner-' + Math.random().toString(36).slice(2),
|
|
897
|
+
method: 'session.list',
|
|
898
|
+
payload: {},
|
|
899
|
+
}),
|
|
900
|
+
})
|
|
901
|
+
const body = await response.json()
|
|
902
|
+
if (body?.result === undefined) return null
|
|
903
|
+
return build(body.result?.value?.items)
|
|
904
|
+
} catch {
|
|
905
|
+
return null // no catalog; the caller reports it and the row keeps working
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function trashIcon() {
|
|
910
|
+
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
911
|
+
const svg = document.createElementNS(SVG_NS, 'svg')
|
|
912
|
+
for (const [k, v] of Object.entries({ width: '16', height: '16', viewBox: '0 0 16 16', fill: 'none' }))
|
|
913
|
+
svg.setAttribute(k, v)
|
|
914
|
+
for (const [tag, attrs] of [
|
|
915
|
+
['path', { d: 'M3 5h10', stroke: 'currentColor', 'stroke-width': '1.4', 'stroke-linecap': 'round' }],
|
|
916
|
+
['path', { d: 'M6 5V3.6h4V5', stroke: 'currentColor', 'stroke-width': '1.4', 'stroke-linejoin': 'round' }],
|
|
917
|
+
[
|
|
918
|
+
'path',
|
|
919
|
+
{ d: 'M4.6 5l.6 7.4h5.6L11.4 5', stroke: 'currentColor', 'stroke-width': '1.4', 'stroke-linejoin': 'round' },
|
|
920
|
+
],
|
|
921
|
+
]) {
|
|
922
|
+
const node = document.createElementNS(SVG_NS, tag)
|
|
923
|
+
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v)
|
|
924
|
+
svg.appendChild(node)
|
|
925
|
+
}
|
|
926
|
+
return svg
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* The kit's trash glyph as a DOM node, or null when it cannot be had. The
|
|
931
|
+
* row ⋮ menu is imperative DOM with no React tree of its own, and a kit icon
|
|
932
|
+
* takes only `{size, className}` — there is no markup to copy by hand — so
|
|
933
|
+
* React renders the glyph into a scratch container, the SVG is lifted out of
|
|
934
|
+
* it, and the scratch root is torn down: the item must not leave a React
|
|
935
|
+
* root mounted behind it. Null keeps the hand-drawn SVG above in charge.
|
|
936
|
+
*/
|
|
937
|
+
function kitTrashIcon(primitives) {
|
|
938
|
+
const Icon = kitIcon(primitives, 'IconTrashOutline16')
|
|
939
|
+
if (Icon === null) return null
|
|
940
|
+
try {
|
|
941
|
+
const scratch = document.createElement('span')
|
|
942
|
+
const root = require(REACT_DOM).createRoot(scratch)
|
|
943
|
+
require(REACT_DOM_SYNC).flushSync(() => root.render(React.createElement(Icon, { size: 16 })))
|
|
944
|
+
const glyph = scratch.firstElementChild
|
|
945
|
+
const node = glyph === null ? null : glyph.cloneNode(true)
|
|
946
|
+
root.unmount()
|
|
947
|
+
return node
|
|
948
|
+
} catch (error) {
|
|
949
|
+
report('icon-fallback', { icon: 'IconTrashOutline16', message: String(error?.message ?? error) })
|
|
950
|
+
return null
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Insert the delete item directly below the archive item. */
|
|
955
|
+
function augmentMenu(found, rowEl, catalog, ctx, dict) {
|
|
956
|
+
const { menu, anchor } = found
|
|
957
|
+
if (menu.querySelector(`[${ITEM_ATTR}]`) !== null) return true
|
|
958
|
+
const container = itemContainer(anchor, menu)
|
|
959
|
+
if (container === undefined) {
|
|
960
|
+
report('skip', { reason: 'no-item-container' })
|
|
961
|
+
return false
|
|
962
|
+
}
|
|
963
|
+
const session = resolveSession(rowEl, catalog)
|
|
964
|
+
if (session === undefined) {
|
|
965
|
+
report('skip', { reason: 'unresolved-session', title: sessionTitle(rowEl) })
|
|
966
|
+
return false
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const running = session.running === true
|
|
970
|
+
const item = document.createElement('button')
|
|
971
|
+
item.type = 'button'
|
|
972
|
+
item.setAttribute(ITEM_ATTR, '1')
|
|
973
|
+
item.disabled = running
|
|
974
|
+
item.title = running ? dict.menuDeleteDisabled : dict.menuDelete
|
|
975
|
+
item.style.cssText = [
|
|
976
|
+
'display:flex',
|
|
977
|
+
'align-items:center',
|
|
978
|
+
'gap:8px',
|
|
979
|
+
'width:100%',
|
|
980
|
+
'padding:6px 12px',
|
|
981
|
+
'border:none',
|
|
982
|
+
'background:none',
|
|
983
|
+
'color:var(--dsw-alias-state-error-primary, #d64545)',
|
|
984
|
+
'font:inherit',
|
|
985
|
+
'font-size:13px',
|
|
986
|
+
'text-align:left',
|
|
987
|
+
'cursor:' + (running ? 'default' : 'pointer'),
|
|
988
|
+
'opacity:' + (running ? '0.45' : '1'),
|
|
989
|
+
].join(';')
|
|
990
|
+
const label = document.createElement('span')
|
|
991
|
+
label.textContent = dict.menuDelete
|
|
992
|
+
// The kit's own glyph when the shell handed one over, else the hand-drawn
|
|
993
|
+
// SVG: `loadPrimitives` is the guard the dialog next to it already uses.
|
|
994
|
+
item.append(kitTrashIcon(loadPrimitives(require)) ?? trashIcon(), label)
|
|
995
|
+
item.addEventListener('mouseenter', () => {
|
|
996
|
+
if (!running) item.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12))'
|
|
997
|
+
})
|
|
998
|
+
item.addEventListener('mouseleave', () => {
|
|
999
|
+
item.style.background = 'none'
|
|
1000
|
+
})
|
|
1001
|
+
item.addEventListener('click', async (event) => {
|
|
1002
|
+
event.preventDefault()
|
|
1003
|
+
event.stopPropagation()
|
|
1004
|
+
if (running) return
|
|
1005
|
+
const title = sessionTitle(rowEl) || session.id
|
|
1006
|
+
const deleted = await deleteWithDialog({
|
|
1007
|
+
require,
|
|
1008
|
+
labels: dict.labels,
|
|
1009
|
+
title,
|
|
1010
|
+
run: () => requestDelete(session.id).then(() => forgetDeleted(ctx, session.id)),
|
|
1011
|
+
})
|
|
1012
|
+
report('menu-delete', { id: session.id, ok: deleted, source: session.source })
|
|
1013
|
+
})
|
|
1014
|
+
container.after(item)
|
|
1015
|
+
report('menu-item-added', { id: session.id, running, source: session.source })
|
|
1016
|
+
return true
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Watch for the row menu opening and augment it. The row comes from the ⋮
|
|
1021
|
+
* trigger the user just pressed; the menu is identified by its labels.
|
|
1022
|
+
*/
|
|
1023
|
+
function installMenuEntry(ctx, dict) {
|
|
1024
|
+
let pendingRow = null
|
|
1025
|
+
let pendingAt = 0
|
|
1026
|
+
let catalog = null
|
|
1027
|
+
|
|
1028
|
+
const onPointerDown = (event) => {
|
|
1029
|
+
const target = event.target
|
|
1030
|
+
if (!(target instanceof Element)) return
|
|
1031
|
+
const row = target.closest('[role="treeitem"]')
|
|
1032
|
+
if (row === null) return
|
|
1033
|
+
const trigger = target.closest('button')
|
|
1034
|
+
if (trigger === null) return
|
|
1035
|
+
const name = trigger.getAttribute('aria-label') ?? ''
|
|
1036
|
+
const isRowTrigger =
|
|
1037
|
+
TRIGGER_PREFIXES.some((prefix) => name.startsWith(prefix)) ||
|
|
1038
|
+
trigger.closest('[class*="rowActions"]') !== null
|
|
1039
|
+
if (!isRowTrigger) {
|
|
1040
|
+
report('trigger-ignored', { name: name.slice(0, 60) })
|
|
1041
|
+
return
|
|
1042
|
+
}
|
|
1043
|
+
pendingRow = row
|
|
1044
|
+
pendingAt = Date.now()
|
|
1045
|
+
report('trigger', { name: name.slice(0, 60) })
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const attempt = (added) => {
|
|
1049
|
+
const found = findSessionMenu(added)
|
|
1050
|
+
if (found === undefined) return
|
|
1051
|
+
report('menu-found', { archive: (found.anchor.textContent ?? '').trim().slice(0, 40) })
|
|
1052
|
+
const fresh = pendingRow !== null && pendingRow.isConnected && Date.now() - pendingAt <= TRIGGER_WINDOW_MS
|
|
1053
|
+
const row = fresh ? pendingRow : found.menu.closest('[role="treeitem"]')
|
|
1054
|
+
if (row === null || row === undefined) {
|
|
1055
|
+
report('skip', { reason: 'no-row', fresh })
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
if (augmentMenu(found, row, catalog, ctx, dict)) {
|
|
1059
|
+
pendingRow = null
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
fetchCatalog()
|
|
1063
|
+
.then((resolved) => {
|
|
1064
|
+
catalog = resolved
|
|
1065
|
+
if (found.menu.isConnected) augmentMenu(found, row, catalog, ctx, dict)
|
|
1066
|
+
})
|
|
1067
|
+
.catch(() => {})
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const observer = new MutationObserver((mutations) => {
|
|
1071
|
+
for (const mutation of mutations) {
|
|
1072
|
+
for (const node of mutation.addedNodes) {
|
|
1073
|
+
if (node.nodeType !== 1) continue
|
|
1074
|
+
attempt(node)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
})
|
|
1078
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1079
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
1080
|
+
fetchCatalog()
|
|
1081
|
+
.then((resolved) => {
|
|
1082
|
+
catalog = resolved
|
|
1083
|
+
report('catalog', { ok: resolved !== null, known: resolved === null ? 0 : resolved.byId.size })
|
|
1084
|
+
})
|
|
1085
|
+
.catch(() => {})
|
|
1086
|
+
report('install', { ok: true })
|
|
1087
|
+
|
|
1088
|
+
ctx.effect(
|
|
1089
|
+
() => () => {
|
|
1090
|
+
observer.disconnect()
|
|
1091
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1092
|
+
},
|
|
1093
|
+
'session-cleaner: menu observer',
|
|
1094
|
+
)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ------------------------------------------------------------------ entry
|
|
1098
|
+
|
|
1099
|
+
function apply(ctx) {
|
|
1100
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-cleaner: dictionaries')
|
|
1101
|
+
const t = ctx.locale.bind(NS)
|
|
1102
|
+
const primitives = loadPrimitives(require)
|
|
1103
|
+
report('primitives', { ok: primitives !== null })
|
|
1104
|
+
ctx.slots.inject('settings.section', () =>
|
|
1105
|
+
ctx.slots.register(
|
|
1106
|
+
{
|
|
1107
|
+
name: 'settings.section',
|
|
1108
|
+
id: 'session-cleaner',
|
|
1109
|
+
order: 30,
|
|
1110
|
+
label: () => t('nav'),
|
|
1111
|
+
locale: NS,
|
|
1112
|
+
inject: () => ({
|
|
1113
|
+
t,
|
|
1114
|
+
primitives,
|
|
1115
|
+
uiWorkspace: ctx.uiWorkspace,
|
|
1116
|
+
forget: (sessionId) => forgetDeleted(ctx, sessionId),
|
|
1117
|
+
}),
|
|
1118
|
+
},
|
|
1119
|
+
SessionCleanerSection,
|
|
1120
|
+
),
|
|
1121
|
+
)
|
|
1122
|
+
report('section-registered', { id: 'session-cleaner' })
|
|
1123
|
+
|
|
1124
|
+
const dict = {
|
|
1125
|
+
menuDelete: t('menuDelete'),
|
|
1126
|
+
menuDeleteDisabled: t('menuDeleteDisabled'),
|
|
1127
|
+
labels: deleteLabels(t),
|
|
1128
|
+
}
|
|
1129
|
+
const start = () => {
|
|
1130
|
+
try {
|
|
1131
|
+
installMenuEntry(ctx, dict)
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
report('install-failed', { message: String(error?.message ?? error) })
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (document.body === null) {
|
|
1137
|
+
document.addEventListener('DOMContentLoaded', start, { once: true })
|
|
1138
|
+
} else {
|
|
1139
|
+
start()
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
exports.apply = apply
|
|
1144
|
+
exports.inject = inject
|
|
1145
|
+
return module.exports
|
|
1146
|
+
},
|
|
1147
|
+
})
|