@murchey/dsh-session-manager 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,857 @@
1
+ /**
2
+ * Session Shredder — browser half.
3
+ *
4
+ * A dedicated main panel (also reachable from its sidebar icon) that lists
5
+ * every stored session with its on-disk footprint, lets the user select the
6
+ * ones they no longer need, and permanently deletes them through the Host's
7
+ * authenticated `/api/session-shredder` routes.
8
+ *
9
+ * The page keeps its own controls and styling (theme tokens only) so it does
10
+ * not depend on the Host's private UI primitives.
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: '@murchey/dsh-session-manager',
14
+ factory(require) {
15
+ const React = require('react');
16
+ const h = React.createElement;
17
+ const NS = 'sessionShredder';
18
+
19
+ const zh = {
20
+ panel: '会话存储',
21
+ title: '会话存储',
22
+ subtitle: '查看每个会话在磁盘上占用的空间,并永久删除不再需要的会话。',
23
+ refresh: '刷新',
24
+ refreshing: '刷新中…',
25
+ search: '搜索会话',
26
+ searchPlaceholder: '按标题、ID 或项目目录搜索',
27
+ sortLabel: '排序',
28
+ sortSize: '按大小',
29
+ sortTime: '按时间',
30
+ total: '共 {count} 个会话',
31
+ usage: '总计 {size}',
32
+ hidden: '另有 {count} 个未显示',
33
+ selected: '已选 {count} 个 · {size}',
34
+ selectedNone: '未选择会话',
35
+ selectAll: '全选可删除的会话',
36
+ selectRow: '选择会话 {title}',
37
+ deleteSelected: '删除所选',
38
+ delete: '删除',
39
+ deleteRow: '删除会话 {title}',
40
+ prune: '清理失效栏目',
41
+ pruningLabel: '清理中…',
42
+ pruneHint: '移除侧边栏中数据已不存在的会话条目',
43
+ pruneBlocked: '会话列表尚未完整加载,暂时无法清理',
44
+ pruneDone: '已清理 {count} 个失效条目。',
45
+ pruneNone: '没有发现失效条目,侧边栏已经是干净的。',
46
+ pruneFailed: '清理失败:{message}',
47
+ live: '运行中',
48
+ subagent: '子代理',
49
+ archived: '已归档',
50
+ orphan: '仅磁盘',
51
+ empty: '还没有会话',
52
+ noMatches: '没有匹配的会话',
53
+ loading: '正在读取会话存储…',
54
+ loadError: '读取失败:{message}',
55
+ retry: '重试',
56
+ deleteTitle: '永久删除会话?',
57
+ deleteBody: '将从磁盘彻底删除 {count} 个会话,共 {size}。此操作不可撤销。',
58
+ deleteAttachments: '同时删除不再被其他会话引用的附件',
59
+ cancel: '取消',
60
+ confirmDelete: '永久删除',
61
+ deleting: '正在删除…',
62
+ done: '已永久删除 {count} 个会话,释放 {size}。',
63
+ partial: '删除完成,但有部分数据未能清理。',
64
+ failed: '删除失败:{message}',
65
+ details: '详情',
66
+ warnings: '警告',
67
+ close: '关闭',
68
+ sessionFallback: '未命名会话',
69
+ createdAt: '创建于 {date}',
70
+ cannotSelectLive: '运行中的会话不能删除,请先结束它',
71
+ note: '删除会移除会话日志、投影缓存和无引用附件;运行中的会话必须先结束。删除不可撤销。',
72
+ };
73
+
74
+ const en = {
75
+ panel: 'Session storage',
76
+ title: 'Session storage',
77
+ subtitle: 'See how much disk space each session uses and permanently delete the ones you no longer need.',
78
+ refresh: 'Refresh',
79
+ refreshing: 'Refreshing…',
80
+ search: 'Search sessions',
81
+ searchPlaceholder: 'Search by title, ID, or project folder',
82
+ sortLabel: 'Sort',
83
+ sortSize: 'By size',
84
+ sortTime: 'By date',
85
+ total: '{count} sessions',
86
+ usage: '{size} total',
87
+ hidden: '{count} more not shown',
88
+ selected: '{count} selected · {size}',
89
+ selectedNone: 'No sessions selected',
90
+ selectAll: 'Select all deletable sessions',
91
+ selectRow: 'Select session {title}',
92
+ deleteSelected: 'Delete selected',
93
+ delete: 'Delete',
94
+ deleteRow: 'Delete session {title}',
95
+ prune: 'Clear stale entries',
96
+ pruningLabel: 'Clearing…',
97
+ pruneHint: 'Remove sidebar entries whose session data no longer exists',
98
+ pruneBlocked: 'The session list is not fully loaded yet; cleanup is unavailable',
99
+ pruneDone: 'Cleared {count} stale entries.',
100
+ pruneNone: 'No stale entries found; the sidebar is already clean.',
101
+ pruneFailed: 'Cleanup failed: {message}',
102
+ live: 'Live',
103
+ subagent: 'Subagent',
104
+ archived: 'Archived',
105
+ orphan: 'Disk only',
106
+ empty: 'No sessions yet',
107
+ noMatches: 'No matching sessions',
108
+ loading: 'Reading session storage…',
109
+ loadError: 'Could not load: {message}',
110
+ retry: 'Retry',
111
+ deleteTitle: 'Permanently delete sessions?',
112
+ deleteBody: 'Permanently delete {count} sessions ({size}) from disk. This cannot be undone.',
113
+ deleteAttachments: 'Also delete attachments no other session references',
114
+ cancel: 'Cancel',
115
+ confirmDelete: 'Delete permanently',
116
+ deleting: 'Deleting…',
117
+ done: 'Permanently deleted {count} sessions and freed {size}.',
118
+ partial: 'Deletion finished, but some data could not be cleaned up.',
119
+ failed: 'Delete failed: {message}',
120
+ details: 'Details',
121
+ warnings: 'Warnings',
122
+ close: 'Close',
123
+ sessionFallback: 'Untitled session',
124
+ createdAt: 'Created {date}',
125
+ cannotSelectLive: 'Live sessions cannot be deleted; stop the session first',
126
+ note: 'Deletion removes the session log, projection cache, and unreferenced attachments. Live sessions must be stopped first. This cannot be undone.',
127
+ };
128
+
129
+ const CSS = `
130
+ .ss-page{height:100%;min-height:0;display:flex;flex-direction:column;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);font-size:14px;line-height:1.6;overflow:hidden}
131
+ .ss-scroll{flex:1;min-height:0;overflow:auto;scrollbar-gutter:stable}
132
+ .ss-content{max-width:960px;margin:0 auto;padding:28px clamp(20px,4vw,48px) 48px}
133
+ .ss-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}
134
+ .ss-head-actions{display:flex;align-items:center;gap:8px;flex:none;flex-wrap:wrap}
135
+ .ss-head h1{font-size:20px;font-weight:500;line-height:28px;margin:0}
136
+ .ss-subtitle{color:var(--dsw-alias-label-secondary);margin:6px 0 0;font-size:13px;line-height:20px;max-width:64ch}
137
+ .ss-stats{display:flex;flex-wrap:wrap;gap:8px 18px;color:var(--dsw-alias-label-secondary);font-size:13px;margin-bottom:18px}
138
+ .ss-stats strong{color:var(--dsw-alias-label-primary);font-weight:500}
139
+ .ss-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}
140
+ .ss-search{display:flex;align-items:center;gap:8px;flex:1 1 260px;min-width:0;height:36px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}
141
+ .ss-search:focus-within{border-color:var(--dsw-alias-brand-primary)}
142
+ .ss-search svg{flex:none;color:var(--dsw-alias-label-secondary)}
143
+ .ss-search input{flex:1;min-width:0;height:100%;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;outline:none}
144
+ .ss-search input::placeholder{color:var(--dsw-alias-label-secondary)}
145
+ .ss-search-clear{border:0;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px;line-height:1;padding:2px 4px;border-radius:6px}
146
+ .ss-search-clear:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
147
+ .ss-sort{display:flex;gap:4px;padding:2px;border-radius:10px;background:var(--dsw-alias-bg-layer-1)}
148
+ .ss-sort-btn{border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;padding:5px 10px;border-radius:8px;cursor:pointer}
149
+ .ss-sort-btn:hover{background:var(--dsw-alias-bg-layer-2)}
150
+ .ss-sort-active{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
151
+ .ss-selection{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;margin-bottom:10px;border-radius:12px;background:var(--dsw-alias-bg-layer-1);min-height:44px}
152
+ .ss-select-all{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;cursor:pointer;min-width:0}
153
+ .ss-select-all input{accent-color:var(--dsw-alias-brand-primary);width:15px;height:15px;flex:none}
154
+ .ss-list{list-style:none;margin:0;padding:0;border-top:.5px solid var(--dsw-alias-border-l1)}
155
+ .ss-row{display:flex;align-items:flex-start;gap:12px;padding:12px 8px;border-bottom:.5px solid var(--dsw-alias-border-l1)}
156
+ .ss-row:hover{background:var(--dsw-alias-bg-layer-1)}
157
+ .ss-row-check{margin-top:3px;accent-color:var(--dsw-alias-brand-primary);width:15px;height:15px;flex:none}
158
+ .ss-row-main{flex:1;min-width:0}
159
+ .ss-row-top{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
160
+ .ss-row-title{flex:1 1 200px;min-width:0;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
161
+ .ss-row-size{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);font-size:13px}
162
+ .ss-row-delete{flex:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;padding:2px 6px;border-radius:7px;align-self:center}
163
+ .ss-row-delete:hover{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
164
+ .ss-row-delete:disabled{opacity:.4;cursor:default;background:transparent;color:var(--dsw-alias-label-secondary)}
165
+ .ss-muted{color:var(--dsw-alias-label-secondary);font-size:12px}
166
+ .ss-badges{display:inline-flex;gap:4px;flex:none;flex-wrap:wrap}
167
+ .ss-badge{display:inline-flex;align-items:center;height:18px;padding:0 7px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:18px;white-space:nowrap}
168
+ .ss-badge-live{color:var(--dsw-alias-state-warn-primary)}
169
+ .ss-badge-archived{color:var(--dsw-alias-state-idle-primary);border:.5px solid var(--dsw-alias-border-l1)}
170
+ .ss-row-meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;margin-top:4px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
171
+ .ss-id{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:0 5px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
172
+ .ss-cwd{min-width:0;max-width:56ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
173
+ .ss-meta-date{white-space:nowrap}
174
+ .ss-bar{height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);margin-top:8px;overflow:hidden}
175
+ .ss-bar-fill{display:block;height:100%;border-radius:2px;background:color-mix(in srgb, var(--dsw-alias-brand-primary) 70%, transparent)}
176
+ .ss-row-live .ss-bar-fill{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 60%, transparent)}
177
+ .ss-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dsw-alias-label-secondary);padding:64px 20px;gap:10px}
178
+ .ss-empty p{margin:0}
179
+ .ss-spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-brand-primary);animation:ss-spin .8s linear infinite}
180
+ @keyframes ss-spin{to{transform:rotate(360deg)}}
181
+ .ss-error{color:var(--dsw-alias-state-error-primary)}
182
+ .ss-error-banner{padding:8px 12px;border-radius:10px;margin-bottom:10px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px}
183
+ .ss-notice{display:flex;align-items:flex-start;gap:10px;padding:9px 12px;border-radius:10px;margin-bottom:12px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:19px}
184
+ .ss-notice-success{color:var(--dsw-alias-state-success-primary)}
185
+ .ss-notice-error{color:var(--dsw-alias-state-error-primary)}
186
+ .ss-notice-warning{color:var(--dsw-alias-state-warn-primary)}
187
+ .ss-notice-body{min-width:0;flex:1}
188
+ .ss-notice details{margin-top:2px;width:100%}
189
+ .ss-notice summary{cursor:pointer}
190
+ .ss-notice pre{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;color:var(--dsw-alias-label-secondary);max-height:160px;overflow:auto}
191
+ .ss-notice-dismiss{margin-left:auto;border:0;background:transparent;color:inherit;cursor:pointer;font-size:16px;line-height:1;padding:2px 4px;border-radius:6px;flex:none}
192
+ .ss-notice-dismiss:hover{background:var(--dsw-alias-bg-layer-2)}
193
+ .ss-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:19px;margin:20px 0 0}
194
+ .ss-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 12px;border-radius:14px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
195
+ .ss-btn:hover{background:var(--dsw-alias-bg-layer-2)}
196
+ .ss-btn:disabled{opacity:.5;cursor:default}
197
+ .ss-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, var(--dsw-alias-border-l1))}
198
+ .ss-btn-danger:hover{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent)}
199
+ .ss-overlay{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;background:transparent;-webkit-backdrop-filter:brightness(.55) blur(2px);backdrop-filter:brightness(.55) blur(2px)}
200
+ .ss-dialog{width:min(480px,100%);max-height:calc(100dvh - 48px);overflow:auto;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:18px;padding:20px}
201
+ .ss-dialog h2{font-size:18px;font-weight:500;line-height:26px;margin:0 0 8px}
202
+ .ss-dialog p{margin:0 0 12px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
203
+ .ss-dialog-list{max-height:160px;overflow:auto;margin:0 0 12px;padding-left:18px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px}
204
+ .ss-dialog-list li{overflow-wrap:anywhere}
205
+ .ss-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
206
+ .ss-check{display:flex;align-items:flex-start;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer}
207
+ .ss-check input{margin-top:2px;accent-color:var(--dsw-alias-brand-primary);width:15px;height:15px;flex:none}
208
+ .ss-page :focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
209
+ @media (max-width:620px){.ss-cwd{max-width:100%}.ss-head{flex-direction:column}.ss-head>.ss-btn{align-self:flex-start}}
210
+ `;
211
+
212
+ function cx(...parts) {
213
+ return parts.filter(Boolean).join(' ');
214
+ }
215
+
216
+ function formatBytes(bytes) {
217
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
218
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
219
+ let value = bytes;
220
+ let unit = 0;
221
+ while (value >= 1024 && unit < units.length - 1) {
222
+ value /= 1024;
223
+ unit++;
224
+ }
225
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
226
+ }
227
+
228
+ function formatDate(ms) {
229
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';
230
+ try {
231
+ return new Intl.DateTimeFormat(undefined, {
232
+ year: 'numeric',
233
+ month: 'short',
234
+ day: 'numeric',
235
+ }).format(new Date(ms));
236
+ } catch {
237
+ return '';
238
+ }
239
+ }
240
+
241
+ async function requestJson(url, init = {}) {
242
+ const headers = init.body === undefined ? init.headers : { 'content-type': 'application/json', ...init.headers };
243
+ const response = await fetch(url, { credentials: 'same-origin', ...init, headers });
244
+ let value = null;
245
+ try {
246
+ value = await response.json();
247
+ } catch {
248
+ value = null;
249
+ }
250
+ if (!response.ok) {
251
+ const message = value && typeof value.error === 'string' ? value.error : `${response.status} ${response.statusText}`;
252
+ throw new Error(message);
253
+ }
254
+ return value ?? {};
255
+ }
256
+
257
+ function ShredderIcon({ size = 16 }) {
258
+ return h(
259
+ 'svg',
260
+ {
261
+ viewBox: '0 0 24 24',
262
+ width: size,
263
+ height: size,
264
+ fill: 'none',
265
+ stroke: 'currentColor',
266
+ strokeWidth: 1.6,
267
+ strokeLinecap: 'round',
268
+ strokeLinejoin: 'round',
269
+ 'aria-hidden': 'true',
270
+ focusable: 'false',
271
+ },
272
+ h('path', { d: 'M4 7h16' }),
273
+ h('path', { d: 'M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7' }),
274
+ h('path', { d: 'M6.5 7l.9 12.1A1.5 1.5 0 0 0 8.9 20.5h6.2a1.5 1.5 0 0 0 1.5-1.4L17.5 7' }),
275
+ h('path', { d: 'M10 11v5M14 11v5' }),
276
+ );
277
+ }
278
+
279
+ function SearchIcon() {
280
+ return h(
281
+ 'svg',
282
+ {
283
+ viewBox: '0 0 24 24',
284
+ width: 15,
285
+ height: 15,
286
+ fill: 'none',
287
+ stroke: 'currentColor',
288
+ strokeWidth: 1.8,
289
+ strokeLinecap: 'round',
290
+ 'aria-hidden': 'true',
291
+ focusable: 'false',
292
+ },
293
+ h('circle', { cx: 11, cy: 11, r: 6.5 }),
294
+ h('path', { d: 'm16 16 4 4' }),
295
+ );
296
+ }
297
+
298
+ function SessionShredderPage({ t, refreshSessions, pruneStaleSessions }) {
299
+ const [sessions, setSessions] = React.useState([]);
300
+ const [total, setTotal] = React.useState(0);
301
+ const [totalBytes, setTotalBytes] = React.useState(0);
302
+ const [hidden, setHidden] = React.useState(0);
303
+ const [loading, setLoading] = React.useState(true);
304
+ const [error, setError] = React.useState(null);
305
+ const [query, setQuery] = React.useState('');
306
+ const [sort, setSort] = React.useState('size');
307
+ const [selected, setSelected] = React.useState(() => new Set());
308
+ const [confirmOpen, setConfirmOpen] = React.useState(false);
309
+ const [purgeAttachments, setPurgeAttachments] = React.useState(true);
310
+ const [busy, setBusy] = React.useState(false);
311
+ const [pruning, setPruning] = React.useState(false);
312
+ const [notice, setNotice] = React.useState(null);
313
+ const cancelRef = React.useRef(null);
314
+ const deleteRef = React.useRef(null);
315
+ const selectAllRef = React.useRef(null);
316
+ const wasOpen = React.useRef(false);
317
+
318
+ const load = React.useCallback(async () => {
319
+ setLoading(true);
320
+ try {
321
+ const value = await requestJson('/api/session-shredder/sessions');
322
+ setSessions(Array.isArray(value.sessions) ? value.sessions : []);
323
+ setTotal(typeof value.total === 'number' ? value.total : 0);
324
+ setTotalBytes(typeof value.totalBytes === 'number' ? value.totalBytes : 0);
325
+ setHidden(typeof value.hidden === 'number' ? value.hidden : 0);
326
+ setError(null);
327
+ } catch (loadError) {
328
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
329
+ } finally {
330
+ setLoading(false);
331
+ }
332
+ }, []);
333
+
334
+ React.useEffect(() => {
335
+ void load();
336
+ }, [load]);
337
+
338
+ React.useEffect(() => {
339
+ setSelected((previous) => {
340
+ if (previous.size === 0) return previous;
341
+ const eligible = new Set(sessions.filter((session) => !session.live).map((session) => session.id));
342
+ const next = new Set([...previous].filter((id) => eligible.has(id)));
343
+ return next.size === previous.size ? previous : next;
344
+ });
345
+ }, [sessions]);
346
+
347
+ React.useEffect(() => {
348
+ if (confirmOpen) {
349
+ wasOpen.current = true;
350
+ const timer = setTimeout(() => cancelRef.current?.focus(), 0);
351
+ return () => clearTimeout(timer);
352
+ }
353
+ if (wasOpen.current) {
354
+ wasOpen.current = false;
355
+ deleteRef.current?.focus();
356
+ }
357
+ return undefined;
358
+ }, [confirmOpen]);
359
+
360
+ const visible = React.useMemo(() => {
361
+ const needle = query.trim().toLowerCase();
362
+ const rows = sessions.filter((session) => {
363
+ if (needle === '') return true;
364
+ return (
365
+ String(session.title ?? '').toLowerCase().includes(needle) ||
366
+ session.id.toLowerCase().includes(needle) ||
367
+ String(session.cwd ?? '').toLowerCase().includes(needle)
368
+ );
369
+ });
370
+ rows.sort((left, right) => {
371
+ if (sort === 'size') return right.bytes - left.bytes || (right.createdAt ?? 0) - (left.createdAt ?? 0);
372
+ return (right.createdAt ?? 0) - (left.createdAt ?? 0) || right.bytes - left.bytes;
373
+ });
374
+ return rows;
375
+ }, [sessions, query, sort]);
376
+
377
+ const eligibleVisible = React.useMemo(() => visible.filter((session) => !session.live), [visible]);
378
+ const allSelected = eligibleVisible.length > 0 && eligibleVisible.every((session) => selected.has(session.id));
379
+ const someSelected = eligibleVisible.some((session) => selected.has(session.id));
380
+
381
+ React.useEffect(() => {
382
+ if (selectAllRef.current !== null) selectAllRef.current.indeterminate = someSelected && !allSelected;
383
+ }, [someSelected, allSelected]);
384
+
385
+ const selectedSessions = React.useMemo(
386
+ () => sessions.filter((session) => selected.has(session.id)),
387
+ [sessions, selected],
388
+ );
389
+ const selectedBytes = selectedSessions.reduce((sum, session) => sum + session.bytes, 0);
390
+ const maxBytes = Math.max(1, ...sessions.map((session) => session.bytes));
391
+
392
+ const toggle = (id) => {
393
+ setSelected((previous) => {
394
+ const next = new Set(previous);
395
+ if (next.has(id)) next.delete(id);
396
+ else next.add(id);
397
+ return next;
398
+ });
399
+ };
400
+
401
+ const toggleAll = () => {
402
+ setSelected((previous) => {
403
+ const next = new Set(previous);
404
+ if (allSelected) {
405
+ for (const session of eligibleVisible) next.delete(session.id);
406
+ } else {
407
+ for (const session of eligibleVisible) next.add(session.id);
408
+ }
409
+ return next;
410
+ });
411
+ };
412
+
413
+ const performDelete = async () => {
414
+ const ids = selectedSessions.map((session) => session.id);
415
+ if (ids.length === 0) return;
416
+ setBusy(true);
417
+ try {
418
+ const value = await requestJson('/api/session-shredder/delete', {
419
+ method: 'POST',
420
+ body: JSON.stringify({ sessionIds: ids, confirm: true, purgeAttachments }),
421
+ });
422
+ const details = [];
423
+ if (Array.isArray(value.warnings)) details.push(...value.warnings);
424
+ for (const target of Array.isArray(value.targets) ? value.targets : []) {
425
+ for (const item of Array.isArray(target.errors) ? target.errors : []) details.push(`${target.id}: ${item}`);
426
+ }
427
+ setNotice({
428
+ kind: value.ok ? 'success' : 'warning',
429
+ text: value.ok
430
+ ? t('done', { count: ids.length, size: formatBytes(value.deletedBytes) })
431
+ : t('partial'),
432
+ details: details.join('\n'),
433
+ });
434
+ setConfirmOpen(false);
435
+ setSelected(new Set());
436
+ await load();
437
+ try {
438
+ // Refresh the Client catalog and drop any row whose data is gone,
439
+ // including the row just deleted. The manual "clear stale entries"
440
+ // button remains for leftovers from older sessions.
441
+ if (typeof pruneStaleSessions === 'function') await pruneStaleSessions();
442
+ else await refreshSessions?.();
443
+ } catch {
444
+ // The page already reflects the deletion; a sidebar sync failure is not a purge failure.
445
+ }
446
+ } catch (deleteError) {
447
+ setConfirmOpen(false);
448
+ setNotice({
449
+ kind: 'error',
450
+ text: t('failed', { message: deleteError instanceof Error ? deleteError.message : String(deleteError) }),
451
+ });
452
+ } finally {
453
+ setBusy(false);
454
+ }
455
+ };
456
+
457
+ const pruneEntries = async () => {
458
+ if (pruning || loading) return;
459
+ if (hidden > 0) {
460
+ setNotice({ kind: 'error', text: t('pruneBlocked') });
461
+ return;
462
+ }
463
+ setPruning(true);
464
+ try {
465
+ if (typeof pruneStaleSessions !== 'function') throw new Error('prune API unavailable');
466
+ const removed = await pruneStaleSessions();
467
+ if (typeof removed !== 'number' || removed < 0) throw new Error(t('pruneBlocked'));
468
+ setNotice({
469
+ kind: removed > 0 ? 'success' : 'info',
470
+ text: removed > 0 ? t('pruneDone', { count: removed }) : t('pruneNone'),
471
+ });
472
+ await load();
473
+ } catch (pruneError) {
474
+ setNotice({
475
+ kind: 'error',
476
+ text: t('pruneFailed', { message: pruneError instanceof Error ? pruneError.message : String(pruneError) }),
477
+ });
478
+ } finally {
479
+ setPruning(false);
480
+ }
481
+ };
482
+
483
+ const onDialogKeyDown = (event) => {
484
+ if (event.key === 'Escape' && !busy) {
485
+ event.preventDefault();
486
+ event.stopPropagation();
487
+ setConfirmOpen(false);
488
+ return;
489
+ }
490
+ if (event.key !== 'Tab') return;
491
+ const focusable = [...event.currentTarget.querySelectorAll('button, input, [href], select, textarea, [tabindex]:not([tabindex="-1"])')].filter(
492
+ (element) => !element.disabled,
493
+ );
494
+ if (focusable.length === 0) return;
495
+ const first = focusable[0];
496
+ const last = focusable[focusable.length - 1];
497
+ if (event.shiftKey && document.activeElement === first) {
498
+ event.preventDefault();
499
+ last.focus();
500
+ } else if (!event.shiftKey && document.activeElement === last) {
501
+ event.preventDefault();
502
+ first.focus();
503
+ }
504
+ };
505
+
506
+ const sortButton = (value, label) =>
507
+ h(
508
+ 'button',
509
+ {
510
+ type: 'button',
511
+ className: cx('ss-sort-btn', sort === value && 'ss-sort-active'),
512
+ 'aria-pressed': sort === value,
513
+ onClick: () => setSort(value),
514
+ },
515
+ label,
516
+ );
517
+
518
+ const row = (session) =>
519
+ h(
520
+ 'li',
521
+ { key: session.id, className: cx('ss-row', session.live && 'ss-row-live') },
522
+ h('input', {
523
+ type: 'checkbox',
524
+ className: 'ss-row-check',
525
+ checked: selected.has(session.id),
526
+ disabled: session.live,
527
+ onChange: () => toggle(session.id),
528
+ 'aria-label': t('selectRow', { title: session.title ?? session.id }),
529
+ title: session.live ? t('cannotSelectLive') : undefined,
530
+ }),
531
+ h(
532
+ 'div',
533
+ { className: 'ss-row-main' },
534
+ h(
535
+ 'div',
536
+ { className: 'ss-row-top' },
537
+ h('span', { className: 'ss-row-title', title: session.title ?? session.id }, session.title ?? t('sessionFallback')),
538
+ h(
539
+ 'span',
540
+ { className: 'ss-badges' },
541
+ session.live ? h('span', { className: 'ss-badge ss-badge-live' }, t('live')) : null,
542
+ session.archived ? h('span', { className: 'ss-badge ss-badge-archived' }, t('archived')) : null,
543
+ session.subagent ? h('span', { className: 'ss-badge' }, t('subagent')) : null,
544
+ session.orphan ? h('span', { className: 'ss-badge' }, t('orphan')) : null,
545
+ ),
546
+ h('span', { className: 'ss-row-size' }, formatBytes(session.bytes)),
547
+ h(
548
+ 'button',
549
+ {
550
+ type: 'button',
551
+ className: 'ss-row-delete',
552
+ disabled: session.live,
553
+ onClick: () => {
554
+ setSelected(new Set([session.id]));
555
+ setConfirmOpen(true);
556
+ },
557
+ 'aria-label': t('deleteRow', { title: session.title ?? session.id }),
558
+ },
559
+ t('delete'),
560
+ ),
561
+ ),
562
+ h(
563
+ 'div',
564
+ { className: 'ss-row-meta' },
565
+ h('code', { className: 'ss-id' }, session.id),
566
+ session.cwd ? h('span', { className: 'ss-cwd', title: session.cwd }, session.cwd) : null,
567
+ session.createdAt ? h('span', { className: 'ss-meta-date' }, t('createdAt', { date: formatDate(session.createdAt) })) : null,
568
+ ),
569
+ h(
570
+ 'div',
571
+ { className: 'ss-bar', 'aria-hidden': 'true' },
572
+ h('span', {
573
+ className: 'ss-bar-fill',
574
+ style: { width: session.bytes > 0 ? `${Math.max(1, (session.bytes / maxBytes) * 100)}%` : '0%' },
575
+ }),
576
+ ),
577
+ ),
578
+ );
579
+
580
+ let body;
581
+ if (loading && sessions.length === 0) {
582
+ body = h('div', { className: 'ss-empty', role: 'status' }, h('span', { className: 'ss-spinner' }), h('p', null, t('loading')));
583
+ } else if (error !== null && sessions.length === 0) {
584
+ body = h(
585
+ 'div',
586
+ { className: 'ss-empty', role: 'alert' },
587
+ h('p', { className: 'ss-error' }, t('loadError', { message: error })),
588
+ h('button', { type: 'button', className: 'ss-btn', onClick: () => void load() }, t('retry')),
589
+ );
590
+ } else if (visible.length === 0) {
591
+ body = h('div', { className: 'ss-empty', role: 'status' }, h('p', null, t(sessions.length === 0 ? 'empty' : 'noMatches')));
592
+ } else {
593
+ body = h('ul', { className: 'ss-list', 'aria-busy': loading }, visible.map(row));
594
+ }
595
+
596
+ return h(
597
+ 'section',
598
+ { className: 'ss-page', 'aria-label': t('title') },
599
+ h('style', null, CSS),
600
+ h(
601
+ 'div',
602
+ { className: 'ss-scroll' },
603
+ h(
604
+ 'div',
605
+ { className: 'ss-content' },
606
+ h(
607
+ 'div',
608
+ { className: 'ss-head' },
609
+ h(
610
+ 'div',
611
+ null,
612
+ h('h1', null, t('title')),
613
+ h('p', { className: 'ss-subtitle' }, t('subtitle')),
614
+ ),
615
+ h(
616
+ 'div',
617
+ { className: 'ss-head-actions' },
618
+ h(
619
+ 'button',
620
+ {
621
+ type: 'button',
622
+ className: 'ss-btn',
623
+ title: hidden > 0 ? t('pruneBlocked') : t('pruneHint'),
624
+ disabled: loading || pruning || hidden > 0,
625
+ onClick: () => void pruneEntries(),
626
+ },
627
+ pruning ? t('pruningLabel') : t('prune'),
628
+ ),
629
+ h(
630
+ 'button',
631
+ { type: 'button', className: 'ss-btn', onClick: () => void load(), disabled: loading },
632
+ loading ? t('refreshing') : t('refresh'),
633
+ ),
634
+ ),
635
+ ),
636
+ h(
637
+ 'div',
638
+ { className: 'ss-stats' },
639
+ h('span', null, t('total', { count: total })),
640
+ h('span', null, t('usage', { size: formatBytes(totalBytes) })),
641
+ hidden > 0 ? h('span', null, t('hidden', { count: hidden })) : null,
642
+ ),
643
+ error !== null && sessions.length > 0 ? h('div', { className: 'ss-error-banner', role: 'alert' }, t('loadError', { message: error })) : null,
644
+ notice !== null
645
+ ? h(
646
+ 'div',
647
+ { className: cx('ss-notice', `ss-notice-${notice.kind}`), role: notice.kind === 'error' ? 'alert' : 'status' },
648
+ h(
649
+ 'div',
650
+ { className: 'ss-notice-body' },
651
+ h('span', null, notice.text),
652
+ notice.details
653
+ ? h('details', null, h('summary', null, t('details')), h('pre', null, notice.details))
654
+ : null,
655
+ ),
656
+ h(
657
+ 'button',
658
+ { type: 'button', className: 'ss-notice-dismiss', 'aria-label': t('close'), onClick: () => setNotice(null) },
659
+ '×',
660
+ ),
661
+ )
662
+ : null,
663
+ h(
664
+ 'div',
665
+ { className: 'ss-toolbar' },
666
+ h(
667
+ 'div',
668
+ { className: 'ss-search' },
669
+ h(SearchIcon),
670
+ h('input', {
671
+ type: 'search',
672
+ value: query,
673
+ onChange: (event) => setQuery(event.target.value),
674
+ placeholder: t('searchPlaceholder'),
675
+ 'aria-label': t('search'),
676
+ }),
677
+ query !== ''
678
+ ? h(
679
+ 'button',
680
+ { type: 'button', className: 'ss-search-clear', 'aria-label': t('close'), onClick: () => setQuery('') },
681
+ '×',
682
+ )
683
+ : null,
684
+ ),
685
+ h(
686
+ 'div',
687
+ { className: 'ss-sort', role: 'group', 'aria-label': t('sortLabel') },
688
+ sortButton('size', t('sortSize')),
689
+ sortButton('time', t('sortTime')),
690
+ ),
691
+ ),
692
+ h(
693
+ 'div',
694
+ { className: 'ss-selection' },
695
+ h(
696
+ 'label',
697
+ { className: 'ss-select-all' },
698
+ h('input', {
699
+ ref: selectAllRef,
700
+ type: 'checkbox',
701
+ checked: allSelected,
702
+ disabled: eligibleVisible.length === 0,
703
+ onChange: toggleAll,
704
+ 'aria-label': t('selectAll'),
705
+ }),
706
+ h(
707
+ 'span',
708
+ null,
709
+ selectedSessions.length > 0
710
+ ? t('selected', { count: selectedSessions.length, size: formatBytes(selectedBytes) })
711
+ : t('selectedNone'),
712
+ ),
713
+ ),
714
+ h(
715
+ 'button',
716
+ {
717
+ ref: deleteRef,
718
+ type: 'button',
719
+ className: 'ss-btn ss-btn-danger',
720
+ disabled: selectedSessions.length === 0 || busy,
721
+ onClick: () => setConfirmOpen(true),
722
+ },
723
+ t('deleteSelected'),
724
+ ),
725
+ ),
726
+ body,
727
+ h('p', { className: 'ss-note' }, t('note')),
728
+ ),
729
+ ),
730
+ confirmOpen
731
+ ? h(
732
+ 'div',
733
+ {
734
+ className: 'ss-overlay',
735
+ onMouseDown: (event) => {
736
+ if (event.target === event.currentTarget && !busy) setConfirmOpen(false);
737
+ },
738
+ },
739
+ h(
740
+ 'div',
741
+ {
742
+ className: 'ss-dialog',
743
+ role: 'dialog',
744
+ 'aria-modal': 'true',
745
+ 'aria-labelledby': 'ss-delete-title',
746
+ onKeyDown: onDialogKeyDown,
747
+ },
748
+ h('h2', { id: 'ss-delete-title' }, t('deleteTitle')),
749
+ h('p', null, t('deleteBody', { count: selectedSessions.length, size: formatBytes(selectedBytes) })),
750
+ h(
751
+ 'ul',
752
+ { className: 'ss-dialog-list' },
753
+ selectedSessions.slice(0, 8).map((session) => h('li', { key: session.id }, session.title ?? session.id)),
754
+ ),
755
+ selectedSessions.length > 8
756
+ ? h('p', { className: 'ss-muted' }, `+${selectedSessions.length - 8}`)
757
+ : null,
758
+ h(
759
+ 'label',
760
+ { className: 'ss-check' },
761
+ h('input', {
762
+ type: 'checkbox',
763
+ checked: purgeAttachments,
764
+ onChange: (event) => setPurgeAttachments(event.target.checked),
765
+ disabled: busy,
766
+ }),
767
+ h('span', null, t('deleteAttachments')),
768
+ ),
769
+ h(
770
+ 'div',
771
+ { className: 'ss-dialog-actions' },
772
+ h(
773
+ 'button',
774
+ { ref: cancelRef, type: 'button', className: 'ss-btn', disabled: busy, onClick: () => setConfirmOpen(false) },
775
+ t('cancel'),
776
+ ),
777
+ h(
778
+ 'button',
779
+ { type: 'button', className: 'ss-btn ss-btn-danger', disabled: busy, onClick: () => void performDelete() },
780
+ busy ? t('deleting') : t('confirmDelete'),
781
+ ),
782
+ ),
783
+ ),
784
+ )
785
+ : null,
786
+ );
787
+ }
788
+
789
+ return {
790
+ inject: ['slots', 'locale', 'sessions'],
791
+ apply(ctx) {
792
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-shredder: dictionaries');
793
+ const t = ctx.locale.bind(NS);
794
+ const refreshSessions = async () => {
795
+ try {
796
+ await ctx.sessions.refresh();
797
+ } catch {
798
+ // Best-effort: keep the deletion result even if the catalog pull fails.
799
+ }
800
+ };
801
+ /**
802
+ * Pull the authoritative Host catalog, then drop any Client-side row
803
+ * whose session no longer exists there. This cleans entries left behind
804
+ * by deletions performed before the page learned to refresh the list.
805
+ */
806
+ const pruneStaleSessions = async (keepIds) => {
807
+ let keep;
808
+ if (Array.isArray(keepIds)) {
809
+ keep = new Set(keepIds);
810
+ } else {
811
+ // Pull the authoritative list at click time so a session created
812
+ // after this page loaded is never mistaken for a stale row.
813
+ const data = await requestJson('/api/session-shredder/sessions');
814
+ if (data !== null && typeof data.hidden === 'number' && data.hidden > 0) return -1;
815
+ keep = new Set((Array.isArray(data?.sessions) ? data.sessions : []).map((session) => session.id));
816
+ }
817
+ await ctx.sessions.refresh();
818
+ const snapshot = ctx.sessions.list.getSnapshot();
819
+ const stale = snapshot.ids.filter((id) => !keep.has(id));
820
+ if (stale.length === 0) return 0;
821
+ if (typeof ctx.sessions.handleSessionRemoved !== 'function') return -1;
822
+ for (const id of stale) {
823
+ try {
824
+ ctx.sessions.handleSessionRemoved(id);
825
+ } catch {
826
+ // Keep going: one stubborn row must not block the rest of the cleanup.
827
+ }
828
+ }
829
+ return stale.length;
830
+ };
831
+ ctx.slots.inject('main', () =>
832
+ ctx.slots.register(
833
+ {
834
+ name: 'main',
835
+ key: 'session-shredder',
836
+ locale: NS,
837
+ inject: () => ({ refreshSessions, pruneStaleSessions }),
838
+ },
839
+ SessionShredderPage,
840
+ ),
841
+ );
842
+ ctx.slots.inject('sidebar.panellist', () =>
843
+ ctx.slots.register(
844
+ {
845
+ name: 'sidebar.panellist',
846
+ id: 'session-shredder',
847
+ order: 20,
848
+ locale: NS,
849
+ label: () => t('panel'),
850
+ },
851
+ ShredderIcon,
852
+ ),
853
+ );
854
+ },
855
+ };
856
+ },
857
+ });