dsh-plugin-delete-session 0.0.0-stage → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,841 @@
1
+ /**
2
+ * Browser half of the session-trash bundle.
3
+ *
4
+ * Registers three additive surfaces:
5
+ * - a destructive row in one Session's "..." menu;
6
+ * - a small recycle-bin button beside Settings;
7
+ * - the `shell.overlay` dialogs (delete confirmation, recycle bin) and the
8
+ * post-delete notice with an immediate undo.
9
+ *
10
+ * All durable work runs in the Host half over the `/session-trash-api` HTTP
11
+ * route, which enforces the same Host/Origin and browser-credential checks as
12
+ * every other `/api` request; this module only renders and calls it.
13
+ */
14
+
15
+ window.__ModuleLoader__.load({
16
+ id: 'dsh-plugin-delete-session',
17
+ factory(require) {
18
+ const React = require('react')
19
+ const h = React.createElement
20
+ let Toast
21
+ let IconWarningOutlineRegular
22
+ try {
23
+ // The archive UI uses the same primitive. Keep the delete action usable
24
+ // (with a plain fallback notice) if a future DSH build moves this module.
25
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
26
+ Toast = typeof primitives.Toast === 'function' ? primitives.Toast : undefined
27
+ IconWarningOutlineRegular = typeof primitives.IconWarningOutlineRegular === 'function'
28
+ ? primitives.IconWarningOutlineRegular
29
+ : undefined
30
+ } catch {
31
+ Toast = undefined
32
+ IconWarningOutlineRegular = undefined
33
+ }
34
+ const NS = 'session-trash'
35
+
36
+ const zh = {
37
+ 'menu.delete': '删除对话',
38
+ 'dialog.title': '删除对话',
39
+ 'dialog.desc': '确定要删除“{title}”吗?删除后会立即从列表移除,并在回收站保留 {days} 天。期间可以恢复,逾期将永久删除。',
40
+ 'dialog.cancel': '取消',
41
+ 'dialog.confirm': '删除',
42
+ 'dialog.deleting': '正在删除…',
43
+ 'toast.deleted': '会话已删除,可',
44
+ 'toast.deletedEmpty': '空会话已删除',
45
+ 'toast.undo': '撤销',
46
+ 'toast.restored': '会话已恢复',
47
+ 'trash.title': '已删除的对话',
48
+ 'trash.subtitle': '保留最近 {days} 天,期间可以随时恢复。',
49
+ 'trash.empty': '回收站是空的',
50
+ 'trash.loading': '正在加载…',
51
+ 'trash.meta': '删除于 {time} · 剩余 {days} 天 · {size}',
52
+ 'trash.restore': '恢复',
53
+ 'trash.restoring': '恢复中…',
54
+ 'trash.purge': '彻底删除',
55
+ 'trash.purgeConfirm': '确认永久删除这条对话?此操作无法撤销。',
56
+ 'trash.purgeYes': '永久删除',
57
+ 'trash.cancel': '取消',
58
+ 'trash.close': '关闭',
59
+ 'trash.failed': '操作失败:{message}',
60
+ 'footer.label': '已删除的对话',
61
+ 'untitled': '未命名对话',
62
+ 'error.bad-id': '无效的对话 ID',
63
+ 'error.not-found': '找不到这个对话,它可能已经被删除了',
64
+ 'error.no-entry': '回收站里没有这个对话,可能已经恢复或已过期',
65
+ 'error.conflict': '原位置已存在同名对话数据,已取消恢复',
66
+ 'error.lost-data': '回收站中的对话数据已丢失,无法恢复',
67
+ 'error.connection': '无法连接到 DSH 服务',
68
+ 'error.busy': '这个对话仍在使用中,请稍候重试',
69
+ 'error.running': '这个对话仍在运行中,请先停止任务再删除',
70
+ 'error.archive-failed': '无法归档这个对话,删除已取消',
71
+ 'error.detach-failed': '无法从工作区移除这个对话,删除已取消',
72
+ 'error.flush-failed': '无法写入会话日志,删除已取消',
73
+ 'error.request': '请求失败',
74
+ }
75
+
76
+ const en = {
77
+ 'menu.delete': 'Delete session',
78
+ 'dialog.title': 'Delete session',
79
+ 'dialog.desc': 'Delete “{title}”? It will disappear from the list immediately and stay in the recycle bin for {days} days. You can restore it during that window; after it expires the copy is removed permanently.',
80
+ 'dialog.cancel': 'Cancel',
81
+ 'dialog.confirm': 'Delete',
82
+ 'dialog.deleting': 'Deleting…',
83
+ 'toast.deleted': 'Session deleted. You can ',
84
+ 'toast.deletedEmpty': 'Blank session deleted',
85
+ 'toast.undo': 'undo',
86
+ 'toast.restored': 'Session restored',
87
+ 'trash.title': 'Deleted sessions',
88
+ 'trash.subtitle': 'Kept for the last {days} days; restore any of them before it expires.',
89
+ 'trash.empty': 'The recycle bin is empty',
90
+ 'trash.loading': 'Loading…',
91
+ 'trash.meta': 'Deleted {time} · {days} days left · {size}',
92
+ 'trash.restore': 'Restore',
93
+ 'trash.restoring': 'Restoring…',
94
+ 'trash.purge': 'Delete permanently',
95
+ 'trash.purgeConfirm': 'Delete this session permanently? This cannot be undone.',
96
+ 'trash.purgeYes': 'Delete permanently',
97
+ 'trash.cancel': 'Cancel',
98
+ 'trash.close': 'Close',
99
+ 'trash.failed': 'Action failed: {message}',
100
+ 'footer.label': 'Deleted sessions',
101
+ 'untitled': 'Untitled session',
102
+ 'error.bad-id': 'Invalid session ID',
103
+ 'error.not-found': 'This session no longer exists',
104
+ 'error.no-entry': 'This session is not in the recycle bin; it may already be restored or expired',
105
+ 'error.conflict': 'Session data already exists at the original location; restore cancelled',
106
+ 'error.lost-data': 'The deleted copy is missing; it cannot be restored',
107
+ 'error.connection': 'Cannot reach the DSH service',
108
+ 'error.busy': 'This session is still in use; try again in a moment',
109
+ 'error.running': 'This session is still running; stop its task before deleting it',
110
+ 'error.archive-failed': 'Could not archive this session; deletion was cancelled',
111
+ 'error.detach-failed': 'Could not remove this session from its workspace; deletion was cancelled',
112
+ 'error.flush-failed': 'Could not write the session log; deletion was cancelled',
113
+ 'error.request': 'Request failed',
114
+ }
115
+
116
+ function createStore(initial) {
117
+ let value = initial
118
+ const listeners = new Set()
119
+ return {
120
+ get: () => value,
121
+ set: (next) => {
122
+ if (Object.is(next, value)) return
123
+ value = next
124
+ for (const listener of [...listeners]) listener()
125
+ },
126
+ subscribe: (listener) => {
127
+ listeners.add(listener)
128
+ return () => {
129
+ listeners.delete(listener)
130
+ }
131
+ },
132
+ }
133
+ }
134
+
135
+ function useStore(store) {
136
+ return React.useSyncExternalStore(store.subscribe, store.get, store.get)
137
+ }
138
+
139
+ function TrashIcon({ size = 14 }) {
140
+ return h('svg', {
141
+ viewBox: '0 0 16 16',
142
+ width: size,
143
+ height: size,
144
+ fill: 'none',
145
+ stroke: 'currentColor',
146
+ strokeWidth: 1.4,
147
+ strokeLinecap: 'round',
148
+ strokeLinejoin: 'round',
149
+ 'aria-hidden': true,
150
+ focusable: 'false',
151
+ style: { flex: 'none', display: 'block' },
152
+ },
153
+ h('path', { d: 'M2.4 4.4h11.2' }),
154
+ h('path', { d: 'M6.1 4.4V3.1c0-.5.4-.9.9-.9h1.9c.5 0 .9.4.9.9v1.3' }),
155
+ h('path', { d: 'M4.1 4.4l.6 8.4c0 .6.5 1 1 1h4.6c.5 0 1-.4 1-1l.6-8.4' }),
156
+ h('path', { d: 'M6.6 6.9v4.2M9.4 6.9v4.2' })
157
+ )
158
+ }
159
+
160
+ function RestoreIcon({ size = 14 }) {
161
+ return h('svg', {
162
+ viewBox: '0 0 16 16',
163
+ width: size,
164
+ height: size,
165
+ fill: 'none',
166
+ stroke: 'currentColor',
167
+ strokeWidth: 1.4,
168
+ strokeLinecap: 'round',
169
+ strokeLinejoin: 'round',
170
+ 'aria-hidden': true,
171
+ focusable: 'false',
172
+ style: { flex: 'none', display: 'block' },
173
+ },
174
+ h('path', { d: 'M3.2 6.4a5 5 0 1 1 .4 4.1' }),
175
+ h('path', { d: 'M3 3.4v3.1h3.1' })
176
+ )
177
+ }
178
+
179
+ const MENU_CSS = `
180
+ .dsh-st-menu-item{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 10px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:18px;text-align:start;cursor:pointer}
181
+ .dsh-st-menu-item:hover,.dsh-st-menu-item:focus-visible{background:color-mix(in oklab,var(--dsw-alias-state-error-primary) 10%,transparent);outline:none}
182
+ .dsh-st-menu-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
183
+ `
184
+
185
+ const FOOTER_CSS = `
186
+ .dsh-st-footer-button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
187
+ .dsh-st-footer-button:hover{background:color-mix(in oklab,var(--dsw-alias-label-primary) 8%,transparent);color:var(--dsw-alias-label-primary)}
188
+ .dsh-st-footer-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
189
+ `
190
+
191
+ const OVERLAY_CSS = `
192
+ .dsh-st-backdrop{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(15,23,42,.42);pointer-events:auto}
193
+ .dsh-st-dialog{box-sizing:border-box;display:flex;flex-direction:column;gap:14px;width:min(440px,100%);max-height:min(82vh,640px);padding:20px;border-radius:14px;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 24px 64px rgba(0,0,0,.32);color:var(--dsw-alias-label-primary);font:inherit}
194
+ .dsh-st-head{display:flex;align-items:flex-start;gap:12px}
195
+ .dsh-st-head-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
196
+ .dsh-st-title{margin:0;font-size:15px;line-height:22px;font-weight:600}
197
+ .dsh-st-desc{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
198
+ .dsh-st-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px;flex:none}
199
+ .dsh-st-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px;padding:7px 14px;cursor:pointer}
200
+ .dsh-st-btn:hover:not(:disabled){background:color-mix(in oklab,var(--dsw-alias-label-primary) 8%,transparent)}
201
+ .dsh-st-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
202
+ .dsh-st-btn:disabled{opacity:.55;cursor:default}
203
+ .dsh-st-btn-danger{border-color:color-mix(in oklab,var(--dsw-alias-state-error-primary) 45%,transparent);color:var(--dsw-alias-state-error-primary)}
204
+ .dsh-st-btn-danger:hover:not(:disabled){background:color-mix(in oklab,var(--dsw-alias-state-error-primary) 12%,transparent)}
205
+ .dsh-st-small{padding:4px 9px;border-radius:7px;font-size:12px;line-height:16px}
206
+ .dsh-st-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:16px;line-height:1;cursor:pointer;flex:none}
207
+ .dsh-st-icon-btn:hover{background:color-mix(in oklab,var(--dsw-alias-label-primary) 8%,transparent);color:var(--dsw-alias-label-primary)}
208
+ .dsh-st-icon-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
209
+ .dsh-st-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}
210
+ .dsh-st-list{display:flex;flex-direction:column;gap:2px;overflow:auto;margin:0 -6px;padding:0 6px;min-height:48px}
211
+ .dsh-st-placeholder{padding:18px 8px;text-align:center;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary)}
212
+ .dsh-st-row{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px}
213
+ .dsh-st-row:hover{background:color-mix(in oklab,var(--dsw-alias-label-primary) 6%,transparent)}
214
+ .dsh-st-row-main{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
215
+ .dsh-st-row-title{font-size:13px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
216
+ .dsh-st-row-meta{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
217
+ .dsh-st-row-actions{display:flex;align-items:center;gap:4px;flex:none}
218
+ `
219
+
220
+ function DeleteMenuItem(props) {
221
+ const { sessionId, displayTitle, requestDelete } = props
222
+ const tr = textOf(props.t, zh)
223
+ const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
224
+ const setMenuOpen = Array.isArray(menu) ? menu[1] : undefined
225
+ return h(React.Fragment, null,
226
+ h('style', { key: 'style' }, MENU_CSS),
227
+ h('button', {
228
+ key: 'button',
229
+ type: 'button',
230
+ role: 'menuitem',
231
+ className: 'dsh-st-menu-item',
232
+ onClick: (event) => {
233
+ event.preventDefault()
234
+ event.stopPropagation()
235
+ if (typeof setMenuOpen === 'function') setMenuOpen(false)
236
+ requestDelete(sessionId, displayTitle)
237
+ },
238
+ },
239
+ h(TrashIcon, { size: 14 }),
240
+ h('span', { className: 'dsh-st-menu-label' }, tr('menu.delete'))
241
+ )
242
+ )
243
+ }
244
+
245
+ function TrashFooterButton(props) {
246
+ const tr = textOf(props.t, zh)
247
+ const label = tr('footer.label')
248
+ return h(React.Fragment, null,
249
+ h('style', { key: 'style' }, FOOTER_CSS),
250
+ h('button', {
251
+ key: 'button',
252
+ type: 'button',
253
+ className: 'dsh-st-footer-button',
254
+ title: label,
255
+ 'aria-label': label,
256
+ onClick: () => props.openTrash(),
257
+ }, h(TrashIcon, { size: 16 }))
258
+ )
259
+ }
260
+
261
+ function TrashOverlay(props) {
262
+ const face = props.face
263
+ const tr = textOf(props.t, zh)
264
+ const stores = face.stores
265
+ const actions = face.actions
266
+ const showNotice = face.showNotice
267
+ const request = useStore(stores.deleteRequest)
268
+ const open = useStore(stores.trashOpen)
269
+ const notice = useStore(stores.notice)
270
+ const listState = useStore(stores.entries)
271
+ const retentionDays = useStore(face.retentionDays)
272
+
273
+ const [busy, setBusy] = React.useState(false)
274
+ const [error, setError] = React.useState(null)
275
+ const [rowBusy, setRowBusy] = React.useState(null)
276
+ const [confirming, setConfirming] = React.useState(null)
277
+ const cancelRef = React.useRef(null)
278
+ const closeRef = React.useRef(null)
279
+
280
+ React.useEffect(() => {
281
+ if (!open) return
282
+ setError(null)
283
+ setConfirming(null)
284
+ void actions.loadTrash()
285
+ }, [open, actions])
286
+
287
+ React.useEffect(() => {
288
+ if (request) setError(null)
289
+ }, [request])
290
+
291
+ React.useEffect(() => {
292
+ const frame = requestAnimationFrame(() => {
293
+ if (request) {
294
+ cancelRef.current?.focus?.()
295
+ } else if (open) {
296
+ closeRef.current?.focus?.()
297
+ }
298
+ })
299
+ return () => cancelAnimationFrame(frame)
300
+ }, [request, open])
301
+
302
+ React.useEffect(() => {
303
+ if (!request && !open) return undefined
304
+ const onKeyDown = (event) => {
305
+ if (event.key !== 'Escape' || busy) return
306
+ if (request) {
307
+ actions.cancelDelete()
308
+ setError(null)
309
+ } else if (open) {
310
+ actions.closeTrash()
311
+ }
312
+ }
313
+ document.addEventListener('keydown', onKeyDown)
314
+ return () => document.removeEventListener('keydown', onKeyDown)
315
+ }, [request, open, busy, actions])
316
+
317
+ React.useEffect(() => {
318
+ if (Toast !== undefined || !notice) return undefined
319
+ const timer = setTimeout(() => actions.dismissNotice(), 7000)
320
+ return () => clearTimeout(timer)
321
+ }, [notice, actions])
322
+
323
+ const deleteTitle = request && (request.title || tr('untitled'))
324
+ const confirmDelete = async () => {
325
+ if (busy) return
326
+ setBusy(true)
327
+ setError(null)
328
+ try {
329
+ await actions.confirmDelete()
330
+ } catch (reason) {
331
+ setError(errorText(reason, tr))
332
+ } finally {
333
+ setBusy(false)
334
+ }
335
+ }
336
+ const restoreRow = async (sessionId) => {
337
+ if (rowBusy) return
338
+ setRowBusy(sessionId)
339
+ setError(null)
340
+ try {
341
+ await actions.restore(sessionId)
342
+ } catch (reason) {
343
+ setError(errorText(reason, tr))
344
+ } finally {
345
+ setRowBusy(null)
346
+ }
347
+ }
348
+ const purgeRow = async (sessionId) => {
349
+ if (rowBusy) return
350
+ setRowBusy(sessionId)
351
+ setError(null)
352
+ try {
353
+ await actions.purge(sessionId)
354
+ setConfirming(null)
355
+ } catch (reason) {
356
+ setError(errorText(reason, tr))
357
+ } finally {
358
+ setRowBusy(null)
359
+ }
360
+ }
361
+ const undoDelete = async () => {
362
+ try {
363
+ await actions.undoDelete()
364
+ } catch (reason) {
365
+ showNotice({ kind: 'error', message: errorText(reason, tr) })
366
+ }
367
+ }
368
+
369
+ const rows = listState.items.map((entry) => {
370
+ const days = Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 86_400_000))
371
+ const title = entry.title || tr('untitled')
372
+ const rowIsBusy = rowBusy === entry.sessionId
373
+ const asking = confirming === entry.sessionId
374
+ return h('div', { key: entry.sessionId, className: 'dsh-st-row' },
375
+ h(RestoreIcon, { size: 14 }),
376
+ h('div', { className: 'dsh-st-row-main' },
377
+ h('div', { className: 'dsh-st-row-title', title }, title),
378
+ h('div', { className: 'dsh-st-row-meta' }, tr('trash.meta', {
379
+ time: new Date(entry.deletedAt).toLocaleString(),
380
+ days: String(days),
381
+ size: formatBytes(entry.bytes),
382
+ }))
383
+ ),
384
+ h('div', { className: 'dsh-st-row-actions' },
385
+ asking
386
+ ? h(React.Fragment, null,
387
+ h('button', {
388
+ type: 'button',
389
+ className: 'dsh-st-btn dsh-st-small dsh-st-btn-danger',
390
+ disabled: rowIsBusy,
391
+ title: tr('trash.purgeConfirm'),
392
+ 'aria-label': tr('trash.purgeConfirm'),
393
+ onClick: () => { void purgeRow(entry.sessionId) },
394
+ }, tr('trash.purgeYes')),
395
+ h('button', {
396
+ type: 'button',
397
+ className: 'dsh-st-btn dsh-st-small',
398
+ disabled: rowIsBusy,
399
+ onClick: () => setConfirming(null),
400
+ }, tr('trash.cancel'))
401
+ )
402
+ : h(React.Fragment, null,
403
+ h('button', {
404
+ type: 'button',
405
+ className: 'dsh-st-btn dsh-st-small',
406
+ disabled: rowIsBusy,
407
+ onClick: () => { void restoreRow(entry.sessionId) },
408
+ }, rowIsBusy ? tr('trash.restoring') : tr('trash.restore')),
409
+ h('button', {
410
+ type: 'button',
411
+ className: 'dsh-st-btn dsh-st-small',
412
+ disabled: rowIsBusy,
413
+ onClick: () => setConfirming(entry.sessionId),
414
+ }, tr('trash.purge'))
415
+ )
416
+ )
417
+ )
418
+ })
419
+
420
+ let listBody
421
+ if (listState.phase === 'loading' && listState.items.length === 0) {
422
+ listBody = h('div', { className: 'dsh-st-placeholder', role: 'status' }, tr('trash.loading'))
423
+ } else if (listState.items.length === 0) {
424
+ listBody = h('div', { className: 'dsh-st-placeholder' }, tr('trash.empty'))
425
+ } else {
426
+ listBody = h('div', { className: 'dsh-st-list' }, rows)
427
+ }
428
+
429
+ const toastText = notice === null
430
+ ? ''
431
+ : notice.kind === 'deleted'
432
+ ? (notice.empty === true ? tr('toast.deletedEmpty') : tr('toast.deleted'))
433
+ : notice.kind === 'restored'
434
+ ? tr('toast.restored')
435
+ : tr('trash.failed', { message: notice.message ?? '' })
436
+ const toastActions = notice !== null && notice.kind === 'deleted' && notice.empty !== true
437
+ ? [{ label: tr('toast.undo'), onClick: () => { void undoDelete() } }]
438
+ : []
439
+
440
+ return h(React.Fragment, null,
441
+ h('style', { key: 'style' }, OVERLAY_CSS),
442
+ request && h('div', {
443
+ key: 'delete-backdrop',
444
+ className: 'dsh-st-backdrop',
445
+ onMouseDown: (event) => {
446
+ if (event.target === event.currentTarget && !busy) {
447
+ actions.cancelDelete()
448
+ setError(null)
449
+ }
450
+ },
451
+ },
452
+ h('div', {
453
+ className: 'dsh-st-dialog',
454
+ role: 'dialog',
455
+ 'aria-modal': 'true',
456
+ 'aria-labelledby': 'dsh-st-delete-title',
457
+ },
458
+ h('h2', { className: 'dsh-st-title', id: 'dsh-st-delete-title' }, tr('dialog.title')),
459
+ h('p', { className: 'dsh-st-desc' }, tr('dialog.desc', {
460
+ title: deleteTitle,
461
+ days: String(retentionDays),
462
+ })),
463
+ error && h('p', { className: 'dsh-st-error', role: 'alert' }, tr('trash.failed', { message: error })),
464
+ h('div', { className: 'dsh-st-actions' },
465
+ h('button', {
466
+ type: 'button',
467
+ className: 'dsh-st-btn',
468
+ ref: cancelRef,
469
+ disabled: busy,
470
+ onClick: () => {
471
+ if (busy) return
472
+ actions.cancelDelete()
473
+ setError(null)
474
+ },
475
+ }, tr('dialog.cancel')),
476
+ h('button', {
477
+ type: 'button',
478
+ className: 'dsh-st-btn dsh-st-btn-danger',
479
+ disabled: busy,
480
+ onClick: () => { void confirmDelete() },
481
+ }, busy ? tr('dialog.deleting') : tr('dialog.confirm'))
482
+ )
483
+ )
484
+ ),
485
+ open && h('div', {
486
+ key: 'trash-backdrop',
487
+ className: 'dsh-st-backdrop',
488
+ onMouseDown: (event) => {
489
+ if (event.target === event.currentTarget && !busy) actions.closeTrash()
490
+ },
491
+ },
492
+ h('div', {
493
+ className: 'dsh-st-dialog',
494
+ role: 'dialog',
495
+ 'aria-modal': 'true',
496
+ 'aria-labelledby': 'dsh-st-trash-title',
497
+ },
498
+ h('div', { className: 'dsh-st-head' },
499
+ h('div', { className: 'dsh-st-head-main' },
500
+ h('h2', { className: 'dsh-st-title', id: 'dsh-st-trash-title' }, tr('trash.title')),
501
+ h('p', { className: 'dsh-st-desc' }, tr('trash.subtitle', { days: String(retentionDays) }))
502
+ ),
503
+ h('button', {
504
+ type: 'button',
505
+ className: 'dsh-st-icon-btn',
506
+ ref: closeRef,
507
+ 'aria-label': tr('trash.close'),
508
+ title: tr('trash.close'),
509
+ onClick: () => actions.closeTrash(),
510
+ }, '×')
511
+ ),
512
+ error && h('p', { className: 'dsh-st-error', role: 'alert' }, tr('trash.failed', { message: error })),
513
+ listState.error && h('p', { className: 'dsh-st-error', role: 'alert' }, tr('trash.failed', { message: listState.error })),
514
+ listBody,
515
+ h('div', { className: 'dsh-st-actions' },
516
+ h('button', {
517
+ type: 'button',
518
+ className: 'dsh-st-btn',
519
+ onClick: () => actions.closeTrash(),
520
+ }, tr('trash.close'))
521
+ )
522
+ )
523
+ ),
524
+ notice && (Toast !== undefined
525
+ ? h(Toast, {
526
+ key: `dsh-st-notice-${notice.seq ?? 0}`,
527
+ text: toastText,
528
+ onDone: () => actions.dismissNotice(),
529
+ holdMs: 6000,
530
+ ...(notice.kind === 'error'
531
+ ? (IconWarningOutlineRegular !== undefined
532
+ ? { icon: h(IconWarningOutlineRegular, {}) }
533
+ : {})
534
+ : { tone: 'success' }),
535
+ ...(toastActions.length > 0 ? { actions: toastActions } : {}),
536
+ })
537
+ : h('div', {
538
+ key: `dsh-st-notice-${notice.seq ?? 0}`,
539
+ role: 'alert',
540
+ style: {
541
+ position: 'fixed',
542
+ top: '40px',
543
+ left: '50%',
544
+ transform: 'translateX(-50%)',
545
+ zIndex: 2147483001,
546
+ display: 'flex',
547
+ alignItems: 'center',
548
+ gap: '8px',
549
+ maxWidth: 'min(640px, calc(100vw - 48px))',
550
+ padding: '12px 16px',
551
+ borderRadius: '12px',
552
+ background: 'var(--dsw-alias-toast-bg, #1f2329)',
553
+ color: 'var(--dsw-alias-toast-label, #fff)',
554
+ boxShadow: 'var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.24))',
555
+ font: 'inherit',
556
+ fontSize: '14px',
557
+ lineHeight: '22px',
558
+ pointerEvents: 'auto',
559
+ },
560
+ },
561
+ h('span', { style: { minWidth: 0 } }, toastText),
562
+ ...toastActions.map((action) => h('button', {
563
+ key: action.label,
564
+ type: 'button',
565
+ onClick: action.onClick,
566
+ style: {
567
+ border: 0,
568
+ background: 'transparent',
569
+ color: 'var(--dsw-static-deepseek-400, #8ab4ff)',
570
+ font: 'inherit',
571
+ cursor: 'pointer',
572
+ marginInline: '3px',
573
+ pointerEvents: 'auto',
574
+ },
575
+ }, action.label))
576
+ ))
577
+ )
578
+ }
579
+
580
+ return {
581
+ inject: ['slots'],
582
+ apply(ctx) {
583
+ const locale = ctx.get('locale')
584
+ const retentionDays = createStore(7)
585
+
586
+ ctx.effect(() => {
587
+ const dispose = locale?.register?.(NS, { zh, en })
588
+ return () => {
589
+ if (typeof dispose === 'function') dispose()
590
+ }
591
+ }, 'session-trash: dictionaries')
592
+
593
+ const deleteRequest = createStore(null)
594
+ const trashOpen = createStore(false)
595
+ const notice = createStore(null)
596
+ const entries = createStore({ phase: 'idle', items: [], error: null })
597
+ let noticeSeq = 0
598
+ const showNotice = (value) => {
599
+ notice.set(value === null ? null : { ...value, seq: ++noticeSeq })
600
+ }
601
+
602
+ const call = async (method, payload) => {
603
+ let response
604
+ try {
605
+ response = await fetch('/session-trash-api', {
606
+ method: 'POST',
607
+ credentials: 'same-origin',
608
+ headers: { 'content-type': 'application/json' },
609
+ body: JSON.stringify({ method, payload: payload ?? {} }),
610
+ })
611
+ } catch (reason) {
612
+ const error = new Error(messageOf(reason))
613
+ error.code = 'connection'
614
+ throw error
615
+ }
616
+ if (!response.ok) {
617
+ const error = new Error(`HTTP ${response.status}`)
618
+ error.code = response.status === 401 || response.status === 403 ? 'connection' : 'request'
619
+ throw error
620
+ }
621
+ let result
622
+ try {
623
+ result = await response.json()
624
+ } catch (reason) {
625
+ const error = new Error(messageOf(reason))
626
+ error.code = 'request'
627
+ throw error
628
+ }
629
+ if (!result || result.ok !== true) {
630
+ const error = new Error(result?.error?.message ?? zh['error.request'])
631
+ error.code = String(result?.error?.code ?? 'request').replace(/^session-trash\//, '')
632
+ throw error
633
+ }
634
+ return result.value
635
+ }
636
+
637
+ const refreshSessions = () => {
638
+ try {
639
+ const sessions = ctx.get('sessions')
640
+ const pending = sessions?.refresh?.()
641
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {})
642
+ } catch {
643
+ // A refresh failure only delays the list reconciliation; the Host
644
+ // state is already changed.
645
+ }
646
+ }
647
+
648
+ const loadTrash = async () => {
649
+ const current = entries.get()
650
+ entries.set({ phase: 'loading', items: current.items, error: null })
651
+ try {
652
+ const value = await call('list')
653
+ entries.set({
654
+ phase: 'ready',
655
+ items: Array.isArray(value?.entries) ? value.entries : [],
656
+ error: null,
657
+ })
658
+ } catch (reason) {
659
+ entries.set({ phase: 'error', items: [], error: messageOf(reason) })
660
+ }
661
+ }
662
+
663
+ const retireSession = (sessionId) => {
664
+ try {
665
+ const sessions = ctx.get('sessions')
666
+ if (!sessions || typeof sessions.retireScope !== 'function') return false
667
+ const record = sessions.scopes?.get?.(sessionId)
668
+ if (record === undefined) return false
669
+ // Drop this page's resident scope so the Host can close the agent
670
+ // and the row actually leaves the list instead of falling into the
671
+ // ungrouped bucket as a still-live session.
672
+ sessions.retireScope(sessionId, record)
673
+ refreshSessions()
674
+ return true
675
+ } catch {
676
+ return false
677
+ }
678
+ }
679
+ const sessionIsMain = (sessionId) => {
680
+ try {
681
+ const row = ctx.get('sessions')?.list?.getSnapshot?.().byId?.[sessionId]
682
+ return (row?.retainedBy?.mainView ?? 0) > 0
683
+ } catch {
684
+ return true
685
+ }
686
+ }
687
+
688
+ const actions = {
689
+ requestDelete(sessionId, title) {
690
+ deleteRequest.set({
691
+ sessionId,
692
+ title: typeof title === 'string' ? title.slice(0, 200) : '',
693
+ })
694
+ },
695
+ cancelDelete() {
696
+ deleteRequest.set(null)
697
+ },
698
+ async confirmDelete() {
699
+ const request = deleteRequest.get()
700
+ if (request === null) return
701
+ const { sessionId, title } = request
702
+ // The current conversation is cleared by the archive update itself;
703
+ // every other resident scope is retired here so the Host sees the
704
+ // session as closed before it starts copying files.
705
+ if (!sessionIsMain(sessionId)) retireSession(sessionId)
706
+ let value
707
+ try {
708
+ value = await call('delete', { sessionId, title })
709
+ } catch (reason) {
710
+ refreshSessions()
711
+ throw reason
712
+ }
713
+ retireSession(sessionId)
714
+ deleteRequest.set(null)
715
+ refreshSessions()
716
+ showNotice({
717
+ kind: 'deleted',
718
+ sessionId,
719
+ empty: value?.empty === true,
720
+ })
721
+ },
722
+ async undoDelete() {
723
+ const current = notice.get()
724
+ if (!current || current.kind !== 'deleted') return
725
+ showNotice(null)
726
+ await call('restore', { sessionId: current.sessionId })
727
+ refreshSessions()
728
+ void loadTrash()
729
+ showNotice({ kind: 'restored' })
730
+ },
731
+ openTrash() {
732
+ trashOpen.set(true)
733
+ },
734
+ closeTrash() {
735
+ trashOpen.set(false)
736
+ },
737
+ loadTrash,
738
+ async restore(sessionId) {
739
+ await call('restore', { sessionId })
740
+ await loadTrash()
741
+ refreshSessions()
742
+ showNotice({ kind: 'restored' })
743
+ },
744
+ async purge(sessionId) {
745
+ retireSession(sessionId)
746
+ await call('purge', { sessionId })
747
+ refreshSessions()
748
+ await loadTrash()
749
+ },
750
+ dismissNotice() {
751
+ showNotice(null)
752
+ },
753
+ }
754
+
755
+ const face = {
756
+ retentionDays,
757
+ stores: { deleteRequest, trashOpen, notice, entries },
758
+ actions,
759
+ showNotice,
760
+ }
761
+
762
+ void call('info').then((value) => {
763
+ if (value && Number.isFinite(value.retentionDays) && value.retentionDays > 0) {
764
+ retentionDays.set(value.retentionDays)
765
+ }
766
+ }).catch(() => {})
767
+
768
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
769
+ name: 'sidebar.workspaces.session.menu.item',
770
+ id: 'dsh-plugin-delete-session',
771
+ order: 500,
772
+ locale: NS,
773
+ inject: () => ({ requestDelete: actions.requestDelete }),
774
+ }, DeleteMenuItem))
775
+
776
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
777
+ name: 'sidebar.footer.action',
778
+ id: 'session-trash',
779
+ order: 100,
780
+ locale: NS,
781
+ inject: () => ({ openTrash: actions.openTrash }),
782
+ }, TrashFooterButton))
783
+
784
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
785
+ name: 'shell.overlay',
786
+ id: 'session-trash-overlay',
787
+ locale: NS,
788
+ inject: () => ({ face }),
789
+ }, TrashOverlay))
790
+ },
791
+ }
792
+ },
793
+ })
794
+
795
+ function textOf(translate, fallback) {
796
+ return (key, params) => {
797
+ let text
798
+ if (typeof translate === 'function') {
799
+ try {
800
+ text = translate(key, params)
801
+ } catch {
802
+ text = undefined
803
+ }
804
+ }
805
+ if (typeof text !== 'string' || text === key || text.endsWith(`:${key}`)) {
806
+ text = fallback?.[key] ?? key
807
+ }
808
+ if (params === undefined) return text
809
+ return text.replace(/\{(\w+)\}/g, (match, name) => (
810
+ Object.hasOwn(params, name) ? String(params[name]) : match
811
+ ))
812
+ }
813
+ }
814
+
815
+ function messageOf(reason) {
816
+ if (reason instanceof Error) return reason.message
817
+ return String(reason)
818
+ }
819
+
820
+ function errorText(reason, tr) {
821
+ const code = reason && typeof reason === 'object' ? reason.code : undefined
822
+ if (typeof code === 'string' && code !== '' && code !== 'request' && code !== 'session-trash/failed') {
823
+ const key = `error.${code}`
824
+ const text = tr(key)
825
+ if (text !== key) return text
826
+ }
827
+ return messageOf(reason)
828
+ }
829
+
830
+ function formatBytes(bytes) {
831
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 KB'
832
+ const units = ['B', 'KB', 'MB', 'GB']
833
+ let value = bytes
834
+ let index = 0
835
+ while (value >= 1024 && index < units.length - 1) {
836
+ value /= 1024
837
+ index += 1
838
+ }
839
+ const digits = value >= 10 || index === 0 ? 0 : 1
840
+ return `${value.toFixed(digits)} ${units[index]}`
841
+ }