@sunshaobei/dsh-session-manager 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.md +115 -2
- package/README.zh.md +115 -0
- package/client.css +1613 -0
- package/client.js +2189 -0
- package/cordis.patch.yml +16 -0
- package/index.js +78 -0
- package/package.json +61 -4
- package/packages/session-trash-host/lib/index.js +1904 -0
- package/packages/session-trash-host/lib/types/index.d.ts +32 -0
- package/packages/session-trash-host/package.json +26 -0
package/client.js
ADDED
|
@@ -0,0 +1,2189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the session-trash bundle.
|
|
3
|
+
* Hand-written `__ModuleLoader__` package shell for DeepSeek Harness.
|
|
4
|
+
*/
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: 'dsh-session-manager',
|
|
7
|
+
factory: (require) => {
|
|
8
|
+
'use strict';
|
|
9
|
+
const module = { exports: {} };
|
|
10
|
+
const exports = module.exports;
|
|
11
|
+
|
|
12
|
+
const React = require('react');
|
|
13
|
+
const { MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
14
|
+
const { useState, useEffect, useCallback } = React;
|
|
15
|
+
const h = React.createElement;
|
|
16
|
+
|
|
17
|
+
const API_PREFIX = '/api/session-trash';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Host toast timing, mirrored so a plugin notice lives as long as a host
|
|
21
|
+
* one: `LONG_TOAST_HOLD_MS` in dsh-client-ui-workspace is 6e3 (this layer
|
|
22
|
+
* used 4000), and Toast.module.css fades over 1000ms after that hold.
|
|
23
|
+
*/
|
|
24
|
+
const TOAST_HOLD_MS = 6000;
|
|
25
|
+
const TOAST_FADE_MS = 1000;
|
|
26
|
+
|
|
27
|
+
/** Host tooltip delay for an icon-only action button (dsh-client-ui-workspace). */
|
|
28
|
+
const TOOLTIP_DELAY_MS = 500;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hover info card timing: `OPEN` matches the panel's existing 200ms hover
|
|
32
|
+
* delay, `CLOSE` is the grace period that lets the pointer travel from the
|
|
33
|
+
* row onto the card without it vanishing on the way.
|
|
34
|
+
*/
|
|
35
|
+
const TOOLTIP_OPEN_DELAY_MS = 200;
|
|
36
|
+
const TOOLTIP_CLOSE_DELAY_MS = 250;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sub-session (delegation children) display limits: rows rendered before the
|
|
40
|
+
* "N more" control, and the list length above which the permanent-delete
|
|
41
|
+
* confirmation scrolls its own list instead of growing the dialog.
|
|
42
|
+
*/
|
|
43
|
+
const CHILD_ROW_LIMIT = 50;
|
|
44
|
+
const CASCADE_DIALOG_LIST_LIMIT = 8;
|
|
45
|
+
|
|
46
|
+
const MESSAGES = {
|
|
47
|
+
en: {
|
|
48
|
+
undo: 'Undo',
|
|
49
|
+
loadFailed: 'Failed to load the recycle bin',
|
|
50
|
+
restored: 'Restored session “{title}”',
|
|
51
|
+
undoFailed: 'Undo failed',
|
|
52
|
+
restoreFailed: 'Restore failed: {message}',
|
|
53
|
+
confirmPermanentTitle: 'Confirm permanent deletion',
|
|
54
|
+
confirmPermanentBody: 'Permanently delete the session “{title}”? This cannot be undone and its conversation history will be erased.',
|
|
55
|
+
deleteFailed: 'Deletion failed: {message}',
|
|
56
|
+
unknownError: 'Unknown error',
|
|
57
|
+
deleted: 'Session permanently deleted',
|
|
58
|
+
deleteSession: 'Delete Session',
|
|
59
|
+
deleteArchivedTitle: 'Permanently delete archived sessions',
|
|
60
|
+
deleteWorkspaceBody: 'Permanently delete {count} session(s) from the workspace “{title}”? This cannot be undone.',
|
|
61
|
+
deletedSelected: 'Selected archived sessions permanently deleted',
|
|
62
|
+
deleteAllTitle: 'Delete all archived sessions',
|
|
63
|
+
deleteAllBody: 'Permanently delete all {count} archived session(s)? This cannot be undone.',
|
|
64
|
+
emptied: 'Permanently deleted all {count} session(s) from the recycle bin',
|
|
65
|
+
emptyFailed: 'Failed to empty the recycle bin: {message}',
|
|
66
|
+
historyFailed: 'Failed to load session history: {message}',
|
|
67
|
+
other: 'Other',
|
|
68
|
+
otherProjects: 'Other projects',
|
|
69
|
+
otherWorkspaces: 'Other workspaces',
|
|
70
|
+
unnamedProject: 'Unnamed project',
|
|
71
|
+
unnamedSession: 'Unnamed session',
|
|
72
|
+
recycleBin: 'Session Recycle Bin',
|
|
73
|
+
archivedCount: '{count} archived session(s)',
|
|
74
|
+
searchPlaceholder: 'Search archived sessions...',
|
|
75
|
+
allProjects: 'All projects',
|
|
76
|
+
newestFirst: 'Recently deleted (newest first)',
|
|
77
|
+
oldestFirst: 'Oldest deleted (oldest first)',
|
|
78
|
+
deleteAll: 'Delete all',
|
|
79
|
+
loading: 'Loading archived sessions...',
|
|
80
|
+
empty: 'The recycle bin is empty',
|
|
81
|
+
noMatches: 'No matching archived sessions found',
|
|
82
|
+
sessionCount: '{count} session(s)',
|
|
83
|
+
projectActions: 'Project actions',
|
|
84
|
+
selectedSuffix: '{title} ({count} selected)',
|
|
85
|
+
deleteSelected: 'Permanently delete selected ({count})',
|
|
86
|
+
deleteProject: 'Permanently delete all in project',
|
|
87
|
+
turnCount: '{count} turn(s)',
|
|
88
|
+
view: 'View',
|
|
89
|
+
restore: 'Restore',
|
|
90
|
+
permanentlyDelete: 'Permanently delete',
|
|
91
|
+
previewTitle: 'Session: {title}',
|
|
92
|
+
loadingHistory: 'Loading conversation history...',
|
|
93
|
+
noHistory: 'No conversation history',
|
|
94
|
+
user: '👤 User',
|
|
95
|
+
assistant: '🤖 Assistant',
|
|
96
|
+
cancel: 'Cancel',
|
|
97
|
+
confirmDelete: 'Confirm deletion',
|
|
98
|
+
storagePath: 'Storage path: {path}',
|
|
99
|
+
noActiveSession: 'Could not identify the active session ID',
|
|
100
|
+
movedToBin: 'Moved session “{title}” to the recycle bin',
|
|
101
|
+
currentSession: 'Current session',
|
|
102
|
+
session: 'Session',
|
|
103
|
+
moveFailed: 'Failed to move session to the recycle bin: {message}',
|
|
104
|
+
moveToBin: 'Move to Recycle Bin',
|
|
105
|
+
sessionRunning: 'The session is currently running and cannot be deleted.',
|
|
106
|
+
// Sub-session (delegation children) badge, inline list and cascade delete.
|
|
107
|
+
listSeparator: ', ',
|
|
108
|
+
childrenCount: '{count} sub-session(s)',
|
|
109
|
+
childrenBadgeLabel: 'Sub-sessions of this session: {count}',
|
|
110
|
+
childrenRunning: 'Running',
|
|
111
|
+
childrenRunningHint: 'Can be deleted once it finishes',
|
|
112
|
+
childrenMore: '{count} more',
|
|
113
|
+
childrenEmpty: 'No sub-sessions',
|
|
114
|
+
childrenJustNow: 'Just now',
|
|
115
|
+
childrenMinutesAgo: '{count} min ago',
|
|
116
|
+
childrenHoursAgo: '{count} h ago',
|
|
117
|
+
childrenYesterday: 'Yesterday',
|
|
118
|
+
childrenThisWeek: 'This week',
|
|
119
|
+
childrenEarlier: 'Earlier',
|
|
120
|
+
selectSessionOnly: 'Selects this session only, not its sub-sessions',
|
|
121
|
+
childrenRunningBlocked: 'Sub-session “{titles}” is still running — delete it after it finishes.',
|
|
122
|
+
childrenNoneSelected: 'Select at least one sub-session first.',
|
|
123
|
+
cascadeDeleteTitle: 'Confirm permanent deletion',
|
|
124
|
+
cascadeDeleteBody: 'Permanently delete {parents} main session(s) + {children} sub-session(s) ({size} in total)? This cannot be undone.',
|
|
125
|
+
orphanCardTitle: 'Deleted main session · {count} sub-session(s) kept',
|
|
126
|
+
orphanKept: 'Kept',
|
|
127
|
+
},
|
|
128
|
+
zh: {
|
|
129
|
+
undo: '撤销',
|
|
130
|
+
loadFailed: '加载回收站列表失败',
|
|
131
|
+
restored: '已恢复会话「{title}」',
|
|
132
|
+
undoFailed: '撤销失败',
|
|
133
|
+
restoreFailed: '恢复失败: {message}',
|
|
134
|
+
confirmPermanentTitle: '确认彻底删除',
|
|
135
|
+
confirmPermanentBody: '确定要永久删除会话「{title}」吗?此操作不可撤销,历史对话记录将被完全清除。',
|
|
136
|
+
deleteFailed: '删除失败: {message}',
|
|
137
|
+
unknownError: '未知错误',
|
|
138
|
+
deleted: '已彻底删除会话',
|
|
139
|
+
deleteSession: '删除会话',
|
|
140
|
+
deleteArchivedTitle: '彻底删除归档会话',
|
|
141
|
+
deleteWorkspaceBody: '确定要永久删除工作区「{title}」下的 {count} 个会话吗?此操作不可撤销。',
|
|
142
|
+
deletedSelected: '已彻底删除所选归档会话',
|
|
143
|
+
deleteAllTitle: '全部删除归档会话',
|
|
144
|
+
deleteAllBody: '确定要永久删除全部 {count} 个归档会话吗?此操作不可撤销。',
|
|
145
|
+
emptied: '已清空回收站中全部 {count} 个会话',
|
|
146
|
+
emptyFailed: '清空失败: {message}',
|
|
147
|
+
historyFailed: '获取会话记录失败: {message}',
|
|
148
|
+
other: '其他',
|
|
149
|
+
otherProjects: '其他项目',
|
|
150
|
+
otherWorkspaces: '其他工作区',
|
|
151
|
+
unnamedProject: '未命名项目',
|
|
152
|
+
unnamedSession: '未命名会话',
|
|
153
|
+
recycleBin: '会话回收站',
|
|
154
|
+
archivedCount: '共 {count} 个已归档会话',
|
|
155
|
+
searchPlaceholder: '搜索已归档聊天...',
|
|
156
|
+
allProjects: '所有项目',
|
|
157
|
+
newestFirst: '最近删除 (降序)',
|
|
158
|
+
oldestFirst: '最早删除 (正序)',
|
|
159
|
+
deleteAll: '全部删除',
|
|
160
|
+
loading: '加载已归档会话...',
|
|
161
|
+
empty: '回收站暂无会话',
|
|
162
|
+
noMatches: '未找到匹配的归档会话',
|
|
163
|
+
sessionCount: '{count} 个聊天',
|
|
164
|
+
projectActions: '项目操作',
|
|
165
|
+
selectedSuffix: '{title} (选中的 {count} 项)',
|
|
166
|
+
deleteSelected: '彻底删除选中 ({count})',
|
|
167
|
+
deleteProject: '彻底删除项目全部',
|
|
168
|
+
turnCount: '{count} 轮对话',
|
|
169
|
+
view: '查看',
|
|
170
|
+
restore: '还原',
|
|
171
|
+
permanentlyDelete: '彻底删除',
|
|
172
|
+
previewTitle: '查看会话:{title}',
|
|
173
|
+
loadingHistory: '正在读取历史对话记录...',
|
|
174
|
+
noHistory: '暂无对话记录',
|
|
175
|
+
user: '👤 用户',
|
|
176
|
+
assistant: '🤖 助手',
|
|
177
|
+
cancel: '取消',
|
|
178
|
+
confirmDelete: '确定删除',
|
|
179
|
+
storagePath: '存储路径: {path}',
|
|
180
|
+
noActiveSession: '无法识别当前活动会话 ID',
|
|
181
|
+
movedToBin: '已将会话「{title}」移入回收站',
|
|
182
|
+
currentSession: '当前会话',
|
|
183
|
+
session: '会话',
|
|
184
|
+
moveFailed: '移入回收站失败: {message}',
|
|
185
|
+
moveToBin: '移入回收站',
|
|
186
|
+
sessionRunning: '会话正在运行中,无法删除',
|
|
187
|
+
// 子会话(委派后代)徽标、行内清单与级联删除
|
|
188
|
+
listSeparator: '、',
|
|
189
|
+
childrenCount: '{count} 个子会话',
|
|
190
|
+
childrenBadgeLabel: '该会话的子会话数:{count}',
|
|
191
|
+
childrenRunning: '运行中',
|
|
192
|
+
childrenRunningHint: '结束后可删除',
|
|
193
|
+
childrenMore: '还有 {count} 条',
|
|
194
|
+
childrenEmpty: '没有子会话',
|
|
195
|
+
childrenJustNow: '刚刚',
|
|
196
|
+
childrenMinutesAgo: '{count} 分钟前',
|
|
197
|
+
childrenHoursAgo: '{count} 小时前',
|
|
198
|
+
childrenYesterday: '昨天',
|
|
199
|
+
childrenThisWeek: '本周',
|
|
200
|
+
childrenEarlier: '更早',
|
|
201
|
+
selectSessionOnly: '仅选择该会话,不含其子会话',
|
|
202
|
+
childrenRunningBlocked: '子会话「{titles}」仍在运行中,结束后才能删除。',
|
|
203
|
+
childrenNoneSelected: '请先勾选至少一个子会话。',
|
|
204
|
+
cascadeDeleteTitle: '彻底删除会话',
|
|
205
|
+
cascadeDeleteBody: '确定要彻底删除 {parents} 个主会话 + {children} 个子会话(共 {size})吗?此操作不可撤销。',
|
|
206
|
+
orphanCardTitle: '已删除的主会话 · 保留 {count} 个子会话',
|
|
207
|
+
orphanKept: '已保留',
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Host locale service (0.1.5 Web app mounts `@deepseek-ai/dsh-client-locale`
|
|
213
|
+
* as `locale`). It owns the Language row in Settings and resolves explicit
|
|
214
|
+
* choice → browser → English; the browser is only its fallback, so asking
|
|
215
|
+
* the browser directly would disagree with the rest of the app whenever the
|
|
216
|
+
* two differ.
|
|
217
|
+
*
|
|
218
|
+
* NEVER add 'locale' to exports.inject: older cohorts (0.1.1/0.1.2) have no
|
|
219
|
+
* such service, and a missing injection parks the whole fiber INACTIVE (the
|
|
220
|
+
* same failure the typert inject caused before). Probe it via ctx.get.
|
|
221
|
+
*/
|
|
222
|
+
const LOCALE_NS = 'session-trash';
|
|
223
|
+
let hostLocale;
|
|
224
|
+
let hostTranslate;
|
|
225
|
+
|
|
226
|
+
function attachHostLocale(ctx) {
|
|
227
|
+
hostLocale = typeof ctx?.get === 'function' ? ctx.get('locale') : undefined;
|
|
228
|
+
if (!hostLocale || typeof hostLocale.register !== 'function' || typeof hostLocale.bind !== 'function') return;
|
|
229
|
+
try {
|
|
230
|
+
const dispose = hostLocale.register(LOCALE_NS, MESSAGES);
|
|
231
|
+
ctx.effect?.(() => dispose, 'session-trash: locale dictionaries');
|
|
232
|
+
} catch {
|
|
233
|
+
// A twin instance may already own the namespace; `bind` still resolves
|
|
234
|
+
// the same dictionaries, so fall through and probe below.
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const bound = hostLocale.bind(LOCALE_NS);
|
|
238
|
+
// Trust the service only once OUR keys resolve through it — translate()
|
|
239
|
+
// returns the raw key when the namespace carries no dictionary.
|
|
240
|
+
if (bound('recycleBin') !== 'recycleBin') hostTranslate = bound;
|
|
241
|
+
} catch { /* keep the built-in table */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function browserLocale() {
|
|
245
|
+
return window?.navigator?.language || window?.navigator?.languages?.[0] || '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Active language for the built-in table: host choice first, browser second. */
|
|
249
|
+
function activeLocale() {
|
|
250
|
+
let active;
|
|
251
|
+
try {
|
|
252
|
+
active = hostLocale?.getLocale?.()?.active ?? hostLocale?.getSnapshot?.()?.active;
|
|
253
|
+
} catch { /* service teardown — fall back to the browser */ }
|
|
254
|
+
return /^zh(?:-|$)/i.test(String(active || browserLocale())) ? 'zh' : 'en';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function t(key, params = {}) {
|
|
258
|
+
if (hostTranslate) {
|
|
259
|
+
try { return hostTranslate(key, params); } catch { /* fall through */ }
|
|
260
|
+
}
|
|
261
|
+
const template = MESSAGES[activeLocale()]?.[key] ?? MESSAGES.en[key] ?? key;
|
|
262
|
+
return String(template).replace(/\{(\w+)\}/g, (_match, name) => String(params[name] ?? ''));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function localizedError(error) {
|
|
266
|
+
if (error?.code === 'SESSION_RUNNING') return t('sessionRunning');
|
|
267
|
+
return error?.message || t('unknownError');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function localizeServerFallback(value) {
|
|
271
|
+
if (value === '未命名会话' || value === 'Unnamed session') return t('unnamedSession');
|
|
272
|
+
if (value === '其他项目' || value === 'Other projects') return t('otherProjects');
|
|
273
|
+
if (value === '其他工作区' || value === 'Other workspaces') return t('otherWorkspaces');
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Safe icon helper functions (zero-fail direct element creation) */
|
|
278
|
+
function renderTrashIcon(props = {}) {
|
|
279
|
+
const { style, ...rest } = props;
|
|
280
|
+
return h(
|
|
281
|
+
'svg',
|
|
282
|
+
{
|
|
283
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
284
|
+
width: props.width || '15',
|
|
285
|
+
height: props.height || '15',
|
|
286
|
+
viewBox: '0 0 24 24',
|
|
287
|
+
fill: 'none',
|
|
288
|
+
stroke: 'currentColor',
|
|
289
|
+
strokeWidth: '2',
|
|
290
|
+
strokeLinecap: 'round',
|
|
291
|
+
strokeLinejoin: 'round',
|
|
292
|
+
'aria-hidden': 'true',
|
|
293
|
+
style,
|
|
294
|
+
...rest,
|
|
295
|
+
},
|
|
296
|
+
h('path', { d: 'M3 6h18' }),
|
|
297
|
+
h('path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }),
|
|
298
|
+
h('path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }),
|
|
299
|
+
h('line', { x1: '10', y1: '11', x2: '10', y2: '17' }),
|
|
300
|
+
h('line', { x1: '14', y1: '11', x2: '14', y2: '17' })
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderFolderIcon(props = {}) {
|
|
305
|
+
const { style, ...rest } = props;
|
|
306
|
+
return h(
|
|
307
|
+
'svg',
|
|
308
|
+
{
|
|
309
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
310
|
+
width: props.width || '16',
|
|
311
|
+
height: props.height || '16',
|
|
312
|
+
viewBox: '0 0 24 24',
|
|
313
|
+
fill: 'none',
|
|
314
|
+
stroke: 'currentColor',
|
|
315
|
+
strokeWidth: '2',
|
|
316
|
+
strokeLinecap: 'round',
|
|
317
|
+
strokeLinejoin: 'round',
|
|
318
|
+
'aria-hidden': 'true',
|
|
319
|
+
style,
|
|
320
|
+
...rest,
|
|
321
|
+
},
|
|
322
|
+
h('path', { d: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z' })
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function renderEyeIcon(props = {}) {
|
|
327
|
+
const { style, ...rest } = props;
|
|
328
|
+
return h(
|
|
329
|
+
'svg',
|
|
330
|
+
{
|
|
331
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
332
|
+
width: props.width || '14',
|
|
333
|
+
height: props.height || '14',
|
|
334
|
+
viewBox: '0 0 24 24',
|
|
335
|
+
fill: 'none',
|
|
336
|
+
stroke: 'currentColor',
|
|
337
|
+
strokeWidth: '2',
|
|
338
|
+
strokeLinecap: 'round',
|
|
339
|
+
strokeLinejoin: 'round',
|
|
340
|
+
'aria-hidden': 'true',
|
|
341
|
+
style,
|
|
342
|
+
...rest,
|
|
343
|
+
},
|
|
344
|
+
h('path', { d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z' }),
|
|
345
|
+
h('circle', { cx: '12', cy: '12', r: '3' })
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderRestoreIcon(props = {}) {
|
|
350
|
+
const { style, ...rest } = props;
|
|
351
|
+
return h(
|
|
352
|
+
'svg',
|
|
353
|
+
{
|
|
354
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
355
|
+
width: props.width || '14',
|
|
356
|
+
height: props.height || '14',
|
|
357
|
+
viewBox: '0 0 24 24',
|
|
358
|
+
fill: 'none',
|
|
359
|
+
stroke: 'currentColor',
|
|
360
|
+
strokeWidth: '2',
|
|
361
|
+
strokeLinecap: 'round',
|
|
362
|
+
strokeLinejoin: 'round',
|
|
363
|
+
'aria-hidden': 'true',
|
|
364
|
+
style,
|
|
365
|
+
...rest,
|
|
366
|
+
},
|
|
367
|
+
h('polyline', { points: '1 4 1 10 7 10' }),
|
|
368
|
+
h('path', { d: 'M3.51 15a9 9 0 1 0 2.13-9.36L1 10' })
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function renderDiskIcon(props = {}) {
|
|
373
|
+
const { style, ...rest } = props;
|
|
374
|
+
return h(
|
|
375
|
+
'svg',
|
|
376
|
+
{
|
|
377
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
378
|
+
width: props.width || '14',
|
|
379
|
+
height: props.height || '14',
|
|
380
|
+
viewBox: '0 0 24 24',
|
|
381
|
+
fill: 'none',
|
|
382
|
+
stroke: 'currentColor',
|
|
383
|
+
strokeWidth: '2',
|
|
384
|
+
strokeLinecap: 'round',
|
|
385
|
+
strokeLinejoin: 'round',
|
|
386
|
+
'aria-hidden': 'true',
|
|
387
|
+
style,
|
|
388
|
+
...rest,
|
|
389
|
+
},
|
|
390
|
+
h('rect', { x: '3', y: '6', width: '18', height: '12', rx: '3' }),
|
|
391
|
+
h('line', { x1: '3', y1: '12', x2: '21', y2: '12' }),
|
|
392
|
+
h('circle', { cx: '7', cy: '15', r: '1', fill: 'currentColor' }),
|
|
393
|
+
h('circle', { cx: '11', cy: '15', r: '1', fill: 'currentColor' })
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const TrashIcon = (props) => renderTrashIcon(props);
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Sub-session glyph for the children badge: one parent path forking into a
|
|
401
|
+
* child, the same metaphor as the delegated sessions the badge counts.
|
|
402
|
+
* Defaults match renderDiskIcon so both badges stay the same size.
|
|
403
|
+
*/
|
|
404
|
+
function renderChildrenIcon(props = {}) {
|
|
405
|
+
const { style, ...rest } = props;
|
|
406
|
+
return h(
|
|
407
|
+
'svg',
|
|
408
|
+
{
|
|
409
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
410
|
+
width: props.width || '14',
|
|
411
|
+
height: props.height || '14',
|
|
412
|
+
viewBox: '0 0 24 24',
|
|
413
|
+
fill: 'none',
|
|
414
|
+
stroke: 'currentColor',
|
|
415
|
+
strokeWidth: '2',
|
|
416
|
+
strokeLinecap: 'round',
|
|
417
|
+
strokeLinejoin: 'round',
|
|
418
|
+
'aria-hidden': 'true',
|
|
419
|
+
style,
|
|
420
|
+
...rest,
|
|
421
|
+
},
|
|
422
|
+
h('line', { x1: '6', y1: '3', x2: '6', y2: '15' }),
|
|
423
|
+
h('circle', { cx: '18', cy: '6', r: '3' }),
|
|
424
|
+
h('circle', { cx: '6', cy: '18', r: '3' }),
|
|
425
|
+
h('path', { d: 'M18 9a9 9 0 0 1-9 9' })
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Inject the plugin stylesheet (external client.css) exactly once.
|
|
431
|
+
* The host serves it at GET /api/session-trash/client.css; all colors
|
|
432
|
+
* are CSS variables that adapt to the OS light/dark color mode.
|
|
433
|
+
*/
|
|
434
|
+
function injectStylesheet() {
|
|
435
|
+
if (typeof document === 'undefined') return;
|
|
436
|
+
if (document.getElementById('dsh-session-manager-stylesheet')) return;
|
|
437
|
+
const link = document.createElement('link');
|
|
438
|
+
link.id = 'dsh-session-manager-stylesheet';
|
|
439
|
+
link.rel = 'stylesheet';
|
|
440
|
+
link.href = '/api/session-trash/client.css';
|
|
441
|
+
(document.head ?? document.documentElement).appendChild(link);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Show a top-centre floating toast prompt, matching the host's own. */
|
|
445
|
+
function showToastLayer(message, undoFn, actionText = t('undo'), holdMs = TOAST_HOLD_MS) {
|
|
446
|
+
if (typeof document === 'undefined') return;
|
|
447
|
+
|
|
448
|
+
let container = document.getElementById('dsh-session-manager-toast-container');
|
|
449
|
+
if (!container) {
|
|
450
|
+
container = document.createElement('div');
|
|
451
|
+
container.id = 'dsh-session-manager-toast-container';
|
|
452
|
+
container.className = 'dsh-trash-toast-container';
|
|
453
|
+
document.body.appendChild(container);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const toastEl = document.createElement('div');
|
|
457
|
+
toastEl.className = 'dsh-trash-toast';
|
|
458
|
+
|
|
459
|
+
const textSpan = document.createElement('span');
|
|
460
|
+
textSpan.textContent = message;
|
|
461
|
+
toastEl.appendChild(textSpan);
|
|
462
|
+
|
|
463
|
+
if (undoFn) {
|
|
464
|
+
const btn = document.createElement('button');
|
|
465
|
+
btn.textContent = actionText;
|
|
466
|
+
btn.className = 'dsh-trash-toast-undo';
|
|
467
|
+
btn.addEventListener('click', async () => {
|
|
468
|
+
try {
|
|
469
|
+
await undoFn();
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.error('Toast restore failed:', err);
|
|
472
|
+
}
|
|
473
|
+
toastEl.remove();
|
|
474
|
+
});
|
|
475
|
+
toastEl.appendChild(btn);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
container.appendChild(toastEl);
|
|
479
|
+
// One value drives BOTH the CSS fade delay and this unmount, exactly as
|
|
480
|
+
// the host's Toast does; the entry animation is CSS-only (the previous
|
|
481
|
+
// version drove opacity/transform from JS and timed out at 4000ms).
|
|
482
|
+
toastEl.style.setProperty('--dsh-toast-hold', `${holdMs}ms`);
|
|
483
|
+
setTimeout(() => toastEl.remove(), holdMs + TOAST_FADE_MS);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Formats file size in KB or MB */
|
|
487
|
+
function formatFileSize(bytes) {
|
|
488
|
+
if (!bytes || bytes <= 0) return '0 KB';
|
|
489
|
+
const kb = bytes / 1024;
|
|
490
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
491
|
+
return `${(kb / 1024).toFixed(1)} MB`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Formats timestamp into YYYY/M/D HH:mm */
|
|
495
|
+
function formatTime(ts) {
|
|
496
|
+
if (!ts) return '';
|
|
497
|
+
const d = new Date(ts);
|
|
498
|
+
const year = d.getFullYear();
|
|
499
|
+
const month = d.getMonth() + 1;
|
|
500
|
+
const day = d.getDate();
|
|
501
|
+
const hours = String(d.getHours()).padStart(2, '0');
|
|
502
|
+
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
503
|
+
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Relative age, for the row where an absolute timestamp does not fit.
|
|
510
|
+
*
|
|
511
|
+
* Buckets are calendar-based, not "N hours ago" arithmetic: a 23:30 event
|
|
512
|
+
* read at 00:10 is "yesterday", not "0 hours ago". The absolute timestamp
|
|
513
|
+
* is never lost — the caller keeps it in the row's `title` tooltip.
|
|
514
|
+
*/
|
|
515
|
+
function formatRelativeTime(ts) {
|
|
516
|
+
if (!ts) return '';
|
|
517
|
+
const then = new Date(ts);
|
|
518
|
+
const now = new Date();
|
|
519
|
+
const ageMs = now.getTime() - then.getTime();
|
|
520
|
+
if (ageMs < 60 * 1000) return t('childrenJustNow');
|
|
521
|
+
const minutes = Math.floor(ageMs / 60000);
|
|
522
|
+
if (minutes < 60) return t('childrenMinutesAgo', { count: minutes });
|
|
523
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
524
|
+
if (then.getTime() >= startOfToday) return t('childrenHoursAgo', { count: Math.floor(minutes / 60) });
|
|
525
|
+
if (then.getTime() >= startOfToday - DAY_MS) return t('childrenYesterday');
|
|
526
|
+
if (then.getTime() >= startOfToday - 6 * DAY_MS) return t('childrenThisWeek');
|
|
527
|
+
return t('childrenEarlier');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Make signed API requests. */
|
|
531
|
+
async function api(ctx, path, options = {}) {
|
|
532
|
+
const response = await fetch(path, {
|
|
533
|
+
method: options.method || 'GET',
|
|
534
|
+
headers: {
|
|
535
|
+
'Content-Type': 'application/json',
|
|
536
|
+
// The host's only contract header: the minimal CSRF custom header the
|
|
537
|
+
// routes require on non-GET requests. No locale header is sent — the
|
|
538
|
+
// host does not read one, and server fallback labels are localized on
|
|
539
|
+
// this side by localizeServerFallback().
|
|
540
|
+
'x-dsh-plugin': 'session-trash',
|
|
541
|
+
...(options.headers || {}),
|
|
542
|
+
},
|
|
543
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
544
|
+
// Only the deferred /children request passes one: closing the settings
|
|
545
|
+
// section must not let a late grouping response land on a dead panel.
|
|
546
|
+
signal: options.signal,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const payload = await response.json().catch(() => ({}));
|
|
550
|
+
if (!response.ok || payload.ok === false) {
|
|
551
|
+
const message = payload?.error?.message || `HTTP ${response.status}`;
|
|
552
|
+
const error = new Error(message);
|
|
553
|
+
error.code = payload?.error?.code;
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
return payload.data;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Display form of a log path: the part under the user's home reads `~/…`.
|
|
561
|
+
* The browser has no HOME to compare against, so the two shapes a DSH
|
|
562
|
+
* profile can sit under are matched by prefix and anything else is shown
|
|
563
|
+
* unchanged; the row keeps the FULL path in its `title`.
|
|
564
|
+
*/
|
|
565
|
+
function abbreviateHomePath(path) {
|
|
566
|
+
return String(path ?? '')
|
|
567
|
+
.replace(/^(?:\/Users|\/home)\/[^/]+\//, '~/')
|
|
568
|
+
.replace(/^\/root\//, '~/')
|
|
569
|
+
.replace(/^([A-Za-z]:\\Users)\\[^\\]+\\/, '~\\');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Ids /purge could not delete. The frozen route contract reports them as
|
|
574
|
+
* `failed`; builds before it used `errors`, and a plugin may be loaded
|
|
575
|
+
* against either, so read both rather than silently reporting success.
|
|
576
|
+
*/
|
|
577
|
+
function purgeFailures(result) {
|
|
578
|
+
const list = result?.failed ?? result?.errors ?? [];
|
|
579
|
+
return Array.isArray(list) ? list : [];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* `orphans` arrives as { [missingParentId]: ChildInfo[] } (a deleted main
|
|
584
|
+
* session whose children were kept). Keep the host's key order and drop
|
|
585
|
+
* groups with no children — there is nothing to show for those.
|
|
586
|
+
*/
|
|
587
|
+
function orphanGroups(orphans) {
|
|
588
|
+
if (!orphans || typeof orphans !== 'object') return [];
|
|
589
|
+
return Object.keys(orphans)
|
|
590
|
+
.map((parentId) => ({
|
|
591
|
+
parentId,
|
|
592
|
+
children: Array.isArray(orphans[parentId]) ? orphans[parentId] : [],
|
|
593
|
+
}))
|
|
594
|
+
.filter((group) => group.children.length > 0);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** 强制浏览器端 sessions / workspaces 服务从服务端重新拉取权威列表 */
|
|
598
|
+
function refreshSessionViews(ctx) {
|
|
599
|
+
try { ctx?.sessions?.refresh?.(); } catch {}
|
|
600
|
+
try {
|
|
601
|
+
const workspaces = ctx?.get?.('workspaces') ?? ctx?.workspaces;
|
|
602
|
+
workspaces?.refresh?.();
|
|
603
|
+
} catch {}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** 同步浏览器端会话存储
|
|
607
|
+
* 注意:sessions.list 快照的真实结构是 { items, current, state, phase, error, ... },
|
|
608
|
+
* 并没有 byId / order 字段;原地改快照也无法触发 zustand 通知(是空操作)。
|
|
609
|
+
* 正确做法是调用 sessions.refresh() + workspaces.refresh() 从服务端重新拉取,
|
|
610
|
+
* 让已物理删除的会话消失、已还原的会话重新出现。 */
|
|
611
|
+
function purgeFromBrowserSessionStore(ctx, sessionIds) {
|
|
612
|
+
if (!sessionIds || sessionIds.length === 0) return;
|
|
613
|
+
refreshSessionViews(ctx);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** ── 轻量级 Markdown 渲染器(自包含,不依赖外部库,仅用 React.createElement) ── */
|
|
617
|
+
|
|
618
|
+
// 行内解析:code span / bold / italic / strikethrough / link / image
|
|
619
|
+
function parseInline(text, keyPrefix) {
|
|
620
|
+
const nodes = [];
|
|
621
|
+
const parts = String(text).split(/(`[^`]+`)/g);
|
|
622
|
+
parts.forEach((part, i) => {
|
|
623
|
+
if (!part) return;
|
|
624
|
+
const codeMatch = part.match(/^`([^`]+)`$/);
|
|
625
|
+
if (codeMatch) {
|
|
626
|
+
nodes.push(h('code', { key: `${keyPrefix}-c${i}`, className: 'dsh-trash-md-code-inline' }, codeMatch[1]));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
pushInlineTokens(nodes, part, `${keyPrefix}-${i}`);
|
|
630
|
+
});
|
|
631
|
+
return nodes;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function pushInlineTokens(nodes, text, keyPrefix) {
|
|
635
|
+
const tokenRe = /(\!\[[^\]]*\]\([^)]*\)|\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g;
|
|
636
|
+
let lastIndex = 0;
|
|
637
|
+
let m;
|
|
638
|
+
let k = 0;
|
|
639
|
+
while ((m = tokenRe.exec(text)) !== null) {
|
|
640
|
+
if (m.index > lastIndex) {
|
|
641
|
+
nodes.push(text.slice(lastIndex, m.index));
|
|
642
|
+
}
|
|
643
|
+
const tok = m[0];
|
|
644
|
+
const key = `${keyPrefix}-t${k++}`;
|
|
645
|
+
const imgMatch = tok.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
|
646
|
+
if (imgMatch) {
|
|
647
|
+
nodes.push(h('a', { key, href: imgMatch[2], target: '_blank', rel: 'noreferrer' }, imgMatch[1] || imgMatch[2]));
|
|
648
|
+
lastIndex = m.index + tok.length;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const linkMatch = tok.match(/^\[([^\]]*)\]\(([^)]*)\)$/);
|
|
652
|
+
if (linkMatch) {
|
|
653
|
+
nodes.push(h('a', { key, href: linkMatch[2], target: '_blank', rel: 'noreferrer', className: 'dsh-trash-md-link' }, linkMatch[1] || linkMatch[2]));
|
|
654
|
+
lastIndex = m.index + tok.length;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const boldMatch = tok.match(/^\*\*([^*]+)\*\*$/);
|
|
658
|
+
if (boldMatch) {
|
|
659
|
+
nodes.push(h('strong', { key }, parseInline(boldMatch[1], key)));
|
|
660
|
+
lastIndex = m.index + tok.length;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const italicMatch = tok.match(/^\*([^*]+)\*$/);
|
|
664
|
+
if (italicMatch) {
|
|
665
|
+
nodes.push(h('em', { key }, parseInline(italicMatch[1], key)));
|
|
666
|
+
lastIndex = m.index + tok.length;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const strikeMatch = tok.match(/^~~([^~]+)~~$/);
|
|
670
|
+
if (strikeMatch) {
|
|
671
|
+
nodes.push(h('s', { key }, parseInline(strikeMatch[1], key)));
|
|
672
|
+
lastIndex = m.index + tok.length;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
lastIndex = m.index + tok.length;
|
|
676
|
+
}
|
|
677
|
+
if (lastIndex < text.length) {
|
|
678
|
+
nodes.push(text.slice(lastIndex));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 块级解析 → React 节点数组
|
|
683
|
+
function renderMarkdown(content) {
|
|
684
|
+
const lines = String(content || '').split('\n');
|
|
685
|
+
const blocks = [];
|
|
686
|
+
let i = 0;
|
|
687
|
+
while (i < lines.length) {
|
|
688
|
+
const line = lines[i];
|
|
689
|
+
// 围栏代码块
|
|
690
|
+
const fenceMatch = line.match(/^```([\w+-]*)\s*$/);
|
|
691
|
+
if (fenceMatch) {
|
|
692
|
+
const lang = fenceMatch[1] || '';
|
|
693
|
+
const buf = [];
|
|
694
|
+
i++;
|
|
695
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
696
|
+
buf.push(lines[i]);
|
|
697
|
+
i++;
|
|
698
|
+
}
|
|
699
|
+
i++;
|
|
700
|
+
blocks.push({ type: 'code', lang, code: buf.join('\n') });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
// 空行
|
|
704
|
+
if (!line.trim()) {
|
|
705
|
+
i++;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
// 分隔线
|
|
709
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
710
|
+
blocks.push({ type: 'hr' });
|
|
711
|
+
i++;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
// 标题
|
|
715
|
+
const headMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
716
|
+
if (headMatch) {
|
|
717
|
+
blocks.push({ type: 'header', level: headMatch[1].length, text: headMatch[2] });
|
|
718
|
+
i++;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
// 引用
|
|
722
|
+
if (/^\s*>\s?/.test(line)) {
|
|
723
|
+
const buf = [];
|
|
724
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
725
|
+
buf.push(lines[i].replace(/^\s*>\s?/, ''));
|
|
726
|
+
i++;
|
|
727
|
+
}
|
|
728
|
+
blocks.push({ type: 'quote', text: buf.join('\n') });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
// 无序列表
|
|
732
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
733
|
+
const items = [];
|
|
734
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
|
735
|
+
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''));
|
|
736
|
+
i++;
|
|
737
|
+
}
|
|
738
|
+
blocks.push({ type: 'ulist', items });
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
// 有序列表
|
|
742
|
+
if (/^\s*\d+[.)]\s+/.test(line)) {
|
|
743
|
+
const items = [];
|
|
744
|
+
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
|
|
745
|
+
items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ''));
|
|
746
|
+
i++;
|
|
747
|
+
}
|
|
748
|
+
blocks.push({ type: 'olist', items });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
// 表格:当前行含 | 且下一行为分隔行
|
|
752
|
+
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
753
|
+
const headerRow = line;
|
|
754
|
+
i += 2;
|
|
755
|
+
const rows = [];
|
|
756
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
|
757
|
+
rows.push(lines[i]);
|
|
758
|
+
i++;
|
|
759
|
+
}
|
|
760
|
+
blocks.push({ type: 'table', header: headerRow, rows });
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
// 段落:收集连续的非空、非块起始行
|
|
764
|
+
const buf = [line];
|
|
765
|
+
i++;
|
|
766
|
+
while (i < lines.length && lines[i].trim() && !/^(```|#{1,6}\s|>\s?|[-*+]\s|\d+[.)]\s|---+$)/.test(lines[i])) {
|
|
767
|
+
buf.push(lines[i]);
|
|
768
|
+
i++;
|
|
769
|
+
}
|
|
770
|
+
blocks.push({ type: 'para', text: buf.join('\n') });
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return blocks.map((block, idx) => {
|
|
774
|
+
const key = `md-${idx}`;
|
|
775
|
+
switch (block.type) {
|
|
776
|
+
case 'code':
|
|
777
|
+
return h('pre', { key, className: 'dsh-trash-md-codeblock' },
|
|
778
|
+
block.lang ? h('div', { key: `${key}-lang`, className: 'dsh-trash-md-lang' }, block.lang) : null,
|
|
779
|
+
h('code', { key: `${key}-code`, className: 'dsh-trash-md-codeblock-content' }, block.code)
|
|
780
|
+
);
|
|
781
|
+
case 'hr':
|
|
782
|
+
return h('hr', { key, className: 'dsh-trash-md-hr' });
|
|
783
|
+
case 'header': {
|
|
784
|
+
const lvl = Math.min(block.level, 6);
|
|
785
|
+
const tag = `h${lvl}`;
|
|
786
|
+
return h(tag, { key }, parseInline(block.text, key));
|
|
787
|
+
}
|
|
788
|
+
case 'quote':
|
|
789
|
+
return h('blockquote', { key, className: 'dsh-trash-md-quote' }, renderMarkdown(block.text));
|
|
790
|
+
case 'ulist':
|
|
791
|
+
return h('ul', { key, className: 'dsh-trash-md-list' },
|
|
792
|
+
block.items.map((item, j) => h('li', { key: `${key}-${j}`, className: 'dsh-trash-md-list-item' }, parseInline(item, `${key}-${j}`)))
|
|
793
|
+
);
|
|
794
|
+
case 'olist':
|
|
795
|
+
return h('ol', { key, className: 'dsh-trash-md-list' },
|
|
796
|
+
block.items.map((item, j) => h('li', { key: `${key}-${j}`, className: 'dsh-trash-md-list-item' }, parseInline(item, `${key}-${j}`)))
|
|
797
|
+
);
|
|
798
|
+
case 'table': {
|
|
799
|
+
const parseRow = (row) => row.split('|').map((c) => c.trim()).filter((c, j, arr) => !(j === 0 && c === '') && !(j === arr.length - 1 && c === ''));
|
|
800
|
+
const headerCells = parseRow(block.header);
|
|
801
|
+
return h('div', { key, className: 'dsh-trash-md-table-wrap' },
|
|
802
|
+
h('table', { key: `${key}-table`, className: 'dsh-trash-md-table' },
|
|
803
|
+
h('thead', null,
|
|
804
|
+
h('tr', null, headerCells.map((c, j) => h('th', { key: `${key}-h${j}`, className: 'dsh-trash-md-th' }, parseInline(c, `${key}-h${j}`))))
|
|
805
|
+
),
|
|
806
|
+
h('tbody', null,
|
|
807
|
+
block.rows.map((row, r) => h('tr', { key: `${key}-r${r}` },
|
|
808
|
+
parseRow(row).map((c, j) => h('td', { key: `${key}-c${r}-${j}`, className: 'dsh-trash-md-td' }, parseInline(c, `${key}-c${r}-${j}`)))
|
|
809
|
+
))
|
|
810
|
+
)
|
|
811
|
+
)
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
default:
|
|
815
|
+
return h('p', { key, className: 'dsh-trash-md-p' }, parseInline(block.text, key));
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Resolve a session's display title from the browser store, falling back to
|
|
822
|
+
* the caller's value. MODULE-LEVEL on purpose: the header action and the
|
|
823
|
+
* sidebar row injector are module-level functions and cannot see a helper
|
|
824
|
+
* declared inside the TrashTab component (that produced a synchronous
|
|
825
|
+
* ReferenceError inside the click handler — the "button does nothing" bug).
|
|
826
|
+
*/
|
|
827
|
+
function resolveSessionTitle(ctx, sessionId, fallback) {
|
|
828
|
+
try {
|
|
829
|
+
const byId = ctx?.sessions?.list?.getSnapshot?.()?.byId ?? {};
|
|
830
|
+
const title = byId[sessionId]?.title;
|
|
831
|
+
// 如果浏览器 store 中的标题就是 sessionId 本身,说明该会话已被归档、
|
|
832
|
+
// 浏览器 store 中只剩占位符,此时应优先使用服务端返回的 fallback 标题
|
|
833
|
+
// (服务端从 archivedTitles 或日志解析中获取了正确的名称)。
|
|
834
|
+
if (title && title !== sessionId) return title;
|
|
835
|
+
return fallback;
|
|
836
|
+
} catch {
|
|
837
|
+
return fallback;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** 当前活动会话 ID:sessions.list 快照的 `current` 字段(0.1.1-rc.2 与 0.1.5-rc.1 均提供)。 */
|
|
842
|
+
function activeSessionId(ctx) {
|
|
843
|
+
try {
|
|
844
|
+
return ctx?.sessions?.list?.getSnapshot?.()?.current ?? undefined;
|
|
845
|
+
} catch {
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Archive (move to recycle bin) an arbitrary session by id. */
|
|
851
|
+
async function archiveSession(ctx, sessionId, title) {
|
|
852
|
+
if (!sessionId) {
|
|
853
|
+
showToastLayer(t('noActiveSession'));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
await api(ctx, `${API_PREFIX}/archive`, { method: 'POST', body: { sessionId, title } });
|
|
858
|
+
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
859
|
+
showToastLayer(t('movedToBin', { title: title || t('currentSession') }), async () => {
|
|
860
|
+
try {
|
|
861
|
+
await api(ctx, `${API_PREFIX}/unarchive`, { method: 'POST', body: { sessionId } });
|
|
862
|
+
refreshSessionViews(ctx);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
console.error(t('undoFailed'), error);
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
} catch (error) {
|
|
868
|
+
showToastLayer(t('moveFailed', { message: localizedError(error) }));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** The menu owner provides the exact row, including non-active or same-title sessions. */
|
|
873
|
+
function DeleteSessionMenuItem({ ctx, sessionId, displayTitle, useMenuOpenState }) {
|
|
874
|
+
const [, setMenuOpen] = useMenuOpenState();
|
|
875
|
+
return h(MenuItemButton, {
|
|
876
|
+
icon: renderTrashIcon({ width: 14, height: 14 }),
|
|
877
|
+
disabled: !sessionId,
|
|
878
|
+
onSelect: () => {
|
|
879
|
+
setMenuOpen(false);
|
|
880
|
+
return archiveSession(ctx, sessionId, displayTitle || '');
|
|
881
|
+
},
|
|
882
|
+
}, t('deleteSession'));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** TrashTab Component */
|
|
886
|
+
function TrashTab({ ctx }) {
|
|
887
|
+
const [items, setItems] = useState([]);
|
|
888
|
+
const [loading, setLoading] = useState(true);
|
|
889
|
+
const [error, setError] = useState(null);
|
|
890
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
891
|
+
const [filterWorkspace, setFilterWorkspace] = useState('');
|
|
892
|
+
const [sortOrder, setSortOrder] = useState('desc');
|
|
893
|
+
const [confirmModal, setConfirmModal] = useState(null);
|
|
894
|
+
const [previewSession, setPreviewSession] = useState(null);
|
|
895
|
+
const [previewMessages, setPreviewMessages] = useState([]);
|
|
896
|
+
const [previewLoading, setPreviewLoading] = useState(false);
|
|
897
|
+
const [activeWorkspaceMenu, setActiveWorkspaceMenu] = useState(null);
|
|
898
|
+
const [selectedSessionIds, setSelectedSessionIds] = useState(new Set());
|
|
899
|
+
const [hoverTooltip, setHoverTooltip] = useState(null);
|
|
900
|
+
const hoverTimerRef = React.useRef(null);
|
|
901
|
+
const tooltipCloseRef = React.useRef(null);
|
|
902
|
+
const tooltipRef = React.useRef(null);
|
|
903
|
+
|
|
904
|
+
// ── Hover info card ──────────────────────────────────────────────────
|
|
905
|
+
// The card must be able to dismiss ITSELF: the row's onMouseLeave can
|
|
906
|
+
// never fire again once that row is gone, so deleting the last row used
|
|
907
|
+
// to leave the card floating over an empty panel.
|
|
908
|
+
const cancelTooltipClose = () => {
|
|
909
|
+
if (tooltipCloseRef.current) clearTimeout(tooltipCloseRef.current);
|
|
910
|
+
tooltipCloseRef.current = null;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const closeTooltip = () => {
|
|
914
|
+
cancelTooltipClose();
|
|
915
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
916
|
+
hoverTimerRef.current = null;
|
|
917
|
+
setHoverTooltip(null);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const scheduleTooltipClose = () => {
|
|
921
|
+
cancelTooltipClose();
|
|
922
|
+
tooltipCloseRef.current = setTimeout(() => {
|
|
923
|
+
tooltipCloseRef.current = null;
|
|
924
|
+
// A text selection being dragged past the card edge is not a reason
|
|
925
|
+
// to take the text away; Esc and the other closers still work.
|
|
926
|
+
const selected = typeof window?.getSelection === 'function'
|
|
927
|
+
? String(window.getSelection() ?? '')
|
|
928
|
+
: '';
|
|
929
|
+
if (selected) return;
|
|
930
|
+
setHoverTooltip(null);
|
|
931
|
+
}, TOOLTIP_CLOSE_DELAY_MS);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
/** Anchor the card to the row it describes — never to the pointer. */
|
|
935
|
+
const openTooltipFor = (item, displayTitle, rect) => {
|
|
936
|
+
setHoverTooltip({
|
|
937
|
+
sessionId: item.sessionId,
|
|
938
|
+
title: displayTitle,
|
|
939
|
+
id: item.sessionId,
|
|
940
|
+
// null when the host found no log (ghost session): the row is then
|
|
941
|
+
// omitted entirely rather than showing an empty value.
|
|
942
|
+
logPath: typeof item.logPath === 'string' && item.logPath ? item.logPath : null,
|
|
943
|
+
anchor: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right },
|
|
944
|
+
placement: 'above',
|
|
945
|
+
left: rect.left,
|
|
946
|
+
top: rect.top - 8,
|
|
947
|
+
});
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
// Every dismissal route that does not depend on the hovered row surviving:
|
|
951
|
+
// Esc, wheel, a click outside the card, a viewport resize, and the card's
|
|
952
|
+
// own mouseleave (delayed so the pointer can travel onto it).
|
|
953
|
+
useEffect(() => {
|
|
954
|
+
if (!hoverTooltip) return undefined;
|
|
955
|
+
const onPointerDown = (e) => {
|
|
956
|
+
const card = tooltipRef.current;
|
|
957
|
+
if (card && typeof card.contains === 'function' && card.contains(e.target)) return;
|
|
958
|
+
closeTooltip();
|
|
959
|
+
};
|
|
960
|
+
const onKeyDown = (e) => { if (e.key === 'Escape') closeTooltip(); };
|
|
961
|
+
const onResize = () => closeTooltip();
|
|
962
|
+
const onScroll = () => closeTooltip();
|
|
963
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
964
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
965
|
+
window.addEventListener('resize', onResize);
|
|
966
|
+
window.addEventListener('wheel', onScroll, { passive: true });
|
|
967
|
+
window.addEventListener('scroll', onScroll, true);
|
|
968
|
+
return () => {
|
|
969
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
970
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
971
|
+
window.removeEventListener('resize', onResize);
|
|
972
|
+
window.removeEventListener('wheel', onScroll);
|
|
973
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
974
|
+
};
|
|
975
|
+
}, [hoverTooltip]);
|
|
976
|
+
|
|
977
|
+
// A list change can delete the hovered session without a click on it.
|
|
978
|
+
useEffect(() => {
|
|
979
|
+
if (!hoverTooltip) return;
|
|
980
|
+
if (!items.some((item) => item.sessionId === hoverTooltip.sessionId)) closeTooltip();
|
|
981
|
+
}, [items, hoverTooltip]);
|
|
982
|
+
|
|
983
|
+
// Measured placement: clamp into the viewport and flip below the row when
|
|
984
|
+
// there is no room above it. Runs on the real box, so a long title cannot
|
|
985
|
+
// push the card off-screen.
|
|
986
|
+
useEffect(() => {
|
|
987
|
+
const el = tooltipRef.current;
|
|
988
|
+
if (!hoverTooltip || !el || typeof el.getBoundingClientRect !== 'function') return;
|
|
989
|
+
const box = el.getBoundingClientRect();
|
|
990
|
+
const viewportWidth = Number(window?.innerWidth) || 0;
|
|
991
|
+
const viewportHeight = Number(window?.innerHeight) || 0;
|
|
992
|
+
const margin = 8;
|
|
993
|
+
let placement = hoverTooltip.placement;
|
|
994
|
+
if (viewportHeight > 0) {
|
|
995
|
+
if (placement === 'above' && hoverTooltip.anchor.top - box.height - margin < 0) placement = 'below';
|
|
996
|
+
else if (placement === 'below' && hoverTooltip.anchor.bottom + box.height + margin > viewportHeight) placement = 'above';
|
|
997
|
+
}
|
|
998
|
+
const wantedLeft = viewportWidth > 0
|
|
999
|
+
? Math.min(Math.max(hoverTooltip.anchor.left, margin), Math.max(margin, viewportWidth - box.width - margin))
|
|
1000
|
+
: hoverTooltip.anchor.left;
|
|
1001
|
+
const wantedTop = placement === 'above'
|
|
1002
|
+
? hoverTooltip.anchor.top - margin
|
|
1003
|
+
: hoverTooltip.anchor.bottom + margin;
|
|
1004
|
+
if (wantedLeft === hoverTooltip.left && wantedTop === hoverTooltip.top && placement === hoverTooltip.placement) return;
|
|
1005
|
+
setHoverTooltip((prev) => (prev ? { ...prev, left: wantedLeft, top: wantedTop, placement } : prev));
|
|
1006
|
+
}, [hoverTooltip]);
|
|
1007
|
+
|
|
1008
|
+
// ── Sub-sessions (delegated children) ────────────────────────────────
|
|
1009
|
+
// `childIndex` is the /children answer: null means "not known" (never
|
|
1010
|
+
// fetched, failed, or degraded) — and an unknown count renders NO badge
|
|
1011
|
+
// rather than a wrong one. `uncheckedChildren` holds only the opt-OUT
|
|
1012
|
+
// ids, so a freshly grouped child is checked by default.
|
|
1013
|
+
const [childIndex, setChildIndex] = useState(null);
|
|
1014
|
+
const [childEpoch, setChildEpoch] = useState(0);
|
|
1015
|
+
const [expandedCards, setExpandedCards] = useState(() => new Set());
|
|
1016
|
+
const [uncheckedChildren, setUncheckedChildren] = useState(() => ({}));
|
|
1017
|
+
const [fullChildLists, setFullChildLists] = useState(() => new Set());
|
|
1018
|
+
const childRequestRef = React.useRef({ signature: null, promise: null, controller: null, retried: null });
|
|
1019
|
+
|
|
1020
|
+
const toggleSelectSession = (sessionId) => {
|
|
1021
|
+
setSelectedSessionIds((prev) => {
|
|
1022
|
+
const next = new Set(prev);
|
|
1023
|
+
if (next.has(sessionId)) next.delete(sessionId);
|
|
1024
|
+
else next.add(sessionId);
|
|
1025
|
+
return next;
|
|
1026
|
+
});
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
// 点击外部自动隐藏工作区 ... 更多菜单 (捕获阶段 pointerdown)
|
|
1030
|
+
useEffect(() => {
|
|
1031
|
+
const handleClickOutside = (e) => {
|
|
1032
|
+
if (e?.target?.closest && e.target.closest('.dsh-more-menu-container')) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
setActiveWorkspaceMenu(null);
|
|
1036
|
+
};
|
|
1037
|
+
document.addEventListener('pointerdown', handleClickOutside, true);
|
|
1038
|
+
return () => document.removeEventListener('pointerdown', handleClickOutside, true);
|
|
1039
|
+
}, []);
|
|
1040
|
+
|
|
1041
|
+
const currentTitle = (sessionId, fallback) => resolveSessionTitle(ctx, sessionId, fallback);
|
|
1042
|
+
|
|
1043
|
+
const loadItems = useCallback(async () => {
|
|
1044
|
+
setError(null);
|
|
1045
|
+
try {
|
|
1046
|
+
const value = await api(ctx, `${API_PREFIX}/list`);
|
|
1047
|
+
setItems(value?.items ?? []);
|
|
1048
|
+
// A fresh /list snapshot is the only thing that can add or remove a
|
|
1049
|
+
// card, so it is also what re-asks for the sub-session grouping.
|
|
1050
|
+
setChildEpoch((epoch) => epoch + 1);
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
setError(err.message || t('loadFailed'));
|
|
1053
|
+
} finally {
|
|
1054
|
+
setLoading(false);
|
|
1055
|
+
}
|
|
1056
|
+
}, [ctx]);
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Ask the host for the sub-session grouping that backfills the badges.
|
|
1060
|
+
*
|
|
1061
|
+
* Deferred on purpose: the panel paints the /list snapshot first, so the
|
|
1062
|
+
* first screen stays zero-I/O and this grouping only backfills numbers.
|
|
1063
|
+
* Dedupe is by id signature while a request is in flight, and aborting
|
|
1064
|
+
* supersedes the previous request instead of racing it.
|
|
1065
|
+
*
|
|
1066
|
+
* The body carries NO sessionIds. The host narrows a report it had to
|
|
1067
|
+
* build by scanning the whole lineage anyway, so omitting the list costs
|
|
1068
|
+
* the same — and it is the only way the orphans of an already-deleted
|
|
1069
|
+
* main session are reported: a parent id that is not requested is never
|
|
1070
|
+
* returned (tests/session-trash-children.test.js, "an unrequested parent
|
|
1071
|
+
* id is not reported"), and a kept child's parent is by definition not in
|
|
1072
|
+
* the trash list. Cards match against `data.groups` below.
|
|
1073
|
+
*/
|
|
1074
|
+
const loadChildGroups = useCallback((signature) => {
|
|
1075
|
+
const state = childRequestRef.current;
|
|
1076
|
+
if (state.signature === signature && state.promise) return state.promise;
|
|
1077
|
+
state.controller?.abort();
|
|
1078
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1079
|
+
const run = (async () => {
|
|
1080
|
+
try {
|
|
1081
|
+
const data = await api(ctx, `${API_PREFIX}/children`, {
|
|
1082
|
+
method: 'POST',
|
|
1083
|
+
body: {},
|
|
1084
|
+
signal: controller?.signal,
|
|
1085
|
+
});
|
|
1086
|
+
if (controller?.signal?.aborted) return;
|
|
1087
|
+
// degraded ⇒ the host could not answer authoritatively. Surfacing
|
|
1088
|
+
// nothing is the only honest option: a badge is a count claim.
|
|
1089
|
+
if (!data || data.degraded === true) {
|
|
1090
|
+
setChildIndex(null);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
setChildIndex({ groups: data.groups ?? {}, orphans: orphanGroups(data.orphans) });
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
// Failure is silent and terminal for this signature: one attempt
|
|
1096
|
+
// per /list refresh, so a broken route cannot become a retry storm.
|
|
1097
|
+
if (controller?.signal?.aborted) return;
|
|
1098
|
+
setChildIndex(null);
|
|
1099
|
+
}
|
|
1100
|
+
})();
|
|
1101
|
+
state.signature = signature;
|
|
1102
|
+
state.promise = run;
|
|
1103
|
+
state.controller = controller;
|
|
1104
|
+
run.finally(() => {
|
|
1105
|
+
if (state.promise === run) state.promise = null;
|
|
1106
|
+
});
|
|
1107
|
+
return run;
|
|
1108
|
+
}, [ctx]);
|
|
1109
|
+
|
|
1110
|
+
const childIds = React.useMemo(
|
|
1111
|
+
() => items.map((item) => item.sessionId).filter(Boolean),
|
|
1112
|
+
[items]
|
|
1113
|
+
);
|
|
1114
|
+
const childSignature = childIds.join('|');
|
|
1115
|
+
|
|
1116
|
+
useEffect(() => {
|
|
1117
|
+
if (loading || childIds.length === 0) return undefined;
|
|
1118
|
+
loadChildGroups(childSignature);
|
|
1119
|
+
// Closing the settings section (or reloading the list) cancels the
|
|
1120
|
+
// grouping request the closed panel can no longer use.
|
|
1121
|
+
return () => {
|
|
1122
|
+
const state = childRequestRef.current;
|
|
1123
|
+
state.controller?.abort();
|
|
1124
|
+
state.controller = null;
|
|
1125
|
+
state.promise = null;
|
|
1126
|
+
state.signature = null;
|
|
1127
|
+
};
|
|
1128
|
+
}, [loading, childSignature, childEpoch, loadChildGroups]);
|
|
1129
|
+
|
|
1130
|
+
useEffect(() => {
|
|
1131
|
+
loadItems();
|
|
1132
|
+
const offEvent = ctx?.on?.('workspace/archived-sessions-changed', () => loadItems());
|
|
1133
|
+
const offRpc = ctx?.get?.('typert')?.on?.('host/archived-sessions-changed', () => loadItems());
|
|
1134
|
+
const offPurge = ctx?.on?.('workspace/session-permanently-deleted', ({ sessionId }) => {
|
|
1135
|
+
if (sessionId) purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
1136
|
+
loadItems();
|
|
1137
|
+
});
|
|
1138
|
+
return () => {
|
|
1139
|
+
offEvent?.();
|
|
1140
|
+
offRpc?.();
|
|
1141
|
+
offPurge?.();
|
|
1142
|
+
};
|
|
1143
|
+
}, [loadItems, ctx]);
|
|
1144
|
+
|
|
1145
|
+
/** 恢复单个会话 */
|
|
1146
|
+
const handleUnarchive = async (session) => {
|
|
1147
|
+
try {
|
|
1148
|
+
await api(ctx, `${API_PREFIX}/unarchive`, { method: 'POST', body: { sessionId: session.sessionId } });
|
|
1149
|
+
loadItems();
|
|
1150
|
+
|
|
1151
|
+
// 触发 Harness 官方 sessions / workspaces 服务重新拉取活跃列表
|
|
1152
|
+
// (fetch/load/reload 并不存在,正确方法是 refresh())
|
|
1153
|
+
refreshSessionViews(ctx);
|
|
1154
|
+
|
|
1155
|
+
showToastLayer(t('restored', { title: currentTitle(session.sessionId, session.title) || session.title || session.sessionId }), async () => {
|
|
1156
|
+
try {
|
|
1157
|
+
await api(ctx, `${API_PREFIX}/archive`, { method: 'POST', body: { sessionId: session.sessionId } });
|
|
1158
|
+
loadItems();
|
|
1159
|
+
try { refreshSessionViews(ctx); } catch {}
|
|
1160
|
+
} catch (e) {
|
|
1161
|
+
console.error(t('undoFailed'), e);
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
showToastLayer(t('restoreFailed', { message: localizedError(err) }));
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
/** 物理彻底删除单个会话 */
|
|
1170
|
+
const handlePurgeSingle = async (sessionId, sessionTitle) => {
|
|
1171
|
+
setConfirmModal({
|
|
1172
|
+
title: t('confirmPermanentTitle'),
|
|
1173
|
+
body: t('confirmPermanentBody', { title: sessionTitle }),
|
|
1174
|
+
onConfirm: async () => {
|
|
1175
|
+
try {
|
|
1176
|
+
const result = await api(ctx, `${API_PREFIX}/purge`, { method: 'POST', body: { sessionIds: [sessionId] } });
|
|
1177
|
+
const failures = purgeFailures(result);
|
|
1178
|
+
const failedIds = new Set(failures.map((e) => e.sessionId));
|
|
1179
|
+
if (failedIds.has(sessionId)) {
|
|
1180
|
+
const errItem = failures.find((e) => e.sessionId === sessionId);
|
|
1181
|
+
showToastLayer(t('deleteFailed', { message: localizedError(errItem) }));
|
|
1182
|
+
} else {
|
|
1183
|
+
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
1184
|
+
showToastLayer(t('deleted'));
|
|
1185
|
+
}
|
|
1186
|
+
setConfirmModal(null);
|
|
1187
|
+
closeTooltip();
|
|
1188
|
+
loadItems();
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
showToastLayer(t('deleteFailed', { message: localizedError(err) }));
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
/** 物理彻底删除指定工作区下的所有/选中的归档会话 */
|
|
1197
|
+
const handlePurgeWorkspace = async (workspacePath, workspaceTitle, groupItems) => {
|
|
1198
|
+
setConfirmModal({
|
|
1199
|
+
title: t('deleteArchivedTitle'),
|
|
1200
|
+
body: t('deleteWorkspaceBody', { title: workspaceTitle, count: groupItems.length }),
|
|
1201
|
+
onConfirm: async () => {
|
|
1202
|
+
try {
|
|
1203
|
+
const ids = groupItems.map((i) => i.sessionId);
|
|
1204
|
+
const result = await api(ctx, `${API_PREFIX}/purge-workspace`, { method: 'POST', body: { workspacePath, sessionIds: ids } });
|
|
1205
|
+
const failedIds = new Set(purgeFailures(result).map((e) => e.sessionId));
|
|
1206
|
+
const succeededIds = ids.filter((id) => !failedIds.has(id));
|
|
1207
|
+
purgeFromBrowserSessionStore(ctx, succeededIds);
|
|
1208
|
+
setConfirmModal(null);
|
|
1209
|
+
setActiveWorkspaceMenu(null);
|
|
1210
|
+
loadItems();
|
|
1211
|
+
showToastLayer(t('deletedSelected'));
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
showToastLayer(t('deleteFailed', { message: localizedError(err) }));
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
/** 彻底删除全部归档会话 */
|
|
1220
|
+
const handleEmptyAll = () => {
|
|
1221
|
+
setConfirmModal({
|
|
1222
|
+
title: t('deleteAllTitle'),
|
|
1223
|
+
body: t('deleteAllBody', { count: items.length }),
|
|
1224
|
+
onConfirm: async () => {
|
|
1225
|
+
try {
|
|
1226
|
+
const ids = items.map((i) => i.sessionId);
|
|
1227
|
+
await api(ctx, `${API_PREFIX}/empty`, { method: 'POST' });
|
|
1228
|
+
purgeFromBrowserSessionStore(ctx, ids);
|
|
1229
|
+
setConfirmModal(null);
|
|
1230
|
+
loadItems();
|
|
1231
|
+
showToastLayer(t('emptied', { count: items.length }));
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
showToastLayer(t('emptyFailed', { message: localizedError(err) }));
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
/** 打开预览 Modal */
|
|
1240
|
+
const handleOpenPreview = async (session) => {
|
|
1241
|
+
const title = currentTitle(session.sessionId, session.title) || session.sessionId;
|
|
1242
|
+
setPreviewSession({ ...session, title });
|
|
1243
|
+
setPreviewLoading(true);
|
|
1244
|
+
setPreviewMessages([]);
|
|
1245
|
+
try {
|
|
1246
|
+
const res = await api(ctx, `${API_PREFIX}/messages?sessionId=${encodeURIComponent(session.sessionId)}`);
|
|
1247
|
+
setPreviewMessages(res?.messages ?? []);
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
showToastLayer(t('historyFailed', { message: localizedError(err) }));
|
|
1250
|
+
} finally {
|
|
1251
|
+
setPreviewLoading(false);
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
/** Sub-session rows grouped for one card ('orphan:<id>' keys the kept shells). */
|
|
1256
|
+
const childrenOf = (cardKey) => (childIndex ? (childIndex.groups[cardKey] ?? []) : null);
|
|
1257
|
+
|
|
1258
|
+
const orphanCards = childIndex?.orphans ?? [];
|
|
1259
|
+
|
|
1260
|
+
/** Opt-out set for one card: a child is checked unless the user cleared it. */
|
|
1261
|
+
const uncheckedOf = (cardKey) => uncheckedChildren[cardKey];
|
|
1262
|
+
|
|
1263
|
+
const toggleChild = (cardKey, childId) => {
|
|
1264
|
+
setUncheckedChildren((prev) => {
|
|
1265
|
+
const current = new Set(prev[cardKey]);
|
|
1266
|
+
if (current.has(childId)) current.delete(childId);
|
|
1267
|
+
else current.add(childId);
|
|
1268
|
+
return { ...prev, [cardKey]: current };
|
|
1269
|
+
});
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
const forgetCardSelection = (cardKey) => {
|
|
1273
|
+
setUncheckedChildren((prev) => {
|
|
1274
|
+
if (!(cardKey in prev)) return prev;
|
|
1275
|
+
const next = { ...prev };
|
|
1276
|
+
delete next[cardKey];
|
|
1277
|
+
return next;
|
|
1278
|
+
});
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
const toggleCardChildren = (cardKey) => {
|
|
1282
|
+
setExpandedCards((prev) => {
|
|
1283
|
+
const next = new Set(prev);
|
|
1284
|
+
if (next.has(cardKey)) next.delete(cardKey);
|
|
1285
|
+
else next.add(cardKey);
|
|
1286
|
+
return next;
|
|
1287
|
+
});
|
|
1288
|
+
// Fallback retry, once per id signature: a card whose grouping never
|
|
1289
|
+
// arrived is the only reason to ask again, and one extra attempt is
|
|
1290
|
+
// what keeps a broken route from becoming a retry storm.
|
|
1291
|
+
const state = childRequestRef.current;
|
|
1292
|
+
if (childIndex || state.retried === childSignature) return;
|
|
1293
|
+
state.retried = childSignature;
|
|
1294
|
+
loadChildGroups(childSignature);
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
const liveChildren = (children) => children.filter((child) => child.live);
|
|
1298
|
+
|
|
1299
|
+
/** Deletable (checked, not running) child ids of one card. */
|
|
1300
|
+
const checkedChildIds = (cardKey, children) => {
|
|
1301
|
+
const off = uncheckedOf(cardKey);
|
|
1302
|
+
return children.filter((child) => !child.live && !off?.has(child.id)).map((child) => child.id);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Permanent delete of a card AND its checked sub-sessions.
|
|
1307
|
+
*
|
|
1308
|
+
* `parentIds` are the ROW ids to purge and the un-checked children travel
|
|
1309
|
+
* as `excludeIds`: the host deletes each row plus all of its descendants
|
|
1310
|
+
* minus `excludeIds`, so listing the children again would only make the
|
|
1311
|
+
* two lists disagree. `parentCount` is what the dialog may call a main
|
|
1312
|
+
* session (0 for a kept-children shell, whose rows ARE children).
|
|
1313
|
+
*/
|
|
1314
|
+
const handlePurgeCard = (cardKey, parentIds, parentCount, parentSize, children) => {
|
|
1315
|
+
if (parentIds.length === 0) {
|
|
1316
|
+
// /purge rejects an empty sessionIds[] after the fact; say so before.
|
|
1317
|
+
showToastLayer(t('childrenNoneSelected'));
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const running = liveChildren(children);
|
|
1321
|
+
if (running.length > 0) {
|
|
1322
|
+
// Refused here, before the request: the host refuses too, but only the
|
|
1323
|
+
// panel can name WHICH sub-session is still running.
|
|
1324
|
+
showToastLayer(t('childrenRunningBlocked', {
|
|
1325
|
+
titles: running.map((child) => child.title || child.id).join(t('listSeparator')),
|
|
1326
|
+
}));
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const doomed = children.filter((child) => !uncheckedOf(cardKey)?.has(child.id));
|
|
1330
|
+
const kept = children.filter((child) => uncheckedOf(cardKey)?.has(child.id));
|
|
1331
|
+
const size = (parentSize || 0) + doomed.reduce((sum, child) => sum + (child.fileSize || 0), 0);
|
|
1332
|
+
setConfirmModal({
|
|
1333
|
+
title: t('cascadeDeleteTitle'),
|
|
1334
|
+
body: t('cascadeDeleteBody', {
|
|
1335
|
+
parents: parentCount,
|
|
1336
|
+
children: doomed.length,
|
|
1337
|
+
size: formatFileSize(size),
|
|
1338
|
+
}),
|
|
1339
|
+
// A long cascade must not stretch the dialog past the viewport.
|
|
1340
|
+
items: doomed.length > CASCADE_DIALOG_LIST_LIMIT ? doomed : null,
|
|
1341
|
+
onConfirm: async () => {
|
|
1342
|
+
try {
|
|
1343
|
+
const result = await api(ctx, `${API_PREFIX}/purge`, {
|
|
1344
|
+
method: 'POST',
|
|
1345
|
+
body: { sessionIds: parentIds, excludeIds: kept.map((child) => child.id) },
|
|
1346
|
+
});
|
|
1347
|
+
const failures = purgeFailures(result);
|
|
1348
|
+
if (failures.length > 0) {
|
|
1349
|
+
showToastLayer(t('deleteFailed', { message: localizedError(failures[0]) }));
|
|
1350
|
+
} else {
|
|
1351
|
+
purgeFromBrowserSessionStore(ctx, parentIds);
|
|
1352
|
+
showToastLayer(t('deleted'));
|
|
1353
|
+
}
|
|
1354
|
+
setConfirmModal(null);
|
|
1355
|
+
// The hovered row is about to be unmounted, so its mouseleave can
|
|
1356
|
+
// no longer close the card: do it here.
|
|
1357
|
+
closeTooltip();
|
|
1358
|
+
forgetCardSelection(cardKey);
|
|
1359
|
+
setExpandedCards((prev) => {
|
|
1360
|
+
const next = new Set(prev);
|
|
1361
|
+
next.delete(cardKey);
|
|
1362
|
+
return next;
|
|
1363
|
+
});
|
|
1364
|
+
loadItems();
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
showToastLayer(t('deleteFailed', { message: localizedError(err) }));
|
|
1367
|
+
}
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
const childDisplayTitle = (child) => child.title || child.id;
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* "size · time" — each half dropped when the host reported null. A
|
|
1376
|
+
* sub-session with no cached projection has no size to report, and
|
|
1377
|
+
* printing 0 KB there would be a guess (same rule as the turn count).
|
|
1378
|
+
*/
|
|
1379
|
+
const childMetaText = (child) => [
|
|
1380
|
+
typeof child.fileSize === 'number' ? formatFileSize(child.fileSize) : null,
|
|
1381
|
+
child.createdAt ? formatRelativeTime(child.createdAt) : null,
|
|
1382
|
+
].filter(Boolean).join(' · ');
|
|
1383
|
+
|
|
1384
|
+
/** The absolute timestamp the relative label replaced, kept in a tooltip. */
|
|
1385
|
+
const childTimeTitle = (child) => (child.createdAt ? formatTime(child.createdAt) : '');
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* The full "size · timestamp" for the row's own tooltip: the narrow panel
|
|
1389
|
+
* hides the meta line, and the data must not disappear with it.
|
|
1390
|
+
*/
|
|
1391
|
+
const childMetaTitle = (child) => [
|
|
1392
|
+
typeof child.fileSize === 'number' ? formatFileSize(child.fileSize) : null,
|
|
1393
|
+
childTimeTitle(child) || null,
|
|
1394
|
+
].filter(Boolean).join(' · ');
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Compact sub-session row: checkbox, title, running pill, size · age, and
|
|
1398
|
+
* a 24px delete icon.
|
|
1399
|
+
*
|
|
1400
|
+
* The TITLE is the preview affordance (a real button, so it is reachable
|
|
1401
|
+
* by Tab and Enter) instead of a standing "view" pill: the row's width
|
|
1402
|
+
* budget at a 320px settings column cannot pay for a second text button,
|
|
1403
|
+
* and a hover-only control would be unusable by keyboard and touch.
|
|
1404
|
+
* Delete is an always-visible icon button for the same touch reason.
|
|
1405
|
+
*/
|
|
1406
|
+
const renderChildRow = (cardKey, child) => h('div', {
|
|
1407
|
+
key: child.id,
|
|
1408
|
+
className: child.live ? 'dsh-trash-child dsh-trash-child--live' : 'dsh-trash-child',
|
|
1409
|
+
title: childMetaTitle(child),
|
|
1410
|
+
},
|
|
1411
|
+
h('input', {
|
|
1412
|
+
type: 'checkbox',
|
|
1413
|
+
className: 'dsh-trash-checkbox dsh-trash-child-check',
|
|
1414
|
+
checked: !child.live && !uncheckedOf(cardKey)?.has(child.id),
|
|
1415
|
+
disabled: child.live,
|
|
1416
|
+
'aria-label': childDisplayTitle(child),
|
|
1417
|
+
title: child.live ? t('childrenRunningHint') : childDisplayTitle(child),
|
|
1418
|
+
onChange: (e) => {
|
|
1419
|
+
e.stopPropagation();
|
|
1420
|
+
toggleChild(cardKey, child.id);
|
|
1421
|
+
},
|
|
1422
|
+
}),
|
|
1423
|
+
h('button', {
|
|
1424
|
+
type: 'button',
|
|
1425
|
+
className: 'dsh-trash-child-title',
|
|
1426
|
+
title: childDisplayTitle(child),
|
|
1427
|
+
// The accessible name carries size and age as well: the narrow panel
|
|
1428
|
+
// hides the meta LINE (display:none removes it from the a11y tree)
|
|
1429
|
+
// and a tooltip is not readable by keyboard or screen reader. Keeping
|
|
1430
|
+
// it here at EVERY width means one announcement path, not two.
|
|
1431
|
+
'aria-label': [t('previewTitle', { title: childDisplayTitle(child) }), childMetaText(child)]
|
|
1432
|
+
.filter(Boolean).join(' · '),
|
|
1433
|
+
onClick: (e) => {
|
|
1434
|
+
e.stopPropagation();
|
|
1435
|
+
handleOpenPreview({ sessionId: child.id, title: childDisplayTitle(child) });
|
|
1436
|
+
},
|
|
1437
|
+
}, childDisplayTitle(child)),
|
|
1438
|
+
child.live
|
|
1439
|
+
? h('span', { className: 'dsh-trash-child-live', title: t('childrenRunningHint') }, t('childrenRunning'))
|
|
1440
|
+
: null,
|
|
1441
|
+
// Visual only: the same size/age is in the title button's name above,
|
|
1442
|
+
// so a screen reader must not hear it twice.
|
|
1443
|
+
h('span', {
|
|
1444
|
+
className: 'dsh-trash-child-meta',
|
|
1445
|
+
'aria-hidden': 'true',
|
|
1446
|
+
title: childTimeTitle(child),
|
|
1447
|
+
}, childMetaText(child)),
|
|
1448
|
+
// No restore for a sub-session: it never entered the recycle bin, so
|
|
1449
|
+
// there is nothing to put back — only the permanent delete.
|
|
1450
|
+
h('button', {
|
|
1451
|
+
type: 'button',
|
|
1452
|
+
className: 'dsh-trash-child-delete',
|
|
1453
|
+
'aria-label': t('permanentlyDelete'),
|
|
1454
|
+
title: t('permanentlyDelete'),
|
|
1455
|
+
disabled: child.live,
|
|
1456
|
+
onClick: (e) => {
|
|
1457
|
+
e.stopPropagation();
|
|
1458
|
+
if (child.live) return;
|
|
1459
|
+
handlePurgeSingle(child.id, childDisplayTitle(child));
|
|
1460
|
+
},
|
|
1461
|
+
}, renderTrashIcon({ className: 'dsh-trash-child-delete-icon' }))
|
|
1462
|
+
);
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Full-width inline list under a card. `children === null` means the
|
|
1466
|
+
* grouping has not arrived (or was dropped): two skeleton rows hold the
|
|
1467
|
+
* exact height instead of the card jumping when the rows land.
|
|
1468
|
+
*/
|
|
1469
|
+
const renderChildRegion = (cardKey, children) => {
|
|
1470
|
+
const regionId = `dsh-trash-children-${cardKey}`;
|
|
1471
|
+
if (children === null) {
|
|
1472
|
+
return h('div', { key: `${cardKey}:children`, id: regionId, className: 'dsh-trash-children', role: 'group' },
|
|
1473
|
+
h('div', { key: 'skeleton-1', className: 'dsh-trash-child dsh-trash-child--skeleton' }),
|
|
1474
|
+
h('div', { key: 'skeleton-2', className: 'dsh-trash-child dsh-trash-child--skeleton' })
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
const showAll = fullChildLists.has(cardKey);
|
|
1478
|
+
const shown = showAll ? children : children.slice(0, CHILD_ROW_LIMIT);
|
|
1479
|
+
return h('div', { key: `${cardKey}:children`, id: regionId, className: 'dsh-trash-children', role: 'group' },
|
|
1480
|
+
shown.length === 0
|
|
1481
|
+
? h('div', { key: 'empty', className: 'dsh-trash-children-empty' }, t('childrenEmpty'))
|
|
1482
|
+
: shown.map((child) => renderChildRow(cardKey, child)),
|
|
1483
|
+
children.length > shown.length
|
|
1484
|
+
? h('button', {
|
|
1485
|
+
key: 'more',
|
|
1486
|
+
type: 'button',
|
|
1487
|
+
className: 'dsh-trash-children-more',
|
|
1488
|
+
onClick: (e) => {
|
|
1489
|
+
e.stopPropagation();
|
|
1490
|
+
setFullChildLists((prev) => new Set(prev).add(cardKey));
|
|
1491
|
+
},
|
|
1492
|
+
}, t('childrenMore', { count: children.length - shown.length }))
|
|
1493
|
+
: null
|
|
1494
|
+
);
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* The badge IS the disclosure control: a button carrying
|
|
1499
|
+
* aria-expanded/aria-controls, so Enter and Space work for free and Esc
|
|
1500
|
+
* collapses. Rendered only when the count is known AND non-zero — an
|
|
1501
|
+
* unknown or empty count renders nothing at all, as before this feature.
|
|
1502
|
+
*/
|
|
1503
|
+
const renderChildBadge = (cardKey, count) => {
|
|
1504
|
+
if (!count) return null;
|
|
1505
|
+
const expanded = expandedCards.has(cardKey);
|
|
1506
|
+
return h('button', {
|
|
1507
|
+
type: 'button',
|
|
1508
|
+
className: 'dsh-trash-badge dsh-trash-badge--children',
|
|
1509
|
+
'aria-expanded': expanded ? 'true' : 'false',
|
|
1510
|
+
'aria-controls': `dsh-trash-children-${cardKey}`,
|
|
1511
|
+
'aria-label': t('childrenBadgeLabel', { count }),
|
|
1512
|
+
title: t('childrenCount', { count }),
|
|
1513
|
+
onClick: (e) => {
|
|
1514
|
+
e.stopPropagation();
|
|
1515
|
+
toggleCardChildren(cardKey);
|
|
1516
|
+
},
|
|
1517
|
+
onKeyDown: (e) => {
|
|
1518
|
+
if (e.key !== 'Escape' || !expanded) return;
|
|
1519
|
+
e.stopPropagation();
|
|
1520
|
+
toggleCardChildren(cardKey);
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
renderChildrenIcon({ className: 'dsh-trash-icon-badge' }),
|
|
1524
|
+
h('span', { className: 'dsh-trash-badge-num' }, String(count))
|
|
1525
|
+
);
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
// 提取工作区下拉列表
|
|
1529
|
+
const workspaceOptions = React.useMemo(() => {
|
|
1530
|
+
const map = new Map();
|
|
1531
|
+
for (const item of items) {
|
|
1532
|
+
const key = item.workspacePath || item.cwd || t('other');
|
|
1533
|
+
const title = localizeServerFallback(item.workspaceTitle) || (item.cwd ? item.cwd.split('/').pop() : t('otherProjects'));
|
|
1534
|
+
if (!map.has(key)) map.set(key, { path: key, title });
|
|
1535
|
+
}
|
|
1536
|
+
return [...map.values()];
|
|
1537
|
+
}, [items]);
|
|
1538
|
+
|
|
1539
|
+
// 过滤、排序并按工作区分组
|
|
1540
|
+
const groupedWorkspaces = React.useMemo(() => {
|
|
1541
|
+
let list = [...items];
|
|
1542
|
+
|
|
1543
|
+
if (searchQuery.trim()) {
|
|
1544
|
+
const q = searchQuery.trim().toLowerCase();
|
|
1545
|
+
list = list.filter((i) => {
|
|
1546
|
+
const title = (currentTitle(i.sessionId, i.title) || i.sessionId).toLowerCase();
|
|
1547
|
+
return title.includes(q);
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (filterWorkspace) {
|
|
1552
|
+
list = list.filter((i) => (i.workspacePath || i.cwd) === filterWorkspace);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// 按工作区分组(保持服务端返回的工作区顺序,与左侧 sidebar 一致)
|
|
1556
|
+
const groups = new Map();
|
|
1557
|
+
const groupOrder = [];
|
|
1558
|
+
for (const item of list) {
|
|
1559
|
+
const key = item.workspacePath || item.cwd || t('otherWorkspaces');
|
|
1560
|
+
const title = localizeServerFallback(item.workspaceTitle) || (item.cwd ? item.cwd.split('/').pop() : t('unnamedProject'));
|
|
1561
|
+
if (!groups.has(key)) {
|
|
1562
|
+
groups.set(key, { path: key, title, items: [] });
|
|
1563
|
+
groupOrder.push(key);
|
|
1564
|
+
}
|
|
1565
|
+
groups.get(key).items.push(item);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// 每个工作区内部按时间排序,不影响工作区之间的顺序
|
|
1569
|
+
for (const group of groups.values()) {
|
|
1570
|
+
group.items.sort((a, b) => {
|
|
1571
|
+
const timeA = a.archivedAt || 0;
|
|
1572
|
+
const timeB = b.archivedAt || 0;
|
|
1573
|
+
return sortOrder === 'asc' ? timeA - timeB : timeB - timeA;
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
return groupOrder.map((key) => groups.get(key)).filter(Boolean);
|
|
1578
|
+
}, [items, searchQuery, filterWorkspace, sortOrder, ctx]);
|
|
1579
|
+
|
|
1580
|
+
return h('div', { className: 'dsh-trash-container' },
|
|
1581
|
+
// Header
|
|
1582
|
+
h('div', { className: 'dsh-trash-header' },
|
|
1583
|
+
h('div', { className: 'dsh-trash-header-left' },
|
|
1584
|
+
h('h2', { className: 'dsh-trash-title' },
|
|
1585
|
+
renderTrashIcon({ className: 'dsh-trash-icon-title' }),
|
|
1586
|
+
t('recycleBin')
|
|
1587
|
+
),
|
|
1588
|
+
h('p', { className: 'dsh-trash-subtitle' }, t('archivedCount', { count: items.length }))
|
|
1589
|
+
)
|
|
1590
|
+
),
|
|
1591
|
+
|
|
1592
|
+
// Top Bar: 搜索框
|
|
1593
|
+
h('div', { className: 'dsh-trash-search' },
|
|
1594
|
+
h('svg', { className: 'dsh-trash-search-icon', width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' },
|
|
1595
|
+
h('circle', { cx: '11', cy: '11', r: '8' }),
|
|
1596
|
+
h('line', { x1: '21', y1: '21', x2: '16.65', y2: '16.65' })
|
|
1597
|
+
),
|
|
1598
|
+
h('input', {
|
|
1599
|
+
type: 'text',
|
|
1600
|
+
className: 'dsh-trash-search-input',
|
|
1601
|
+
placeholder: t('searchPlaceholder'),
|
|
1602
|
+
value: searchQuery,
|
|
1603
|
+
onChange: (e) => setSearchQuery(e.target.value),
|
|
1604
|
+
})
|
|
1605
|
+
),
|
|
1606
|
+
|
|
1607
|
+
// Filter Controls Toolbar
|
|
1608
|
+
h('div', { className: 'dsh-trash-toolbar' },
|
|
1609
|
+
h('select', {
|
|
1610
|
+
className: 'dsh-trash-select',
|
|
1611
|
+
value: filterWorkspace,
|
|
1612
|
+
onChange: (e) => setFilterWorkspace(e.target.value),
|
|
1613
|
+
},
|
|
1614
|
+
h('option', { value: '' }, t('allProjects')),
|
|
1615
|
+
workspaceOptions.map((w) => h('option', { key: w.path, value: w.path }, w.title))
|
|
1616
|
+
),
|
|
1617
|
+
|
|
1618
|
+
h('select', {
|
|
1619
|
+
className: 'dsh-trash-select',
|
|
1620
|
+
value: sortOrder,
|
|
1621
|
+
onChange: (e) => setSortOrder(e.target.value),
|
|
1622
|
+
},
|
|
1623
|
+
h('option', { value: 'desc' }, t('newestFirst')),
|
|
1624
|
+
h('option', { value: 'asc' }, t('oldestFirst'))
|
|
1625
|
+
),
|
|
1626
|
+
|
|
1627
|
+
items.length > 0 &&
|
|
1628
|
+
h('button', {
|
|
1629
|
+
className: 'dsh-trash-btn-danger-outline',
|
|
1630
|
+
onClick: handleEmptyAll,
|
|
1631
|
+
},
|
|
1632
|
+
renderTrashIcon({ className: 'dsh-trash-icon-btn' }),
|
|
1633
|
+
t('deleteAll')
|
|
1634
|
+
)
|
|
1635
|
+
),
|
|
1636
|
+
|
|
1637
|
+
// List Content
|
|
1638
|
+
loading
|
|
1639
|
+
? h('div', { className: 'dsh-trash-state' }, t('loading'))
|
|
1640
|
+
: error
|
|
1641
|
+
? h('div', { className: 'dsh-trash-state dsh-trash-state--error' }, error)
|
|
1642
|
+
: items.length === 0
|
|
1643
|
+
? h('div', { className: 'dsh-trash-state' }, t('empty'))
|
|
1644
|
+
: groupedWorkspaces.length === 0
|
|
1645
|
+
? h('div', { className: 'dsh-trash-state' }, t('noMatches'))
|
|
1646
|
+
: h('div', { className: 'dsh-trash-groups' },
|
|
1647
|
+
groupedWorkspaces.map((group) => {
|
|
1648
|
+
const groupSelectedIds = group.items.map((i) => i.sessionId).filter((id) => selectedSessionIds.has(id));
|
|
1649
|
+
const hasSelected = groupSelectedIds.length > 0;
|
|
1650
|
+
|
|
1651
|
+
return h('div', { key: group.path, className: 'dsh-trash-group' },
|
|
1652
|
+
// Workspace Section Header
|
|
1653
|
+
h('div', { className: 'dsh-trash-group-header' },
|
|
1654
|
+
h('div', { className: 'dsh-trash-group-title-group' },
|
|
1655
|
+
renderFolderIcon({ className: 'dsh-trash-icon-folder' }),
|
|
1656
|
+
h('span', { className: 'dsh-trash-group-title' }, group.title),
|
|
1657
|
+
h('span', { className: 'dsh-trash-group-path', title: group.path }, group.path)
|
|
1658
|
+
),
|
|
1659
|
+
h('div', { className: 'dsh-trash-group-right' },
|
|
1660
|
+
h('span', { className: 'dsh-trash-group-count' }, t('sessionCount', { count: group.items.length })),
|
|
1661
|
+
h('div', { className: 'dsh-more-menu-container dsh-trash-more-wrap' },
|
|
1662
|
+
h('button', {
|
|
1663
|
+
type: 'button',
|
|
1664
|
+
className: 'dsh-trash-more-btn',
|
|
1665
|
+
title: t('projectActions'),
|
|
1666
|
+
onClick: (e) => {
|
|
1667
|
+
e.stopPropagation();
|
|
1668
|
+
setActiveWorkspaceMenu(activeWorkspaceMenu === group.path ? null : group.path);
|
|
1669
|
+
},
|
|
1670
|
+
}, '•••'),
|
|
1671
|
+
activeWorkspaceMenu === group.path &&
|
|
1672
|
+
h('div', { className: 'dsh-trash-menu' },
|
|
1673
|
+
h('button', {
|
|
1674
|
+
className: 'dsh-trash-menu-item--danger',
|
|
1675
|
+
onClick: (e) => {
|
|
1676
|
+
e.stopPropagation();
|
|
1677
|
+
const targets = hasSelected
|
|
1678
|
+
? group.items.filter((i) => selectedSessionIds.has(i.sessionId))
|
|
1679
|
+
: group.items;
|
|
1680
|
+
handlePurgeWorkspace(group.path, hasSelected ? t('selectedSuffix', { title: group.title, count: groupSelectedIds.length }) : group.title, targets);
|
|
1681
|
+
},
|
|
1682
|
+
},
|
|
1683
|
+
renderTrashIcon({ className: 'dsh-trash-icon-menu' }),
|
|
1684
|
+
hasSelected ? t('deleteSelected', { count: groupSelectedIds.length }) : t('deleteProject')
|
|
1685
|
+
)
|
|
1686
|
+
)
|
|
1687
|
+
)
|
|
1688
|
+
)
|
|
1689
|
+
),
|
|
1690
|
+
|
|
1691
|
+
// Session Cards List
|
|
1692
|
+
h('div', { className: 'dsh-trash-cards' },
|
|
1693
|
+
group.items.map((item) => {
|
|
1694
|
+
const displayTitle = currentTitle(item.sessionId, localizeServerFallback(item.title)) || item.sessionId;
|
|
1695
|
+
const isChecked = selectedSessionIds.has(item.sessionId);
|
|
1696
|
+
const children = childrenOf(item.sessionId);
|
|
1697
|
+
const childCount = children ? children.length : 0;
|
|
1698
|
+
const childrenExpanded = expandedCards.has(item.sessionId);
|
|
1699
|
+
|
|
1700
|
+
// The inline list is a SIBLING of the row, not a child of
|
|
1701
|
+
// it: it must span the whole card width, and a click
|
|
1702
|
+
// inside it must not be a click on the card.
|
|
1703
|
+
return [
|
|
1704
|
+
h('div', {
|
|
1705
|
+
key: item.sessionId,
|
|
1706
|
+
className: isChecked ? 'dsh-trash-card dsh-trash-card--checked' : 'dsh-trash-card',
|
|
1707
|
+
},
|
|
1708
|
+
// 微圆复选框:只选这一行本身,不通配它的子会话(子行的
|
|
1709
|
+
// 勾选是另一件事——级联删除的范围),所以两者必须能一眼分辨。
|
|
1710
|
+
h('input', {
|
|
1711
|
+
type: 'checkbox',
|
|
1712
|
+
className: 'dsh-trash-checkbox dsh-trash-checkbox--card',
|
|
1713
|
+
checked: isChecked,
|
|
1714
|
+
'aria-label': t('selectSessionOnly'),
|
|
1715
|
+
title: t('selectSessionOnly'),
|
|
1716
|
+
onChange: (e) => {
|
|
1717
|
+
e.stopPropagation();
|
|
1718
|
+
toggleSelectSession(item.sessionId);
|
|
1719
|
+
},
|
|
1720
|
+
}),
|
|
1721
|
+
|
|
1722
|
+
h('div', { className: 'dsh-trash-card-main' },
|
|
1723
|
+
// Title: the ONLY hover target that opens the info
|
|
1724
|
+
// card. The row itself must stay inert — hovering the
|
|
1725
|
+
// action buttons has to leave their own native
|
|
1726
|
+
// tooltips alone, and the meta row / badges / checkbox
|
|
1727
|
+
// are not what the card describes. The card anchors to
|
|
1728
|
+
// THIS box.
|
|
1729
|
+
h('div', {
|
|
1730
|
+
className: 'dsh-trash-card-title',
|
|
1731
|
+
onMouseEnter: (e) => {
|
|
1732
|
+
cancelTooltipClose();
|
|
1733
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1734
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
1735
|
+
hoverTimerRef.current = setTimeout(() => {
|
|
1736
|
+
hoverTimerRef.current = null;
|
|
1737
|
+
openTooltipFor(item, displayTitle, rect);
|
|
1738
|
+
}, TOOLTIP_OPEN_DELAY_MS);
|
|
1739
|
+
},
|
|
1740
|
+
// Delayed, not immediate: the pointer has to be
|
|
1741
|
+
// able to cross the gap onto the card itself.
|
|
1742
|
+
onMouseLeave: scheduleTooltipClose,
|
|
1743
|
+
}, displayTitle),
|
|
1744
|
+
// Metadata Row (时间, 轮数, 容量 Badge, 子会话 Badge)
|
|
1745
|
+
h('div', { className: 'dsh-trash-card-meta' },
|
|
1746
|
+
h('span', { className: 'dsh-trash-card-meta-item dsh-trash-card-meta-item--time' }, formatTime(item.archivedAt)),
|
|
1747
|
+
// 轮数只有在投影缓存能给出「当前代际」记录时才显示。
|
|
1748
|
+
// 为 null 表示未知(例如会话的检查点停留在升级前的
|
|
1749
|
+
// 格式代际),此时宁可不显示,也不能谎报 0 轮。
|
|
1750
|
+
typeof item.turnCount === 'number'
|
|
1751
|
+
? h('span', { className: 'dsh-trash-card-meta-item dsh-trash-card-meta-item--turns' }, t('turnCount', { count: item.turnCount }))
|
|
1752
|
+
: null,
|
|
1753
|
+
h('span', { className: 'dsh-trash-badge' },
|
|
1754
|
+
renderDiskIcon({ className: 'dsh-trash-icon-badge' }),
|
|
1755
|
+
formatFileSize(item.fileSize)
|
|
1756
|
+
),
|
|
1757
|
+
renderChildBadge(item.sessionId, childCount)
|
|
1758
|
+
)
|
|
1759
|
+
),
|
|
1760
|
+
|
|
1761
|
+
// Action Buttons (微型极简胶囊按钮,大幅留出左侧空间)。
|
|
1762
|
+
// 文案包一层 span:窄容器里只隐藏文字、保留图标,因此每颗
|
|
1763
|
+
// 按钮都必须自带 aria-label/title,收起后仍有可访问名称。
|
|
1764
|
+
h('div', { className: 'dsh-trash-card-actions' },
|
|
1765
|
+
h('button', {
|
|
1766
|
+
type: 'button',
|
|
1767
|
+
className: 'dsh-trash-btn-ghost',
|
|
1768
|
+
'aria-label': t('view'),
|
|
1769
|
+
title: t('view'),
|
|
1770
|
+
onClick: () => handleOpenPreview(item),
|
|
1771
|
+
},
|
|
1772
|
+
renderEyeIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1773
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('view'))
|
|
1774
|
+
),
|
|
1775
|
+
h('button', {
|
|
1776
|
+
type: 'button',
|
|
1777
|
+
className: 'dsh-trash-btn-ghost',
|
|
1778
|
+
'aria-label': t('restore'),
|
|
1779
|
+
title: t('restore'),
|
|
1780
|
+
onClick: () => handleUnarchive(item),
|
|
1781
|
+
},
|
|
1782
|
+
renderRestoreIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1783
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('restore'))
|
|
1784
|
+
),
|
|
1785
|
+
h('button', {
|
|
1786
|
+
type: 'button',
|
|
1787
|
+
className: 'dsh-trash-btn-danger-text',
|
|
1788
|
+
'aria-label': t('permanentlyDelete'),
|
|
1789
|
+
title: t('permanentlyDelete'),
|
|
1790
|
+
onClick: () => (
|
|
1791
|
+
childCount > 0
|
|
1792
|
+
? handlePurgeCard(item.sessionId, [item.sessionId], 1, item.fileSize, children)
|
|
1793
|
+
: handlePurgeSingle(item.sessionId, displayTitle)
|
|
1794
|
+
),
|
|
1795
|
+
},
|
|
1796
|
+
renderTrashIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1797
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('permanentlyDelete'))
|
|
1798
|
+
)
|
|
1799
|
+
)
|
|
1800
|
+
),
|
|
1801
|
+
childrenExpanded ? renderChildRegion(item.sessionId, children) : null,
|
|
1802
|
+
];
|
|
1803
|
+
})
|
|
1804
|
+
)
|
|
1805
|
+
);
|
|
1806
|
+
})
|
|
1807
|
+
),
|
|
1808
|
+
|
|
1809
|
+
// Kept sub-sessions of an already-deleted main session. They never
|
|
1810
|
+
// entered the recycle bin, so there is nothing to restore — only to
|
|
1811
|
+
// view and to delete. The next /children answer drops the shell once
|
|
1812
|
+
// its last child is gone.
|
|
1813
|
+
orphanCards.length > 0 && h('div', { className: 'dsh-trash-cards' },
|
|
1814
|
+
orphanCards.map((group) => {
|
|
1815
|
+
const cardKey = `orphan:${group.parentId}`;
|
|
1816
|
+
const expanded = expandedCards.has(cardKey);
|
|
1817
|
+
return [
|
|
1818
|
+
h('div', { key: cardKey, className: 'dsh-trash-card dsh-trash-card--orphan' },
|
|
1819
|
+
h('div', { className: 'dsh-trash-card-main' },
|
|
1820
|
+
h('div', { className: 'dsh-trash-card-title' }, t('orphanCardTitle', { count: group.children.length })),
|
|
1821
|
+
// No timestamp and no size of its own: the MAIN session is
|
|
1822
|
+
// gone, so anything shown here would be the children's data
|
|
1823
|
+
// wearing the parent's label.
|
|
1824
|
+
h('div', { className: 'dsh-trash-card-meta' },
|
|
1825
|
+
h('span', { className: 'dsh-trash-tag-kept' }, t('orphanKept')),
|
|
1826
|
+
renderChildBadge(cardKey, group.children.length)
|
|
1827
|
+
)
|
|
1828
|
+
),
|
|
1829
|
+
h('div', { className: 'dsh-trash-card-actions' },
|
|
1830
|
+
// "View" is the first kept child: the parent that used to own
|
|
1831
|
+
// this row no longer exists to preview.
|
|
1832
|
+
h('button', {
|
|
1833
|
+
type: 'button',
|
|
1834
|
+
className: 'dsh-trash-btn-ghost',
|
|
1835
|
+
'aria-label': t('view'),
|
|
1836
|
+
title: t('view'),
|
|
1837
|
+
onClick: () => handleOpenPreview({
|
|
1838
|
+
sessionId: group.children[0].id,
|
|
1839
|
+
title: childDisplayTitle(group.children[0]),
|
|
1840
|
+
}),
|
|
1841
|
+
},
|
|
1842
|
+
renderEyeIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1843
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('view'))
|
|
1844
|
+
),
|
|
1845
|
+
// Batch delete of the checked kept children: the deleted
|
|
1846
|
+
// parent cannot cascade for them, so they are the row ids.
|
|
1847
|
+
h('button', {
|
|
1848
|
+
type: 'button',
|
|
1849
|
+
className: 'dsh-trash-btn-danger-text',
|
|
1850
|
+
'aria-label': t('permanentlyDelete'),
|
|
1851
|
+
title: t('permanentlyDelete'),
|
|
1852
|
+
onClick: () => handlePurgeCard(
|
|
1853
|
+
cardKey,
|
|
1854
|
+
checkedChildIds(cardKey, group.children),
|
|
1855
|
+
0,
|
|
1856
|
+
group.children.reduce((sum, child) => sum + (child.fileSize || 0), 0),
|
|
1857
|
+
group.children
|
|
1858
|
+
),
|
|
1859
|
+
},
|
|
1860
|
+
renderTrashIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1861
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('permanentlyDelete'))
|
|
1862
|
+
)
|
|
1863
|
+
)
|
|
1864
|
+
),
|
|
1865
|
+
expanded ? renderChildRegion(cardKey, group.children) : null,
|
|
1866
|
+
];
|
|
1867
|
+
})
|
|
1868
|
+
),
|
|
1869
|
+
|
|
1870
|
+
// Footer Summary
|
|
1871
|
+
items.length > 0 && h('div', { className: 'dsh-trash-footer' }, t('archivedCount', { count: items.length })),
|
|
1872
|
+
|
|
1873
|
+
// Modal 1: 查看内容 Message Preview Modal
|
|
1874
|
+
previewSession &&
|
|
1875
|
+
h('div', { className: 'dsh-trash-overlay', onClick: () => setPreviewSession(null) },
|
|
1876
|
+
h('div', { className: 'dsh-trash-preview', onClick: (e) => e.stopPropagation() },
|
|
1877
|
+
h('div', { className: 'dsh-trash-preview-header' },
|
|
1878
|
+
h('div', { className: 'dsh-trash-preview-title' }, t('previewTitle', { title: previewSession.title })),
|
|
1879
|
+
h('button', {
|
|
1880
|
+
className: 'dsh-trash-close',
|
|
1881
|
+
onClick: () => setPreviewSession(null),
|
|
1882
|
+
}, '✕')
|
|
1883
|
+
),
|
|
1884
|
+
h('div', { className: 'dsh-trash-preview-body' },
|
|
1885
|
+
previewLoading
|
|
1886
|
+
? h('div', { className: 'dsh-trash-preview-state' }, t('loadingHistory'))
|
|
1887
|
+
: previewMessages.length === 0
|
|
1888
|
+
? h('div', { className: 'dsh-trash-preview-state' }, t('noHistory'))
|
|
1889
|
+
: previewMessages.map((msg, index) =>
|
|
1890
|
+
h('div', { key: index, className: 'dsh-trash-message' },
|
|
1891
|
+
h('div', { className: 'dsh-trash-message-header' },
|
|
1892
|
+
h('span', { className: 'dsh-trash-message-role' }, msg.role === 'user' ? t('user') : t('assistant')),
|
|
1893
|
+
msg.timestamp && h('span', { className: 'dsh-trash-message-time' }, formatTime(msg.timestamp))
|
|
1894
|
+
),
|
|
1895
|
+
h('div', { className: msg.role === 'user' ? 'dsh-trash-bubble-user' : 'dsh-trash-bubble-assistant' },
|
|
1896
|
+
h('div', { className: 'dsh-trash-md' }, renderMarkdown(msg.content))
|
|
1897
|
+
)
|
|
1898
|
+
)
|
|
1899
|
+
)
|
|
1900
|
+
)
|
|
1901
|
+
)
|
|
1902
|
+
),
|
|
1903
|
+
|
|
1904
|
+
// Modal 2: 确认操作 Modal
|
|
1905
|
+
confirmModal &&
|
|
1906
|
+
h('div', { className: 'dsh-trash-overlay', onClick: () => setConfirmModal(null) },
|
|
1907
|
+
h('div', { className: 'dsh-trash-modal', onClick: (e) => e.stopPropagation() },
|
|
1908
|
+
h('h3', { className: 'dsh-trash-modal-title' }, confirmModal.title),
|
|
1909
|
+
h('p', { className: 'dsh-trash-modal-body' }, confirmModal.body),
|
|
1910
|
+
// A long cascade names what it will erase; past the limit the list
|
|
1911
|
+
// scrolls inside the dialog instead of growing it.
|
|
1912
|
+
confirmModal.items &&
|
|
1913
|
+
h('div', { className: 'dsh-trash-modal-list' },
|
|
1914
|
+
confirmModal.items.map((child) => h('div', {
|
|
1915
|
+
key: child.id,
|
|
1916
|
+
className: 'dsh-trash-modal-list-item',
|
|
1917
|
+
title: childDisplayTitle(child),
|
|
1918
|
+
}, childDisplayTitle(child)))
|
|
1919
|
+
),
|
|
1920
|
+
h('div', { className: 'dsh-trash-modal-actions' },
|
|
1921
|
+
h('button', { className: 'dsh-trash-btn-cancel', onClick: () => setConfirmModal(null) }, t('cancel')),
|
|
1922
|
+
h('button', { className: 'dsh-trash-btn-confirm', onClick: confirmModal.onConfirm }, t('confirmDelete'))
|
|
1923
|
+
)
|
|
1924
|
+
)
|
|
1925
|
+
),
|
|
1926
|
+
|
|
1927
|
+
// Hover info card: full title, id, and the session's log path on disk.
|
|
1928
|
+
// Anchored to the TITLE (left/top computed in JS), not to the pointer,
|
|
1929
|
+
// so it can be read, selected and clicked.
|
|
1930
|
+
hoverTooltip &&
|
|
1931
|
+
h('div', {
|
|
1932
|
+
ref: tooltipRef,
|
|
1933
|
+
className: `dsh-trash-tooltip dsh-trash-tooltip--${hoverTooltip.placement}`,
|
|
1934
|
+
style: { left: `${hoverTooltip.left}px`, top: `${hoverTooltip.top}px` },
|
|
1935
|
+
onMouseEnter: cancelTooltipClose,
|
|
1936
|
+
onMouseLeave: scheduleTooltipClose,
|
|
1937
|
+
},
|
|
1938
|
+
h('div', { className: 'dsh-trash-tooltip-title' }, hoverTooltip.title),
|
|
1939
|
+
h('div', { className: 'dsh-trash-tooltip-sub' }, `ID: ${hoverTooltip.id}`),
|
|
1940
|
+
// No log on disk -> no row at all (never an empty or "not found"
|
|
1941
|
+
// placeholder). The project path is already in the group header
|
|
1942
|
+
// above the card, so it is not repeated here.
|
|
1943
|
+
hoverTooltip.logPath
|
|
1944
|
+
? h('div', {
|
|
1945
|
+
className: 'dsh-trash-tooltip-sub',
|
|
1946
|
+
title: hoverTooltip.logPath,
|
|
1947
|
+
}, t('storagePath', { path: abbreviateHomePath(hoverTooltip.logPath) }))
|
|
1948
|
+
: null
|
|
1949
|
+
)
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/** Header Action component for active session header */
|
|
1954
|
+
function DeleteSessionAction(props) {
|
|
1955
|
+
const ctx = props.ctx;
|
|
1956
|
+
const session = props.session || props.activeSession;
|
|
1957
|
+
// 会话 ID 来源(按代际依次回退):插槽 props(<=0.1.2 提供 sessionId/session)→
|
|
1958
|
+
// sessions.list 快照的 current(0.1.5-rc.1 的插槽不再传 sessionId,但快照带 current)。
|
|
1959
|
+
const sessionId =
|
|
1960
|
+
props.sessionId ||
|
|
1961
|
+
props.id ||
|
|
1962
|
+
session?.id ||
|
|
1963
|
+
session?.sessionId ||
|
|
1964
|
+
activeSessionId(ctx) ||
|
|
1965
|
+
ctx?.sessions?.active?.getSnapshot()?.session?.id ||
|
|
1966
|
+
ctx?.sessions?.currentId;
|
|
1967
|
+
|
|
1968
|
+
// Tooltip state for this icon-only action. React clears `currentTarget`
|
|
1969
|
+
// once a handler returns, so the rect is read synchronously and only the
|
|
1970
|
+
// state update is delayed.
|
|
1971
|
+
const [tipAt, setTipAt] = useState(null);
|
|
1972
|
+
const tipTimer = React.useRef(null);
|
|
1973
|
+
const showTooltip = (event) => {
|
|
1974
|
+
if (tipTimer.current) clearTimeout(tipTimer.current);
|
|
1975
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
1976
|
+
const at = { x: rect.right, y: rect.bottom + 6 };
|
|
1977
|
+
tipTimer.current = setTimeout(() => setTipAt(at), TOOLTIP_DELAY_MS);
|
|
1978
|
+
};
|
|
1979
|
+
// Keyboard focus gets it immediately — a delayed bubble is useless to
|
|
1980
|
+
// someone tabbing through the header.
|
|
1981
|
+
const showTooltipNow = (event) => {
|
|
1982
|
+
if (tipTimer.current) clearTimeout(tipTimer.current);
|
|
1983
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
1984
|
+
setTipAt({ x: rect.right, y: rect.bottom + 6 });
|
|
1985
|
+
};
|
|
1986
|
+
const hideTooltip = () => {
|
|
1987
|
+
if (tipTimer.current) clearTimeout(tipTimer.current);
|
|
1988
|
+
setTipAt(null);
|
|
1989
|
+
};
|
|
1990
|
+
useEffect(() => () => {
|
|
1991
|
+
if (tipTimer.current) clearTimeout(tipTimer.current);
|
|
1992
|
+
}, []);
|
|
1993
|
+
|
|
1994
|
+
const handleArchive = async (e) => {
|
|
1995
|
+
if (e && e.stopPropagation) e.stopPropagation();
|
|
1996
|
+
if (!sessionId) {
|
|
1997
|
+
showToastLayer(t('noActiveSession'));
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
const title = (session?.title || resolveSessionTitle(ctx, sessionId, '')) ?? '';
|
|
2001
|
+
try {
|
|
2002
|
+
await api(ctx, `${API_PREFIX}/archive`, { method: 'POST', body: { sessionId, title } });
|
|
2003
|
+
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
2004
|
+
showToastLayer(t('movedToBin', { title: title || t('currentSession') }), async () => {
|
|
2005
|
+
try {
|
|
2006
|
+
await api(ctx, `${API_PREFIX}/unarchive`, { method: 'POST', body: { sessionId } });
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
console.error(t('undoFailed'), e);
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
showToastLayer(t('moveFailed', { message: localizedError(err) }));
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
return h(
|
|
2017
|
+
React.Fragment,
|
|
2018
|
+
null,
|
|
2019
|
+
h(
|
|
2020
|
+
'button',
|
|
2021
|
+
{
|
|
2022
|
+
type: 'button',
|
|
2023
|
+
className: 'dsh-trash-header-action',
|
|
2024
|
+
// No native `title`: the themed bubble below replaces it. An
|
|
2025
|
+
// OS-drawn tooltip cannot follow the palette or the reduced-motion
|
|
2026
|
+
// setting, and sitting beside the host's own themed tooltips it
|
|
2027
|
+
// read as an unstyled plugin. aria-label keeps the accessible name.
|
|
2028
|
+
'aria-label': t('moveToBin'),
|
|
2029
|
+
onMouseEnter: showTooltip,
|
|
2030
|
+
onMouseLeave: hideTooltip,
|
|
2031
|
+
onFocus: showTooltipNow,
|
|
2032
|
+
onBlur: hideTooltip,
|
|
2033
|
+
onClick: handleArchive,
|
|
2034
|
+
},
|
|
2035
|
+
renderTrashIcon({ width: 14, height: 14 })
|
|
2036
|
+
),
|
|
2037
|
+
tipAt &&
|
|
2038
|
+
h(
|
|
2039
|
+
'div',
|
|
2040
|
+
{
|
|
2041
|
+
className: 'dsh-trash-tooltip-host',
|
|
2042
|
+
style: { left: `${tipAt.x}px`, top: `${tipAt.y}px` },
|
|
2043
|
+
},
|
|
2044
|
+
t('moveToBin')
|
|
2045
|
+
)
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
/** Inject Trash Icon into settings sidebar */
|
|
2051
|
+
function injectSettingsMenuIcon() {
|
|
2052
|
+
if (typeof document === 'undefined') return;
|
|
2053
|
+
const candidates = [...document.querySelectorAll('*')].filter(
|
|
2054
|
+
(el) => el.children.length === 0 && el.textContent?.trim() === t('recycleBin')
|
|
2055
|
+
);
|
|
2056
|
+
for (const textEl of candidates) {
|
|
2057
|
+
const itemContainer =
|
|
2058
|
+
textEl.closest('button') ||
|
|
2059
|
+
textEl.closest('a') ||
|
|
2060
|
+
textEl.closest('[role="tab"]') ||
|
|
2061
|
+
textEl.closest('[role="button"]') ||
|
|
2062
|
+
textEl.closest('li') ||
|
|
2063
|
+
textEl.parentElement;
|
|
2064
|
+
if (!itemContainer) continue;
|
|
2065
|
+
|
|
2066
|
+
const svg = itemContainer.querySelector('svg');
|
|
2067
|
+
if (svg) {
|
|
2068
|
+
if (!svg.dataset.dshTrashIconInjected) {
|
|
2069
|
+
svg.dataset.dshTrashIconInjected = 'true';
|
|
2070
|
+
const temp = document.createElement('div');
|
|
2071
|
+
temp.innerHTML =
|
|
2072
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
|
|
2073
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
2074
|
+
'<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>' +
|
|
2075
|
+
'<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>' +
|
|
2076
|
+
'<line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
|
2077
|
+
const newSvg = temp.firstElementChild;
|
|
2078
|
+
if (newSvg) {
|
|
2079
|
+
newSvg.dataset.dshTrashIconInjected = 'true';
|
|
2080
|
+
if (svg.getAttribute('class')) newSvg.setAttribute('class', svg.getAttribute('class'));
|
|
2081
|
+
svg.parentNode?.replaceChild(newSvg, svg);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Keep the Settings → "会话回收站" nav item's icon in sync.
|
|
2090
|
+
*
|
|
2091
|
+
* v0.4.4 removed the sidebar row injection that used to share this
|
|
2092
|
+
* observer: DSH now ships its own archive hover button on every session
|
|
2093
|
+
* row (`sidebar.workspaces.session.row.action`), so a second, differently
|
|
2094
|
+
* worded trash affordance next to it did the same job under a different
|
|
2095
|
+
* verb. That also retired the dominant cost of this observer — the
|
|
2096
|
+
* per-mutation scan of every session row, with its title-index and
|
|
2097
|
+
* position-disambiguation passes — leaving only this idempotent icon swap.
|
|
2098
|
+
*/
|
|
2099
|
+
function startSettingsIconSync() {
|
|
2100
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
|
2101
|
+
let raf = 0;
|
|
2102
|
+
const schedule = () => {
|
|
2103
|
+
if (raf) return;
|
|
2104
|
+
raf = requestAnimationFrame(() => {
|
|
2105
|
+
raf = 0;
|
|
2106
|
+
try {
|
|
2107
|
+
injectSettingsMenuIcon();
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
console.error('session-trash: settings icon inject failed', error);
|
|
2110
|
+
}
|
|
2111
|
+
});
|
|
2112
|
+
};
|
|
2113
|
+
const observer = new MutationObserver(schedule);
|
|
2114
|
+
observer.observe(document.documentElement ?? document, { childList: true, subtree: true });
|
|
2115
|
+
schedule();
|
|
2116
|
+
return () => {
|
|
2117
|
+
observer.disconnect();
|
|
2118
|
+
if (raf) cancelAnimationFrame(raf);
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/** Plugin body apply */
|
|
2123
|
+
function apply(ctx) {
|
|
2124
|
+
injectStylesheet();
|
|
2125
|
+
// Adopt the host Language setting (and publish our dictionaries) before
|
|
2126
|
+
// anything renders a label.
|
|
2127
|
+
attachHostLocale(ctx);
|
|
2128
|
+
|
|
2129
|
+
// DSH 0.1.5 的 settings.section 注册契约只接受 id / order / label
|
|
2130
|
+
// (见 dsh-cordis-client-runner 内置的 slot 目录:registerOptions 就这三项,
|
|
2131
|
+
// label 的类型是 `string | (() => string)`)。设置面板按 order 升序把注册项
|
|
2132
|
+
// 排成一列,所以这里只传契约内字段;label 仍用 thunk,两代际都合法。
|
|
2133
|
+
//
|
|
2134
|
+
// order 必须落在「一定可见」的区间:0.1.5 的设置面板把导航列高度固定为约
|
|
2135
|
+
// 808px(17 项 × 44px),而面板 overflow:hidden、导航列 overflow:visible 且
|
|
2136
|
+
// 没有滚动条 —— 窗口可视高度放不下整个面板时,**列表末尾的条目会被直接裁掉,
|
|
2137
|
+
// 既看不见也滚不到**。原先取 200(倒数第二项),窗口稍矮(面板 < 808px,
|
|
2138
|
+
// 约等于视口 < 800px)就整项消失,这正是「设置里没有会话回收站」的真正原因。
|
|
2139
|
+
// 25 落在 20(mnemon「Memory System」)与 30(cost-meter「Cost」)之间,
|
|
2140
|
+
// 属于最上面几项,任何能看见设置导航的窗口都能看见它。
|
|
2141
|
+
ctx.slots.inject('settings.section', () =>
|
|
2142
|
+
ctx.slots.register(
|
|
2143
|
+
{
|
|
2144
|
+
name: 'settings.section',
|
|
2145
|
+
id: 'trash',
|
|
2146
|
+
order: 25,
|
|
2147
|
+
label: () => t('recycleBin'),
|
|
2148
|
+
},
|
|
2149
|
+
(props) => TrashTab({ ...props, ctx })
|
|
2150
|
+
)
|
|
2151
|
+
);
|
|
2152
|
+
|
|
2153
|
+
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
2154
|
+
ctx.slots.register(
|
|
2155
|
+
{
|
|
2156
|
+
name: 'conversation.session.header.actions',
|
|
2157
|
+
id: 'delete-session',
|
|
2158
|
+
order: 400,
|
|
2159
|
+
// The label must name what the click does. The action archives to
|
|
2160
|
+
// the recycle bin (reversible, undoable via the toast) — it does
|
|
2161
|
+
// not delete anything — so it says "移入回收站", the same verb as
|
|
2162
|
+
// the panel and as the button's own title. It used to read
|
|
2163
|
+
// "删除会话" while the button tooltip said "移入回收站": one button,
|
|
2164
|
+
// two names, and a trash icon promising a permanence it did not
|
|
2165
|
+
// have.
|
|
2166
|
+
label: () => t('moveToBin'),
|
|
2167
|
+
},
|
|
2168
|
+
(props) => DeleteSessionAction({ ...props, ctx })
|
|
2169
|
+
)
|
|
2170
|
+
);
|
|
2171
|
+
|
|
2172
|
+
ctx.slots.inject('sidebar.workspaces.session.menu.item', () =>
|
|
2173
|
+
ctx.slots.register({
|
|
2174
|
+
name: 'sidebar.workspaces.session.menu.item',
|
|
2175
|
+
id: 'dsh-session-manager.delete',
|
|
2176
|
+
order: 500,
|
|
2177
|
+
label: () => t('deleteSession'),
|
|
2178
|
+
}, (props) => DeleteSessionMenuItem({ ...props, ctx }))
|
|
2179
|
+
);
|
|
2180
|
+
|
|
2181
|
+
ctx.effect(() => startSettingsIconSync());
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
exports.apply = apply;
|
|
2185
|
+
exports.inject = ['slots', 'connection', 'sessions', 'workspaces'];
|
|
2186
|
+
|
|
2187
|
+
return module.exports;
|
|
2188
|
+
},
|
|
2189
|
+
});
|