dsh-plugin-session-cleaner 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1845 @@
1
+ // Generated by build/client.mjs — do not edit. Source lives in ../client/.
2
+ window.__ModuleLoader__.load({id:"dsh-plugin-session-cleaner",factory:function(require){
3
+ const __modules = {};
4
+ __modules["src/contract.cjs"] = (() => {
5
+ const module = { exports: {} };
6
+ const exports = module.exports;
7
+ /**
8
+ * The Remote contract, shared verbatim by both halves.
9
+ *
10
+ * The Host's typert manifest and the Web client's mounted descriptors are both
11
+ * built from this file, so the two halves cannot drift.
12
+ *
13
+ * Written as CommonJS on purpose: the Web client bundles this file into a
14
+ * single self-contained `client.js`, and Node's ESM interop exposes these
15
+ * `exports.*` assignments to the Host half as named imports, so one file
16
+ * serves both without a build-time transform.
17
+ *
18
+ * dsh-typert-loader requires EVERY codec — parameters and results — to be
19
+ * `strict` (a string typeSymbol plus a create() factory), and the gateway's
20
+ * boundary check only calls `codec.create().parse(value)`. A pass-through
21
+ * parser therefore satisfies the wire contract exactly: the payloads are plain
22
+ * JSON projection data, and the gateway's own assertJsonValue still guards the
23
+ * boundary, so hand-rolling one line of validation beats shipping a schema
24
+ * library inside the client bundle.
25
+ */
26
+
27
+ /** Pass-through boundary schema: whatever crosses is already lossless JSON. */
28
+ const passthrough = () => ({ parse: (value) => value })
29
+
30
+ /** The one schema entry both halves register for their codecs. */
31
+ const SESSION_CLEANER_JSON_SCHEMA = Object.freeze({
32
+ name: 'SessionCleanerJson',
33
+ create: passthrough,
34
+ })
35
+
36
+ /** Strict JSON codec, valid on both the Host manifest and the client mount. */
37
+ const JSON_CODEC = Object.freeze({
38
+ mode: 'strict',
39
+ typeSymbol: 'dsh-plugin-session-cleaner#SessionCleanerJson',
40
+ create: passthrough,
41
+ })
42
+
43
+ /** One ordered business parameter. */
44
+ const param = (name, wire = name) => Object.freeze({
45
+ name,
46
+ wire,
47
+ source: 'json',
48
+ codec: JSON_CODEC,
49
+ })
50
+
51
+ const SESSION_CLEANER_LIST = Object.freeze({
52
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/list',
53
+ service: 'sessionCleaner',
54
+ namespace: 'sessionCleaner',
55
+ method: 'list',
56
+ invocation: { kind: 'direct' },
57
+ parameters: [],
58
+ result: JSON_CODEC,
59
+ })
60
+
61
+ const SESSION_CLEANER_PREVIEW = Object.freeze({
62
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/preview',
63
+ service: 'sessionCleaner',
64
+ namespace: 'sessionCleaner',
65
+ method: 'preview',
66
+ invocation: { kind: 'direct' },
67
+ parameters: [param('sessionIds')],
68
+ result: JSON_CODEC,
69
+ })
70
+
71
+ const SESSION_CLEANER_READ = Object.freeze({
72
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/read',
73
+ service: 'sessionCleaner',
74
+ namespace: 'sessionCleaner',
75
+ method: 'read',
76
+ invocation: { kind: 'direct' },
77
+ parameters: [param('sessionId'), param('options')],
78
+ result: JSON_CODEC,
79
+ })
80
+
81
+ const SESSION_CLEANER_DELETE = Object.freeze({
82
+ id: 'dsh-plugin-session-cleaner#sessionCleaner/delete',
83
+ service: 'sessionCleaner',
84
+ namespace: 'sessionCleaner',
85
+ method: 'delete',
86
+ invocation: { kind: 'direct' },
87
+ parameters: [param('sessionIds'), param('options')],
88
+ result: JSON_CODEC,
89
+ })
90
+
91
+ const TYPERT_REMOTE = Object.freeze({
92
+ package: 'dsh-plugin-session-cleaner',
93
+ descriptors: [SESSION_CLEANER_LIST, SESSION_CLEANER_PREVIEW, SESSION_CLEANER_READ, SESSION_CLEANER_DELETE],
94
+ })
95
+
96
+ exports.SESSION_CLEANER_JSON_SCHEMA = SESSION_CLEANER_JSON_SCHEMA
97
+ exports.SESSION_CLEANER_LIST = SESSION_CLEANER_LIST
98
+ exports.SESSION_CLEANER_PREVIEW = SESSION_CLEANER_PREVIEW
99
+ exports.SESSION_CLEANER_READ = SESSION_CLEANER_READ
100
+ exports.SESSION_CLEANER_DELETE = SESSION_CLEANER_DELETE
101
+ exports.TYPERT_REMOTE = TYPERT_REMOTE
102
+ exports.default = TYPERT_REMOTE
103
+
104
+ return module.exports;
105
+ })();
106
+ __modules["client/panel-page.js"] = (() => {
107
+ const module = { exports: {} };
108
+ const exports = module.exports;
109
+ /**
110
+ * The global Session-cleanup page: list every session grouped by workspace,
111
+ * filter, multi-select, delete — and preview a conversation before deleting it
112
+ * (a Preview button on each row, or a double-click anywhere on the row).
113
+ *
114
+ * Rows are titled (the id only appears inside the preview dialog, under the
115
+ * title). Workspace groups collapse by default; subagent sessions nest under
116
+ * their parent row, with an expand caret of their own, and orphaned subagents
117
+ * (their parent is gone) root themselves so they stay visible.
118
+ *
119
+ * Registered as a top-level main panel (the `main` keyed slot) with its own
120
+ * sidebar entry, next to Plugins and Automation Tasks. The session menu row
121
+ * calls the same Host service, so the page adds reach without a second
122
+ * deletion path.
123
+ *
124
+ * The page chrome follows the shipped global pages (ui-schedule, ui-plugin-
125
+ * manager): a full-height scroll area whose content is centered at 960px with
126
+ * a page heading; on macOS the heading also yields to the traffic-light
127
+ * clearance the layout publishes as `--dsh-frame-top-clearance`.
128
+ */
129
+
130
+ /** Format a byte count for compact display. */
131
+ function formatBytes(bytes) {
132
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
133
+ const units = ['B', 'KiB', 'MiB', 'GiB']
134
+ let value = bytes
135
+ let unit = 0
136
+ while (value >= 1024 && unit < units.length - 1) {
137
+ value /= 1024
138
+ unit += 1
139
+ }
140
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
141
+ }
142
+
143
+ /** Read a service result defensively: RPC may settle unwrapped or wrapped. */
144
+ function unwrap(result) {
145
+ if (result !== null && typeof result === 'object' && 'ok' in result && 'value' in result) {
146
+ return result.ok ? result.value : { ok: false, error: result.error?.message ?? 'remote call failed' }
147
+ }
148
+ return result
149
+ }
150
+
151
+ /**
152
+ * One checkbox flip in the panel, kept aligned with the deletion closure:
153
+ * the Host deletes a session's descendants together with it, so the checked
154
+ * set (and the "N sessions (X)" totals built from it) must match that
155
+ * closure exactly.
156
+ *
157
+ * - Selecting a session selects its whole descendant subtree.
158
+ * - Deselecting one drops its subtree AND every selected ancestor: a checked
159
+ * ancestor would drag the subtree back into the delete set on the Host
160
+ * side, and the panel totals would understate what actually gets removed.
161
+ *
162
+ * Children link upward through `parent`; the subtree is walked breadth-first
163
+ * so grandchildren join even when their parent is missing from the list.
164
+ */
165
+ function toggleWithDescendants(sessions, selected, id) {
166
+ const childrenOf = new Map()
167
+ const parentOf = new Map()
168
+ for (const session of sessions) {
169
+ if (session.parent !== undefined) {
170
+ parentOf.set(session.id, session.parent)
171
+ const list = childrenOf.get(session.parent)
172
+ if (list === undefined) childrenOf.set(session.parent, [session.id])
173
+ else list.push(session.id)
174
+ }
175
+ }
176
+
177
+ const next = new Set(selected)
178
+ if (!next.has(id)) {
179
+ next.add(id)
180
+ const queue = [id]
181
+ while (queue.length > 0) {
182
+ for (const child of childrenOf.get(queue.shift()) ?? []) {
183
+ next.add(child)
184
+ queue.push(child)
185
+ }
186
+ }
187
+ } else {
188
+ next.delete(id)
189
+ const queue = [id]
190
+ while (queue.length > 0) {
191
+ for (const child of childrenOf.get(queue.shift()) ?? []) {
192
+ next.delete(child)
193
+ queue.push(child)
194
+ }
195
+ }
196
+ // Un-check every ancestor still selected: keeping one would re-include
197
+ // this subtree in its deletion closure.
198
+ let ancestor = parentOf.get(id)
199
+ while (ancestor !== undefined && next.has(ancestor)) {
200
+ next.delete(ancestor)
201
+ ancestor = parentOf.get(ancestor)
202
+ }
203
+ }
204
+ return next
205
+ }
206
+
207
+ /**
208
+ * Drive a set of session ids toward a wanted checked state through the row
209
+ * cascade, so bulk affordances ("select all" of the toolbar, of a workspace
210
+ * group) keep the checked set aligned with the Host's deletion closure.
211
+ */
212
+ function setSelection(sessions, selected, ids, wanted) {
213
+ let next = new Set(selected)
214
+ for (const id of ids) {
215
+ if (next.has(id) !== wanted) next = toggleWithDescendants(sessions, next, id)
216
+ }
217
+ return next
218
+ }
219
+
220
+ /** Last path segment of a Windows or POSIX path; '' when there is none. */
221
+ function baseName(value) {
222
+ const parts = String(value).split(/[\\/]/).filter((part) => part.length > 0)
223
+ return parts.length > 0 ? parts[parts.length - 1] : ''
224
+ }
225
+
226
+ /**
227
+ * Group sessions by workspace and nest subagents under their parents.
228
+ *
229
+ * A group's display path is the most common `cwd` among its sessions (each
230
+ * session reports its own; they agree within a workspace), its name the path's
231
+ * last segment, falling back to the raw workspace key when no session reports
232
+ * a cwd. A session whose parent is missing from the group roots its own tree
233
+ * — an orphaned subagent stays visible instead of vanishing from the list.
234
+ * Roots sort newest first; subagents sort oldest first (spawn order). Groups
235
+ * sort by total bytes, largest first.
236
+ */
237
+ function buildWorkspaceGroups(sessions) {
238
+ const byWorkspace = new Map()
239
+ for (const session of sessions) {
240
+ const list = byWorkspace.get(session.workspaceKey)
241
+ if (list === undefined) byWorkspace.set(session.workspaceKey, [session])
242
+ else list.push(session)
243
+ }
244
+
245
+ const groups = []
246
+ for (const [workspaceKey, list] of byWorkspace) {
247
+ const cwdCounts = new Map()
248
+ for (const session of list) {
249
+ if (typeof session.cwd === 'string' && session.cwd !== '') {
250
+ cwdCounts.set(session.cwd, (cwdCounts.get(session.cwd) ?? 0) + 1)
251
+ }
252
+ }
253
+ let path
254
+ let best = 0
255
+ for (const [cwd, count] of cwdCounts) {
256
+ if (count > best) {
257
+ best = count
258
+ path = cwd
259
+ }
260
+ }
261
+ const name = baseName(path ?? '') || workspaceKey
262
+
263
+ const ids = new Set(list.map((session) => session.id))
264
+ const childrenOf = new Map()
265
+ for (const session of list) {
266
+ if (session.parent === undefined) continue
267
+ const siblings = childrenOf.get(session.parent)
268
+ if (siblings === undefined) childrenOf.set(session.parent, [session])
269
+ else siblings.push(session)
270
+ }
271
+ const buildNode = (session) => ({
272
+ session,
273
+ orphan: session.parent !== undefined && !ids.has(session.parent),
274
+ children: (childrenOf.get(session.id) ?? []).map(buildNode),
275
+ })
276
+ const roots = list
277
+ .filter((session) => session.parent === undefined || !ids.has(session.parent))
278
+ .map(buildNode)
279
+ const sortTree = (nodes) => {
280
+ nodes.sort((left, right) => (left.session.createdAt ?? 0) - (right.session.createdAt ?? 0))
281
+ for (const node of nodes) sortTree(node.children)
282
+ return nodes
283
+ }
284
+ roots.sort((left, right) => (right.session.createdAt ?? 0) - (left.session.createdAt ?? 0))
285
+ for (const node of roots) sortTree(node.children)
286
+
287
+ groups.push({
288
+ key: workspaceKey,
289
+ name,
290
+ path,
291
+ roots,
292
+ count: list.length,
293
+ childCount: list.filter((session) => session.parent !== undefined).length,
294
+ bytes: list.reduce((sum, session) => sum + (session.bytes ?? 0), 0),
295
+ })
296
+ }
297
+ groups.sort((left, right) => right.bytes - left.bytes)
298
+ return groups
299
+ }
300
+
301
+ /** Flatten a display forest back into its sessions, parents before children. */
302
+ function collectSessions(nodes, out = []) {
303
+ for (const node of nodes) {
304
+ out.push(node.session)
305
+ collectSessions(node.children, out)
306
+ }
307
+ return out
308
+ }
309
+
310
+ /**
311
+ * The descendant subagent sessions of one id, breadth-first over the loaded
312
+ * `parent` links — the ids the Host would delete together with it.
313
+ */
314
+ function collectDescendants(sessions, sessionId) {
315
+ const childrenOf = new Map()
316
+ for (const session of sessions) {
317
+ if (session.parent === undefined) continue
318
+ const list = childrenOf.get(session.parent)
319
+ if (list === undefined) childrenOf.set(session.parent, [session])
320
+ else list.push(session)
321
+ }
322
+ const out = []
323
+ const queue = [sessionId]
324
+ while (queue.length > 0) {
325
+ for (const child of childrenOf.get(queue.shift()) ?? []) {
326
+ out.push(child)
327
+ queue.push(child.id)
328
+ }
329
+ }
330
+ return out
331
+ }
332
+
333
+ /** Does a session match the filter needle across its display fields? */
334
+ function sessionMatches(session, needle) {
335
+ return session.id.toLowerCase().includes(needle)
336
+ || (session.title ?? '').toLowerCase().includes(needle)
337
+ || session.workspaceKey.toLowerCase().includes(needle)
338
+ || (session.cwd ?? '').toLowerCase().includes(needle)
339
+ }
340
+
341
+ /**
342
+ * Keep the nodes a filter still shows. A matching node keeps its whole subtree
343
+ * (the user matched the parent, the children come along); a non-matching node
344
+ * survives only through matching descendants, which keeps the tree shape
345
+ * intact when only a subagent matches.
346
+ */
347
+ function filterNodes(nodes, needle) {
348
+ const out = []
349
+ for (const node of nodes) {
350
+ const children = filterNodes(node.children, needle)
351
+ if (sessionMatches(node.session, needle)) out.push(node)
352
+ else if (children.length > 0) out.push({ session: node.session, orphan: node.orphan, children })
353
+ }
354
+ return out
355
+ }
356
+
357
+ /** A group survives a filter through its name/path or any surviving session. */
358
+ function filterGroup(group, needle) {
359
+ if (group.name.toLowerCase().includes(needle) || (group.path ?? '').toLowerCase().includes(needle)) return group
360
+ const roots = filterNodes(group.roots, needle)
361
+ if (roots.length === 0) return undefined
362
+ return { ...group, roots }
363
+ }
364
+
365
+ // The shipped global pages pad their heading for the macOS traffic lights
366
+ // only ([data-platform='darwin']); on Windows the titlebar already sits above
367
+ // the content area, so the clearance must not be added twice.
368
+ const isDarwin = typeof document === 'object' && document !== null
369
+ && document.documentElement?.getAttribute?.('data-platform') === 'darwin'
370
+
371
+ /** Locale date-time for a message stamp; '' when the value is not a date. */
372
+ function formatTime(time) {
373
+ try {
374
+ const date = new Date(time)
375
+ if (Number.isNaN(date.getTime())) return ''
376
+ return date.toLocaleString()
377
+ } catch {
378
+ return ''
379
+ }
380
+ }
381
+
382
+ /** Locale date for a session's creation stamp; '' when unknown. */
383
+ function formatDay(time) {
384
+ try {
385
+ const date = new Date(time)
386
+ if (Number.isNaN(date.getTime())) return ''
387
+ return date.toLocaleDateString()
388
+ } catch {
389
+ return ''
390
+ }
391
+ }
392
+
393
+ /** Render transcript messages as read-only rows (badge, stamp, text). */
394
+ function renderMessages(React, t, messages) {
395
+ return messages.map((message, index) => {
396
+ const meta = []
397
+ if (typeof message.time === 'number') {
398
+ const stamp = formatTime(message.time)
399
+ if (stamp !== '') meta.push(stamp)
400
+ }
401
+ if (typeof message.model === 'string') meta.push(message.model)
402
+ return React.createElement('div', { key: index, style: messageStyle }, [
403
+ React.createElement('div', { key: 'head', style: messageHeadStyle }, [
404
+ React.createElement('span', {
405
+ key: 'role',
406
+ style: message.role === 'assistant' ? assistantBadgeStyle : userBadgeStyle,
407
+ }, t(message.role === 'assistant' ? 'preview.assistant' : 'preview.user')),
408
+ meta.length === 0 ? null : React.createElement('span', { key: 'meta', style: messageMetaStyle }, meta.join(' · ')),
409
+ ]),
410
+ React.createElement('div', { key: 'text', style: messageTextStyle }, message.text),
411
+ ])
412
+ })
413
+ }
414
+
415
+ module.exports = function createPanelPage(React, t, acquire, readFailure, primitives) {
416
+ const { useState, useEffect, useMemo, useRef } = React
417
+ // Optional: called after a successful batch deletion so the client can
418
+ // re-pull its session list and drop the deleted rows from the sidebar.
419
+ const afterDelete = primitives?.afterDelete
420
+ // Optional: fires when a deletion happens OUTSIDE this panel (the sidebar
421
+ // menu dialog). This page's list is a scan snapshot taken on mount, so it
422
+ // subscribes and re-scans — otherwise deleted rows would linger here while
423
+ // the sidebar has already dropped them.
424
+ const deletionSignal = primitives?.deletionSignal
425
+
426
+ return function SessionCleanerPage() {
427
+ const [state, setState] = useState({ phase: 'loading' })
428
+ const [query, setQuery] = useState('')
429
+ const [selected, setSelected] = useState(() => new Set())
430
+ const [busy, setBusy] = useState(false)
431
+ const [notice, setNotice] = useState(undefined)
432
+ // Workspace groups and parent rows the user has expanded; both default to
433
+ // collapsed, and a text filter overrides them (matches must be visible).
434
+ const [openWorkspaces, setOpenWorkspaces] = useState(() => new Set())
435
+ const [openSessions, setOpenSessions] = useState(() => new Set())
436
+ // The pending conversation preview: { phase: 'loading'|'ready'|'error',
437
+ // sessionId, data?, error? } — undefined while no dialog is open.
438
+ const [preview, setPreview] = useState(undefined)
439
+ const previewSeq = useRef(0)
440
+ // The preview dialog's user-chosen size; undefined = the responsive
441
+ // default. The state lives in the page (which outlives the dialog), so a
442
+ // chosen size survives closing and reopening.
443
+ const [previewSize, setPreviewSize] = useState(undefined)
444
+ const previewPanelRef = useRef(null)
445
+
446
+ /**
447
+ * (Re)scan the Host's disk. The quiet variant is the post-delete
448
+ * reconciliation: it keeps the current list, notice and dshHome on screen
449
+ * while the scan is in flight, and on failure keeps them too — the rows
450
+ * dropped optimistically after the delete are a better guess than a
451
+ * loading flash over an empty list.
452
+ */
453
+ const load = async ({ quiet = false } = {}) => {
454
+ if (!quiet) {
455
+ setState({ phase: 'loading' })
456
+ setNotice(undefined)
457
+ }
458
+ const service = await acquire()
459
+ if (service === undefined) {
460
+ if (!quiet) {
461
+ const reason = readFailure?.()
462
+ setState({ phase: 'error', error: reason === undefined ? t('panel.serviceMissing') : t('panel.service.mountFailed', { reason }) })
463
+ }
464
+ return
465
+ }
466
+ try {
467
+ const data = unwrap(await service.list())
468
+ setState({ phase: 'ready', sessions: data?.sessions ?? [], dshHome: data?.dshHome })
469
+ setSelected(new Set())
470
+ } catch (reason) {
471
+ if (!quiet) setState({ phase: 'error', error: String(reason?.message ?? reason) })
472
+ }
473
+ }
474
+
475
+ useEffect(() => { load() }, [])
476
+
477
+ // Deletions from the sidebar menu invalidate this snapshot: re-scan when
478
+ // signaled. The cleanup happens through the subscription's disposer.
479
+ useEffect(() => {
480
+ if (deletionSignal === undefined) return undefined
481
+ return deletionSignal.subscribe(() => { load() })
482
+ }, [])
483
+
484
+ // Escape closes the preview, matching the platform Modal's behaviour.
485
+ useEffect(() => {
486
+ if (preview === undefined || typeof document?.addEventListener !== 'function') return undefined
487
+ const onKeyDown = (event) => { if (event.key === 'Escape') closePreview() }
488
+ document.addEventListener('keydown', onKeyDown)
489
+ return () => { document.removeEventListener('keydown', onKeyDown) }
490
+ }, [preview === undefined])
491
+
492
+ const sessions = state.phase === 'ready' ? state.sessions : []
493
+ const filtering = query.trim() !== ''
494
+ const groups = useMemo(() => buildWorkspaceGroups(sessions), [sessions])
495
+ const visibleGroups = useMemo(() => {
496
+ const needle = query.trim().toLowerCase()
497
+ if (needle === '') return groups
498
+ return groups
499
+ .map((group) => filterGroup(group, needle))
500
+ .filter((group) => group !== undefined)
501
+ }, [groups, query])
502
+ const visibleSessions = useMemo(
503
+ () => visibleGroups.flatMap((group) => collectSessions(group.roots)),
504
+ [visibleGroups],
505
+ )
506
+
507
+ const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => selected.has(session.id))
508
+ const selectedSessions = sessions.filter((session) => selected.has(session.id))
509
+ const selectedBytes = selectedSessions.reduce((sum, session) => sum + session.bytes, 0)
510
+
511
+ const toggle = (id) => {
512
+ setSelected(toggleWithDescendants(sessions, selected, id))
513
+ }
514
+ const toggleAll = () => {
515
+ if (allVisibleSelected) {
516
+ setSelected(new Set())
517
+ return
518
+ }
519
+ // Select-each-visible carries each one's descendants in, including
520
+ // descendants a text filter currently hides from the list.
521
+ setSelected(setSelection(sessions, selected, visibleSessions.map((session) => session.id), true))
522
+ }
523
+ /** The group header checkbox: drive every session under the group toward one state. */
524
+ const toggleGroup = (groupSessions) => {
525
+ const wanted = !(groupSessions.length > 0 && groupSessions.every((session) => selected.has(session.id)))
526
+ setSelected(setSelection(sessions, selected, groupSessions.map((session) => session.id), wanted))
527
+ }
528
+
529
+ const remove = async () => {
530
+ const ids = [...selected]
531
+ if (ids.length === 0) return
532
+ setBusy(true)
533
+ setNotice(undefined)
534
+ const service = await acquire()
535
+ if (service === undefined) {
536
+ setBusy(false)
537
+ const reason = readFailure?.()
538
+ setNotice({ kind: 'error', text: reason === undefined ? t('panel.serviceMissing') : t('service.mountFailed', { reason }) })
539
+ return
540
+ }
541
+ try {
542
+ const result = unwrap(await service.delete(ids, {}))
543
+ const deletedIds = new Set(
544
+ (Array.isArray(result?.deleted) ? result.deleted : [])
545
+ .map((entry) => entry?.id)
546
+ .filter((id) => typeof id === 'string'),
547
+ )
548
+ if (deletedIds.size > 0) {
549
+ // Optimistic removal: the Host confirmed these deletions, so the
550
+ // rows drop the moment the RPC returns instead of after the
551
+ // reconciliation scan below (a full disk walk that flashes the
552
+ // loading state on large profiles). A refused or partially failed
553
+ // delete never reaches here with the un-deleted ids included, so
554
+ // surviving rows stay on screen until the scan reconciles them.
555
+ setState((current) => (current.phase === 'ready'
556
+ ? { ...current, sessions: current.sessions.filter((session) => !deletedIds.has(session.id)) }
557
+ : current))
558
+ const without = (current) => {
559
+ const next = new Set(current)
560
+ for (const id of deletedIds) next.delete(id)
561
+ return next
562
+ }
563
+ setSelected(without)
564
+ setOpenSessions(without)
565
+ // Hand the CONFIRMED ids to the client regardless of the batch
566
+ // outcome — a partially failed delete still deleted these, and
567
+ // leaving them on the sidebar (see forgetSessions) parks them in
568
+ // Ungrouped until the next restart.
569
+ try { afterDelete?.([...deletedIds]) } catch { /* best effort */ }
570
+ }
571
+ if (result?.ok === false) {
572
+ setNotice({ kind: 'error', text: t('panel.failed', { reason: result.error ?? 'unknown' }) })
573
+ } else {
574
+ setNotice({
575
+ kind: 'done',
576
+ text: t('panel.deleted', {
577
+ count: result?.summary?.sessionsDeleted ?? 0,
578
+ size: formatBytes(result?.summary?.bytesDeleted ?? 0),
579
+ }),
580
+ })
581
+ }
582
+ // Reconcile behind the optimistic update: the scan replaces the
583
+ // snapshot (and picks up deletions made elsewhere) without a loading
584
+ // flash, and the quiet path leaves the report above on screen.
585
+ await load({ quiet: true })
586
+ } catch (reason) {
587
+ setNotice({ kind: 'error', text: t('panel.failed', { reason: String(reason?.message ?? reason) }) })
588
+ } finally {
589
+ setBusy(false)
590
+ }
591
+ }
592
+
593
+ /** Flip one membership in an expansion Set (workspaces, parent rows). */
594
+ const toggleSet = (setter, value) => setter((previous) => {
595
+ const next = new Set(previous)
596
+ if (next.has(value)) next.delete(value)
597
+ else next.add(value)
598
+ return next
599
+ })
600
+
601
+ /**
602
+ * Open the read-only transcript dialog for a session. Only the most
603
+ * recent request may settle: a fast double-click on two different rows
604
+ * would otherwise race, and the older read could overwrite the newer
605
+ * dialog after it opened.
606
+ */
607
+ const openPreview = async (sessionId) => {
608
+ const requestId = previewSeq.current + 1
609
+ previewSeq.current = requestId
610
+ setPreview({ phase: 'loading', sessionId })
611
+ const service = await acquire()
612
+ if (previewSeq.current !== requestId) return
613
+ if (service === undefined) {
614
+ const reason = readFailure?.()
615
+ setPreview({ phase: 'error', sessionId, error: reason === undefined ? t('panel.serviceMissing') : t('service.mountFailed', { reason }) })
616
+ return
617
+ }
618
+ try {
619
+ const data = unwrap(await service.read(sessionId, {}))
620
+ if (previewSeq.current !== requestId) return
621
+ if (data?.ok === false) {
622
+ setPreview({ phase: 'error', sessionId, error: t('preview.failed', { reason: data.error ?? 'unknown' }) })
623
+ } else {
624
+ setPreview({ phase: 'ready', sessionId, data })
625
+ }
626
+ } catch (reason) {
627
+ if (previewSeq.current !== requestId) return
628
+ setPreview({ phase: 'error', sessionId, error: t('preview.failed', { reason: String(reason?.message ?? reason) }) })
629
+ }
630
+ }
631
+
632
+ const closePreview = () => {
633
+ previewSeq.current += 1
634
+ setPreview(undefined)
635
+ }
636
+
637
+ /**
638
+ * Drag-resize the preview dialog from its corner grip: pointer deltas
639
+ * become an explicit size, clamped to the viewport. A double-click on the
640
+ * grip returns to the responsive default.
641
+ */
642
+ const startPreviewResize = (event) => {
643
+ event.preventDefault?.()
644
+ event.stopPropagation?.()
645
+ const panel = previewPanelRef.current
646
+ if (panel == null || typeof window?.addEventListener !== 'function') return
647
+ const startX = event.clientX
648
+ const startY = event.clientY
649
+ const startWidth = panel.offsetWidth
650
+ const startHeight = panel.offsetHeight
651
+ const onMove = (moveEvent) => {
652
+ const width = Math.round(Math.min(Math.max(480, startWidth + moveEvent.clientX - startX), window.innerWidth - 48))
653
+ const height = Math.round(Math.min(Math.max(320, startHeight + moveEvent.clientY - startY), window.innerHeight - 48))
654
+ setPreviewSize({ width, height })
655
+ }
656
+ const onUp = () => {
657
+ window.removeEventListener('pointermove', onMove)
658
+ window.removeEventListener('pointerup', onUp)
659
+ }
660
+ window.addEventListener('pointermove', onMove)
661
+ window.addEventListener('pointerup', onUp)
662
+ }
663
+
664
+ /**
665
+ * The transcript dialog: a wide, user-resizable card.
666
+ *
667
+ * The platform Modal is fixed at min(380px, 100%) wide with no size
668
+ * override, so a transcript — the one dialog that wants screen real
669
+ * estate — renders through this own card instead, dressed in the same
670
+ * design aliases (layer-2 fill, prominent elevation, blurred mask) to
671
+ * stay indistinguishable from the shipped dialogs. The page is a
672
+ * long-lived `main` slot component, so the dialog can live in its tree
673
+ * directly — unlike the session-menu rows, whose dialogs must ride the
674
+ * frame-wide `shell.overlay` because the row unmounts with its menu.
675
+ */
676
+ const renderPreview = () => {
677
+ if (preview === undefined) return null
678
+ const data = preview.phase === 'ready' ? preview.data : undefined
679
+
680
+ const body = []
681
+ // The id sits directly under the title in every phase: the dialog's
682
+ // subject stays identifiable even while the transcript is loading.
683
+ body.push(React.createElement('p', { key: 'sessionid', style: previewIdStyle }, [
684
+ React.createElement('span', { key: 'label', style: previewIdLabelStyle }, `${t('preview.sessionId')}: `),
685
+ preview.sessionId,
686
+ ]))
687
+ const descendants = collectDescendants(sessions, preview.sessionId)
688
+ if (descendants.length > 0) {
689
+ body.push(React.createElement('div', { key: 'children', style: previewChildrenStyle }, [
690
+ React.createElement('p', { key: 'head', style: previewChildrenHeadStyle },
691
+ t('preview.children', { count: descendants.length })),
692
+ ...descendants.map((session) => React.createElement('div', { key: session.id, style: previewChildRowStyle }, [
693
+ React.createElement('span', { key: 'badge', style: childBadgeStyle }, t('panel.child')),
694
+ React.createElement('span', {
695
+ key: 'id',
696
+ style: previewChildIdStyle,
697
+ title: session.title ?? session.id,
698
+ }, session.id),
699
+ ])),
700
+ ]))
701
+ }
702
+ if (data !== undefined) {
703
+ // The workspace arrives as its encoded key; the cwd is the same place
704
+ // in human form, so only size and cwd are shown.
705
+ const meta = [data.humanBytes, data.cwd]
706
+ .filter((part) => typeof part === 'string' && part !== '')
707
+ if (meta.length > 0) {
708
+ body.push(React.createElement('p', { key: 'meta', style: previewMetaStyle }, meta.join(' · ')))
709
+ }
710
+ if (data.truncated === true) {
711
+ body.push(React.createElement('p', { key: 'truncated', style: hintStyle },
712
+ t('preview.truncated', { shown: data.messages.length, total: data.totalMessages })))
713
+ }
714
+ body.push(React.createElement('div', { key: 'messages', style: messageListStyle },
715
+ data.messages.length === 0
716
+ ? React.createElement('p', { key: 'empty', style: hintStyle }, t('preview.empty'))
717
+ : renderMessages(React, t, data.messages)))
718
+ } else if (preview.phase === 'loading') {
719
+ body.push(React.createElement('p', { key: 'loading', style: hintStyle }, t('preview.loading')))
720
+ } else {
721
+ body.push(React.createElement('p', { key: 'error', style: errorStyle }, preview.error))
722
+ }
723
+
724
+ const panelStyle = previewSize === undefined
725
+ ? previewPanelStyle
726
+ : { ...previewPanelStyle, width: `${previewSize.width}px`, height: `${previewSize.height}px` }
727
+
728
+ return React.createElement('div', { style: previewOverlayStyle }, [
729
+ React.createElement('div', {
730
+ key: 'mask',
731
+ style: previewMaskStyle,
732
+ onClick: () => { closePreview() },
733
+ }),
734
+ React.createElement('div', {
735
+ key: 'panel',
736
+ ref: previewPanelRef,
737
+ role: 'dialog',
738
+ 'aria-modal': 'true',
739
+ 'aria-label': t('preview.title'),
740
+ style: panelStyle,
741
+ }, [
742
+ React.createElement('div', { key: 'header', style: previewHeaderStyle }, [
743
+ React.createElement('h2', { key: 'title', style: previewHeadingStyle }, t('preview.title')),
744
+ React.createElement('button', {
745
+ key: 'close',
746
+ type: 'button',
747
+ style: previewCloseStyle,
748
+ 'aria-label': t('dialog.close'),
749
+ onClick: closePreview,
750
+ }, '×'),
751
+ ]),
752
+ React.createElement('p', { key: 'subject', style: previewSubjectStyle }, data?.title || preview.sessionId),
753
+ React.createElement('div', { key: 'body', style: previewBodyStyle }, body),
754
+ React.createElement('div', { key: 'footer', style: previewFooterStyle },
755
+ React.createElement('button', { key: 'close', type: 'button', onClick: closePreview, style: buttonStyle }, t('dialog.close'))),
756
+ React.createElement('div', {
757
+ key: 'resize',
758
+ style: resizeGripStyle,
759
+ 'aria-label': t('preview.resize'),
760
+ title: t('preview.resize'),
761
+ onPointerDown: startPreviewResize,
762
+ onDoubleClick: (event) => {
763
+ event.stopPropagation()
764
+ setPreviewSize(undefined)
765
+ },
766
+ }, '◢'),
767
+ ]),
768
+ ])
769
+ }
770
+
771
+ /** One workspace group card: header row, then its session forest when open. */
772
+ const renderGroup = (group) => {
773
+ const open = filtering || openWorkspaces.has(group.key)
774
+ const groupSessions = collectSessions(group.roots)
775
+ const allSelected = groupSessions.length > 0 && groupSessions.every((session) => selected.has(session.id))
776
+ const someSelected = groupSessions.some((session) => selected.has(session.id))
777
+
778
+ const rows = [
779
+ React.createElement('div', {
780
+ key: 'header',
781
+ style: groupHeaderStyle,
782
+ role: 'group',
783
+ 'aria-label': group.name,
784
+ onClick: () => { toggleSet(setOpenWorkspaces, group.key) },
785
+ }, [
786
+ React.createElement('button', {
787
+ key: 'expander',
788
+ type: 'button',
789
+ style: expanderStyle,
790
+ 'aria-expanded': open,
791
+ 'aria-label': t(open ? 'panel.collapse' : 'panel.expand'),
792
+ onClick: (event) => {
793
+ event.stopPropagation()
794
+ toggleSet(setOpenWorkspaces, group.key)
795
+ },
796
+ onDoubleClick: (event) => event.stopPropagation(),
797
+ }, open ? '▼' : '▶'),
798
+ React.createElement('input', {
799
+ key: 'check',
800
+ type: 'checkbox',
801
+ checked: allSelected,
802
+ // A partial selection reads as indeterminate; React has no prop
803
+ // for it, so the ref callback owns that DOM state.
804
+ ref: (element) => { if (element != null) element.indeterminate = someSelected && !allSelected },
805
+ onClick: (event) => event.stopPropagation(),
806
+ onChange: () => toggleGroup(groupSessions),
807
+ }),
808
+ React.createElement('span', {
809
+ key: 'name',
810
+ style: groupNameStyle,
811
+ title: group.path ?? group.key,
812
+ }, group.name),
813
+ React.createElement('span', { key: 'path', style: groupPathStyle }, group.path ?? ''),
814
+ React.createElement('span', { key: 'stats', style: groupStatsStyle },
815
+ t(group.childCount > 0 ? 'panel.groupStatsChildren' : 'panel.groupStats',
816
+ { count: group.count, children: group.childCount, size: formatBytes(group.bytes) })),
817
+ ]),
818
+ ]
819
+ if (open) {
820
+ for (const node of group.roots) rows.push(...renderNode(node, 0))
821
+ }
822
+ return React.createElement('div', { key: group.key, style: listStyle }, rows)
823
+ }
824
+
825
+ /** One session row (a checkbox label) plus, when expanded, its subagents. */
826
+ const renderNode = (node, depth) => {
827
+ const { session } = node
828
+ const hasChildren = node.children.length > 0
829
+ const open = filtering || openSessions.has(session.id)
830
+ const label = session.title ?? session.id
831
+
832
+ return [
833
+ React.createElement('label', {
834
+ key: session.id,
835
+ style: { ...rowStyle, paddingLeft: `${10 + depth * 20}px` },
836
+ // A double-click toggles the checkbox twice on the way in (net no
837
+ // change) and then opens the preview.
838
+ onDoubleClick: () => { void openPreview(session.id) },
839
+ }, [
840
+ // The caret column leads so the checkboxes of the group header, of
841
+ // parent rows and of leaf rows all share one vertical line.
842
+ hasChildren
843
+ ? React.createElement('button', {
844
+ key: 'expander',
845
+ type: 'button',
846
+ style: expanderStyle,
847
+ 'aria-expanded': open,
848
+ 'aria-label': t(open ? 'panel.collapse' : 'panel.expand'),
849
+ // Inside the row label: without these the click would reach the
850
+ // label's checkbox instead of the caret.
851
+ onClick: (event) => {
852
+ event.preventDefault()
853
+ event.stopPropagation()
854
+ toggleSet(setOpenSessions, session.id)
855
+ },
856
+ onDoubleClick: (event) => event.stopPropagation(),
857
+ }, open ? '▼' : '▶')
858
+ : React.createElement('span', { key: 'spacer', style: expanderSpacerStyle }),
859
+ React.createElement('input', {
860
+ key: 'check',
861
+ type: 'checkbox',
862
+ checked: selected.has(session.id),
863
+ onChange: () => toggle(session.id),
864
+ // Inside the row label: rapid check/uncheck clicks end in a
865
+ // dblclick that would bubble to the label's preview handler —
866
+ // the checkbox's only duty is to check and uncheck.
867
+ onDoubleClick: (event) => event.stopPropagation(),
868
+ }),
869
+ React.createElement('span', {
870
+ key: 'title',
871
+ // An untitled session falls back to its id, dimmed.
872
+ style: session.title === undefined ? { ...titleStyle, opacity: 0.55 } : titleStyle,
873
+ title: label,
874
+ }, label),
875
+ session.parent === undefined ? null : React.createElement('span', { key: 'child', style: childBadgeStyle }, t('panel.child')),
876
+ hasChildren ? React.createElement('span', { key: 'count', style: childBadgeStyle }, t('panel.childCount', { count: node.children.length })) : null,
877
+ session.createdAt === undefined ? null : React.createElement('span', { key: 'day', style: metaStyle }, formatDay(session.createdAt)),
878
+ React.createElement('span', { key: 'size', style: sizeStyle }, session.humanBytes),
879
+ React.createElement('button', {
880
+ key: 'preview',
881
+ type: 'button',
882
+ style: previewButtonStyle,
883
+ // Inside the row label: without these the click would reach the
884
+ // label's checkbox instead of the preview.
885
+ onClick: (event) => {
886
+ event.preventDefault()
887
+ event.stopPropagation()
888
+ void openPreview(session.id)
889
+ },
890
+ }, t('panel.preview')),
891
+ ]),
892
+ ...(hasChildren && open ? node.children.flatMap((child) => renderNode(child, depth + 1)) : []),
893
+ ]
894
+ }
895
+
896
+ const body = []
897
+ body.push(React.createElement('p', { key: 'hint', style: hintStyle }, t('panel.hint')))
898
+
899
+ if (state.phase === 'loading') {
900
+ body.push(React.createElement('p', { key: 'loading', style: hintStyle }, t('panel.loading')))
901
+ } else if (state.phase === 'error') {
902
+ body.push(React.createElement('p', { key: 'error', style: errorStyle }, state.error))
903
+ body.push(React.createElement('button', {
904
+ key: 'retry', type: 'button', onClick: load, style: buttonStyle,
905
+ }, t('panel.retry')))
906
+ } else {
907
+ body.push(React.createElement('div', { key: 'toolbar', style: toolbarStyle }, [
908
+ React.createElement('input', {
909
+ key: 'query',
910
+ type: 'search',
911
+ value: query,
912
+ placeholder: t('panel.search'),
913
+ onChange: (event) => setQuery(event.target.value),
914
+ style: inputStyle,
915
+ }),
916
+ React.createElement('button', {
917
+ key: 'toggle', type: 'button', onClick: toggleAll, style: buttonStyle,
918
+ }, allVisibleSelected ? t('panel.clear') : t('panel.selectAll')),
919
+ React.createElement('button', {
920
+ // The list is a snapshot; a manual re-scan also picks up deletions
921
+ // made through the model Tool, which no client sees happen.
922
+ key: 'refresh', type: 'button', disabled: busy, onClick: load, style: buttonStyle,
923
+ }, t('panel.refresh')),
924
+ React.createElement('button', {
925
+ key: 'delete',
926
+ type: 'button',
927
+ disabled: busy || selected.size === 0,
928
+ onClick: remove,
929
+ style: selected.size === 0 ? disabledDangerStyle : dangerButtonStyle,
930
+ }, busy
931
+ ? t('panel.deleting')
932
+ : t('panel.delete', { count: selected.size, size: formatBytes(selectedBytes) })),
933
+ ]))
934
+
935
+ if (notice !== undefined) {
936
+ body.push(React.createElement('p', {
937
+ key: 'notice',
938
+ style: notice.kind === 'error' ? errorStyle : doneStyle,
939
+ }, notice.text))
940
+ }
941
+
942
+ if (visibleGroups.length === 0) {
943
+ body.push(React.createElement('p', { key: 'empty', style: hintStyle }, t('panel.empty')))
944
+ } else {
945
+ body.push(React.createElement('div', { key: 'groups', style: groupsStyle },
946
+ visibleGroups.map((group) => renderGroup(group))))
947
+ }
948
+
949
+ body.push(React.createElement('p', { key: 'root', style: hintStyle }, t('panel.root', { path: state.dshHome ?? '' })))
950
+ }
951
+
952
+ return React.createElement('section', { style: pageStyle, 'aria-label': t('panel.title') },
953
+ React.createElement('div', { style: scrollStyle },
954
+ React.createElement('div', { style: contentStyle }, [
955
+ React.createElement('h1', { key: 'title', style: headingStyle }, t('panel.title')),
956
+ ...body,
957
+ ])),
958
+ renderPreview())
959
+ }
960
+ }
961
+
962
+ const pageStyle = Object.freeze({
963
+ width: '100%', minWidth: 0, height: '100%', minHeight: 0,
964
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
965
+ background: 'var(--dsw-alias-bg-base, transparent)',
966
+ color: 'var(--dsw-alias-label-primary, inherit)',
967
+ fontSize: '14px', lineHeight: '1.6',
968
+ })
969
+ const scrollStyle = Object.freeze({ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', scrollbarGutter: 'stable' })
970
+ const contentStyle = Object.freeze({
971
+ maxWidth: '960px', margin: '0 auto',
972
+ padding: '0 clamp(24px, 4vw, 48px) 48px',
973
+ display: 'flex', flexDirection: 'column', gap: '10px',
974
+ })
975
+ const headingStyle = Object.freeze({
976
+ margin: '0',
977
+ fontSize: '20px', fontWeight: 500, lineHeight: '28px',
978
+ paddingTop: isDarwin ? 'calc(28px + var(--dsh-frame-top-clearance, 0px))' : '28px',
979
+ })
980
+ const hintStyle = Object.freeze({ margin: '0', fontSize: '12px', opacity: 0.7, lineHeight: '18px' })
981
+ const errorStyle = Object.freeze({ margin: '0', fontSize: '12px', color: 'light-dark(#b42318, #ff9c94)', wordBreak: 'break-word' })
982
+ const doneStyle = Object.freeze({ margin: '0', fontSize: '12px', color: 'light-dark(#067647, #75e0a7)' })
983
+ const toolbarStyle = Object.freeze({ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' })
984
+ const inputStyle = Object.freeze({
985
+ flex: '1 1 180px', minWidth: '140px', padding: '6px 10px', borderRadius: '8px', font: 'inherit',
986
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
987
+ background: 'transparent', color: 'inherit',
988
+ })
989
+ const buttonStyle = Object.freeze({
990
+ padding: '6px 12px', borderRadius: '8px', cursor: 'pointer', font: 'inherit', whiteSpace: 'nowrap',
991
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
992
+ background: 'transparent', color: 'inherit',
993
+ })
994
+ const dangerButtonStyle = Object.freeze({
995
+ ...buttonStyle, border: 'none', background: 'light-dark(#d92d20, #f04438)', color: '#ffffff',
996
+ })
997
+ const disabledDangerStyle = Object.freeze({
998
+ ...dangerButtonStyle, opacity: 0.45, cursor: 'not-allowed',
999
+ })
1000
+ const groupsStyle = Object.freeze({ display: 'flex', flexDirection: 'column', gap: '12px' })
1001
+ const listStyle = Object.freeze({
1002
+ display: 'flex', flexDirection: 'column',
1003
+ border: '1px solid var(--dsw-alias-border-l2, light-dark(rgba(0,0,0,.08), rgba(255,255,255,.12)))',
1004
+ borderRadius: '10px',
1005
+ })
1006
+ const groupHeaderStyle = Object.freeze({
1007
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '8px 10px', cursor: 'pointer', userSelect: 'none',
1008
+ background: 'var(--dsw-alias-interactive-bg-hover, light-dark(rgba(38,49,72,.05), rgba(255,255,255,.06)))',
1009
+ borderBottom: '1px solid var(--dsw-alias-border-l1, light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)))',
1010
+ })
1011
+ const groupNameStyle = Object.freeze({
1012
+ fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
1013
+ maxWidth: '45%', overflow: 'hidden', textOverflow: 'ellipsis',
1014
+ })
1015
+ const groupPathStyle = Object.freeze({
1016
+ flex: '1 1 auto', minWidth: 0, fontSize: '11px', opacity: 0.55,
1017
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
1018
+ })
1019
+ const groupStatsStyle = Object.freeze({
1020
+ marginLeft: 'auto', fontSize: '11px', opacity: 0.65, whiteSpace: 'nowrap',
1021
+ fontVariantNumeric: 'tabular-nums',
1022
+ })
1023
+ const rowStyle = Object.freeze({
1024
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 10px', cursor: 'pointer',
1025
+ userSelect: 'none',
1026
+ borderBottom: '1px solid var(--dsw-alias-border-l1, light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)))',
1027
+ })
1028
+ const titleStyle = Object.freeze({ flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' })
1029
+ const metaStyle = Object.freeze({ fontSize: '11px', opacity: 0.6, whiteSpace: 'nowrap' })
1030
+ const expanderStyle = Object.freeze({
1031
+ flexShrink: 0, width: '20px', height: '20px', padding: 0,
1032
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1033
+ fontSize: '10px', lineHeight: 1, opacity: 0.8,
1034
+ cursor: 'pointer', border: 'none', background: 'transparent', color: 'inherit',
1035
+ })
1036
+ const expanderSpacerStyle = Object.freeze({ flexShrink: 0, width: '20px' })
1037
+ const childBadgeStyle = Object.freeze({
1038
+ fontSize: '10px', padding: '1px 6px', borderRadius: '999px', whiteSpace: 'nowrap',
1039
+ background: 'var(--dsw-alias-interactive-bg-hover, light-dark(rgba(38,49,72,.06), rgba(255,255,255,.08)))',
1040
+ })
1041
+ const sizeStyle = Object.freeze({ fontSize: '11px', opacity: 0.7, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' })
1042
+ const previewButtonStyle = Object.freeze({
1043
+ flexShrink: 0, padding: '2px 10px', borderRadius: '6px', font: 'inherit', fontSize: '11px',
1044
+ whiteSpace: 'nowrap', cursor: 'pointer',
1045
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
1046
+ background: 'transparent', color: 'inherit',
1047
+ })
1048
+ const previewOverlayStyle = Object.freeze({
1049
+ position: 'fixed', inset: '0', zIndex: 1000, display: 'flex',
1050
+ alignItems: 'center', justifyContent: 'center', padding: '24px',
1051
+ // An overlay host usually disables hit-testing on its container so it cannot
1052
+ // block the app beneath it; the dialog has to opt back in.
1053
+ pointerEvents: 'auto',
1054
+ })
1055
+ const previewMaskStyle = Object.freeze({
1056
+ position: 'absolute', inset: '0',
1057
+ background: 'var(--dsw-alias-bg-mask-1, light-dark(rgba(15,17,21,.32), rgba(0,0,0,.5)))',
1058
+ backdropFilter: 'var(--dsw-mask-blur, blur(2px))',
1059
+ })
1060
+ // Wide by default (a transcript wants real estate) and resizable through the
1061
+ // corner grip; the same design aliases the platform Modal card uses keep the
1062
+ // look identical while the size stays under our control.
1063
+ const previewPanelStyle = Object.freeze({
1064
+ position: 'relative', zIndex: 1,
1065
+ display: 'flex', flexDirection: 'column',
1066
+ width: 'min(92vw, 920px)',
1067
+ height: 'min(84vh, 680px)',
1068
+ borderRadius: '24px', overflow: 'hidden',
1069
+ background: 'var(--dsw-alias-bg-layer-2, light-dark(#ffffff, #353638))',
1070
+ color: 'var(--dsw-alias-label-primary, light-dark(#0f1115, #f9fafb))',
1071
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 32px rgba(0,0,0,.24))',
1072
+ font: 'inherit', pointerEvents: 'auto',
1073
+ })
1074
+ const previewHeaderStyle = Object.freeze({
1075
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px',
1076
+ padding: '22px 14px 12px 24px',
1077
+ })
1078
+ const previewCloseStyle = Object.freeze({
1079
+ flex: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1080
+ width: '28px', height: '28px', border: 'none', borderRadius: '8px',
1081
+ background: 'transparent', cursor: 'pointer', font: 'inherit', fontSize: '14px',
1082
+ color: 'var(--dsw-alias-label-secondary, inherit)',
1083
+ })
1084
+ const previewHeadingStyle = Object.freeze({ margin: '0', fontSize: '16px', fontWeight: 500, lineHeight: '24px' })
1085
+ const previewSubjectStyle = Object.freeze({
1086
+ margin: '0', padding: '0 24px', fontSize: '14px', lineHeight: '22px',
1087
+ opacity: 0.85, wordBreak: 'break-all',
1088
+ })
1089
+ const previewBodyStyle = Object.freeze({
1090
+ display: 'flex', flexDirection: 'column', minWidth: 0,
1091
+ flex: '1 1 auto', minHeight: 0,
1092
+ marginTop: '12px', padding: '0 24px 4px', overflow: 'hidden',
1093
+ })
1094
+ const previewIdStyle = Object.freeze({
1095
+ margin: '0 0 8px', fontSize: '12px', opacity: 0.75, wordBreak: 'break-all',
1096
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1097
+ })
1098
+ const previewIdLabelStyle = Object.freeze({ opacity: 0.6, fontFamily: 'inherit' })
1099
+ const previewChildrenStyle = Object.freeze({ margin: '0 0 8px', display: 'flex', flexDirection: 'column', gap: '4px' })
1100
+ const previewChildrenHeadStyle = Object.freeze({ margin: '0', fontSize: '11px', opacity: 0.6 })
1101
+ const previewChildRowStyle = Object.freeze({ display: 'flex', gap: '6px', alignItems: 'center', fontSize: '11px', opacity: 0.85 })
1102
+ const previewChildIdStyle = Object.freeze({
1103
+ wordBreak: 'break-all',
1104
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1105
+ })
1106
+ const previewMetaStyle = Object.freeze({ margin: '0 0 8px', fontSize: '12px', opacity: 0.7, wordBreak: 'break-all' })
1107
+ const previewFooterStyle = Object.freeze({
1108
+ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px',
1109
+ padding: '12px 24px 20px',
1110
+ })
1111
+ const resizeGripStyle = Object.freeze({
1112
+ position: 'absolute', right: '0', bottom: '0', width: '22px', height: '22px',
1113
+ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end',
1114
+ padding: '2px 4px', fontSize: '11px', lineHeight: 1, opacity: 0.45,
1115
+ cursor: 'nwse-resize', touchAction: 'none', userSelect: 'none', zIndex: 1,
1116
+ })
1117
+ const messageListStyle = Object.freeze({
1118
+ display: 'flex', flexDirection: 'column', gap: '12px',
1119
+ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: '2px',
1120
+ })
1121
+ const messageStyle = Object.freeze({ display: 'flex', flexDirection: 'column', gap: '4px' })
1122
+ const messageHeadStyle = Object.freeze({ display: 'flex', gap: '8px', alignItems: 'baseline' })
1123
+ const messageMetaStyle = Object.freeze({ fontSize: '11px', opacity: 0.55, whiteSpace: 'nowrap' })
1124
+ const userBadgeStyle = Object.freeze({
1125
+ fontSize: '10px', padding: '1px 6px', borderRadius: '999px', whiteSpace: 'nowrap',
1126
+ background: 'light-dark(rgba(38,49,72,.08), rgba(255,255,255,.14))',
1127
+ })
1128
+ const assistantBadgeStyle = Object.freeze({
1129
+ fontSize: '10px', padding: '1px 6px', borderRadius: '999px', whiteSpace: 'nowrap',
1130
+ background: 'light-dark(rgba(12,123,220,.14), rgba(56,132,255,.24))',
1131
+ })
1132
+ const messageTextStyle = Object.freeze({
1133
+ fontSize: '13px', lineHeight: '1.6', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
1134
+ })
1135
+
1136
+ // Attached after the main export so the factory assignment above cannot
1137
+ // overwrite it; the bundle itself only ever uses the factory.
1138
+ module.exports.toggleWithDescendants = toggleWithDescendants
1139
+ module.exports.renderMessages = renderMessages
1140
+ module.exports.setSelection = setSelection
1141
+ module.exports.buildWorkspaceGroups = buildWorkspaceGroups
1142
+ module.exports.collectSessions = collectSessions
1143
+ module.exports.collectDescendants = collectDescendants
1144
+
1145
+ return module.exports;
1146
+ })();
1147
+ __modules["client/client.js"] = (() => {
1148
+ const module = { exports: {} };
1149
+ const exports = module.exports;
1150
+ /**
1151
+ * session-cleaner Web client half.
1152
+ *
1153
+ * Adds a "delete session" row to the sidebar Session menu — the one place a
1154
+ * user already looks for per-session actions — and confirms before deleting.
1155
+ * It also contributes a top-level Session-cleanup page (sidebar entry next to
1156
+ * Plugins and Automation Tasks) for listing and batch-deleting every session
1157
+ * on disk.
1158
+ *
1159
+ * The Host half owns deletion; this file only presents it. DSH's client module
1160
+ * system loads one self-contained bundle and hands it a `require`, so React and
1161
+ * react-dom come from the platform module table rather than from package
1162
+ * resolution.
1163
+ */
1164
+
1165
+ const { TYPERT_REMOTE } = __modules["src/contract.cjs"]
1166
+ const createPanelPage = __modules["client/panel-page.js"]
1167
+
1168
+ /**
1169
+ * The panel id. It addresses both the `main` keyed-slot entry (the page) and
1170
+ * the `sidebar.panellist` entry (the row that selects it): the layout's
1171
+ * `selectPanel(id)` resolves the same key, so the two must never diverge.
1172
+ * The shipped pages use "plugins" (order 0) and "schedules" (order 10).
1173
+ */
1174
+ const PANEL_ID = 'session-cleaner'
1175
+
1176
+ const MESSAGES = Object.freeze({
1177
+ en: Object.freeze({
1178
+ 'menu.delete': 'Delete session',
1179
+ 'dialog.title': 'Delete this session permanently?',
1180
+ 'dialog.body': 'The session log, its projection cache and its workspace entry will be removed. Descendant subagent sessions are removed too.',
1181
+ 'dialog.warning': 'This cannot be undone. DSH keeps no recycle bin and no backup.',
1182
+ 'dialog.bytes': 'Will free {size}.',
1183
+ 'dialog.more': '{count} sessions on disk (including descendant subagent sessions).',
1184
+ 'dialog.loading': 'Checking what would be removed…',
1185
+ 'dialog.confirm': 'Delete permanently',
1186
+ 'dialog.cancel': 'Cancel',
1187
+ 'dialog.close': 'Close',
1188
+ 'dialog.deleting': 'Deleting…',
1189
+ 'dialog.failed': 'Deletion failed: {reason}',
1190
+ 'service.missing': 'The session-cleaner service did not become reachable. Retry the click; if it persists, reload the page.',
1191
+ 'service.mountFailed': 'Remote mount failed: {reason}',
1192
+ 'panel.nav': 'Session cleanup',
1193
+ 'panel.title': 'Session cleanup',
1194
+ 'panel.hint': 'Every session found on disk, grouped by workspace. Selecting a session also selects its descendant subagent sessions — they are deleted together; a session running in this app cannot be removed and will be refused. Double-click a row (or use its Preview button) to read the conversation and see the session ids before deleting.',
1195
+ 'panel.loading': 'Scanning sessions…',
1196
+ 'panel.retry': 'Retry',
1197
+ 'panel.search': 'Filter by title, id, workspace or path',
1198
+ 'panel.selectAll': 'Select all shown',
1199
+ 'panel.clear': 'Clear selection',
1200
+ 'panel.refresh': 'Rescan',
1201
+ 'panel.delete': 'Delete {count} selected ({size})',
1202
+ 'panel.deleting': 'Deleting…',
1203
+ 'panel.deleted': 'Deleted {count} session(s), freed {size}.',
1204
+ 'panel.failed': 'Deletion failed: {reason}',
1205
+ 'panel.empty': 'No session matches the filter.',
1206
+ 'panel.child': 'subagent',
1207
+ 'panel.childCount': 'subagents ×{count}',
1208
+ 'panel.groupStats': '{count} sessions · {size}',
1209
+ 'panel.groupStatsChildren': '{count} sessions · {children} subagents · {size}',
1210
+ 'panel.expand': 'Expand',
1211
+ 'panel.collapse': 'Collapse',
1212
+ 'panel.preview': 'Preview',
1213
+ 'panel.root': 'Data root: {path}',
1214
+ 'panel.serviceMissing': 'The session-cleaner service is unavailable. Check the network panel and reload the page.',
1215
+ 'preview.title': 'Conversation preview',
1216
+ 'preview.sessionId': 'Session ID',
1217
+ 'preview.children': 'Subagent sessions ({count}) — deleted together with the session above:',
1218
+ 'preview.resize': 'Resize (double-click to reset)',
1219
+ 'preview.loading': 'Reading the conversation…',
1220
+ 'preview.empty': 'No text messages in this session.',
1221
+ 'preview.truncated': 'Showing the first {shown} of {total} messages.',
1222
+ 'preview.failed': 'Failed to read the conversation: {reason}',
1223
+ 'preview.user': 'User',
1224
+ 'preview.assistant': 'Assistant',
1225
+ }),
1226
+ zh: Object.freeze({
1227
+ 'menu.delete': '删除会话',
1228
+ 'dialog.title': '永久删除这个会话?',
1229
+ 'dialog.body': '会话日志、投影缓存与工作区中的记录都会被移除;由它派生的子代理会话也会一并删除。',
1230
+ 'dialog.warning': '此操作不可撤销。DSH 没有回收站,也没有备份。',
1231
+ 'dialog.bytes': '将释放 {size}。',
1232
+ 'dialog.more': '磁盘上共 {count} 个会话(含派生的子代理会话)。',
1233
+ 'dialog.loading': '正在统计将要删除的内容…',
1234
+ 'dialog.confirm': '永久删除',
1235
+ 'dialog.cancel': '取消',
1236
+ 'dialog.close': '关闭',
1237
+ 'dialog.deleting': '正在删除…',
1238
+ 'dialog.failed': '删除失败:{reason}',
1239
+ 'service.missing': '会话清理服务未能在 5 秒内就绪。请再点一次「删除会话」;若反复出现,请重新加载页面。',
1240
+ 'service.mountFailed': 'Remote 契约挂载失败:{reason}',
1241
+ 'panel.nav': '会话清理',
1242
+ 'panel.title': '会话清理',
1243
+ 'panel.hint': '磁盘上的全部会话,按工作区分组。勾选主会话会自动带上其派生的子代理会话(它们会被一并删除);本应用中正在运行的会话无法删除,会被拒绝。双击会话行(或点「预览」)可先查看对话内容和会话 ID 再决定删除。',
1244
+ 'panel.loading': '正在扫描会话…',
1245
+ 'panel.retry': '重试',
1246
+ 'panel.search': '按标题、id、工作区或路径筛选',
1247
+ 'panel.selectAll': '全选当前列表',
1248
+ 'panel.clear': '清空选择',
1249
+ 'panel.refresh': '重新扫描',
1250
+ 'panel.delete': '删除选中的 {count} 个({size})',
1251
+ 'panel.deleting': '正在删除…',
1252
+ 'panel.deleted': '已删除 {count} 个会话,释放 {size}。',
1253
+ 'panel.failed': '删除失败:{reason}',
1254
+ 'panel.empty': '没有符合条件的会话。',
1255
+ 'panel.child': '子代理',
1256
+ 'panel.childCount': '子代理 ×{count}',
1257
+ 'panel.groupStats': '{count} 个会话 · {size}',
1258
+ 'panel.groupStatsChildren': '{count} 个会话 · 子代理 {children} 个 · {size}',
1259
+ 'panel.expand': '展开',
1260
+ 'panel.collapse': '收起',
1261
+ 'panel.preview': '预览',
1262
+ 'panel.root': '数据根目录:{path}',
1263
+ 'panel.serviceMissing': '会话清理服务不可用。请检查连接后重新加载页面。',
1264
+ 'preview.title': '对话预览',
1265
+ 'preview.sessionId': '会话 ID',
1266
+ 'preview.children': '子代理会话({count} 个,随上方会话一并删除):',
1267
+ 'preview.resize': '调整大小(双击恢复默认)',
1268
+ 'preview.loading': '正在读取会话内容…',
1269
+ 'preview.empty': '此会话没有可显示的文本消息。',
1270
+ 'preview.truncated': '仅显示前 {shown} 条消息(共 {total} 条)。',
1271
+ 'preview.failed': '读取会话失败:{reason}',
1272
+ 'preview.user': '用户',
1273
+ 'preview.assistant': '助手',
1274
+ }),
1275
+ })
1276
+
1277
+ /** Pick a locale from the DSH locale service, falling back to English. */
1278
+ function detectLocale(ctx) {
1279
+ try {
1280
+ // The LocaleRuntime snapshot carries the active locale as `active`, which is
1281
+ // either the lowercase id itself or a locale record holding it under `id`.
1282
+ const snapshot = ctx.locale?.getLocale?.() ?? ctx.locale?.getSnapshot?.()
1283
+ const active = snapshot?.active
1284
+ const id = typeof active === 'string' ? active : active?.id ?? ''
1285
+ return String(id).toLowerCase().startsWith('zh') ? 'zh' : 'en'
1286
+ } catch {
1287
+ return 'en'
1288
+ }
1289
+ }
1290
+
1291
+ /** Read a service result defensively: RPC may settle unwrapped or wrapped. */
1292
+ function unwrap(result) {
1293
+ if (result !== null && typeof result === 'object' && 'ok' in result && 'value' in result) {
1294
+ return result.ok ? result.value : { ok: false, error: result.error?.message ?? 'remote call failed' }
1295
+ }
1296
+ return result
1297
+ }
1298
+
1299
+ /**
1300
+ * A menu row cannot own the dialog it opens. Choosing the row closes the menu,
1301
+ * and the row unmounts in that very commit — so a dialog rendered inside it is
1302
+ * dropped before it can paint, and the click looks like it did nothing at all.
1303
+ *
1304
+ * The row therefore publishes a request here and returns; a frame-wide
1305
+ * `shell.overlay` entry, which outlives every menu, renders the dialog. The
1306
+ * shipped rename and archive dialogs use the same split.
1307
+ */
1308
+ function createConfirmStore() {
1309
+ let state = { request: undefined }
1310
+ const listeners = new Set()
1311
+ const publish = (next) => {
1312
+ state = next
1313
+ for (const listener of [...listeners]) listener()
1314
+ }
1315
+ return {
1316
+ subscribe(listener) {
1317
+ listeners.add(listener)
1318
+ return () => { listeners.delete(listener) }
1319
+ },
1320
+ get() {
1321
+ return state
1322
+ },
1323
+ request(sessionId, displayTitle) {
1324
+ publish({ request: { sessionId, displayTitle } })
1325
+ },
1326
+ dismiss() {
1327
+ publish({ request: undefined })
1328
+ },
1329
+ }
1330
+ }
1331
+
1332
+ /**
1333
+ * A deletion happened somewhere outside the panel (the sidebar menu dialog).
1334
+ *
1335
+ * The panel's list is a scan snapshot taken on mount; without this signal the
1336
+ * rows of sessions deleted through the menu would linger in an open panel
1337
+ * until it is reopened. The panel subscribes on mount and re-scans.
1338
+ */
1339
+ function createDeletionSignal() {
1340
+ const listeners = new Set()
1341
+ return {
1342
+ subscribe(listener) {
1343
+ listeners.add(listener)
1344
+ return () => { listeners.delete(listener) }
1345
+ },
1346
+ emit() {
1347
+ for (const listener of [...listeners]) listener()
1348
+ },
1349
+ }
1350
+ }
1351
+
1352
+ module.exports = function createPlugin(require) {
1353
+ const React = require('react')
1354
+
1355
+ // Implicit baseline external of the module-loader lane: resolved through the
1356
+ // frozen platform module table like React, never bundled (see the
1357
+ // ui-workspace README's "动态客户端包" note). Absent only on stand-alone
1358
+ // stubs, where the plain-button fallback below keeps the row working.
1359
+ let MenuItemButton
1360
+ let IconTrashOutlineRegular
1361
+ let Modal
1362
+ let Button
1363
+ try {
1364
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
1365
+ MenuItemButton = primitives.MenuItemButton
1366
+ IconTrashOutlineRegular = primitives.IconTrashOutlineRegular
1367
+ Modal = primitives.Modal
1368
+ Button = primitives.Button
1369
+ } catch { /* optional in this composition */ }
1370
+
1371
+ /** One live dialog per activation; the overlay entry reads it. */
1372
+ const confirmStore = createConfirmStore()
1373
+
1374
+ /** Broadcasts deletions performed outside the panel (see createDeletionSignal). */
1375
+ const deletionSignal = createDeletionSignal()
1376
+
1377
+ /** Subscribe a component to the pending confirm request. */
1378
+ function useConfirmRequest() {
1379
+ const [state, setState] = React.useState(confirmStore.get)
1380
+ React.useEffect(() => confirmStore.subscribe(() => setState(confirmStore.get())), [])
1381
+ return state.request
1382
+ }
1383
+
1384
+ return {
1385
+ name: 'sessionCleanerClient',
1386
+ // The shell publishes `slots` only once its layout has mounted, and the
1387
+ // Remote face is a Cordis service the same way. Reading an undeclared
1388
+ // service on a Cordis context THROWS ("cannot get property "remote"
1389
+ // without inject"): acquire() used to swallow that rejection, resolve to
1390
+ // undefined forever, and leave the dialog stuck on "checking…". `typert`
1391
+ // is required in turn because $mount() registers the contribution through
1392
+ // the CALLER's context (`callerCtx.typert.remotes.register`).
1393
+ inject: ['slots', 'locale', 'remote', 'typert'],
1394
+ apply(ctx) {
1395
+ const t = (key, vars) => {
1396
+ const table = MESSAGES[detectLocale(ctx)] ?? MESSAGES.en
1397
+ const template = table[key] ?? MESSAGES.en[key] ?? key
1398
+ if (vars === undefined) return template
1399
+ return template.replace(/\{(\w+)\}/g, (_match, name) => String(vars[name] ?? ''))
1400
+ }
1401
+
1402
+ let mountPromise
1403
+ let servicePromise
1404
+ /** Human-readable reason from the most recent acquire failure, if any. */
1405
+ let acquireFailure
1406
+
1407
+ /**
1408
+ * The client's session-list snapshot, when this composition publishes
1409
+ * one. The sidebar renders its rows from that snapshot, which the shell
1410
+ * only re-pulls on connect — so after a deletion the rows would linger
1411
+ * until the next restart even though the files are gone. Dynamic
1412
+ * injection: a composition without the service simply keeps the
1413
+ * restart-based behavior.
1414
+ */
1415
+ let sessionList
1416
+ try {
1417
+ if (typeof ctx.inject === 'function') {
1418
+ ctx.inject(['sessions'], (scope) => { sessionList = scope.sessions })
1419
+ }
1420
+ } catch { /* optional service: nothing to refresh */ }
1421
+
1422
+ /**
1423
+ * Drop deleted sessions from the client snapshot and re-pull the
1424
+ * baseline. Ordering is load-bearing: `handleSessionRemoved` must run
1425
+ * AFTER the refresh settles. `refreshList()` swaps in a fresh mutation
1426
+ * array the moment it starts, so a removal recorded earlier — from the
1427
+ * forwarded `api-session/removed` event — is discarded when the pull
1428
+ * lands, and the Host's session/list keeps returning a deleted session
1429
+ * that is still attached live (cancelling its agent never detaches it;
1430
+ * only its creator's disposer can), so the merged baseline resurrects
1431
+ * the row into Ungrouped. Calling `handleSessionRemoved` after the
1432
+ * refresh removes the row now AND leaves the removal mutation in the
1433
+ * array the NEXT refresh replays against the baseline — one round of
1434
+ * protection for as long as the session stays attached.
1435
+ */
1436
+ const forgottenIds = new Set()
1437
+
1438
+ /** Remove one deleted session's row from the client snapshot, if the snapshot service is present. */
1439
+ const forgetSession = (id) => {
1440
+ try { sessionList?.handleSessionRemoved?.(id) } catch { /* snapshot-only repair */ }
1441
+ }
1442
+
1443
+ const forgetSessions = (sessionIds) => {
1444
+ const ids = Array.isArray(sessionIds) ? sessionIds.filter((id) => typeof id === 'string') : []
1445
+ for (const id of ids) forgottenIds.add(id)
1446
+ const forget = () => {
1447
+ for (const id of ids) forgetSession(id)
1448
+ }
1449
+ try {
1450
+ const pending = sessionList?.refresh?.()
1451
+ if (pending && typeof pending.then === 'function') pending.then(forget, forget)
1452
+ else forget()
1453
+ } catch {
1454
+ forget()
1455
+ }
1456
+ }
1457
+
1458
+ try {
1459
+ if (sessionList && typeof sessionList.subscribe === 'function' && typeof sessionList.getListSnapshot === 'function') {
1460
+ // Last line of defense against snapshot resurrections. Any baseline
1461
+ // rebuild (a reconnect's handleConnected, a refresh we did not
1462
+ // trigger) drops the mutation array and re-merges the Host list —
1463
+ // which still contains a deleted-but-attached session. Whenever the
1464
+ // snapshot changes, re-remove every known-deleted id it still
1465
+ // carries. Gating on actual presence keeps this convergent: the
1466
+ // removal drops the row, so the next pass has nothing to do.
1467
+ sessionList.subscribe(() => {
1468
+ if (forgottenIds.size === 0) return
1469
+ let snapshot
1470
+ try { snapshot = sessionList.getListSnapshot() } catch { return }
1471
+ const items = Array.isArray(snapshot?.items) ? snapshot.items : []
1472
+ if (items.length === 0) return
1473
+ for (const entry of items) {
1474
+ if (forgottenIds.has(entry?.sessionId)) forgetSession(entry.sessionId)
1475
+ }
1476
+ })
1477
+ }
1478
+ } catch { /* without a subscribable snapshot, the call-side forget is the only repair */ }
1479
+
1480
+ const readFailure = () => acquireFailure
1481
+
1482
+ /**
1483
+ * Mount the contract once, then resolve the Host namespace.
1484
+ *
1485
+ * Two independently cached steps, because they fail differently:
1486
+ *
1487
+ * - `$mount` registers the contribution under the package name in the
1488
+ * Typert Remote store, which REJECTS a second registration ("Remote
1489
+ * package … is already registered"). It must run at most once and its
1490
+ * success is cached forever; only a failed mount may be retried.
1491
+ * - The namespace itself is a Cordis service provided under the property
1492
+ * name `remote.<namespace>` (the gateway composes the key with
1493
+ * `remoteServiceKey()` and the Service base class registers the
1494
+ * instance via `ctx.provide(name, this)`). It is therefore read as
1495
+ * `scope['remote.sessionCleaner']` — NOT as `scope.remote.sessionCleaner`,
1496
+ * which would be a property lookup on the mount manager and always
1497
+ * `undefined`. A click can land before the namespace fiber has
1498
+ * published, so the inject waits inside a settle window; a window that
1499
+ * lapses may be re-entered by the next click (the mount stays cached).
1500
+ */
1501
+ const mount = async () => {
1502
+ try {
1503
+ // Reading `ctx.remote` without the service in `inject` throws; the
1504
+ // try keeps a mis-declared inject visible as a reported reason
1505
+ // instead of an uncaught rejection out of the dialog effect.
1506
+ const remote = ctx.remote
1507
+ if (remote === undefined || typeof remote.$mount !== 'function') {
1508
+ acquireFailure = 'ctx.remote is not available in this composition'
1509
+ return false
1510
+ }
1511
+ await remote.$mount(TYPERT_REMOTE)
1512
+ acquireFailure = undefined
1513
+ return true
1514
+ } catch (reason) {
1515
+ acquireFailure = String(reason?.message ?? reason)
1516
+ return false
1517
+ }
1518
+ }
1519
+
1520
+ const acquire = async () => {
1521
+ if (mountPromise === undefined) mountPromise = mount()
1522
+ const mounted = await mountPromise
1523
+ if (!mounted) {
1524
+ // The mount itself failed (or no remote yet): a later click retries it.
1525
+ mountPromise = undefined
1526
+ return undefined
1527
+ }
1528
+ if (servicePromise === undefined) {
1529
+ servicePromise = new Promise((resolve) => {
1530
+ let settled = false
1531
+ const timer = setTimeout(() => {
1532
+ if (settled) return
1533
+ settled = true
1534
+ resolve(undefined)
1535
+ }, 5000)
1536
+ try {
1537
+ ctx.inject(['remote.sessionCleaner'], (scope) => {
1538
+ if (settled) return
1539
+ settled = true
1540
+ clearTimeout(timer)
1541
+ resolve(scope['remote.sessionCleaner'])
1542
+ })
1543
+ } catch (reason) {
1544
+ settled = true
1545
+ clearTimeout(timer)
1546
+ acquireFailure = String(reason?.message ?? reason)
1547
+ resolve(undefined)
1548
+ }
1549
+ })
1550
+ }
1551
+ const service = await servicePromise
1552
+ if (service === undefined) {
1553
+ if (acquireFailure === undefined) {
1554
+ acquireFailure = 'the remote.sessionCleaner namespace did not publish within the settle window'
1555
+ }
1556
+ servicePromise = undefined
1557
+ return undefined
1558
+ }
1559
+ acquireFailure = undefined
1560
+ return service
1561
+ }
1562
+
1563
+ /**
1564
+ * The sidebar menu row. It only closes the menu and publishes the request:
1565
+ * it must not hold dialog state, because it unmounts as the menu closes.
1566
+ */
1567
+ function DeleteSessionRow(props) {
1568
+ const { sessionId, displayTitle } = props
1569
+ // Slot-level hook: it returns the menu's [open, setOpen] pair, so the
1570
+ // setter has to be destructured. Calling the pair itself threw a
1571
+ // TypeError before the request below ever ran — which is why the row
1572
+ // appeared but did nothing when clicked.
1573
+ const menuOpenState = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
1574
+ const setMenuOpen = Array.isArray(menuOpenState) && typeof menuOpenState[1] === 'function'
1575
+ ? menuOpenState[1]
1576
+ : () => {}
1577
+
1578
+ const onSelect = () => {
1579
+ setMenuOpen(false)
1580
+ confirmStore.request(sessionId, displayTitle)
1581
+ }
1582
+
1583
+ // The shipped rows render through the platform's MenuItemButton: same
1584
+ // typography, spacing, hover and keyboard walk as pin/rename/fork/
1585
+ // archive, with the leading icon slot every shipped row uses. The
1586
+ // default 16px Regular stroke matches the pin/rename/fork icons.
1587
+ if (MenuItemButton !== undefined && IconTrashOutlineRegular !== undefined) {
1588
+ return React.createElement(MenuItemButton, {
1589
+ icon: React.createElement(IconTrashOutlineRegular),
1590
+ onSelect,
1591
+ }, t('menu.delete'))
1592
+ }
1593
+
1594
+ return React.createElement('button', {
1595
+ type: 'button',
1596
+ role: 'menuitem',
1597
+ style: menuItemStyle,
1598
+ onClick: onSelect,
1599
+ }, t('menu.delete'))
1600
+ }
1601
+
1602
+ /**
1603
+ * The confirmation dialog, mounted once into `shell.overlay`. Rendering
1604
+ * nothing while no request is pending keeps the overlay list inert.
1605
+ */
1606
+ function ConfirmDialog() {
1607
+ const request = useConfirmRequest()
1608
+ const sessionId = request?.sessionId
1609
+ const displayTitle = request?.displayTitle
1610
+ const [preview, setPreview] = React.useState(undefined)
1611
+ const [busy, setBusy] = React.useState(false)
1612
+ const [error, setError] = React.useState(undefined)
1613
+
1614
+ React.useEffect(() => {
1615
+ if (sessionId === undefined) return undefined
1616
+ let cancelled = false
1617
+ setPreview(undefined)
1618
+ setError(undefined)
1619
+ setBusy(false)
1620
+ void (async () => {
1621
+ const service = await acquire()
1622
+ if (cancelled) return
1623
+ if (service === undefined) {
1624
+ const reason = readFailure()
1625
+ setError(reason === undefined ? t('service.missing') : t('service.mountFailed', { reason }))
1626
+ return
1627
+ }
1628
+ try {
1629
+ const value = unwrap(await service.preview([sessionId]))
1630
+ if (!cancelled) setPreview(value)
1631
+ } catch (reason) {
1632
+ if (!cancelled) setError(String(reason?.message ?? reason))
1633
+ }
1634
+ })()
1635
+ return () => { cancelled = true }
1636
+ }, [sessionId])
1637
+
1638
+ // A frame-wide list entry is always mounted: returning a hidden node
1639
+ // rather than null keeps the idle state from reading as "no entry".
1640
+ if (request === undefined) return React.createElement('span', { hidden: true })
1641
+
1642
+ const confirm = async () => {
1643
+ const service = await acquire()
1644
+ if (service === undefined) {
1645
+ const reason = readFailure()
1646
+ setError(reason === undefined ? t('service.missing') : t('service.mountFailed', { reason }))
1647
+ return
1648
+ }
1649
+ setBusy(true)
1650
+ setError(undefined)
1651
+ try {
1652
+ const result = unwrap(await service.delete([sessionId], {}))
1653
+ if (result?.ok === false) {
1654
+ setError(result.error ?? 'unknown failure')
1655
+ return
1656
+ }
1657
+ // The files are gone: drop the rows from the client snapshot (see
1658
+ // forgetSessions) and tell an open cleanup panel to re-scan, since
1659
+ // its list is a mount snapshot.
1660
+ forgetSessions([sessionId])
1661
+ deletionSignal.emit()
1662
+ confirmStore.dismiss()
1663
+ } catch (reason) {
1664
+ setError(String(reason?.message ?? reason))
1665
+ } finally {
1666
+ setBusy(false)
1667
+ }
1668
+ }
1669
+
1670
+ const body = []
1671
+ body.push(React.createElement('p', { key: 'body', style: bodyStyle }, t('dialog.body')))
1672
+ body.push(React.createElement('p', { key: 'warn', style: warnStyle }, t('dialog.warning')))
1673
+ // While an error is shown the loading line is dropped: "checking…"
1674
+ // next to a failure reads as if something were still in flight.
1675
+ if (preview === undefined && error === undefined) {
1676
+ body.push(React.createElement('p', { key: 'size', style: metaStyle }, t('dialog.loading')))
1677
+ } else if (preview !== undefined) {
1678
+ body.push(React.createElement('p', { key: 'size', style: metaStyle }, t('dialog.bytes', { size: preview.humanBytes })))
1679
+ if (preview.targetCount > 1) {
1680
+ body.push(React.createElement('p', { key: 'more', style: metaStyle }, t('dialog.more', { count: preview.targetCount })))
1681
+ }
1682
+ }
1683
+ if (error !== undefined) {
1684
+ body.push(React.createElement('p', { key: 'error', style: errorStyle }, t('dialog.failed', { reason: error })))
1685
+ }
1686
+
1687
+ // The shipped rename / stop-and-archive / risk dialogs all render
1688
+ // through the platform Modal + Button pair (the same implicit baseline
1689
+ // external as the menu row): backdrop blur, card, header with a close
1690
+ // affordance, footer actions and control metrics then match the host
1691
+ // dialogs exactly. The action pairing follows RiskConfirmation:
1692
+ // outline for cancel, primary for the confirm action.
1693
+ if (Modal !== undefined && Button !== undefined) {
1694
+ return React.createElement(Modal, {
1695
+ open: true,
1696
+ onClose: () => { if (!busy) confirmStore.dismiss() },
1697
+ title: t('dialog.title'),
1698
+ closeLabel: t('dialog.close'),
1699
+ description: displayTitle || sessionId,
1700
+ footer: React.createElement(React.Fragment, null,
1701
+ React.createElement(Button, {
1702
+ key: 'cancel',
1703
+ variant: 'outline',
1704
+ disabled: busy,
1705
+ onClick: () => confirmStore.dismiss(),
1706
+ }, t('dialog.cancel')),
1707
+ React.createElement(Button, {
1708
+ key: 'confirm',
1709
+ variant: 'primary',
1710
+ disabled: busy || preview === undefined,
1711
+ onClick: confirm,
1712
+ }, busy ? t('dialog.deleting') : t('dialog.confirm'))),
1713
+ }, body)
1714
+ }
1715
+
1716
+ // Stand-alone fallback (no primitives lane): the hand-rolled card.
1717
+ const rows = []
1718
+ rows.push(React.createElement('h2', { key: 'title', style: titleStyle }, t('dialog.title')))
1719
+ rows.push(React.createElement('p', { key: 'subject', style: subjectStyle }, displayTitle || sessionId))
1720
+ rows.push(...body)
1721
+ rows.push(React.createElement('div', { key: 'actions', style: actionsStyle }, [
1722
+ React.createElement('button', {
1723
+ key: 'cancel',
1724
+ type: 'button',
1725
+ disabled: busy,
1726
+ onClick: () => confirmStore.dismiss(),
1727
+ style: buttonStyle,
1728
+ }, t('dialog.cancel')),
1729
+ React.createElement('button', {
1730
+ key: 'confirm',
1731
+ type: 'button',
1732
+ disabled: busy || preview === undefined,
1733
+ onClick: confirm,
1734
+ style: dangerButtonStyle,
1735
+ }, busy ? t('dialog.deleting') : t('dialog.confirm')),
1736
+ ]))
1737
+
1738
+ return React.createElement('div', {
1739
+ role: 'dialog',
1740
+ 'aria-modal': 'true',
1741
+ 'aria-label': t('dialog.title'),
1742
+ style: overlayStyle,
1743
+ onClick: (event) => { if (event.target === event.currentTarget && !busy) confirmStore.dismiss() },
1744
+ }, React.createElement('div', { style: panelStyle }, rows))
1745
+ }
1746
+
1747
+ try {
1748
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
1749
+ name: 'sidebar.workspaces.session.menu.item',
1750
+ id: 'session-cleaner.delete',
1751
+ order: 500,
1752
+ }, DeleteSessionRow))
1753
+ } catch { /* the slot is optional: the Host half stays usable without it */ }
1754
+
1755
+ try {
1756
+ // Frame-wide, so the dialog survives the menu that opened it.
1757
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
1758
+ name: 'shell.overlay',
1759
+ id: 'session-cleaner.confirm',
1760
+ order: 90,
1761
+ }, ConfirmDialog))
1762
+ } catch { /* no overlay host in this composition: deletion stays available through the panel page */ }
1763
+
1764
+ /**
1765
+ * The sidebar glyph for the panel entry. The sidebar shell renders the
1766
+ * row itself — button, tooltip, label, selected state — and hands the
1767
+ * entry `{ size, active }`. The same trash glyph the menu row uses
1768
+ * keeps the two entries recognizable as one feature.
1769
+ */
1770
+ function PanelIcon({ size }) {
1771
+ if (IconTrashOutlineRegular === undefined) return null
1772
+ return React.createElement(IconTrashOutlineRegular, { size })
1773
+ }
1774
+
1775
+ try {
1776
+ // The page itself: a `main` keyed-slot entry under the panel id, next
1777
+ // to the shipped Plugins and Automation Tasks pages. `selectPanel(id)`
1778
+ // dispatches on this key, so a missing entry would make the sidebar
1779
+ // row throw on click — the two registrations below are one unit.
1780
+ const page = createPanelPage(React, t, acquire, readFailure, {
1781
+ Modal,
1782
+ Button,
1783
+ afterDelete: forgetSessions,
1784
+ deletionSignal,
1785
+ })
1786
+ ctx.slots.inject('main', () => ctx.slots.register({
1787
+ name: 'main',
1788
+ key: PANEL_ID,
1789
+ }, page))
1790
+ } catch { /* the layout panel seat is optional in this composition */ }
1791
+
1792
+ try {
1793
+ // The sidebar entry, at the same level as Plugins (order 0) and
1794
+ // Automation Tasks (order 10). A `label` thunk is re-read on every
1795
+ // projection, so the row text follows a live locale change without
1796
+ // re-registering.
1797
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
1798
+ name: 'sidebar.panellist',
1799
+ id: PANEL_ID,
1800
+ order: 20,
1801
+ label: () => t('panel.nav'),
1802
+ }, PanelIcon))
1803
+ } catch { /* without the sidebar list the page stays reachable through the menu rows */ }
1804
+ },
1805
+ }
1806
+ }
1807
+
1808
+ const overlayStyle = Object.freeze({
1809
+ position: 'fixed', inset: '0', zIndex: 1000, display: 'flex',
1810
+ alignItems: 'center', justifyContent: 'center',
1811
+ // An overlay host usually disables hit-testing on its container so it cannot
1812
+ // block the app beneath it; the dialog has to opt back in.
1813
+ pointerEvents: 'auto',
1814
+ background: 'light-dark(rgba(15,17,21,.32), rgba(0,0,0,.5))',
1815
+ })
1816
+ const panelStyle = Object.freeze({
1817
+ minWidth: '320px', maxWidth: '440px', padding: '20px', borderRadius: '16px',
1818
+ background: 'var(--dsw-alias-bg-module-platform, light-dark(#ffffff, #353638))',
1819
+ color: 'var(--dsw-alias-label-primary, light-dark(#0f1115, #f9fafb))',
1820
+ boxShadow: '0 8px 32px rgba(0,0,0,.24)', font: 'inherit',
1821
+ })
1822
+ const titleStyle = Object.freeze({ margin: '0 0 8px', fontSize: '15px', fontWeight: 600 })
1823
+ const subjectStyle = Object.freeze({ margin: '0 0 12px', fontSize: '13px', opacity: 0.75, wordBreak: 'break-all' })
1824
+ const bodyStyle = Object.freeze({ margin: '0 0 8px', fontSize: '13px', lineHeight: '20px' })
1825
+ const warnStyle = Object.freeze({ margin: '0 0 8px', fontSize: '13px', lineHeight: '20px', color: 'light-dark(#b42318, #ff9c94)' })
1826
+ const metaStyle = Object.freeze({ margin: '0 0 8px', fontSize: '12px', opacity: 0.7 })
1827
+ const errorStyle = Object.freeze({ margin: '0 0 8px', fontSize: '12px', color: 'light-dark(#b42318, #ff9c94)', wordBreak: 'break-word' })
1828
+ const actionsStyle = Object.freeze({ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '16px' })
1829
+ const buttonStyle = Object.freeze({
1830
+ padding: '6px 14px', borderRadius: '8px', cursor: 'pointer', font: 'inherit',
1831
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
1832
+ background: 'transparent', color: 'inherit',
1833
+ })
1834
+ const dangerButtonStyle = Object.freeze({
1835
+ ...buttonStyle, border: 'none', background: 'light-dark(#d92d20, #f04438)', color: '#ffffff',
1836
+ })
1837
+ const menuItemStyle = Object.freeze({
1838
+ display: 'block', width: '100%', padding: '6px 12px', textAlign: 'left',
1839
+ border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit',
1840
+ })
1841
+
1842
+ return module.exports;
1843
+ })();
1844
+ return __modules["client/client.js"](require);
1845
+ }});