@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/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
+ })