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/README.md +209 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1845 -0
- package/lib/index.js +2 -0
- package/lib/remote.js +3 -0
- package/lib/typert.host.js +3 -0
- package/locale/en.json +13 -0
- package/locale/zh.json +13 -0
- package/package.json +44 -4
- package/src/cleaner.js +712 -0
- package/src/contract.cjs +96 -0
- package/src/index.js +437 -0
- package/src/remote.js +12 -0
- package/src/typert.host.js +38 -0
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
|
+
}});
|