dsh-plugin-session-cleaner 0.0.0-stage → 0.2.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/lib/client.js ADDED
@@ -0,0 +1,1675 @@
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
+
418
+ return function SessionCleanerPage() {
419
+ const [state, setState] = useState({ phase: 'loading' })
420
+ const [query, setQuery] = useState('')
421
+ const [selected, setSelected] = useState(() => new Set())
422
+ const [busy, setBusy] = useState(false)
423
+ const [notice, setNotice] = useState(undefined)
424
+ // Workspace groups and parent rows the user has expanded; both default to
425
+ // collapsed, and a text filter overrides them (matches must be visible).
426
+ const [openWorkspaces, setOpenWorkspaces] = useState(() => new Set())
427
+ const [openSessions, setOpenSessions] = useState(() => new Set())
428
+ // The pending conversation preview: { phase: 'loading'|'ready'|'error',
429
+ // sessionId, data?, error? } — undefined while no dialog is open.
430
+ const [preview, setPreview] = useState(undefined)
431
+ const previewSeq = useRef(0)
432
+ // The preview dialog's user-chosen size; undefined = the responsive
433
+ // default. The state lives in the page (which outlives the dialog), so a
434
+ // chosen size survives closing and reopening.
435
+ const [previewSize, setPreviewSize] = useState(undefined)
436
+ const previewPanelRef = useRef(null)
437
+
438
+ const load = async () => {
439
+ setState({ phase: 'loading' })
440
+ setNotice(undefined)
441
+ const service = await acquire()
442
+ if (service === undefined) {
443
+ const reason = readFailure?.()
444
+ setState({ phase: 'error', error: reason === undefined ? t('panel.serviceMissing') : t('service.mountFailed', { reason }) })
445
+ return
446
+ }
447
+ try {
448
+ const data = unwrap(await service.list())
449
+ setState({ phase: 'ready', sessions: data?.sessions ?? [], dshHome: data?.dshHome })
450
+ setSelected(new Set())
451
+ } catch (reason) {
452
+ setState({ phase: 'error', error: String(reason?.message ?? reason) })
453
+ }
454
+ }
455
+
456
+ useEffect(() => { load() }, [])
457
+
458
+ // Escape closes the preview, matching the platform Modal's behaviour.
459
+ useEffect(() => {
460
+ if (preview === undefined || typeof document?.addEventListener !== 'function') return undefined
461
+ const onKeyDown = (event) => { if (event.key === 'Escape') closePreview() }
462
+ document.addEventListener('keydown', onKeyDown)
463
+ return () => { document.removeEventListener('keydown', onKeyDown) }
464
+ }, [preview === undefined])
465
+
466
+ const sessions = state.phase === 'ready' ? state.sessions : []
467
+ const filtering = query.trim() !== ''
468
+ const groups = useMemo(() => buildWorkspaceGroups(sessions), [sessions])
469
+ const visibleGroups = useMemo(() => {
470
+ const needle = query.trim().toLowerCase()
471
+ if (needle === '') return groups
472
+ return groups
473
+ .map((group) => filterGroup(group, needle))
474
+ .filter((group) => group !== undefined)
475
+ }, [groups, query])
476
+ const visibleSessions = useMemo(
477
+ () => visibleGroups.flatMap((group) => collectSessions(group.roots)),
478
+ [visibleGroups],
479
+ )
480
+
481
+ const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => selected.has(session.id))
482
+ const selectedSessions = sessions.filter((session) => selected.has(session.id))
483
+ const selectedBytes = selectedSessions.reduce((sum, session) => sum + session.bytes, 0)
484
+
485
+ const toggle = (id) => {
486
+ setSelected(toggleWithDescendants(sessions, selected, id))
487
+ }
488
+ const toggleAll = () => {
489
+ if (allVisibleSelected) {
490
+ setSelected(new Set())
491
+ return
492
+ }
493
+ // Select-each-visible carries each one's descendants in, including
494
+ // descendants a text filter currently hides from the list.
495
+ setSelected(setSelection(sessions, selected, visibleSessions.map((session) => session.id), true))
496
+ }
497
+ /** The group header checkbox: drive every session under the group toward one state. */
498
+ const toggleGroup = (groupSessions) => {
499
+ const wanted = !(groupSessions.length > 0 && groupSessions.every((session) => selected.has(session.id)))
500
+ setSelected(setSelection(sessions, selected, groupSessions.map((session) => session.id), wanted))
501
+ }
502
+
503
+ const remove = async () => {
504
+ const ids = [...selected]
505
+ if (ids.length === 0) return
506
+ setBusy(true)
507
+ setNotice(undefined)
508
+ const service = await acquire()
509
+ if (service === undefined) {
510
+ setBusy(false)
511
+ const reason = readFailure?.()
512
+ setNotice({ kind: 'error', text: reason === undefined ? t('panel.serviceMissing') : t('service.mountFailed', { reason }) })
513
+ return
514
+ }
515
+ try {
516
+ const result = unwrap(await service.delete(ids, {}))
517
+ if (result?.ok === false) {
518
+ setNotice({ kind: 'error', text: t('panel.failed', { reason: result.error ?? 'unknown' }) })
519
+ } else {
520
+ setNotice({
521
+ kind: 'done',
522
+ text: t('panel.deleted', {
523
+ count: result?.summary?.sessionsDeleted ?? 0,
524
+ size: formatBytes(result?.summary?.bytesDeleted ?? 0),
525
+ }),
526
+ })
527
+ }
528
+ await load()
529
+ } catch (reason) {
530
+ setNotice({ kind: 'error', text: t('panel.failed', { reason: String(reason?.message ?? reason) }) })
531
+ } finally {
532
+ setBusy(false)
533
+ }
534
+ }
535
+
536
+ /** Flip one membership in an expansion Set (workspaces, parent rows). */
537
+ const toggleSet = (setter, value) => setter((previous) => {
538
+ const next = new Set(previous)
539
+ if (next.has(value)) next.delete(value)
540
+ else next.add(value)
541
+ return next
542
+ })
543
+
544
+ /**
545
+ * Open the read-only transcript dialog for a session. Only the most
546
+ * recent request may settle: a fast double-click on two different rows
547
+ * would otherwise race, and the older read could overwrite the newer
548
+ * dialog after it opened.
549
+ */
550
+ const openPreview = async (sessionId) => {
551
+ const requestId = previewSeq.current + 1
552
+ previewSeq.current = requestId
553
+ setPreview({ phase: 'loading', sessionId })
554
+ const service = await acquire()
555
+ if (previewSeq.current !== requestId) return
556
+ if (service === undefined) {
557
+ const reason = readFailure?.()
558
+ setPreview({ phase: 'error', sessionId, error: reason === undefined ? t('panel.serviceMissing') : t('service.mountFailed', { reason }) })
559
+ return
560
+ }
561
+ try {
562
+ const data = unwrap(await service.read(sessionId, {}))
563
+ if (previewSeq.current !== requestId) return
564
+ if (data?.ok === false) {
565
+ setPreview({ phase: 'error', sessionId, error: t('preview.failed', { reason: data.error ?? 'unknown' }) })
566
+ } else {
567
+ setPreview({ phase: 'ready', sessionId, data })
568
+ }
569
+ } catch (reason) {
570
+ if (previewSeq.current !== requestId) return
571
+ setPreview({ phase: 'error', sessionId, error: t('preview.failed', { reason: String(reason?.message ?? reason) }) })
572
+ }
573
+ }
574
+
575
+ const closePreview = () => {
576
+ previewSeq.current += 1
577
+ setPreview(undefined)
578
+ }
579
+
580
+ /**
581
+ * Drag-resize the preview dialog from its corner grip: pointer deltas
582
+ * become an explicit size, clamped to the viewport. A double-click on the
583
+ * grip returns to the responsive default.
584
+ */
585
+ const startPreviewResize = (event) => {
586
+ event.preventDefault?.()
587
+ event.stopPropagation?.()
588
+ const panel = previewPanelRef.current
589
+ if (panel == null || typeof window?.addEventListener !== 'function') return
590
+ const startX = event.clientX
591
+ const startY = event.clientY
592
+ const startWidth = panel.offsetWidth
593
+ const startHeight = panel.offsetHeight
594
+ const onMove = (moveEvent) => {
595
+ const width = Math.round(Math.min(Math.max(480, startWidth + moveEvent.clientX - startX), window.innerWidth - 48))
596
+ const height = Math.round(Math.min(Math.max(320, startHeight + moveEvent.clientY - startY), window.innerHeight - 48))
597
+ setPreviewSize({ width, height })
598
+ }
599
+ const onUp = () => {
600
+ window.removeEventListener('pointermove', onMove)
601
+ window.removeEventListener('pointerup', onUp)
602
+ }
603
+ window.addEventListener('pointermove', onMove)
604
+ window.addEventListener('pointerup', onUp)
605
+ }
606
+
607
+ /**
608
+ * The transcript dialog: a wide, user-resizable card.
609
+ *
610
+ * The platform Modal is fixed at min(380px, 100%) wide with no size
611
+ * override, so a transcript — the one dialog that wants screen real
612
+ * estate — renders through this own card instead, dressed in the same
613
+ * design aliases (layer-2 fill, prominent elevation, blurred mask) to
614
+ * stay indistinguishable from the shipped dialogs. The page is a
615
+ * long-lived `main` slot component, so the dialog can live in its tree
616
+ * directly — unlike the session-menu rows, whose dialogs must ride the
617
+ * frame-wide `shell.overlay` because the row unmounts with its menu.
618
+ */
619
+ const renderPreview = () => {
620
+ if (preview === undefined) return null
621
+ const data = preview.phase === 'ready' ? preview.data : undefined
622
+
623
+ const body = []
624
+ // The id sits directly under the title in every phase: the dialog's
625
+ // subject stays identifiable even while the transcript is loading.
626
+ body.push(React.createElement('p', { key: 'sessionid', style: previewIdStyle }, [
627
+ React.createElement('span', { key: 'label', style: previewIdLabelStyle }, `${t('preview.sessionId')}: `),
628
+ preview.sessionId,
629
+ ]))
630
+ const descendants = collectDescendants(sessions, preview.sessionId)
631
+ if (descendants.length > 0) {
632
+ body.push(React.createElement('div', { key: 'children', style: previewChildrenStyle }, [
633
+ React.createElement('p', { key: 'head', style: previewChildrenHeadStyle },
634
+ t('preview.children', { count: descendants.length })),
635
+ ...descendants.map((session) => React.createElement('div', { key: session.id, style: previewChildRowStyle }, [
636
+ React.createElement('span', { key: 'badge', style: childBadgeStyle }, t('panel.child')),
637
+ React.createElement('span', {
638
+ key: 'id',
639
+ style: previewChildIdStyle,
640
+ title: session.title ?? session.id,
641
+ }, session.id),
642
+ ])),
643
+ ]))
644
+ }
645
+ if (data !== undefined) {
646
+ // The workspace arrives as its encoded key; the cwd is the same place
647
+ // in human form, so only size and cwd are shown.
648
+ const meta = [data.humanBytes, data.cwd]
649
+ .filter((part) => typeof part === 'string' && part !== '')
650
+ if (meta.length > 0) {
651
+ body.push(React.createElement('p', { key: 'meta', style: previewMetaStyle }, meta.join(' · ')))
652
+ }
653
+ if (data.truncated === true) {
654
+ body.push(React.createElement('p', { key: 'truncated', style: hintStyle },
655
+ t('preview.truncated', { shown: data.messages.length, total: data.totalMessages })))
656
+ }
657
+ body.push(React.createElement('div', { key: 'messages', style: messageListStyle },
658
+ data.messages.length === 0
659
+ ? React.createElement('p', { key: 'empty', style: hintStyle }, t('preview.empty'))
660
+ : renderMessages(React, t, data.messages)))
661
+ } else if (preview.phase === 'loading') {
662
+ body.push(React.createElement('p', { key: 'loading', style: hintStyle }, t('preview.loading')))
663
+ } else {
664
+ body.push(React.createElement('p', { key: 'error', style: errorStyle }, preview.error))
665
+ }
666
+
667
+ const panelStyle = previewSize === undefined
668
+ ? previewPanelStyle
669
+ : { ...previewPanelStyle, width: `${previewSize.width}px`, height: `${previewSize.height}px` }
670
+
671
+ return React.createElement('div', { style: previewOverlayStyle }, [
672
+ React.createElement('div', {
673
+ key: 'mask',
674
+ style: previewMaskStyle,
675
+ onClick: () => { closePreview() },
676
+ }),
677
+ React.createElement('div', {
678
+ key: 'panel',
679
+ ref: previewPanelRef,
680
+ role: 'dialog',
681
+ 'aria-modal': 'true',
682
+ 'aria-label': t('preview.title'),
683
+ style: panelStyle,
684
+ }, [
685
+ React.createElement('div', { key: 'header', style: previewHeaderStyle }, [
686
+ React.createElement('h2', { key: 'title', style: previewHeadingStyle }, t('preview.title')),
687
+ React.createElement('button', {
688
+ key: 'close',
689
+ type: 'button',
690
+ style: previewCloseStyle,
691
+ 'aria-label': t('dialog.close'),
692
+ onClick: closePreview,
693
+ }, '×'),
694
+ ]),
695
+ React.createElement('p', { key: 'subject', style: previewSubjectStyle }, data?.title || preview.sessionId),
696
+ React.createElement('div', { key: 'body', style: previewBodyStyle }, body),
697
+ React.createElement('div', { key: 'footer', style: previewFooterStyle },
698
+ React.createElement('button', { key: 'close', type: 'button', onClick: closePreview, style: buttonStyle }, t('dialog.close'))),
699
+ React.createElement('div', {
700
+ key: 'resize',
701
+ style: resizeGripStyle,
702
+ 'aria-label': t('preview.resize'),
703
+ title: t('preview.resize'),
704
+ onPointerDown: startPreviewResize,
705
+ onDoubleClick: (event) => {
706
+ event.stopPropagation()
707
+ setPreviewSize(undefined)
708
+ },
709
+ }, '◢'),
710
+ ]),
711
+ ])
712
+ }
713
+
714
+ /** One workspace group card: header row, then its session forest when open. */
715
+ const renderGroup = (group) => {
716
+ const open = filtering || openWorkspaces.has(group.key)
717
+ const groupSessions = collectSessions(group.roots)
718
+ const allSelected = groupSessions.length > 0 && groupSessions.every((session) => selected.has(session.id))
719
+ const someSelected = groupSessions.some((session) => selected.has(session.id))
720
+
721
+ const rows = [
722
+ React.createElement('div', {
723
+ key: 'header',
724
+ style: groupHeaderStyle,
725
+ role: 'group',
726
+ 'aria-label': group.name,
727
+ onClick: () => { toggleSet(setOpenWorkspaces, group.key) },
728
+ }, [
729
+ React.createElement('button', {
730
+ key: 'expander',
731
+ type: 'button',
732
+ style: expanderStyle,
733
+ 'aria-expanded': open,
734
+ 'aria-label': t(open ? 'panel.collapse' : 'panel.expand'),
735
+ onClick: (event) => {
736
+ event.stopPropagation()
737
+ toggleSet(setOpenWorkspaces, group.key)
738
+ },
739
+ onDoubleClick: (event) => event.stopPropagation(),
740
+ }, open ? '▼' : '▶'),
741
+ React.createElement('input', {
742
+ key: 'check',
743
+ type: 'checkbox',
744
+ checked: allSelected,
745
+ // A partial selection reads as indeterminate; React has no prop
746
+ // for it, so the ref callback owns that DOM state.
747
+ ref: (element) => { if (element != null) element.indeterminate = someSelected && !allSelected },
748
+ onClick: (event) => event.stopPropagation(),
749
+ onChange: () => toggleGroup(groupSessions),
750
+ }),
751
+ React.createElement('span', {
752
+ key: 'name',
753
+ style: groupNameStyle,
754
+ title: group.path ?? group.key,
755
+ }, group.name),
756
+ React.createElement('span', { key: 'path', style: groupPathStyle }, group.path ?? ''),
757
+ React.createElement('span', { key: 'stats', style: groupStatsStyle },
758
+ t(group.childCount > 0 ? 'panel.groupStatsChildren' : 'panel.groupStats',
759
+ { count: group.count, children: group.childCount, size: formatBytes(group.bytes) })),
760
+ ]),
761
+ ]
762
+ if (open) {
763
+ for (const node of group.roots) rows.push(...renderNode(node, 0))
764
+ }
765
+ return React.createElement('div', { key: group.key, style: listStyle }, rows)
766
+ }
767
+
768
+ /** One session row (a checkbox label) plus, when expanded, its subagents. */
769
+ const renderNode = (node, depth) => {
770
+ const { session } = node
771
+ const hasChildren = node.children.length > 0
772
+ const open = filtering || openSessions.has(session.id)
773
+ const label = session.title ?? session.id
774
+
775
+ return [
776
+ React.createElement('label', {
777
+ key: session.id,
778
+ style: { ...rowStyle, paddingLeft: `${10 + depth * 20}px` },
779
+ // A double-click toggles the checkbox twice on the way in (net no
780
+ // change) and then opens the preview.
781
+ onDoubleClick: () => { void openPreview(session.id) },
782
+ }, [
783
+ // The caret column leads so the checkboxes of the group header, of
784
+ // parent rows and of leaf rows all share one vertical line.
785
+ hasChildren
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
+ // Inside the row label: without these the click would reach the
793
+ // label's checkbox instead of the caret.
794
+ onClick: (event) => {
795
+ event.preventDefault()
796
+ event.stopPropagation()
797
+ toggleSet(setOpenSessions, session.id)
798
+ },
799
+ onDoubleClick: (event) => event.stopPropagation(),
800
+ }, open ? '▼' : '▶')
801
+ : React.createElement('span', { key: 'spacer', style: expanderSpacerStyle }),
802
+ React.createElement('input', {
803
+ key: 'check',
804
+ type: 'checkbox',
805
+ checked: selected.has(session.id),
806
+ onChange: () => toggle(session.id),
807
+ // Inside the row label: rapid check/uncheck clicks end in a
808
+ // dblclick that would bubble to the label's preview handler —
809
+ // the checkbox's only duty is to check and uncheck.
810
+ onDoubleClick: (event) => event.stopPropagation(),
811
+ }),
812
+ React.createElement('span', {
813
+ key: 'title',
814
+ // An untitled session falls back to its id, dimmed.
815
+ style: session.title === undefined ? { ...titleStyle, opacity: 0.55 } : titleStyle,
816
+ title: label,
817
+ }, label),
818
+ session.parent === undefined ? null : React.createElement('span', { key: 'child', style: childBadgeStyle }, t('panel.child')),
819
+ hasChildren ? React.createElement('span', { key: 'count', style: childBadgeStyle }, t('panel.childCount', { count: node.children.length })) : null,
820
+ session.createdAt === undefined ? null : React.createElement('span', { key: 'day', style: metaStyle }, formatDay(session.createdAt)),
821
+ React.createElement('span', { key: 'size', style: sizeStyle }, session.humanBytes),
822
+ React.createElement('button', {
823
+ key: 'preview',
824
+ type: 'button',
825
+ style: previewButtonStyle,
826
+ // Inside the row label: without these the click would reach the
827
+ // label's checkbox instead of the preview.
828
+ onClick: (event) => {
829
+ event.preventDefault()
830
+ event.stopPropagation()
831
+ void openPreview(session.id)
832
+ },
833
+ }, t('panel.preview')),
834
+ ]),
835
+ ...(hasChildren && open ? node.children.flatMap((child) => renderNode(child, depth + 1)) : []),
836
+ ]
837
+ }
838
+
839
+ const body = []
840
+ body.push(React.createElement('p', { key: 'hint', style: hintStyle }, t('panel.hint')))
841
+
842
+ if (state.phase === 'loading') {
843
+ body.push(React.createElement('p', { key: 'loading', style: hintStyle }, t('panel.loading')))
844
+ } else if (state.phase === 'error') {
845
+ body.push(React.createElement('p', { key: 'error', style: errorStyle }, state.error))
846
+ body.push(React.createElement('button', {
847
+ key: 'retry', type: 'button', onClick: load, style: buttonStyle,
848
+ }, t('panel.retry')))
849
+ } else {
850
+ body.push(React.createElement('div', { key: 'toolbar', style: toolbarStyle }, [
851
+ React.createElement('input', {
852
+ key: 'query',
853
+ type: 'search',
854
+ value: query,
855
+ placeholder: t('panel.search'),
856
+ onChange: (event) => setQuery(event.target.value),
857
+ style: inputStyle,
858
+ }),
859
+ React.createElement('button', {
860
+ key: 'toggle', type: 'button', onClick: toggleAll, style: buttonStyle,
861
+ }, allVisibleSelected ? t('panel.clear') : t('panel.selectAll')),
862
+ React.createElement('button', {
863
+ key: 'delete',
864
+ type: 'button',
865
+ disabled: busy || selected.size === 0,
866
+ onClick: remove,
867
+ style: selected.size === 0 ? disabledDangerStyle : dangerButtonStyle,
868
+ }, busy
869
+ ? t('panel.deleting')
870
+ : t('panel.delete', { count: selected.size, size: formatBytes(selectedBytes) })),
871
+ ]))
872
+
873
+ if (notice !== undefined) {
874
+ body.push(React.createElement('p', {
875
+ key: 'notice',
876
+ style: notice.kind === 'error' ? errorStyle : doneStyle,
877
+ }, notice.text))
878
+ }
879
+
880
+ if (visibleGroups.length === 0) {
881
+ body.push(React.createElement('p', { key: 'empty', style: hintStyle }, t('panel.empty')))
882
+ } else {
883
+ body.push(React.createElement('div', { key: 'groups', style: groupsStyle },
884
+ visibleGroups.map((group) => renderGroup(group))))
885
+ }
886
+
887
+ body.push(React.createElement('p', { key: 'root', style: hintStyle }, t('panel.root', { path: state.dshHome ?? '' })))
888
+ }
889
+
890
+ return React.createElement('section', { style: pageStyle, 'aria-label': t('panel.title') },
891
+ React.createElement('div', { style: scrollStyle },
892
+ React.createElement('div', { style: contentStyle }, [
893
+ React.createElement('h1', { key: 'title', style: headingStyle }, t('panel.title')),
894
+ ...body,
895
+ ])),
896
+ renderPreview())
897
+ }
898
+ }
899
+
900
+ const pageStyle = Object.freeze({
901
+ width: '100%', minWidth: 0, height: '100%', minHeight: 0,
902
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
903
+ background: 'var(--dsw-alias-bg-base, transparent)',
904
+ color: 'var(--dsw-alias-label-primary, inherit)',
905
+ fontSize: '14px', lineHeight: '1.6',
906
+ })
907
+ const scrollStyle = Object.freeze({ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', scrollbarGutter: 'stable' })
908
+ const contentStyle = Object.freeze({
909
+ maxWidth: '960px', margin: '0 auto',
910
+ padding: '0 clamp(24px, 4vw, 48px) 48px',
911
+ display: 'flex', flexDirection: 'column', gap: '10px',
912
+ })
913
+ const headingStyle = Object.freeze({
914
+ margin: '0',
915
+ fontSize: '20px', fontWeight: 500, lineHeight: '28px',
916
+ paddingTop: isDarwin ? 'calc(28px + var(--dsh-frame-top-clearance, 0px))' : '28px',
917
+ })
918
+ const hintStyle = Object.freeze({ margin: '0', fontSize: '12px', opacity: 0.7, lineHeight: '18px' })
919
+ const errorStyle = Object.freeze({ margin: '0', fontSize: '12px', color: 'light-dark(#b42318, #ff9c94)', wordBreak: 'break-word' })
920
+ const doneStyle = Object.freeze({ margin: '0', fontSize: '12px', color: 'light-dark(#067647, #75e0a7)' })
921
+ const toolbarStyle = Object.freeze({ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' })
922
+ const inputStyle = Object.freeze({
923
+ flex: '1 1 180px', minWidth: '140px', padding: '6px 10px', borderRadius: '8px', font: 'inherit',
924
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
925
+ background: 'transparent', color: 'inherit',
926
+ })
927
+ const buttonStyle = Object.freeze({
928
+ padding: '6px 12px', borderRadius: '8px', cursor: 'pointer', font: 'inherit', whiteSpace: 'nowrap',
929
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
930
+ background: 'transparent', color: 'inherit',
931
+ })
932
+ const dangerButtonStyle = Object.freeze({
933
+ ...buttonStyle, border: 'none', background: 'light-dark(#d92d20, #f04438)', color: '#ffffff',
934
+ })
935
+ const disabledDangerStyle = Object.freeze({
936
+ ...dangerButtonStyle, opacity: 0.45, cursor: 'not-allowed',
937
+ })
938
+ const groupsStyle = Object.freeze({ display: 'flex', flexDirection: 'column', gap: '12px' })
939
+ const listStyle = Object.freeze({
940
+ display: 'flex', flexDirection: 'column',
941
+ border: '1px solid var(--dsw-alias-border-l2, light-dark(rgba(0,0,0,.08), rgba(255,255,255,.12)))',
942
+ borderRadius: '10px',
943
+ })
944
+ const groupHeaderStyle = Object.freeze({
945
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '8px 10px', cursor: 'pointer', userSelect: 'none',
946
+ background: 'var(--dsw-alias-interactive-bg-hover, light-dark(rgba(38,49,72,.05), rgba(255,255,255,.06)))',
947
+ borderBottom: '1px solid var(--dsw-alias-border-l1, light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)))',
948
+ })
949
+ const groupNameStyle = Object.freeze({
950
+ fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
951
+ maxWidth: '45%', overflow: 'hidden', textOverflow: 'ellipsis',
952
+ })
953
+ const groupPathStyle = Object.freeze({
954
+ flex: '1 1 auto', minWidth: 0, fontSize: '11px', opacity: 0.55,
955
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
956
+ })
957
+ const groupStatsStyle = Object.freeze({
958
+ marginLeft: 'auto', fontSize: '11px', opacity: 0.65, whiteSpace: 'nowrap',
959
+ fontVariantNumeric: 'tabular-nums',
960
+ })
961
+ const rowStyle = Object.freeze({
962
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 10px', cursor: 'pointer',
963
+ userSelect: 'none',
964
+ borderBottom: '1px solid var(--dsw-alias-border-l1, light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)))',
965
+ })
966
+ const titleStyle = Object.freeze({ flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' })
967
+ const metaStyle = Object.freeze({ fontSize: '11px', opacity: 0.6, whiteSpace: 'nowrap' })
968
+ const expanderStyle = Object.freeze({
969
+ flexShrink: 0, width: '20px', height: '20px', padding: 0,
970
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
971
+ fontSize: '10px', lineHeight: 1, opacity: 0.8,
972
+ cursor: 'pointer', border: 'none', background: 'transparent', color: 'inherit',
973
+ })
974
+ const expanderSpacerStyle = Object.freeze({ flexShrink: 0, width: '20px' })
975
+ const childBadgeStyle = Object.freeze({
976
+ fontSize: '10px', padding: '1px 6px', borderRadius: '999px', whiteSpace: 'nowrap',
977
+ background: 'var(--dsw-alias-interactive-bg-hover, light-dark(rgba(38,49,72,.06), rgba(255,255,255,.08)))',
978
+ })
979
+ const sizeStyle = Object.freeze({ fontSize: '11px', opacity: 0.7, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' })
980
+ const previewButtonStyle = Object.freeze({
981
+ flexShrink: 0, padding: '2px 10px', borderRadius: '6px', font: 'inherit', fontSize: '11px',
982
+ whiteSpace: 'nowrap', cursor: 'pointer',
983
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
984
+ background: 'transparent', color: 'inherit',
985
+ })
986
+ const previewOverlayStyle = Object.freeze({
987
+ position: 'fixed', inset: '0', zIndex: 1000, display: 'flex',
988
+ alignItems: 'center', justifyContent: 'center', padding: '24px',
989
+ // An overlay host usually disables hit-testing on its container so it cannot
990
+ // block the app beneath it; the dialog has to opt back in.
991
+ pointerEvents: 'auto',
992
+ })
993
+ const previewMaskStyle = Object.freeze({
994
+ position: 'absolute', inset: '0',
995
+ background: 'var(--dsw-alias-bg-mask-1, light-dark(rgba(15,17,21,.32), rgba(0,0,0,.5)))',
996
+ backdropFilter: 'var(--dsw-mask-blur, blur(2px))',
997
+ })
998
+ // Wide by default (a transcript wants real estate) and resizable through the
999
+ // corner grip; the same design aliases the platform Modal card uses keep the
1000
+ // look identical while the size stays under our control.
1001
+ const previewPanelStyle = Object.freeze({
1002
+ position: 'relative', zIndex: 1,
1003
+ display: 'flex', flexDirection: 'column',
1004
+ width: 'min(92vw, 920px)',
1005
+ height: 'min(84vh, 680px)',
1006
+ borderRadius: '24px', overflow: 'hidden',
1007
+ background: 'var(--dsw-alias-bg-layer-2, light-dark(#ffffff, #353638))',
1008
+ color: 'var(--dsw-alias-label-primary, light-dark(#0f1115, #f9fafb))',
1009
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 32px rgba(0,0,0,.24))',
1010
+ font: 'inherit', pointerEvents: 'auto',
1011
+ })
1012
+ const previewHeaderStyle = Object.freeze({
1013
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px',
1014
+ padding: '22px 14px 12px 24px',
1015
+ })
1016
+ const previewCloseStyle = Object.freeze({
1017
+ flex: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1018
+ width: '28px', height: '28px', border: 'none', borderRadius: '8px',
1019
+ background: 'transparent', cursor: 'pointer', font: 'inherit', fontSize: '14px',
1020
+ color: 'var(--dsw-alias-label-secondary, inherit)',
1021
+ })
1022
+ const previewHeadingStyle = Object.freeze({ margin: '0', fontSize: '16px', fontWeight: 500, lineHeight: '24px' })
1023
+ const previewSubjectStyle = Object.freeze({
1024
+ margin: '0', padding: '0 24px', fontSize: '14px', lineHeight: '22px',
1025
+ opacity: 0.85, wordBreak: 'break-all',
1026
+ })
1027
+ const previewBodyStyle = Object.freeze({
1028
+ display: 'flex', flexDirection: 'column', minWidth: 0,
1029
+ flex: '1 1 auto', minHeight: 0,
1030
+ marginTop: '12px', padding: '0 24px 4px', overflow: 'hidden',
1031
+ })
1032
+ const previewIdStyle = Object.freeze({
1033
+ margin: '0 0 8px', fontSize: '12px', opacity: 0.75, wordBreak: 'break-all',
1034
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1035
+ })
1036
+ const previewIdLabelStyle = Object.freeze({ opacity: 0.6, fontFamily: 'inherit' })
1037
+ const previewChildrenStyle = Object.freeze({ margin: '0 0 8px', display: 'flex', flexDirection: 'column', gap: '4px' })
1038
+ const previewChildrenHeadStyle = Object.freeze({ margin: '0', fontSize: '11px', opacity: 0.6 })
1039
+ const previewChildRowStyle = Object.freeze({ display: 'flex', gap: '6px', alignItems: 'center', fontSize: '11px', opacity: 0.85 })
1040
+ const previewChildIdStyle = Object.freeze({
1041
+ wordBreak: 'break-all',
1042
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1043
+ })
1044
+ const previewMetaStyle = Object.freeze({ margin: '0 0 8px', fontSize: '12px', opacity: 0.7, wordBreak: 'break-all' })
1045
+ const previewFooterStyle = Object.freeze({
1046
+ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px',
1047
+ padding: '12px 24px 20px',
1048
+ })
1049
+ const resizeGripStyle = Object.freeze({
1050
+ position: 'absolute', right: '0', bottom: '0', width: '22px', height: '22px',
1051
+ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end',
1052
+ padding: '2px 4px', fontSize: '11px', lineHeight: 1, opacity: 0.45,
1053
+ cursor: 'nwse-resize', touchAction: 'none', userSelect: 'none', zIndex: 1,
1054
+ })
1055
+ const messageListStyle = Object.freeze({
1056
+ display: 'flex', flexDirection: 'column', gap: '12px',
1057
+ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: '2px',
1058
+ })
1059
+ const messageStyle = Object.freeze({ display: 'flex', flexDirection: 'column', gap: '4px' })
1060
+ const messageHeadStyle = Object.freeze({ display: 'flex', gap: '8px', alignItems: 'baseline' })
1061
+ const messageMetaStyle = Object.freeze({ fontSize: '11px', opacity: 0.55, whiteSpace: 'nowrap' })
1062
+ const userBadgeStyle = Object.freeze({
1063
+ fontSize: '10px', padding: '1px 6px', borderRadius: '999px', whiteSpace: 'nowrap',
1064
+ background: 'light-dark(rgba(38,49,72,.08), rgba(255,255,255,.14))',
1065
+ })
1066
+ const assistantBadgeStyle = Object.freeze({
1067
+ fontSize: '10px', padding: '1px 6px', borderRadius: '999px', whiteSpace: 'nowrap',
1068
+ background: 'light-dark(rgba(12,123,220,.14), rgba(56,132,255,.24))',
1069
+ })
1070
+ const messageTextStyle = Object.freeze({
1071
+ fontSize: '13px', lineHeight: '1.6', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
1072
+ })
1073
+
1074
+ // Attached after the main export so the factory assignment above cannot
1075
+ // overwrite it; the bundle itself only ever uses the factory.
1076
+ module.exports.toggleWithDescendants = toggleWithDescendants
1077
+ module.exports.renderMessages = renderMessages
1078
+ module.exports.setSelection = setSelection
1079
+ module.exports.buildWorkspaceGroups = buildWorkspaceGroups
1080
+ module.exports.collectSessions = collectSessions
1081
+ module.exports.collectDescendants = collectDescendants
1082
+
1083
+ return module.exports;
1084
+ })();
1085
+ __modules["client/client.js"] = (() => {
1086
+ const module = { exports: {} };
1087
+ const exports = module.exports;
1088
+ /**
1089
+ * session-cleaner Web client half.
1090
+ *
1091
+ * Adds a "delete session" row to the sidebar Session menu — the one place a
1092
+ * user already looks for per-session actions — and confirms before deleting.
1093
+ * It also contributes a top-level Session-cleanup page (sidebar entry next to
1094
+ * Plugins and Automation Tasks) for listing and batch-deleting every session
1095
+ * on disk.
1096
+ *
1097
+ * The Host half owns deletion; this file only presents it. DSH's client module
1098
+ * system loads one self-contained bundle and hands it a `require`, so React and
1099
+ * react-dom come from the platform module table rather than from package
1100
+ * resolution.
1101
+ */
1102
+
1103
+ const { TYPERT_REMOTE } = __modules["src/contract.cjs"]
1104
+ const createPanelPage = __modules["client/panel-page.js"]
1105
+
1106
+ /**
1107
+ * The panel id. It addresses both the `main` keyed-slot entry (the page) and
1108
+ * the `sidebar.panellist` entry (the row that selects it): the layout's
1109
+ * `selectPanel(id)` resolves the same key, so the two must never diverge.
1110
+ * The shipped pages use "plugins" (order 0) and "schedules" (order 10).
1111
+ */
1112
+ const PANEL_ID = 'session-cleaner'
1113
+
1114
+ const MESSAGES = Object.freeze({
1115
+ en: Object.freeze({
1116
+ 'menu.delete': 'Delete session',
1117
+ 'dialog.title': 'Delete this session permanently?',
1118
+ 'dialog.body': 'The session log, its projection cache and its workspace entry will be removed. Descendant subagent sessions are removed too.',
1119
+ 'dialog.warning': 'This cannot be undone. DSH keeps no recycle bin and no backup.',
1120
+ 'dialog.bytes': 'Will free {size}.',
1121
+ 'dialog.more': '{count} sessions on disk (including descendant subagent sessions).',
1122
+ 'dialog.loading': 'Checking what would be removed…',
1123
+ 'dialog.confirm': 'Delete permanently',
1124
+ 'dialog.cancel': 'Cancel',
1125
+ 'dialog.close': 'Close',
1126
+ 'dialog.deleting': 'Deleting…',
1127
+ 'dialog.failed': 'Deletion failed: {reason}',
1128
+ 'service.missing': 'The session-cleaner service did not become reachable. Retry the click; if it persists, reload the page.',
1129
+ 'service.mountFailed': 'Remote mount failed: {reason}',
1130
+ 'panel.nav': 'Session cleanup',
1131
+ 'panel.title': 'Session cleanup',
1132
+ '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.',
1133
+ 'panel.loading': 'Scanning sessions…',
1134
+ 'panel.retry': 'Retry',
1135
+ 'panel.search': 'Filter by title, id, workspace or path',
1136
+ 'panel.selectAll': 'Select all shown',
1137
+ 'panel.clear': 'Clear selection',
1138
+ 'panel.delete': 'Delete {count} selected ({size})',
1139
+ 'panel.deleting': 'Deleting…',
1140
+ 'panel.deleted': 'Deleted {count} session(s), freed {size}.',
1141
+ 'panel.failed': 'Deletion failed: {reason}',
1142
+ 'panel.empty': 'No session matches the filter.',
1143
+ 'panel.child': 'subagent',
1144
+ 'panel.childCount': 'subagents ×{count}',
1145
+ 'panel.groupStats': '{count} sessions · {size}',
1146
+ 'panel.groupStatsChildren': '{count} sessions · {children} subagents · {size}',
1147
+ 'panel.expand': 'Expand',
1148
+ 'panel.collapse': 'Collapse',
1149
+ 'panel.preview': 'Preview',
1150
+ 'panel.root': 'Data root: {path}',
1151
+ 'panel.serviceMissing': 'The session-cleaner service is unavailable. Check the network panel and reload the page.',
1152
+ 'preview.title': 'Conversation preview',
1153
+ 'preview.sessionId': 'Session ID',
1154
+ 'preview.children': 'Subagent sessions ({count}) — deleted together with the session above:',
1155
+ 'preview.resize': 'Resize (double-click to reset)',
1156
+ 'preview.loading': 'Reading the conversation…',
1157
+ 'preview.empty': 'No text messages in this session.',
1158
+ 'preview.truncated': 'Showing the first {shown} of {total} messages.',
1159
+ 'preview.failed': 'Failed to read the conversation: {reason}',
1160
+ 'preview.user': 'User',
1161
+ 'preview.assistant': 'Assistant',
1162
+ }),
1163
+ zh: Object.freeze({
1164
+ 'menu.delete': '删除会话',
1165
+ 'dialog.title': '永久删除这个会话?',
1166
+ 'dialog.body': '会话日志、投影缓存与工作区中的记录都会被移除;由它派生的子代理会话也会一并删除。',
1167
+ 'dialog.warning': '此操作不可撤销。DSH 没有回收站,也没有备份。',
1168
+ 'dialog.bytes': '将释放 {size}。',
1169
+ 'dialog.more': '磁盘上共 {count} 个会话(含派生的子代理会话)。',
1170
+ 'dialog.loading': '正在统计将要删除的内容…',
1171
+ 'dialog.confirm': '永久删除',
1172
+ 'dialog.cancel': '取消',
1173
+ 'dialog.close': '关闭',
1174
+ 'dialog.deleting': '正在删除…',
1175
+ 'dialog.failed': '删除失败:{reason}',
1176
+ 'service.missing': '会话清理服务未能在 5 秒内就绪。请再点一次「删除会话」;若反复出现,请重新加载页面。',
1177
+ 'service.mountFailed': 'Remote 契约挂载失败:{reason}',
1178
+ 'panel.nav': '会话清理',
1179
+ 'panel.title': '会话清理',
1180
+ 'panel.hint': '磁盘上的全部会话,按工作区分组。勾选主会话会自动带上其派生的子代理会话(它们会被一并删除);本应用中正在运行的会话无法删除,会被拒绝。双击会话行(或点「预览」)可先查看对话内容和会话 ID 再决定删除。',
1181
+ 'panel.loading': '正在扫描会话…',
1182
+ 'panel.retry': '重试',
1183
+ 'panel.search': '按标题、id、工作区或路径筛选',
1184
+ 'panel.selectAll': '全选当前列表',
1185
+ 'panel.clear': '清空选择',
1186
+ 'panel.delete': '删除选中的 {count} 个({size})',
1187
+ 'panel.deleting': '正在删除…',
1188
+ 'panel.deleted': '已删除 {count} 个会话,释放 {size}。',
1189
+ 'panel.failed': '删除失败:{reason}',
1190
+ 'panel.empty': '没有符合条件的会话。',
1191
+ 'panel.child': '子代理',
1192
+ 'panel.childCount': '子代理 ×{count}',
1193
+ 'panel.groupStats': '{count} 个会话 · {size}',
1194
+ 'panel.groupStatsChildren': '{count} 个会话 · 子代理 {children} 个 · {size}',
1195
+ 'panel.expand': '展开',
1196
+ 'panel.collapse': '收起',
1197
+ 'panel.preview': '预览',
1198
+ 'panel.root': '数据根目录:{path}',
1199
+ 'panel.serviceMissing': '会话清理服务不可用。请检查连接后重新加载页面。',
1200
+ 'preview.title': '对话预览',
1201
+ 'preview.sessionId': '会话 ID',
1202
+ 'preview.children': '子代理会话({count} 个,随上方会话一并删除):',
1203
+ 'preview.resize': '调整大小(双击恢复默认)',
1204
+ 'preview.loading': '正在读取会话内容…',
1205
+ 'preview.empty': '此会话没有可显示的文本消息。',
1206
+ 'preview.truncated': '仅显示前 {shown} 条消息(共 {total} 条)。',
1207
+ 'preview.failed': '读取会话失败:{reason}',
1208
+ 'preview.user': '用户',
1209
+ 'preview.assistant': '助手',
1210
+ }),
1211
+ })
1212
+
1213
+ /** Pick a locale from the DSH locale service, falling back to English. */
1214
+ function detectLocale(ctx) {
1215
+ try {
1216
+ // The LocaleRuntime snapshot carries the active locale as `active`, which is
1217
+ // either the lowercase id itself or a locale record holding it under `id`.
1218
+ const snapshot = ctx.locale?.getLocale?.() ?? ctx.locale?.getSnapshot?.()
1219
+ const active = snapshot?.active
1220
+ const id = typeof active === 'string' ? active : active?.id ?? ''
1221
+ return String(id).toLowerCase().startsWith('zh') ? 'zh' : 'en'
1222
+ } catch {
1223
+ return 'en'
1224
+ }
1225
+ }
1226
+
1227
+ /** Read a service result defensively: RPC may settle unwrapped or wrapped. */
1228
+ function unwrap(result) {
1229
+ if (result !== null && typeof result === 'object' && 'ok' in result && 'value' in result) {
1230
+ return result.ok ? result.value : { ok: false, error: result.error?.message ?? 'remote call failed' }
1231
+ }
1232
+ return result
1233
+ }
1234
+
1235
+ /**
1236
+ * A menu row cannot own the dialog it opens. Choosing the row closes the menu,
1237
+ * and the row unmounts in that very commit — so a dialog rendered inside it is
1238
+ * dropped before it can paint, and the click looks like it did nothing at all.
1239
+ *
1240
+ * The row therefore publishes a request here and returns; a frame-wide
1241
+ * `shell.overlay` entry, which outlives every menu, renders the dialog. The
1242
+ * shipped rename and archive dialogs use the same split.
1243
+ */
1244
+ function createConfirmStore() {
1245
+ let state = { request: undefined }
1246
+ const listeners = new Set()
1247
+ const publish = (next) => {
1248
+ state = next
1249
+ for (const listener of [...listeners]) listener()
1250
+ }
1251
+ return {
1252
+ subscribe(listener) {
1253
+ listeners.add(listener)
1254
+ return () => { listeners.delete(listener) }
1255
+ },
1256
+ get() {
1257
+ return state
1258
+ },
1259
+ request(sessionId, displayTitle) {
1260
+ publish({ request: { sessionId, displayTitle } })
1261
+ },
1262
+ dismiss() {
1263
+ publish({ request: undefined })
1264
+ },
1265
+ }
1266
+ }
1267
+
1268
+ module.exports = function createPlugin(require) {
1269
+ const React = require('react')
1270
+
1271
+ // Implicit baseline external of the module-loader lane: resolved through the
1272
+ // frozen platform module table like React, never bundled (see the
1273
+ // ui-workspace README's "动态客户端包" note). Absent only on stand-alone
1274
+ // stubs, where the plain-button fallback below keeps the row working.
1275
+ let MenuItemButton
1276
+ let IconTrashOutlineRegular
1277
+ let Modal
1278
+ let Button
1279
+ try {
1280
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
1281
+ MenuItemButton = primitives.MenuItemButton
1282
+ IconTrashOutlineRegular = primitives.IconTrashOutlineRegular
1283
+ Modal = primitives.Modal
1284
+ Button = primitives.Button
1285
+ } catch { /* optional in this composition */ }
1286
+
1287
+ /** One live dialog per activation; the overlay entry reads it. */
1288
+ const confirmStore = createConfirmStore()
1289
+
1290
+ /** Subscribe a component to the pending confirm request. */
1291
+ function useConfirmRequest() {
1292
+ const [state, setState] = React.useState(confirmStore.get)
1293
+ React.useEffect(() => confirmStore.subscribe(() => setState(confirmStore.get())), [])
1294
+ return state.request
1295
+ }
1296
+
1297
+ return {
1298
+ name: 'sessionCleanerClient',
1299
+ // The shell publishes `slots` only once its layout has mounted, and the
1300
+ // Remote face is a Cordis service the same way. Reading an undeclared
1301
+ // service on a Cordis context THROWS ("cannot get property "remote"
1302
+ // without inject"): acquire() used to swallow that rejection, resolve to
1303
+ // undefined forever, and leave the dialog stuck on "checking…". `typert`
1304
+ // is required in turn because $mount() registers the contribution through
1305
+ // the CALLER's context (`callerCtx.typert.remotes.register`).
1306
+ inject: ['slots', 'locale', 'remote', 'typert'],
1307
+ apply(ctx) {
1308
+ const t = (key, vars) => {
1309
+ const table = MESSAGES[detectLocale(ctx)] ?? MESSAGES.en
1310
+ const template = table[key] ?? MESSAGES.en[key] ?? key
1311
+ if (vars === undefined) return template
1312
+ return template.replace(/\{(\w+)\}/g, (_match, name) => String(vars[name] ?? ''))
1313
+ }
1314
+
1315
+ let mountPromise
1316
+ let servicePromise
1317
+ /** Human-readable reason from the most recent acquire failure, if any. */
1318
+ let acquireFailure
1319
+
1320
+ const readFailure = () => acquireFailure
1321
+
1322
+ /**
1323
+ * Mount the contract once, then resolve the Host namespace.
1324
+ *
1325
+ * Two independently cached steps, because they fail differently:
1326
+ *
1327
+ * - `$mount` registers the contribution under the package name in the
1328
+ * Typert Remote store, which REJECTS a second registration ("Remote
1329
+ * package … is already registered"). It must run at most once and its
1330
+ * success is cached forever; only a failed mount may be retried.
1331
+ * - The namespace itself is a Cordis service provided under the property
1332
+ * name `remote.<namespace>` (the gateway composes the key with
1333
+ * `remoteServiceKey()` and the Service base class registers the
1334
+ * instance via `ctx.provide(name, this)`). It is therefore read as
1335
+ * `scope['remote.sessionCleaner']` — NOT as `scope.remote.sessionCleaner`,
1336
+ * which would be a property lookup on the mount manager and always
1337
+ * `undefined`. A click can land before the namespace fiber has
1338
+ * published, so the inject waits inside a settle window; a window that
1339
+ * lapses may be re-entered by the next click (the mount stays cached).
1340
+ */
1341
+ const mount = async () => {
1342
+ try {
1343
+ // Reading `ctx.remote` without the service in `inject` throws; the
1344
+ // try keeps a mis-declared inject visible as a reported reason
1345
+ // instead of an uncaught rejection out of the dialog effect.
1346
+ const remote = ctx.remote
1347
+ if (remote === undefined || typeof remote.$mount !== 'function') {
1348
+ acquireFailure = 'ctx.remote is not available in this composition'
1349
+ return false
1350
+ }
1351
+ await remote.$mount(TYPERT_REMOTE)
1352
+ acquireFailure = undefined
1353
+ return true
1354
+ } catch (reason) {
1355
+ acquireFailure = String(reason?.message ?? reason)
1356
+ return false
1357
+ }
1358
+ }
1359
+
1360
+ const acquire = async () => {
1361
+ if (mountPromise === undefined) mountPromise = mount()
1362
+ const mounted = await mountPromise
1363
+ if (!mounted) {
1364
+ // The mount itself failed (or no remote yet): a later click retries it.
1365
+ mountPromise = undefined
1366
+ return undefined
1367
+ }
1368
+ if (servicePromise === undefined) {
1369
+ servicePromise = new Promise((resolve) => {
1370
+ let settled = false
1371
+ const timer = setTimeout(() => {
1372
+ if (settled) return
1373
+ settled = true
1374
+ resolve(undefined)
1375
+ }, 5000)
1376
+ try {
1377
+ ctx.inject(['remote.sessionCleaner'], (scope) => {
1378
+ if (settled) return
1379
+ settled = true
1380
+ clearTimeout(timer)
1381
+ resolve(scope['remote.sessionCleaner'])
1382
+ })
1383
+ } catch (reason) {
1384
+ settled = true
1385
+ clearTimeout(timer)
1386
+ acquireFailure = String(reason?.message ?? reason)
1387
+ resolve(undefined)
1388
+ }
1389
+ })
1390
+ }
1391
+ const service = await servicePromise
1392
+ if (service === undefined) {
1393
+ if (acquireFailure === undefined) {
1394
+ acquireFailure = 'the remote.sessionCleaner namespace did not publish within the settle window'
1395
+ }
1396
+ servicePromise = undefined
1397
+ return undefined
1398
+ }
1399
+ acquireFailure = undefined
1400
+ return service
1401
+ }
1402
+
1403
+ /**
1404
+ * The sidebar menu row. It only closes the menu and publishes the request:
1405
+ * it must not hold dialog state, because it unmounts as the menu closes.
1406
+ */
1407
+ function DeleteSessionRow(props) {
1408
+ const { sessionId, displayTitle } = props
1409
+ // Slot-level hook: it returns the menu's [open, setOpen] pair, so the
1410
+ // setter has to be destructured. Calling the pair itself threw a
1411
+ // TypeError before the request below ever ran — which is why the row
1412
+ // appeared but did nothing when clicked.
1413
+ const menuOpenState = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
1414
+ const setMenuOpen = Array.isArray(menuOpenState) && typeof menuOpenState[1] === 'function'
1415
+ ? menuOpenState[1]
1416
+ : () => {}
1417
+
1418
+ const onSelect = () => {
1419
+ setMenuOpen(false)
1420
+ confirmStore.request(sessionId, displayTitle)
1421
+ }
1422
+
1423
+ // The shipped rows render through the platform's MenuItemButton: same
1424
+ // typography, spacing, hover and keyboard walk as pin/rename/fork/
1425
+ // archive, with the leading icon slot every shipped row uses. The
1426
+ // default 16px Regular stroke matches the pin/rename/fork icons.
1427
+ if (MenuItemButton !== undefined && IconTrashOutlineRegular !== undefined) {
1428
+ return React.createElement(MenuItemButton, {
1429
+ icon: React.createElement(IconTrashOutlineRegular),
1430
+ onSelect,
1431
+ }, t('menu.delete'))
1432
+ }
1433
+
1434
+ return React.createElement('button', {
1435
+ type: 'button',
1436
+ role: 'menuitem',
1437
+ style: menuItemStyle,
1438
+ onClick: onSelect,
1439
+ }, t('menu.delete'))
1440
+ }
1441
+
1442
+ /**
1443
+ * The confirmation dialog, mounted once into `shell.overlay`. Rendering
1444
+ * nothing while no request is pending keeps the overlay list inert.
1445
+ */
1446
+ function ConfirmDialog() {
1447
+ const request = useConfirmRequest()
1448
+ const sessionId = request?.sessionId
1449
+ const displayTitle = request?.displayTitle
1450
+ const [preview, setPreview] = React.useState(undefined)
1451
+ const [busy, setBusy] = React.useState(false)
1452
+ const [error, setError] = React.useState(undefined)
1453
+
1454
+ React.useEffect(() => {
1455
+ if (sessionId === undefined) return undefined
1456
+ let cancelled = false
1457
+ setPreview(undefined)
1458
+ setError(undefined)
1459
+ setBusy(false)
1460
+ void (async () => {
1461
+ const service = await acquire()
1462
+ if (cancelled) return
1463
+ if (service === undefined) {
1464
+ const reason = readFailure()
1465
+ setError(reason === undefined ? t('service.missing') : t('service.mountFailed', { reason }))
1466
+ return
1467
+ }
1468
+ try {
1469
+ const value = unwrap(await service.preview([sessionId]))
1470
+ if (!cancelled) setPreview(value)
1471
+ } catch (reason) {
1472
+ if (!cancelled) setError(String(reason?.message ?? reason))
1473
+ }
1474
+ })()
1475
+ return () => { cancelled = true }
1476
+ }, [sessionId])
1477
+
1478
+ // A frame-wide list entry is always mounted: returning a hidden node
1479
+ // rather than null keeps the idle state from reading as "no entry".
1480
+ if (request === undefined) return React.createElement('span', { hidden: true })
1481
+
1482
+ const confirm = async () => {
1483
+ const service = await acquire()
1484
+ if (service === undefined) {
1485
+ const reason = readFailure()
1486
+ setError(reason === undefined ? t('service.missing') : t('service.mountFailed', { reason }))
1487
+ return
1488
+ }
1489
+ setBusy(true)
1490
+ setError(undefined)
1491
+ try {
1492
+ const result = unwrap(await service.delete([sessionId], {}))
1493
+ if (result?.ok === false) {
1494
+ setError(result.error ?? 'unknown failure')
1495
+ return
1496
+ }
1497
+ confirmStore.dismiss()
1498
+ } catch (reason) {
1499
+ setError(String(reason?.message ?? reason))
1500
+ } finally {
1501
+ setBusy(false)
1502
+ }
1503
+ }
1504
+
1505
+ const body = []
1506
+ body.push(React.createElement('p', { key: 'body', style: bodyStyle }, t('dialog.body')))
1507
+ body.push(React.createElement('p', { key: 'warn', style: warnStyle }, t('dialog.warning')))
1508
+ // While an error is shown the loading line is dropped: "checking…"
1509
+ // next to a failure reads as if something were still in flight.
1510
+ if (preview === undefined && error === undefined) {
1511
+ body.push(React.createElement('p', { key: 'size', style: metaStyle }, t('dialog.loading')))
1512
+ } else if (preview !== undefined) {
1513
+ body.push(React.createElement('p', { key: 'size', style: metaStyle }, t('dialog.bytes', { size: preview.humanBytes })))
1514
+ if (preview.targetCount > 1) {
1515
+ body.push(React.createElement('p', { key: 'more', style: metaStyle }, t('dialog.more', { count: preview.targetCount })))
1516
+ }
1517
+ }
1518
+ if (error !== undefined) {
1519
+ body.push(React.createElement('p', { key: 'error', style: errorStyle }, t('dialog.failed', { reason: error })))
1520
+ }
1521
+
1522
+ // The shipped rename / stop-and-archive / risk dialogs all render
1523
+ // through the platform Modal + Button pair (the same implicit baseline
1524
+ // external as the menu row): backdrop blur, card, header with a close
1525
+ // affordance, footer actions and control metrics then match the host
1526
+ // dialogs exactly. The action pairing follows RiskConfirmation:
1527
+ // outline for cancel, primary for the confirm action.
1528
+ if (Modal !== undefined && Button !== undefined) {
1529
+ return React.createElement(Modal, {
1530
+ open: true,
1531
+ onClose: () => { if (!busy) confirmStore.dismiss() },
1532
+ title: t('dialog.title'),
1533
+ closeLabel: t('dialog.close'),
1534
+ description: displayTitle || sessionId,
1535
+ footer: React.createElement(React.Fragment, null,
1536
+ React.createElement(Button, {
1537
+ key: 'cancel',
1538
+ variant: 'outline',
1539
+ disabled: busy,
1540
+ onClick: () => confirmStore.dismiss(),
1541
+ }, t('dialog.cancel')),
1542
+ React.createElement(Button, {
1543
+ key: 'confirm',
1544
+ variant: 'primary',
1545
+ disabled: busy || preview === undefined,
1546
+ onClick: confirm,
1547
+ }, busy ? t('dialog.deleting') : t('dialog.confirm'))),
1548
+ }, body)
1549
+ }
1550
+
1551
+ // Stand-alone fallback (no primitives lane): the hand-rolled card.
1552
+ const rows = []
1553
+ rows.push(React.createElement('h2', { key: 'title', style: titleStyle }, t('dialog.title')))
1554
+ rows.push(React.createElement('p', { key: 'subject', style: subjectStyle }, displayTitle || sessionId))
1555
+ rows.push(...body)
1556
+ rows.push(React.createElement('div', { key: 'actions', style: actionsStyle }, [
1557
+ React.createElement('button', {
1558
+ key: 'cancel',
1559
+ type: 'button',
1560
+ disabled: busy,
1561
+ onClick: () => confirmStore.dismiss(),
1562
+ style: buttonStyle,
1563
+ }, t('dialog.cancel')),
1564
+ React.createElement('button', {
1565
+ key: 'confirm',
1566
+ type: 'button',
1567
+ disabled: busy || preview === undefined,
1568
+ onClick: confirm,
1569
+ style: dangerButtonStyle,
1570
+ }, busy ? t('dialog.deleting') : t('dialog.confirm')),
1571
+ ]))
1572
+
1573
+ return React.createElement('div', {
1574
+ role: 'dialog',
1575
+ 'aria-modal': 'true',
1576
+ 'aria-label': t('dialog.title'),
1577
+ style: overlayStyle,
1578
+ onClick: (event) => { if (event.target === event.currentTarget && !busy) confirmStore.dismiss() },
1579
+ }, React.createElement('div', { style: panelStyle }, rows))
1580
+ }
1581
+
1582
+ try {
1583
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
1584
+ name: 'sidebar.workspaces.session.menu.item',
1585
+ id: 'session-cleaner.delete',
1586
+ order: 500,
1587
+ }, DeleteSessionRow))
1588
+ } catch { /* the slot is optional: the Host half stays usable without it */ }
1589
+
1590
+ try {
1591
+ // Frame-wide, so the dialog survives the menu that opened it.
1592
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
1593
+ name: 'shell.overlay',
1594
+ id: 'session-cleaner.confirm',
1595
+ order: 90,
1596
+ }, ConfirmDialog))
1597
+ } catch { /* no overlay host in this composition: deletion stays available through the panel page */ }
1598
+
1599
+ /**
1600
+ * The sidebar glyph for the panel entry. The sidebar shell renders the
1601
+ * row itself — button, tooltip, label, selected state — and hands the
1602
+ * entry `{ size, active }`. The same trash glyph the menu row uses
1603
+ * keeps the two entries recognizable as one feature.
1604
+ */
1605
+ function PanelIcon({ size }) {
1606
+ if (IconTrashOutlineRegular === undefined) return null
1607
+ return React.createElement(IconTrashOutlineRegular, { size })
1608
+ }
1609
+
1610
+ try {
1611
+ // The page itself: a `main` keyed-slot entry under the panel id, next
1612
+ // to the shipped Plugins and Automation Tasks pages. `selectPanel(id)`
1613
+ // dispatches on this key, so a missing entry would make the sidebar
1614
+ // row throw on click — the two registrations below are one unit.
1615
+ const page = createPanelPage(React, t, acquire, readFailure, { Modal, Button })
1616
+ ctx.slots.inject('main', () => ctx.slots.register({
1617
+ name: 'main',
1618
+ key: PANEL_ID,
1619
+ }, page))
1620
+ } catch { /* the layout panel seat is optional in this composition */ }
1621
+
1622
+ try {
1623
+ // The sidebar entry, at the same level as Plugins (order 0) and
1624
+ // Automation Tasks (order 10). A `label` thunk is re-read on every
1625
+ // projection, so the row text follows a live locale change without
1626
+ // re-registering.
1627
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
1628
+ name: 'sidebar.panellist',
1629
+ id: PANEL_ID,
1630
+ order: 20,
1631
+ label: () => t('panel.nav'),
1632
+ }, PanelIcon))
1633
+ } catch { /* without the sidebar list the page stays reachable through the menu rows */ }
1634
+ },
1635
+ }
1636
+ }
1637
+
1638
+ const overlayStyle = Object.freeze({
1639
+ position: 'fixed', inset: '0', zIndex: 1000, display: 'flex',
1640
+ alignItems: 'center', justifyContent: 'center',
1641
+ // An overlay host usually disables hit-testing on its container so it cannot
1642
+ // block the app beneath it; the dialog has to opt back in.
1643
+ pointerEvents: 'auto',
1644
+ background: 'light-dark(rgba(15,17,21,.32), rgba(0,0,0,.5))',
1645
+ })
1646
+ const panelStyle = Object.freeze({
1647
+ minWidth: '320px', maxWidth: '440px', padding: '20px', borderRadius: '16px',
1648
+ background: 'var(--dsw-alias-bg-module-platform, light-dark(#ffffff, #353638))',
1649
+ color: 'var(--dsw-alias-label-primary, light-dark(#0f1115, #f9fafb))',
1650
+ boxShadow: '0 8px 32px rgba(0,0,0,.24)', font: 'inherit',
1651
+ })
1652
+ const titleStyle = Object.freeze({ margin: '0 0 8px', fontSize: '15px', fontWeight: 600 })
1653
+ const subjectStyle = Object.freeze({ margin: '0 0 12px', fontSize: '13px', opacity: 0.75, wordBreak: 'break-all' })
1654
+ const bodyStyle = Object.freeze({ margin: '0 0 8px', fontSize: '13px', lineHeight: '20px' })
1655
+ const warnStyle = Object.freeze({ margin: '0 0 8px', fontSize: '13px', lineHeight: '20px', color: 'light-dark(#b42318, #ff9c94)' })
1656
+ const metaStyle = Object.freeze({ margin: '0 0 8px', fontSize: '12px', opacity: 0.7 })
1657
+ const errorStyle = Object.freeze({ margin: '0 0 8px', fontSize: '12px', color: 'light-dark(#b42318, #ff9c94)', wordBreak: 'break-word' })
1658
+ const actionsStyle = Object.freeze({ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '16px' })
1659
+ const buttonStyle = Object.freeze({
1660
+ padding: '6px 14px', borderRadius: '8px', cursor: 'pointer', font: 'inherit',
1661
+ border: '1px solid var(--dsw-alias-border-l3, light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)))',
1662
+ background: 'transparent', color: 'inherit',
1663
+ })
1664
+ const dangerButtonStyle = Object.freeze({
1665
+ ...buttonStyle, border: 'none', background: 'light-dark(#d92d20, #f04438)', color: '#ffffff',
1666
+ })
1667
+ const menuItemStyle = Object.freeze({
1668
+ display: 'block', width: '100%', padding: '6px 12px', textAlign: 'left',
1669
+ border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer', font: 'inherit',
1670
+ })
1671
+
1672
+ return module.exports;
1673
+ })();
1674
+ return __modules["client/client.js"](require);
1675
+ }});