@sunshaobei/dsh-session-manager 0.0.0-stage → 0.4.3-fork.1
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 +94 -2
- package/README.zh.md +94 -0
- package/client.css +1178 -0
- package/client.js +1324 -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 +1316 -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,1324 @@
|
|
|
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
|
+
const MESSAGES = {
|
|
20
|
+
en: {
|
|
21
|
+
undo: 'Undo',
|
|
22
|
+
loadFailed: 'Failed to load the recycle bin',
|
|
23
|
+
restored: 'Restored session “{title}”',
|
|
24
|
+
undoFailed: 'Undo failed',
|
|
25
|
+
restoreFailed: 'Restore failed: {message}',
|
|
26
|
+
confirmPermanentTitle: 'Confirm permanent deletion',
|
|
27
|
+
confirmPermanentBody: 'Permanently delete the session “{title}”? This cannot be undone and its conversation history will be erased.',
|
|
28
|
+
deleteFailed: 'Deletion failed: {message}',
|
|
29
|
+
unknownError: 'Unknown error',
|
|
30
|
+
deleted: 'Session permanently deleted',
|
|
31
|
+
deleteArchivedTitle: 'Permanently delete archived sessions',
|
|
32
|
+
deleteWorkspaceBody: 'Permanently delete {count} session(s) from the workspace “{title}”? This cannot be undone.',
|
|
33
|
+
deletedSelected: 'Selected archived sessions permanently deleted',
|
|
34
|
+
deleteAllTitle: 'Delete all archived sessions',
|
|
35
|
+
deleteAllBody: 'Permanently delete all {count} archived session(s)? This cannot be undone.',
|
|
36
|
+
emptied: 'Permanently deleted all {count} session(s) from the recycle bin',
|
|
37
|
+
emptyFailed: 'Failed to empty the recycle bin: {message}',
|
|
38
|
+
historyFailed: 'Failed to load session history: {message}',
|
|
39
|
+
other: 'Other',
|
|
40
|
+
otherProjects: 'Other projects',
|
|
41
|
+
otherWorkspaces: 'Other workspaces',
|
|
42
|
+
unnamedProject: 'Unnamed project',
|
|
43
|
+
unnamedSession: 'Unnamed session',
|
|
44
|
+
recycleBin: 'Session Recycle Bin',
|
|
45
|
+
archivedCount: '{count} archived session(s)',
|
|
46
|
+
searchPlaceholder: 'Search archived sessions...',
|
|
47
|
+
allProjects: 'All projects',
|
|
48
|
+
newestFirst: 'Recently deleted (newest first)',
|
|
49
|
+
oldestFirst: 'Oldest deleted (oldest first)',
|
|
50
|
+
deleteAll: 'Delete all',
|
|
51
|
+
loading: 'Loading archived sessions...',
|
|
52
|
+
empty: 'The recycle bin is empty',
|
|
53
|
+
noMatches: 'No matching archived sessions found',
|
|
54
|
+
sessionCount: '{count} session(s)',
|
|
55
|
+
projectActions: 'Project actions',
|
|
56
|
+
selectedSuffix: '{title} ({count} selected)',
|
|
57
|
+
deleteSelected: 'Permanently delete selected ({count})',
|
|
58
|
+
deleteProject: 'Permanently delete all in project',
|
|
59
|
+
turnCount: '{count} turn(s)',
|
|
60
|
+
view: 'View',
|
|
61
|
+
restore: 'Restore',
|
|
62
|
+
permanentlyDelete: 'Permanently delete',
|
|
63
|
+
previewTitle: 'Session: {title}',
|
|
64
|
+
loadingHistory: 'Loading conversation history...',
|
|
65
|
+
noHistory: 'No conversation history',
|
|
66
|
+
user: '👤 User',
|
|
67
|
+
assistant: '🤖 Assistant',
|
|
68
|
+
cancel: 'Cancel',
|
|
69
|
+
confirmDelete: 'Confirm deletion',
|
|
70
|
+
path: 'Path: {path}',
|
|
71
|
+
noActiveSession: 'Could not identify the active session ID',
|
|
72
|
+
movedToBin: 'Moved session “{title}” to the recycle bin',
|
|
73
|
+
currentSession: 'Current session',
|
|
74
|
+
session: 'Session',
|
|
75
|
+
moveFailed: 'Failed to move session to the recycle bin: {message}',
|
|
76
|
+
moveToBin: 'Move to Recycle Bin',
|
|
77
|
+
deleteSession: 'Delete Session',
|
|
78
|
+
sessionRunning: 'The session is currently running and cannot be deleted.',
|
|
79
|
+
},
|
|
80
|
+
zh: {
|
|
81
|
+
undo: '撤销',
|
|
82
|
+
loadFailed: '加载回收站列表失败',
|
|
83
|
+
restored: '已恢复会话「{title}」',
|
|
84
|
+
undoFailed: '撤销失败',
|
|
85
|
+
restoreFailed: '恢复失败: {message}',
|
|
86
|
+
confirmPermanentTitle: '确认彻底删除',
|
|
87
|
+
confirmPermanentBody: '确定要永久删除会话「{title}」吗?此操作不可撤销,历史对话记录将被完全清除。',
|
|
88
|
+
deleteFailed: '删除失败: {message}',
|
|
89
|
+
unknownError: '未知错误',
|
|
90
|
+
deleted: '已彻底删除会话',
|
|
91
|
+
deleteArchivedTitle: '彻底删除归档会话',
|
|
92
|
+
deleteWorkspaceBody: '确定要永久删除工作区「{title}」下的 {count} 个会话吗?此操作不可撤销。',
|
|
93
|
+
deletedSelected: '已彻底删除所选归档会话',
|
|
94
|
+
deleteAllTitle: '全部删除归档会话',
|
|
95
|
+
deleteAllBody: '确定要永久删除全部 {count} 个归档会话吗?此操作不可撤销。',
|
|
96
|
+
emptied: '已清空回收站中全部 {count} 个会话',
|
|
97
|
+
emptyFailed: '清空失败: {message}',
|
|
98
|
+
historyFailed: '获取会话记录失败: {message}',
|
|
99
|
+
other: '其他',
|
|
100
|
+
otherProjects: '其他项目',
|
|
101
|
+
otherWorkspaces: '其他工作区',
|
|
102
|
+
unnamedProject: '未命名项目',
|
|
103
|
+
unnamedSession: '未命名会话',
|
|
104
|
+
recycleBin: '会话回收站',
|
|
105
|
+
archivedCount: '共 {count} 个已归档会话',
|
|
106
|
+
searchPlaceholder: '搜索已归档聊天...',
|
|
107
|
+
allProjects: '所有项目',
|
|
108
|
+
newestFirst: '最近删除 (降序)',
|
|
109
|
+
oldestFirst: '最早删除 (正序)',
|
|
110
|
+
deleteAll: '全部删除',
|
|
111
|
+
loading: '加载已归档会话...',
|
|
112
|
+
empty: '回收站暂无会话',
|
|
113
|
+
noMatches: '未找到匹配的归档会话',
|
|
114
|
+
sessionCount: '{count} 个聊天',
|
|
115
|
+
projectActions: '项目操作',
|
|
116
|
+
selectedSuffix: '{title} (选中的 {count} 项)',
|
|
117
|
+
deleteSelected: '彻底删除选中 ({count})',
|
|
118
|
+
deleteProject: '彻底删除项目全部',
|
|
119
|
+
turnCount: '{count} 轮对话',
|
|
120
|
+
view: '查看',
|
|
121
|
+
restore: '还原',
|
|
122
|
+
permanentlyDelete: '彻底删除',
|
|
123
|
+
previewTitle: '查看会话:{title}',
|
|
124
|
+
loadingHistory: '正在读取历史对话记录...',
|
|
125
|
+
noHistory: '暂无对话记录',
|
|
126
|
+
user: '👤 用户',
|
|
127
|
+
assistant: '🤖 助手',
|
|
128
|
+
cancel: '取消',
|
|
129
|
+
confirmDelete: '确定删除',
|
|
130
|
+
path: '路径: {path}',
|
|
131
|
+
noActiveSession: '无法识别当前活动会话 ID',
|
|
132
|
+
movedToBin: '已将会话「{title}」移入回收站',
|
|
133
|
+
currentSession: '当前会话',
|
|
134
|
+
session: '会话',
|
|
135
|
+
moveFailed: '移入回收站失败: {message}',
|
|
136
|
+
moveToBin: '移入回收站',
|
|
137
|
+
deleteSession: '删除会话',
|
|
138
|
+
sessionRunning: '会话正在运行中,无法删除',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Use the host's selected language for both menu entries and notifications.
|
|
143
|
+
const LOCALE_NS = 'session-trash';
|
|
144
|
+
let hostLocale;
|
|
145
|
+
let hostTranslate;
|
|
146
|
+
|
|
147
|
+
function attachHostLocale(ctx) {
|
|
148
|
+
hostLocale = typeof ctx?.get === 'function' ? ctx.get('locale') : undefined;
|
|
149
|
+
if (!hostLocale || typeof hostLocale.register !== 'function' || typeof hostLocale.bind !== 'function') return;
|
|
150
|
+
try {
|
|
151
|
+
const dispose = hostLocale.register(LOCALE_NS, MESSAGES);
|
|
152
|
+
ctx.effect?.(() => dispose, 'session-trash: locale dictionaries');
|
|
153
|
+
} catch {
|
|
154
|
+
// A twin instance may already own the namespace; `bind` still resolves
|
|
155
|
+
// the same dictionaries, so fall through and probe below.
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const bound = hostLocale.bind(LOCALE_NS);
|
|
159
|
+
// Trust the service only once OUR keys resolve through it — translate()
|
|
160
|
+
// returns the raw key when the namespace carries no dictionary.
|
|
161
|
+
if (bound('recycleBin') !== 'recycleBin') hostTranslate = bound;
|
|
162
|
+
} catch { /* keep the built-in table */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function browserLocale() {
|
|
166
|
+
return window?.navigator?.language || window?.navigator?.languages?.[0] || '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Active language for the built-in table: host choice first, browser second. */
|
|
170
|
+
function activeLocale() {
|
|
171
|
+
let active;
|
|
172
|
+
try {
|
|
173
|
+
active = hostLocale?.getLocale?.()?.active ?? hostLocale?.getSnapshot?.()?.active;
|
|
174
|
+
} catch { /* service teardown — fall back to the browser */ }
|
|
175
|
+
return /^zh(?:-|$)/i.test(String(active || browserLocale())) ? 'zh' : 'en';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function t(key, params = {}) {
|
|
179
|
+
if (hostTranslate) {
|
|
180
|
+
try { return hostTranslate(key, params); } catch { /* fall through */ }
|
|
181
|
+
}
|
|
182
|
+
const template = MESSAGES[activeLocale()]?.[key] ?? MESSAGES.en[key] ?? key;
|
|
183
|
+
return String(template).replace(/\{(\w+)\}/g, (_match, name) => String(params[name] ?? ''));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function localizedError(error) {
|
|
187
|
+
if (error?.code === 'SESSION_RUNNING') return t('sessionRunning');
|
|
188
|
+
return error?.message || t('unknownError');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function localizeServerFallback(value) {
|
|
192
|
+
if (value === '未命名会话' || value === 'Unnamed session') return t('unnamedSession');
|
|
193
|
+
if (value === '其他项目' || value === 'Other projects') return t('otherProjects');
|
|
194
|
+
if (value === '其他工作区' || value === 'Other workspaces') return t('otherWorkspaces');
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Safe icon helper functions (zero-fail direct element creation) */
|
|
199
|
+
function renderTrashIcon(props = {}) {
|
|
200
|
+
const { style, ...rest } = props;
|
|
201
|
+
return h(
|
|
202
|
+
'svg',
|
|
203
|
+
{
|
|
204
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
205
|
+
width: props.width || '15',
|
|
206
|
+
height: props.height || '15',
|
|
207
|
+
viewBox: '0 0 24 24',
|
|
208
|
+
fill: 'none',
|
|
209
|
+
stroke: 'currentColor',
|
|
210
|
+
strokeWidth: '2',
|
|
211
|
+
strokeLinecap: 'round',
|
|
212
|
+
strokeLinejoin: 'round',
|
|
213
|
+
'aria-hidden': 'true',
|
|
214
|
+
style,
|
|
215
|
+
...rest,
|
|
216
|
+
},
|
|
217
|
+
h('path', { d: 'M3 6h18' }),
|
|
218
|
+
h('path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }),
|
|
219
|
+
h('path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }),
|
|
220
|
+
h('line', { x1: '10', y1: '11', x2: '10', y2: '17' }),
|
|
221
|
+
h('line', { x1: '14', y1: '11', x2: '14', y2: '17' })
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderFolderIcon(props = {}) {
|
|
226
|
+
const { style, ...rest } = props;
|
|
227
|
+
return h(
|
|
228
|
+
'svg',
|
|
229
|
+
{
|
|
230
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
231
|
+
width: props.width || '16',
|
|
232
|
+
height: props.height || '16',
|
|
233
|
+
viewBox: '0 0 24 24',
|
|
234
|
+
fill: 'none',
|
|
235
|
+
stroke: 'currentColor',
|
|
236
|
+
strokeWidth: '2',
|
|
237
|
+
strokeLinecap: 'round',
|
|
238
|
+
strokeLinejoin: 'round',
|
|
239
|
+
'aria-hidden': 'true',
|
|
240
|
+
style,
|
|
241
|
+
...rest,
|
|
242
|
+
},
|
|
243
|
+
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' })
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderEyeIcon(props = {}) {
|
|
248
|
+
const { style, ...rest } = props;
|
|
249
|
+
return h(
|
|
250
|
+
'svg',
|
|
251
|
+
{
|
|
252
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
253
|
+
width: props.width || '14',
|
|
254
|
+
height: props.height || '14',
|
|
255
|
+
viewBox: '0 0 24 24',
|
|
256
|
+
fill: 'none',
|
|
257
|
+
stroke: 'currentColor',
|
|
258
|
+
strokeWidth: '2',
|
|
259
|
+
strokeLinecap: 'round',
|
|
260
|
+
strokeLinejoin: 'round',
|
|
261
|
+
'aria-hidden': 'true',
|
|
262
|
+
style,
|
|
263
|
+
...rest,
|
|
264
|
+
},
|
|
265
|
+
h('path', { d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z' }),
|
|
266
|
+
h('circle', { cx: '12', cy: '12', r: '3' })
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderRestoreIcon(props = {}) {
|
|
271
|
+
const { style, ...rest } = props;
|
|
272
|
+
return h(
|
|
273
|
+
'svg',
|
|
274
|
+
{
|
|
275
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
276
|
+
width: props.width || '14',
|
|
277
|
+
height: props.height || '14',
|
|
278
|
+
viewBox: '0 0 24 24',
|
|
279
|
+
fill: 'none',
|
|
280
|
+
stroke: 'currentColor',
|
|
281
|
+
strokeWidth: '2',
|
|
282
|
+
strokeLinecap: 'round',
|
|
283
|
+
strokeLinejoin: 'round',
|
|
284
|
+
'aria-hidden': 'true',
|
|
285
|
+
style,
|
|
286
|
+
...rest,
|
|
287
|
+
},
|
|
288
|
+
h('polyline', { points: '1 4 1 10 7 10' }),
|
|
289
|
+
h('path', { d: 'M3.51 15a9 9 0 1 0 2.13-9.36L1 10' })
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderDiskIcon(props = {}) {
|
|
294
|
+
const { style, ...rest } = props;
|
|
295
|
+
return h(
|
|
296
|
+
'svg',
|
|
297
|
+
{
|
|
298
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
299
|
+
width: props.width || '14',
|
|
300
|
+
height: props.height || '14',
|
|
301
|
+
viewBox: '0 0 24 24',
|
|
302
|
+
fill: 'none',
|
|
303
|
+
stroke: 'currentColor',
|
|
304
|
+
strokeWidth: '2',
|
|
305
|
+
strokeLinecap: 'round',
|
|
306
|
+
strokeLinejoin: 'round',
|
|
307
|
+
'aria-hidden': 'true',
|
|
308
|
+
style,
|
|
309
|
+
...rest,
|
|
310
|
+
},
|
|
311
|
+
h('rect', { x: '3', y: '6', width: '18', height: '12', rx: '3' }),
|
|
312
|
+
h('line', { x1: '3', y1: '12', x2: '21', y2: '12' }),
|
|
313
|
+
h('circle', { cx: '7', cy: '15', r: '1', fill: 'currentColor' }),
|
|
314
|
+
h('circle', { cx: '11', cy: '15', r: '1', fill: 'currentColor' })
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const TrashIcon = (props) => renderTrashIcon(props);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Inject the plugin stylesheet (external client.css) exactly once.
|
|
322
|
+
* The host serves it at GET /api/session-trash/client.css; all colors
|
|
323
|
+
* are CSS variables that adapt to the OS light/dark color mode.
|
|
324
|
+
*/
|
|
325
|
+
function injectStylesheet() {
|
|
326
|
+
if (typeof document === 'undefined') return;
|
|
327
|
+
if (document.getElementById('dsh-session-manager-stylesheet')) return;
|
|
328
|
+
const link = document.createElement('link');
|
|
329
|
+
link.id = 'dsh-session-manager-stylesheet';
|
|
330
|
+
link.rel = 'stylesheet';
|
|
331
|
+
link.href = '/api/session-trash/client.css';
|
|
332
|
+
(document.head ?? document.documentElement).appendChild(link);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Show a bottom-left floating toast prompt. */
|
|
336
|
+
function showToastLayer(message, undoFn, actionText = t('undo')) {
|
|
337
|
+
if (typeof document === 'undefined') return;
|
|
338
|
+
|
|
339
|
+
let container = document.getElementById('dsh-session-manager-toast-container');
|
|
340
|
+
if (!container) {
|
|
341
|
+
container = document.createElement('div');
|
|
342
|
+
container.id = 'dsh-session-manager-toast-container';
|
|
343
|
+
container.className = 'dsh-trash-toast-container';
|
|
344
|
+
document.body.appendChild(container);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const toastEl = document.createElement('div');
|
|
348
|
+
toastEl.className = 'dsh-trash-toast';
|
|
349
|
+
|
|
350
|
+
const textSpan = document.createElement('span');
|
|
351
|
+
textSpan.textContent = message;
|
|
352
|
+
toastEl.appendChild(textSpan);
|
|
353
|
+
|
|
354
|
+
if (undoFn) {
|
|
355
|
+
const btn = document.createElement('button');
|
|
356
|
+
btn.textContent = actionText;
|
|
357
|
+
btn.className = 'dsh-trash-toast-undo';
|
|
358
|
+
btn.addEventListener('click', async () => {
|
|
359
|
+
try {
|
|
360
|
+
await undoFn();
|
|
361
|
+
} catch (err) {
|
|
362
|
+
console.error('Toast restore failed:', err);
|
|
363
|
+
}
|
|
364
|
+
toastEl.remove();
|
|
365
|
+
});
|
|
366
|
+
toastEl.appendChild(btn);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
container.appendChild(toastEl);
|
|
370
|
+
requestAnimationFrame(() => {
|
|
371
|
+
toastEl.style.opacity = '1';
|
|
372
|
+
toastEl.style.transform = 'translateY(0)';
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
toastEl.style.opacity = '0';
|
|
377
|
+
toastEl.style.transform = 'translateY(10px)';
|
|
378
|
+
setTimeout(() => toastEl.remove(), 200);
|
|
379
|
+
}, 4000);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Formats file size in KB or MB */
|
|
383
|
+
function formatFileSize(bytes) {
|
|
384
|
+
if (!bytes || bytes <= 0) return '0 KB';
|
|
385
|
+
const kb = bytes / 1024;
|
|
386
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
387
|
+
return `${(kb / 1024).toFixed(1)} MB`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Formats timestamp into YYYY/M/D HH:mm */
|
|
391
|
+
function formatTime(ts) {
|
|
392
|
+
if (!ts) return '';
|
|
393
|
+
const d = new Date(ts);
|
|
394
|
+
const year = d.getFullYear();
|
|
395
|
+
const month = d.getMonth() + 1;
|
|
396
|
+
const day = d.getDate();
|
|
397
|
+
const hours = String(d.getHours()).padStart(2, '0');
|
|
398
|
+
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
399
|
+
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Make signed API requests. */
|
|
403
|
+
async function api(ctx, path, options = {}) {
|
|
404
|
+
const response = await fetch(path, {
|
|
405
|
+
method: options.method || 'GET',
|
|
406
|
+
headers: {
|
|
407
|
+
'Content-Type': 'application/json',
|
|
408
|
+
// The host's only contract header: the minimal CSRF custom header the
|
|
409
|
+
// routes require on non-GET requests. No locale header is sent — the
|
|
410
|
+
// host does not read one, and server fallback labels are localized on
|
|
411
|
+
// this side by localizeServerFallback().
|
|
412
|
+
'x-dsh-plugin': 'session-trash',
|
|
413
|
+
...(options.headers || {}),
|
|
414
|
+
},
|
|
415
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const payload = await response.json().catch(() => ({}));
|
|
419
|
+
if (!response.ok || payload.ok === false) {
|
|
420
|
+
const message = payload?.error?.message || `HTTP ${response.status}`;
|
|
421
|
+
const error = new Error(message);
|
|
422
|
+
error.code = payload?.error?.code;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
return payload.data;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 强制浏览器端 sessions / workspaces 服务从服务端重新拉取权威列表 */
|
|
429
|
+
function refreshSessionViews(ctx) {
|
|
430
|
+
try { ctx?.sessions?.refresh?.(); } catch {}
|
|
431
|
+
try {
|
|
432
|
+
const workspaces = ctx?.get?.('workspaces') ?? ctx?.workspaces;
|
|
433
|
+
workspaces?.refresh?.();
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Refresh authoritative lists after a successful archive/restore. */
|
|
438
|
+
function purgeFromBrowserSessionStore(ctx, sessionIds) {
|
|
439
|
+
if (!sessionIds || sessionIds.length === 0) return;
|
|
440
|
+
refreshSessionViews(ctx);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** ── 轻量级 Markdown 渲染器(自包含,不依赖外部库,仅用 React.createElement) ── */
|
|
444
|
+
|
|
445
|
+
// 行内解析:code span / bold / italic / strikethrough / link / image
|
|
446
|
+
function parseInline(text, keyPrefix) {
|
|
447
|
+
const nodes = [];
|
|
448
|
+
const parts = String(text).split(/(`[^`]+`)/g);
|
|
449
|
+
parts.forEach((part, i) => {
|
|
450
|
+
if (!part) return;
|
|
451
|
+
const codeMatch = part.match(/^`([^`]+)`$/);
|
|
452
|
+
if (codeMatch) {
|
|
453
|
+
nodes.push(h('code', { key: `${keyPrefix}-c${i}`, className: 'dsh-trash-md-code-inline' }, codeMatch[1]));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
pushInlineTokens(nodes, part, `${keyPrefix}-${i}`);
|
|
457
|
+
});
|
|
458
|
+
return nodes;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function pushInlineTokens(nodes, text, keyPrefix) {
|
|
462
|
+
const tokenRe = /(\!\[[^\]]*\]\([^)]*\)|\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g;
|
|
463
|
+
let lastIndex = 0;
|
|
464
|
+
let m;
|
|
465
|
+
let k = 0;
|
|
466
|
+
while ((m = tokenRe.exec(text)) !== null) {
|
|
467
|
+
if (m.index > lastIndex) {
|
|
468
|
+
nodes.push(text.slice(lastIndex, m.index));
|
|
469
|
+
}
|
|
470
|
+
const tok = m[0];
|
|
471
|
+
const key = `${keyPrefix}-t${k++}`;
|
|
472
|
+
const imgMatch = tok.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
|
473
|
+
if (imgMatch) {
|
|
474
|
+
nodes.push(h('a', { key, href: imgMatch[2], target: '_blank', rel: 'noreferrer' }, imgMatch[1] || imgMatch[2]));
|
|
475
|
+
lastIndex = m.index + tok.length;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const linkMatch = tok.match(/^\[([^\]]*)\]\(([^)]*)\)$/);
|
|
479
|
+
if (linkMatch) {
|
|
480
|
+
nodes.push(h('a', { key, href: linkMatch[2], target: '_blank', rel: 'noreferrer', className: 'dsh-trash-md-link' }, linkMatch[1] || linkMatch[2]));
|
|
481
|
+
lastIndex = m.index + tok.length;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const boldMatch = tok.match(/^\*\*([^*]+)\*\*$/);
|
|
485
|
+
if (boldMatch) {
|
|
486
|
+
nodes.push(h('strong', { key }, parseInline(boldMatch[1], key)));
|
|
487
|
+
lastIndex = m.index + tok.length;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const italicMatch = tok.match(/^\*([^*]+)\*$/);
|
|
491
|
+
if (italicMatch) {
|
|
492
|
+
nodes.push(h('em', { key }, parseInline(italicMatch[1], key)));
|
|
493
|
+
lastIndex = m.index + tok.length;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const strikeMatch = tok.match(/^~~([^~]+)~~$/);
|
|
497
|
+
if (strikeMatch) {
|
|
498
|
+
nodes.push(h('s', { key }, parseInline(strikeMatch[1], key)));
|
|
499
|
+
lastIndex = m.index + tok.length;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
lastIndex = m.index + tok.length;
|
|
503
|
+
}
|
|
504
|
+
if (lastIndex < text.length) {
|
|
505
|
+
nodes.push(text.slice(lastIndex));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// 块级解析 → React 节点数组
|
|
510
|
+
function renderMarkdown(content) {
|
|
511
|
+
const lines = String(content || '').split('\n');
|
|
512
|
+
const blocks = [];
|
|
513
|
+
let i = 0;
|
|
514
|
+
while (i < lines.length) {
|
|
515
|
+
const line = lines[i];
|
|
516
|
+
// 围栏代码块
|
|
517
|
+
const fenceMatch = line.match(/^```([\w+-]*)\s*$/);
|
|
518
|
+
if (fenceMatch) {
|
|
519
|
+
const lang = fenceMatch[1] || '';
|
|
520
|
+
const buf = [];
|
|
521
|
+
i++;
|
|
522
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
523
|
+
buf.push(lines[i]);
|
|
524
|
+
i++;
|
|
525
|
+
}
|
|
526
|
+
i++;
|
|
527
|
+
blocks.push({ type: 'code', lang, code: buf.join('\n') });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// 空行
|
|
531
|
+
if (!line.trim()) {
|
|
532
|
+
i++;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
// 分隔线
|
|
536
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
537
|
+
blocks.push({ type: 'hr' });
|
|
538
|
+
i++;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
// 标题
|
|
542
|
+
const headMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
543
|
+
if (headMatch) {
|
|
544
|
+
blocks.push({ type: 'header', level: headMatch[1].length, text: headMatch[2] });
|
|
545
|
+
i++;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
// 引用
|
|
549
|
+
if (/^\s*>\s?/.test(line)) {
|
|
550
|
+
const buf = [];
|
|
551
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
552
|
+
buf.push(lines[i].replace(/^\s*>\s?/, ''));
|
|
553
|
+
i++;
|
|
554
|
+
}
|
|
555
|
+
blocks.push({ type: 'quote', text: buf.join('\n') });
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
// 无序列表
|
|
559
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
560
|
+
const items = [];
|
|
561
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
|
562
|
+
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''));
|
|
563
|
+
i++;
|
|
564
|
+
}
|
|
565
|
+
blocks.push({ type: 'ulist', items });
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
// 有序列表
|
|
569
|
+
if (/^\s*\d+[.)]\s+/.test(line)) {
|
|
570
|
+
const items = [];
|
|
571
|
+
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
|
|
572
|
+
items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ''));
|
|
573
|
+
i++;
|
|
574
|
+
}
|
|
575
|
+
blocks.push({ type: 'olist', items });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// 表格:当前行含 | 且下一行为分隔行
|
|
579
|
+
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
580
|
+
const headerRow = line;
|
|
581
|
+
i += 2;
|
|
582
|
+
const rows = [];
|
|
583
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
|
584
|
+
rows.push(lines[i]);
|
|
585
|
+
i++;
|
|
586
|
+
}
|
|
587
|
+
blocks.push({ type: 'table', header: headerRow, rows });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// 段落:收集连续的非空、非块起始行
|
|
591
|
+
const buf = [line];
|
|
592
|
+
i++;
|
|
593
|
+
while (i < lines.length && lines[i].trim() && !/^(```|#{1,6}\s|>\s?|[-*+]\s|\d+[.)]\s|---+$)/.test(lines[i])) {
|
|
594
|
+
buf.push(lines[i]);
|
|
595
|
+
i++;
|
|
596
|
+
}
|
|
597
|
+
blocks.push({ type: 'para', text: buf.join('\n') });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return blocks.map((block, idx) => {
|
|
601
|
+
const key = `md-${idx}`;
|
|
602
|
+
switch (block.type) {
|
|
603
|
+
case 'code':
|
|
604
|
+
return h('pre', { key, className: 'dsh-trash-md-codeblock' },
|
|
605
|
+
block.lang ? h('div', { key: `${key}-lang`, className: 'dsh-trash-md-lang' }, block.lang) : null,
|
|
606
|
+
h('code', { key: `${key}-code`, className: 'dsh-trash-md-codeblock-content' }, block.code)
|
|
607
|
+
);
|
|
608
|
+
case 'hr':
|
|
609
|
+
return h('hr', { key, className: 'dsh-trash-md-hr' });
|
|
610
|
+
case 'header': {
|
|
611
|
+
const lvl = Math.min(block.level, 6);
|
|
612
|
+
const tag = `h${lvl}`;
|
|
613
|
+
return h(tag, { key }, parseInline(block.text, key));
|
|
614
|
+
}
|
|
615
|
+
case 'quote':
|
|
616
|
+
return h('blockquote', { key, className: 'dsh-trash-md-quote' }, renderMarkdown(block.text));
|
|
617
|
+
case 'ulist':
|
|
618
|
+
return h('ul', { key, className: 'dsh-trash-md-list' },
|
|
619
|
+
block.items.map((item, j) => h('li', { key: `${key}-${j}`, className: 'dsh-trash-md-list-item' }, parseInline(item, `${key}-${j}`)))
|
|
620
|
+
);
|
|
621
|
+
case 'olist':
|
|
622
|
+
return h('ol', { key, className: 'dsh-trash-md-list' },
|
|
623
|
+
block.items.map((item, j) => h('li', { key: `${key}-${j}`, className: 'dsh-trash-md-list-item' }, parseInline(item, `${key}-${j}`)))
|
|
624
|
+
);
|
|
625
|
+
case 'table': {
|
|
626
|
+
const parseRow = (row) => row.split('|').map((c) => c.trim()).filter((c, j, arr) => !(j === 0 && c === '') && !(j === arr.length - 1 && c === ''));
|
|
627
|
+
const headerCells = parseRow(block.header);
|
|
628
|
+
return h('div', { key, className: 'dsh-trash-md-table-wrap' },
|
|
629
|
+
h('table', { key: `${key}-table`, className: 'dsh-trash-md-table' },
|
|
630
|
+
h('thead', null,
|
|
631
|
+
h('tr', null, headerCells.map((c, j) => h('th', { key: `${key}-h${j}`, className: 'dsh-trash-md-th' }, parseInline(c, `${key}-h${j}`))))
|
|
632
|
+
),
|
|
633
|
+
h('tbody', null,
|
|
634
|
+
block.rows.map((row, r) => h('tr', { key: `${key}-r${r}` },
|
|
635
|
+
parseRow(row).map((c, j) => h('td', { key: `${key}-c${r}-${j}`, className: 'dsh-trash-md-td' }, parseInline(c, `${key}-c${r}-${j}`)))
|
|
636
|
+
))
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
default:
|
|
642
|
+
return h('p', { key, className: 'dsh-trash-md-p' }, parseInline(block.text, key));
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Resolve a session's display title from the browser store, falling back to
|
|
649
|
+
* the caller's value. MODULE-LEVEL on purpose: the header action and the
|
|
650
|
+
* sidebar row injector are module-level functions and cannot see a helper
|
|
651
|
+
* declared inside the TrashTab component (that produced a synchronous
|
|
652
|
+
* ReferenceError inside the click handler — the "button does nothing" bug).
|
|
653
|
+
*/
|
|
654
|
+
function resolveSessionTitle(ctx, sessionId, fallback) {
|
|
655
|
+
try {
|
|
656
|
+
const byId = ctx?.sessions?.list?.getSnapshot?.()?.byId ?? {};
|
|
657
|
+
const title = byId[sessionId]?.title;
|
|
658
|
+
// 如果浏览器 store 中的标题就是 sessionId 本身,说明该会话已被归档、
|
|
659
|
+
// 浏览器 store 中只剩占位符,此时应优先使用服务端返回的 fallback 标题
|
|
660
|
+
// (服务端从 archivedTitles 或日志解析中获取了正确的名称)。
|
|
661
|
+
if (title && title !== sessionId) return title;
|
|
662
|
+
return fallback;
|
|
663
|
+
} catch {
|
|
664
|
+
return fallback;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The current UI binding owns selection; the session list does not. */
|
|
669
|
+
function activeSessionId(ctx) {
|
|
670
|
+
return ctx.get('uiSession').adapter.current.value?.key;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function archiveSession(ctx, sessionId, title) {
|
|
674
|
+
if (!sessionId) {
|
|
675
|
+
showToastLayer(t('noActiveSession'));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
await api(ctx, `${API_PREFIX}/archive`, { method: 'POST', body: { sessionId, title } });
|
|
680
|
+
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
681
|
+
showToastLayer(t('movedToBin', { title: title || t('session') }), async () => {
|
|
682
|
+
try {
|
|
683
|
+
await api(ctx, `${API_PREFIX}/unarchive`, { method: 'POST', body: { sessionId } });
|
|
684
|
+
refreshSessionViews(ctx);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
showToastLayer(t('restoreFailed', { message: localizedError(error) }));
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
} catch (error) {
|
|
690
|
+
showToastLayer(t('moveFailed', { message: localizedError(error) }));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** The menu owner provides the exact row, including non-active or same-title sessions. */
|
|
695
|
+
function DeleteSessionMenuItem({ ctx, sessionId, displayTitle, useMenuOpenState }) {
|
|
696
|
+
const [, setMenuOpen] = useMenuOpenState();
|
|
697
|
+
return h(MenuItemButton, {
|
|
698
|
+
icon: renderTrashIcon({ width: 14, height: 14 }),
|
|
699
|
+
disabled: !sessionId,
|
|
700
|
+
onSelect: () => {
|
|
701
|
+
setMenuOpen(false);
|
|
702
|
+
return archiveSession(ctx, sessionId, displayTitle || '');
|
|
703
|
+
},
|
|
704
|
+
}, t('deleteSession'));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** TrashTab Component */
|
|
708
|
+
function TrashTab({ ctx }) {
|
|
709
|
+
const [items, setItems] = useState([]);
|
|
710
|
+
const [loading, setLoading] = useState(true);
|
|
711
|
+
const [error, setError] = useState(null);
|
|
712
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
713
|
+
const [filterWorkspace, setFilterWorkspace] = useState('');
|
|
714
|
+
const [sortOrder, setSortOrder] = useState('desc');
|
|
715
|
+
const [confirmModal, setConfirmModal] = useState(null);
|
|
716
|
+
const [previewSession, setPreviewSession] = useState(null);
|
|
717
|
+
const [previewMessages, setPreviewMessages] = useState([]);
|
|
718
|
+
const [previewLoading, setPreviewLoading] = useState(false);
|
|
719
|
+
const [activeWorkspaceMenu, setActiveWorkspaceMenu] = useState(null);
|
|
720
|
+
const [selectedSessionIds, setSelectedSessionIds] = useState(new Set());
|
|
721
|
+
const [hoverTooltip, setHoverTooltip] = useState(null);
|
|
722
|
+
const hoverTimerRef = React.useRef(null);
|
|
723
|
+
|
|
724
|
+
const toggleSelectSession = (sessionId) => {
|
|
725
|
+
setSelectedSessionIds((prev) => {
|
|
726
|
+
const next = new Set(prev);
|
|
727
|
+
if (next.has(sessionId)) next.delete(sessionId);
|
|
728
|
+
else next.add(sessionId);
|
|
729
|
+
return next;
|
|
730
|
+
});
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// 点击外部自动隐藏工作区 ... 更多菜单 (捕获阶段 pointerdown)
|
|
734
|
+
useEffect(() => {
|
|
735
|
+
const handleClickOutside = (e) => {
|
|
736
|
+
if (e?.target?.closest && e.target.closest('.dsh-more-menu-container')) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
setActiveWorkspaceMenu(null);
|
|
740
|
+
};
|
|
741
|
+
document.addEventListener('pointerdown', handleClickOutside, true);
|
|
742
|
+
return () => document.removeEventListener('pointerdown', handleClickOutside, true);
|
|
743
|
+
}, []);
|
|
744
|
+
|
|
745
|
+
const currentTitle = (sessionId, fallback) => resolveSessionTitle(ctx, sessionId, fallback);
|
|
746
|
+
|
|
747
|
+
const loadItems = useCallback(async () => {
|
|
748
|
+
setError(null);
|
|
749
|
+
try {
|
|
750
|
+
const value = await api(ctx, `${API_PREFIX}/list`);
|
|
751
|
+
setItems(value?.items ?? []);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
setError(err.message || t('loadFailed'));
|
|
754
|
+
} finally {
|
|
755
|
+
setLoading(false);
|
|
756
|
+
}
|
|
757
|
+
}, [ctx]);
|
|
758
|
+
|
|
759
|
+
useEffect(() => {
|
|
760
|
+
loadItems();
|
|
761
|
+
const offEvent = ctx?.on?.('workspace/archived-sessions-changed', () => loadItems());
|
|
762
|
+
const offRpc = ctx?.get?.('typert')?.on?.('host/archived-sessions-changed', () => loadItems());
|
|
763
|
+
const offPurge = ctx?.on?.('workspace/session-permanently-deleted', ({ sessionId }) => {
|
|
764
|
+
if (sessionId) purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
765
|
+
loadItems();
|
|
766
|
+
});
|
|
767
|
+
return () => {
|
|
768
|
+
offEvent?.();
|
|
769
|
+
offRpc?.();
|
|
770
|
+
offPurge?.();
|
|
771
|
+
};
|
|
772
|
+
}, [loadItems, ctx]);
|
|
773
|
+
|
|
774
|
+
/** 恢复单个会话 */
|
|
775
|
+
const handleUnarchive = async (session) => {
|
|
776
|
+
try {
|
|
777
|
+
await api(ctx, `${API_PREFIX}/unarchive`, { method: 'POST', body: { sessionId: session.sessionId } });
|
|
778
|
+
loadItems();
|
|
779
|
+
|
|
780
|
+
// 触发 Harness 官方 sessions / workspaces 服务重新拉取活跃列表
|
|
781
|
+
// (fetch/load/reload 并不存在,正确方法是 refresh())
|
|
782
|
+
refreshSessionViews(ctx);
|
|
783
|
+
|
|
784
|
+
showToastLayer(t('restored', { title: currentTitle(session.sessionId, session.title) || session.title || session.sessionId }), async () => {
|
|
785
|
+
try {
|
|
786
|
+
await api(ctx, `${API_PREFIX}/archive`, { method: 'POST', body: { sessionId: session.sessionId } });
|
|
787
|
+
loadItems();
|
|
788
|
+
try { refreshSessionViews(ctx); } catch {}
|
|
789
|
+
} catch (e) {
|
|
790
|
+
console.error(t('undoFailed'), e);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
} catch (err) {
|
|
794
|
+
showToastLayer(t('restoreFailed', { message: localizedError(err) }));
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
/** 物理彻底删除单个会话 */
|
|
799
|
+
const handlePurgeSingle = async (sessionId, sessionTitle) => {
|
|
800
|
+
setConfirmModal({
|
|
801
|
+
title: t('confirmPermanentTitle'),
|
|
802
|
+
body: t('confirmPermanentBody', { title: sessionTitle }),
|
|
803
|
+
onConfirm: async () => {
|
|
804
|
+
try {
|
|
805
|
+
const result = await api(ctx, `${API_PREFIX}/purge`, { method: 'POST', body: { sessionIds: [sessionId] } });
|
|
806
|
+
const failedIds = new Set((result?.errors ?? []).map((e) => e.sessionId));
|
|
807
|
+
if (failedIds.has(sessionId)) {
|
|
808
|
+
const errItem = result.errors.find((e) => e.sessionId === sessionId);
|
|
809
|
+
showToastLayer(t('deleteFailed', { message: localizedError(errItem) }));
|
|
810
|
+
} else {
|
|
811
|
+
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
812
|
+
showToastLayer(t('deleted'));
|
|
813
|
+
}
|
|
814
|
+
setConfirmModal(null);
|
|
815
|
+
loadItems();
|
|
816
|
+
} catch (err) {
|
|
817
|
+
showToastLayer(t('deleteFailed', { message: localizedError(err) }));
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
/** 物理彻底删除指定工作区下的所有/选中的归档会话 */
|
|
824
|
+
const handlePurgeWorkspace = async (workspacePath, workspaceTitle, groupItems) => {
|
|
825
|
+
setConfirmModal({
|
|
826
|
+
title: t('deleteArchivedTitle'),
|
|
827
|
+
body: t('deleteWorkspaceBody', { title: workspaceTitle, count: groupItems.length }),
|
|
828
|
+
onConfirm: async () => {
|
|
829
|
+
try {
|
|
830
|
+
const ids = groupItems.map((i) => i.sessionId);
|
|
831
|
+
const result = await api(ctx, `${API_PREFIX}/purge-workspace`, { method: 'POST', body: { workspacePath, sessionIds: ids } });
|
|
832
|
+
const failedIds = new Set((result?.errors ?? []).map((e) => e.sessionId));
|
|
833
|
+
const succeededIds = ids.filter((id) => !failedIds.has(id));
|
|
834
|
+
purgeFromBrowserSessionStore(ctx, succeededIds);
|
|
835
|
+
setConfirmModal(null);
|
|
836
|
+
setActiveWorkspaceMenu(null);
|
|
837
|
+
loadItems();
|
|
838
|
+
showToastLayer(t('deletedSelected'));
|
|
839
|
+
} catch (err) {
|
|
840
|
+
showToastLayer(t('deleteFailed', { message: localizedError(err) }));
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/** 彻底删除全部归档会话 */
|
|
847
|
+
const handleEmptyAll = () => {
|
|
848
|
+
setConfirmModal({
|
|
849
|
+
title: t('deleteAllTitle'),
|
|
850
|
+
body: t('deleteAllBody', { count: items.length }),
|
|
851
|
+
onConfirm: async () => {
|
|
852
|
+
try {
|
|
853
|
+
const ids = items.map((i) => i.sessionId);
|
|
854
|
+
await api(ctx, `${API_PREFIX}/empty`, { method: 'POST' });
|
|
855
|
+
purgeFromBrowserSessionStore(ctx, ids);
|
|
856
|
+
setConfirmModal(null);
|
|
857
|
+
loadItems();
|
|
858
|
+
showToastLayer(t('emptied', { count: items.length }));
|
|
859
|
+
} catch (err) {
|
|
860
|
+
showToastLayer(t('emptyFailed', { message: localizedError(err) }));
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
/** 打开预览 Modal */
|
|
867
|
+
const handleOpenPreview = async (session) => {
|
|
868
|
+
const title = currentTitle(session.sessionId, session.title) || session.sessionId;
|
|
869
|
+
setPreviewSession({ ...session, title });
|
|
870
|
+
setPreviewLoading(true);
|
|
871
|
+
setPreviewMessages([]);
|
|
872
|
+
try {
|
|
873
|
+
const res = await api(ctx, `${API_PREFIX}/messages?sessionId=${encodeURIComponent(session.sessionId)}`);
|
|
874
|
+
setPreviewMessages(res?.messages ?? []);
|
|
875
|
+
} catch (err) {
|
|
876
|
+
showToastLayer(t('historyFailed', { message: localizedError(err) }));
|
|
877
|
+
} finally {
|
|
878
|
+
setPreviewLoading(false);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
// 提取工作区下拉列表
|
|
883
|
+
const workspaceOptions = React.useMemo(() => {
|
|
884
|
+
const map = new Map();
|
|
885
|
+
for (const item of items) {
|
|
886
|
+
const key = item.workspacePath || item.cwd || t('other');
|
|
887
|
+
const title = localizeServerFallback(item.workspaceTitle) || (item.cwd ? item.cwd.split('/').pop() : t('otherProjects'));
|
|
888
|
+
if (!map.has(key)) map.set(key, { path: key, title });
|
|
889
|
+
}
|
|
890
|
+
return [...map.values()];
|
|
891
|
+
}, [items]);
|
|
892
|
+
|
|
893
|
+
// 过滤、排序并按工作区分组
|
|
894
|
+
const groupedWorkspaces = React.useMemo(() => {
|
|
895
|
+
let list = [...items];
|
|
896
|
+
|
|
897
|
+
if (searchQuery.trim()) {
|
|
898
|
+
const q = searchQuery.trim().toLowerCase();
|
|
899
|
+
list = list.filter((i) => {
|
|
900
|
+
const title = (currentTitle(i.sessionId, i.title) || i.sessionId).toLowerCase();
|
|
901
|
+
return title.includes(q);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (filterWorkspace) {
|
|
906
|
+
list = list.filter((i) => (i.workspacePath || i.cwd) === filterWorkspace);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 按工作区分组(保持服务端返回的工作区顺序,与左侧 sidebar 一致)
|
|
910
|
+
const groups = new Map();
|
|
911
|
+
const groupOrder = [];
|
|
912
|
+
for (const item of list) {
|
|
913
|
+
const key = item.workspacePath || item.cwd || t('otherWorkspaces');
|
|
914
|
+
const title = localizeServerFallback(item.workspaceTitle) || (item.cwd ? item.cwd.split('/').pop() : t('unnamedProject'));
|
|
915
|
+
if (!groups.has(key)) {
|
|
916
|
+
groups.set(key, { path: key, title, items: [] });
|
|
917
|
+
groupOrder.push(key);
|
|
918
|
+
}
|
|
919
|
+
groups.get(key).items.push(item);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// 每个工作区内部按时间排序,不影响工作区之间的顺序
|
|
923
|
+
for (const group of groups.values()) {
|
|
924
|
+
group.items.sort((a, b) => {
|
|
925
|
+
const timeA = a.archivedAt || 0;
|
|
926
|
+
const timeB = b.archivedAt || 0;
|
|
927
|
+
return sortOrder === 'asc' ? timeA - timeB : timeB - timeA;
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return groupOrder.map((key) => groups.get(key)).filter(Boolean);
|
|
932
|
+
}, [items, searchQuery, filterWorkspace, sortOrder, ctx]);
|
|
933
|
+
|
|
934
|
+
return h('div', { className: 'dsh-trash-container' },
|
|
935
|
+
// Header
|
|
936
|
+
h('div', { className: 'dsh-trash-header' },
|
|
937
|
+
h('div', { className: 'dsh-trash-header-left' },
|
|
938
|
+
h('h2', { className: 'dsh-trash-title' },
|
|
939
|
+
renderTrashIcon({ className: 'dsh-trash-icon-title' }),
|
|
940
|
+
t('recycleBin')
|
|
941
|
+
),
|
|
942
|
+
h('p', { className: 'dsh-trash-subtitle' }, t('archivedCount', { count: items.length }))
|
|
943
|
+
)
|
|
944
|
+
),
|
|
945
|
+
|
|
946
|
+
// Top Bar: 搜索框
|
|
947
|
+
h('div', { className: 'dsh-trash-search' },
|
|
948
|
+
h('svg', { className: 'dsh-trash-search-icon', width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2' },
|
|
949
|
+
h('circle', { cx: '11', cy: '11', r: '8' }),
|
|
950
|
+
h('line', { x1: '21', y1: '21', x2: '16.65', y2: '16.65' })
|
|
951
|
+
),
|
|
952
|
+
h('input', {
|
|
953
|
+
type: 'text',
|
|
954
|
+
className: 'dsh-trash-search-input',
|
|
955
|
+
placeholder: t('searchPlaceholder'),
|
|
956
|
+
value: searchQuery,
|
|
957
|
+
onChange: (e) => setSearchQuery(e.target.value),
|
|
958
|
+
})
|
|
959
|
+
),
|
|
960
|
+
|
|
961
|
+
// Filter Controls Toolbar
|
|
962
|
+
h('div', { className: 'dsh-trash-toolbar' },
|
|
963
|
+
h('select', {
|
|
964
|
+
className: 'dsh-trash-select',
|
|
965
|
+
value: filterWorkspace,
|
|
966
|
+
onChange: (e) => setFilterWorkspace(e.target.value),
|
|
967
|
+
},
|
|
968
|
+
h('option', { value: '' }, t('allProjects')),
|
|
969
|
+
workspaceOptions.map((w) => h('option', { key: w.path, value: w.path }, w.title))
|
|
970
|
+
),
|
|
971
|
+
|
|
972
|
+
h('select', {
|
|
973
|
+
className: 'dsh-trash-select',
|
|
974
|
+
value: sortOrder,
|
|
975
|
+
onChange: (e) => setSortOrder(e.target.value),
|
|
976
|
+
},
|
|
977
|
+
h('option', { value: 'desc' }, t('newestFirst')),
|
|
978
|
+
h('option', { value: 'asc' }, t('oldestFirst'))
|
|
979
|
+
),
|
|
980
|
+
|
|
981
|
+
items.length > 0 &&
|
|
982
|
+
h('button', {
|
|
983
|
+
className: 'dsh-trash-btn-danger-outline',
|
|
984
|
+
onClick: handleEmptyAll,
|
|
985
|
+
},
|
|
986
|
+
renderTrashIcon({ className: 'dsh-trash-icon-btn' }),
|
|
987
|
+
t('deleteAll')
|
|
988
|
+
)
|
|
989
|
+
),
|
|
990
|
+
|
|
991
|
+
// List Content
|
|
992
|
+
loading
|
|
993
|
+
? h('div', { className: 'dsh-trash-state' }, t('loading'))
|
|
994
|
+
: error
|
|
995
|
+
? h('div', { className: 'dsh-trash-state dsh-trash-state--error' }, error)
|
|
996
|
+
: items.length === 0
|
|
997
|
+
? h('div', { className: 'dsh-trash-state' }, t('empty'))
|
|
998
|
+
: groupedWorkspaces.length === 0
|
|
999
|
+
? h('div', { className: 'dsh-trash-state' }, t('noMatches'))
|
|
1000
|
+
: h('div', { className: 'dsh-trash-groups' },
|
|
1001
|
+
groupedWorkspaces.map((group) => {
|
|
1002
|
+
const groupSelectedIds = group.items.map((i) => i.sessionId).filter((id) => selectedSessionIds.has(id));
|
|
1003
|
+
const hasSelected = groupSelectedIds.length > 0;
|
|
1004
|
+
|
|
1005
|
+
return h('div', { key: group.path, className: 'dsh-trash-group' },
|
|
1006
|
+
// Workspace Section Header
|
|
1007
|
+
h('div', { className: 'dsh-trash-group-header' },
|
|
1008
|
+
h('div', { className: 'dsh-trash-group-title-group' },
|
|
1009
|
+
renderFolderIcon({ className: 'dsh-trash-icon-folder' }),
|
|
1010
|
+
h('span', { className: 'dsh-trash-group-title' }, group.title),
|
|
1011
|
+
h('span', { className: 'dsh-trash-group-path', title: group.path }, group.path)
|
|
1012
|
+
),
|
|
1013
|
+
h('div', { className: 'dsh-trash-group-right' },
|
|
1014
|
+
h('span', { className: 'dsh-trash-group-count' }, t('sessionCount', { count: group.items.length })),
|
|
1015
|
+
h('div', { className: 'dsh-more-menu-container dsh-trash-more-wrap' },
|
|
1016
|
+
h('button', {
|
|
1017
|
+
type: 'button',
|
|
1018
|
+
className: 'dsh-trash-more-btn',
|
|
1019
|
+
title: t('projectActions'),
|
|
1020
|
+
onClick: (e) => {
|
|
1021
|
+
e.stopPropagation();
|
|
1022
|
+
setActiveWorkspaceMenu(activeWorkspaceMenu === group.path ? null : group.path);
|
|
1023
|
+
},
|
|
1024
|
+
}, '•••'),
|
|
1025
|
+
activeWorkspaceMenu === group.path &&
|
|
1026
|
+
h('div', { className: 'dsh-trash-menu' },
|
|
1027
|
+
h('button', {
|
|
1028
|
+
className: 'dsh-trash-menu-item--danger',
|
|
1029
|
+
onClick: (e) => {
|
|
1030
|
+
e.stopPropagation();
|
|
1031
|
+
const targets = hasSelected
|
|
1032
|
+
? group.items.filter((i) => selectedSessionIds.has(i.sessionId))
|
|
1033
|
+
: group.items;
|
|
1034
|
+
handlePurgeWorkspace(group.path, hasSelected ? t('selectedSuffix', { title: group.title, count: groupSelectedIds.length }) : group.title, targets);
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
renderTrashIcon({ className: 'dsh-trash-icon-menu' }),
|
|
1038
|
+
hasSelected ? t('deleteSelected', { count: groupSelectedIds.length }) : t('deleteProject')
|
|
1039
|
+
)
|
|
1040
|
+
)
|
|
1041
|
+
)
|
|
1042
|
+
)
|
|
1043
|
+
),
|
|
1044
|
+
|
|
1045
|
+
// Session Cards List
|
|
1046
|
+
h('div', { className: 'dsh-trash-cards' },
|
|
1047
|
+
group.items.map((item) => {
|
|
1048
|
+
const displayTitle = currentTitle(item.sessionId, localizeServerFallback(item.title)) || item.sessionId;
|
|
1049
|
+
const path = item.cwd || item.workspacePath || '-';
|
|
1050
|
+
const isChecked = selectedSessionIds.has(item.sessionId);
|
|
1051
|
+
|
|
1052
|
+
return h('div', {
|
|
1053
|
+
key: item.sessionId,
|
|
1054
|
+
className: isChecked ? 'dsh-trash-card dsh-trash-card--checked' : 'dsh-trash-card',
|
|
1055
|
+
onMouseEnter: (e) => {
|
|
1056
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1057
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
1058
|
+
hoverTimerRef.current = setTimeout(() => {
|
|
1059
|
+
setHoverTooltip({
|
|
1060
|
+
title: displayTitle,
|
|
1061
|
+
id: item.sessionId,
|
|
1062
|
+
path: path,
|
|
1063
|
+
x: rect.left,
|
|
1064
|
+
y: rect.top - 8,
|
|
1065
|
+
});
|
|
1066
|
+
}, 200);
|
|
1067
|
+
},
|
|
1068
|
+
onMouseLeave: () => {
|
|
1069
|
+
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
1070
|
+
setHoverTooltip(null);
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
1073
|
+
// 微圆复选框
|
|
1074
|
+
h('input', {
|
|
1075
|
+
type: 'checkbox',
|
|
1076
|
+
className: 'dsh-trash-checkbox',
|
|
1077
|
+
checked: isChecked,
|
|
1078
|
+
onChange: (e) => {
|
|
1079
|
+
e.stopPropagation();
|
|
1080
|
+
toggleSelectSession(item.sessionId);
|
|
1081
|
+
},
|
|
1082
|
+
}),
|
|
1083
|
+
|
|
1084
|
+
h('div', { className: 'dsh-trash-card-main' },
|
|
1085
|
+
// Title
|
|
1086
|
+
h('div', { className: 'dsh-trash-card-title' }, displayTitle),
|
|
1087
|
+
// Metadata Row (时间, 轮数, 容量 Badge)
|
|
1088
|
+
h('div', { className: 'dsh-trash-card-meta' },
|
|
1089
|
+
h('span', { className: 'dsh-trash-card-meta-item' }, formatTime(item.archivedAt)),
|
|
1090
|
+
h('span', { className: 'dsh-trash-card-meta-item' }, t('turnCount', { count: item.turnCount ?? 0 })),
|
|
1091
|
+
h('span', { className: 'dsh-trash-badge' },
|
|
1092
|
+
renderDiskIcon({ className: 'dsh-trash-icon-badge' }),
|
|
1093
|
+
formatFileSize(item.fileSize)
|
|
1094
|
+
)
|
|
1095
|
+
)
|
|
1096
|
+
),
|
|
1097
|
+
|
|
1098
|
+
// Action Buttons (微型极简胶囊按钮,大幅留出左侧空间)
|
|
1099
|
+
h('div', { className: 'dsh-trash-card-actions' },
|
|
1100
|
+
h('button', {
|
|
1101
|
+
type: 'button',
|
|
1102
|
+
className: 'dsh-trash-btn-ghost',
|
|
1103
|
+
onClick: () => handleOpenPreview(item),
|
|
1104
|
+
},
|
|
1105
|
+
renderEyeIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1106
|
+
t('view')
|
|
1107
|
+
),
|
|
1108
|
+
h('button', {
|
|
1109
|
+
type: 'button',
|
|
1110
|
+
className: 'dsh-trash-btn-ghost',
|
|
1111
|
+
onClick: () => handleUnarchive(item),
|
|
1112
|
+
},
|
|
1113
|
+
renderRestoreIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1114
|
+
t('restore')
|
|
1115
|
+
),
|
|
1116
|
+
h('button', {
|
|
1117
|
+
type: 'button',
|
|
1118
|
+
className: 'dsh-trash-btn-danger-text',
|
|
1119
|
+
onClick: () => handlePurgeSingle(item.sessionId, displayTitle),
|
|
1120
|
+
},
|
|
1121
|
+
renderTrashIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1122
|
+
t('permanentlyDelete')
|
|
1123
|
+
)
|
|
1124
|
+
)
|
|
1125
|
+
);
|
|
1126
|
+
})
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
})
|
|
1130
|
+
),
|
|
1131
|
+
|
|
1132
|
+
// Footer Summary
|
|
1133
|
+
items.length > 0 && h('div', { className: 'dsh-trash-footer' }, t('archivedCount', { count: items.length })),
|
|
1134
|
+
|
|
1135
|
+
// Modal 1: 查看内容 Message Preview Modal
|
|
1136
|
+
previewSession &&
|
|
1137
|
+
h('div', { className: 'dsh-trash-overlay', onClick: () => setPreviewSession(null) },
|
|
1138
|
+
h('div', { className: 'dsh-trash-preview', onClick: (e) => e.stopPropagation() },
|
|
1139
|
+
h('div', { className: 'dsh-trash-preview-header' },
|
|
1140
|
+
h('div', { className: 'dsh-trash-preview-title' }, t('previewTitle', { title: previewSession.title })),
|
|
1141
|
+
h('button', {
|
|
1142
|
+
className: 'dsh-trash-close',
|
|
1143
|
+
onClick: () => setPreviewSession(null),
|
|
1144
|
+
}, '✕')
|
|
1145
|
+
),
|
|
1146
|
+
h('div', { className: 'dsh-trash-preview-body' },
|
|
1147
|
+
previewLoading
|
|
1148
|
+
? h('div', { className: 'dsh-trash-preview-state' }, t('loadingHistory'))
|
|
1149
|
+
: previewMessages.length === 0
|
|
1150
|
+
? h('div', { className: 'dsh-trash-preview-state' }, t('noHistory'))
|
|
1151
|
+
: previewMessages.map((msg, index) =>
|
|
1152
|
+
h('div', { key: index, className: 'dsh-trash-message' },
|
|
1153
|
+
h('div', { className: 'dsh-trash-message-header' },
|
|
1154
|
+
h('span', { className: 'dsh-trash-message-role' }, msg.role === 'user' ? t('user') : t('assistant')),
|
|
1155
|
+
msg.timestamp && h('span', { className: 'dsh-trash-message-time' }, formatTime(msg.timestamp))
|
|
1156
|
+
),
|
|
1157
|
+
h('div', { className: msg.role === 'user' ? 'dsh-trash-bubble-user' : 'dsh-trash-bubble-assistant' },
|
|
1158
|
+
h('div', { className: 'dsh-trash-md' }, renderMarkdown(msg.content))
|
|
1159
|
+
)
|
|
1160
|
+
)
|
|
1161
|
+
)
|
|
1162
|
+
)
|
|
1163
|
+
)
|
|
1164
|
+
),
|
|
1165
|
+
|
|
1166
|
+
// Modal 2: 确认操作 Modal
|
|
1167
|
+
confirmModal &&
|
|
1168
|
+
h('div', { className: 'dsh-trash-overlay', onClick: () => setConfirmModal(null) },
|
|
1169
|
+
h('div', { className: 'dsh-trash-modal', onClick: (e) => e.stopPropagation() },
|
|
1170
|
+
h('h3', { className: 'dsh-trash-modal-title' }, confirmModal.title),
|
|
1171
|
+
h('p', { className: 'dsh-trash-modal-body' }, confirmModal.body),
|
|
1172
|
+
h('div', { className: 'dsh-trash-modal-actions' },
|
|
1173
|
+
h('button', { className: 'dsh-trash-btn-cancel', onClick: () => setConfirmModal(null) }, t('cancel')),
|
|
1174
|
+
h('button', { className: 'dsh-trash-btn-confirm', onClick: confirmModal.onConfirm }, t('confirmDelete'))
|
|
1175
|
+
)
|
|
1176
|
+
)
|
|
1177
|
+
),
|
|
1178
|
+
|
|
1179
|
+
// Tooltip: 悬停展示全标题 & ID & 路径(left/top 由 JS 按鼠标位置内联给出)
|
|
1180
|
+
hoverTooltip &&
|
|
1181
|
+
h('div', {
|
|
1182
|
+
className: 'dsh-trash-tooltip',
|
|
1183
|
+
style: { left: `${hoverTooltip.x}px`, top: `${hoverTooltip.y}px` },
|
|
1184
|
+
},
|
|
1185
|
+
h('div', { className: 'dsh-trash-tooltip-title' }, hoverTooltip.title),
|
|
1186
|
+
h('div', { className: 'dsh-trash-tooltip-sub' }, `ID: ${hoverTooltip.id}`),
|
|
1187
|
+
h('div', { className: 'dsh-trash-tooltip-sub' }, t('path', { path: hoverTooltip.path }))
|
|
1188
|
+
)
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Header action resolves selection when clicked, including after navigation. */
|
|
1193
|
+
function DeleteSessionAction(props) {
|
|
1194
|
+
const { ctx } = props;
|
|
1195
|
+
return h('button', {
|
|
1196
|
+
type: 'button',
|
|
1197
|
+
className: 'dsh-trash-header-action',
|
|
1198
|
+
title: t('moveToBin'),
|
|
1199
|
+
onClick: (event) => {
|
|
1200
|
+
event?.stopPropagation();
|
|
1201
|
+
const sessionId = props.sessionId || activeSessionId(ctx);
|
|
1202
|
+
return archiveSession(ctx, sessionId, resolveSessionTitle(ctx, sessionId, '') || '');
|
|
1203
|
+
},
|
|
1204
|
+
}, renderTrashIcon({ width: 14, height: 14 }));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Inject Trash Icon into settings sidebar */
|
|
1208
|
+
function injectSettingsMenuIcon() {
|
|
1209
|
+
if (typeof document === 'undefined') return;
|
|
1210
|
+
const candidates = [...document.querySelectorAll('*')].filter(
|
|
1211
|
+
(el) => el.children.length === 0 && el.textContent?.trim() === t('recycleBin')
|
|
1212
|
+
);
|
|
1213
|
+
for (const textEl of candidates) {
|
|
1214
|
+
const itemContainer =
|
|
1215
|
+
textEl.closest('button') ||
|
|
1216
|
+
textEl.closest('a') ||
|
|
1217
|
+
textEl.closest('[role="tab"]') ||
|
|
1218
|
+
textEl.closest('[role="button"]') ||
|
|
1219
|
+
textEl.closest('li') ||
|
|
1220
|
+
textEl.parentElement;
|
|
1221
|
+
if (!itemContainer) continue;
|
|
1222
|
+
|
|
1223
|
+
const svg = itemContainer.querySelector('svg');
|
|
1224
|
+
if (svg) {
|
|
1225
|
+
if (!svg.dataset.dshTrashIconInjected) {
|
|
1226
|
+
svg.dataset.dshTrashIconInjected = 'true';
|
|
1227
|
+
const temp = document.createElement('div');
|
|
1228
|
+
temp.innerHTML =
|
|
1229
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" ' +
|
|
1230
|
+
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
1231
|
+
'<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>' +
|
|
1232
|
+
'<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>' +
|
|
1233
|
+
'<line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
|
1234
|
+
const newSvg = temp.firstElementChild;
|
|
1235
|
+
if (newSvg) {
|
|
1236
|
+
newSvg.dataset.dshTrashIconInjected = 'true';
|
|
1237
|
+
if (svg.getAttribute('class')) newSvg.setAttribute('class', svg.getAttribute('class'));
|
|
1238
|
+
svg.parentNode?.replaceChild(newSvg, svg);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Keep the settings icon in sync; session actions use the native slot below. */
|
|
1246
|
+
function startSettingsIntegration() {
|
|
1247
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
|
1248
|
+
let raf = 0;
|
|
1249
|
+
const schedule = () => {
|
|
1250
|
+
if (raf) return;
|
|
1251
|
+
raf = requestAnimationFrame(() => {
|
|
1252
|
+
raf = 0;
|
|
1253
|
+
injectSettingsMenuIcon();
|
|
1254
|
+
});
|
|
1255
|
+
};
|
|
1256
|
+
const observer = new MutationObserver(schedule);
|
|
1257
|
+
observer.observe(document.documentElement ?? document, { childList: true, subtree: true });
|
|
1258
|
+
schedule();
|
|
1259
|
+
return () => {
|
|
1260
|
+
observer.disconnect();
|
|
1261
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Plugin body apply */
|
|
1266
|
+
function apply(ctx) {
|
|
1267
|
+
injectStylesheet();
|
|
1268
|
+
// Adopt the host Language setting (and publish our dictionaries) before
|
|
1269
|
+
// anything renders a label.
|
|
1270
|
+
attachHostLocale(ctx);
|
|
1271
|
+
|
|
1272
|
+
// DSH 0.1.5 的 settings.section 注册契约只接受 id / order / label
|
|
1273
|
+
// (见 dsh-cordis-client-runner 内置的 slot 目录:registerOptions 就这三项,
|
|
1274
|
+
// label 的类型是 `string | (() => string)`)。设置面板按 order 升序把注册项
|
|
1275
|
+
// 排成一列,所以这里只传契约内字段;label 仍用 thunk,两代际都合法。
|
|
1276
|
+
//
|
|
1277
|
+
// order 必须落在「一定可见」的区间:0.1.5 的设置面板把导航列高度固定为约
|
|
1278
|
+
// 808px(17 项 × 44px),而面板 overflow:hidden、导航列 overflow:visible 且
|
|
1279
|
+
// 没有滚动条 —— 窗口可视高度放不下整个面板时,**列表末尾的条目会被直接裁掉,
|
|
1280
|
+
// 既看不见也滚不到**。原先取 200(倒数第二项),窗口稍矮(面板 < 808px,
|
|
1281
|
+
// 约等于视口 < 800px)就整项消失,这正是「设置里没有会话回收站」的真正原因。
|
|
1282
|
+
// 25 落在 20(mnemon「Memory System」)与 30(cost-meter「Cost」)之间,
|
|
1283
|
+
// 属于最上面几项,任何能看见设置导航的窗口都能看见它。
|
|
1284
|
+
ctx.slots.inject('settings.section', () =>
|
|
1285
|
+
ctx.slots.register(
|
|
1286
|
+
{
|
|
1287
|
+
name: 'settings.section',
|
|
1288
|
+
id: 'trash',
|
|
1289
|
+
order: 25,
|
|
1290
|
+
label: () => t('recycleBin'),
|
|
1291
|
+
},
|
|
1292
|
+
(props) => TrashTab({ ...props, ctx })
|
|
1293
|
+
)
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
1297
|
+
ctx.slots.register(
|
|
1298
|
+
{
|
|
1299
|
+
name: 'conversation.session.header.actions',
|
|
1300
|
+
id: 'delete-session',
|
|
1301
|
+
order: 400,
|
|
1302
|
+
label: () => t('deleteSession'),
|
|
1303
|
+
},
|
|
1304
|
+
(props) => DeleteSessionAction({ ...props, ctx })
|
|
1305
|
+
)
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1308
|
+
ctx.slots.inject('sidebar.workspaces.session.menu.item', () =>
|
|
1309
|
+
ctx.slots.register({
|
|
1310
|
+
name: 'sidebar.workspaces.session.menu.item',
|
|
1311
|
+
id: 'dsh-session-manager.delete',
|
|
1312
|
+
order: 500,
|
|
1313
|
+
label: () => t('deleteSession'),
|
|
1314
|
+
}, (props) => DeleteSessionMenuItem({ ...props, ctx }))
|
|
1315
|
+
);
|
|
1316
|
+
ctx.effect(() => startSettingsIntegration());
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
exports.apply = apply;
|
|
1320
|
+
exports.inject = ['slots', 'connection', 'sessions', 'workspaces', 'uiSession', 'locale'];
|
|
1321
|
+
|
|
1322
|
+
return module.exports;
|
|
1323
|
+
},
|
|
1324
|
+
});
|