@sunshaobei/dsh-session-manager 0.4.3-fork.1 → 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/README.md +30 -9
- package/README.zh.md +30 -9
- package/client.css +532 -97
- package/client.js +949 -84
- package/cordis.patch.yml +1 -1
- package/package.json +10 -10
- package/packages/session-trash-host/lib/index.js +842 -254
- package/packages/session-trash-host/package.json +1 -1
package/client.js
CHANGED
|
@@ -16,6 +16,33 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
|
|
17
17
|
const API_PREFIX = '/api/session-trash';
|
|
18
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
|
+
|
|
19
46
|
const MESSAGES = {
|
|
20
47
|
en: {
|
|
21
48
|
undo: 'Undo',
|
|
@@ -28,6 +55,7 @@ window.__ModuleLoader__.load({
|
|
|
28
55
|
deleteFailed: 'Deletion failed: {message}',
|
|
29
56
|
unknownError: 'Unknown error',
|
|
30
57
|
deleted: 'Session permanently deleted',
|
|
58
|
+
deleteSession: 'Delete Session',
|
|
31
59
|
deleteArchivedTitle: 'Permanently delete archived sessions',
|
|
32
60
|
deleteWorkspaceBody: 'Permanently delete {count} session(s) from the workspace “{title}”? This cannot be undone.',
|
|
33
61
|
deletedSelected: 'Selected archived sessions permanently deleted',
|
|
@@ -67,15 +95,35 @@ window.__ModuleLoader__.load({
|
|
|
67
95
|
assistant: '🤖 Assistant',
|
|
68
96
|
cancel: 'Cancel',
|
|
69
97
|
confirmDelete: 'Confirm deletion',
|
|
70
|
-
|
|
98
|
+
storagePath: 'Storage path: {path}',
|
|
71
99
|
noActiveSession: 'Could not identify the active session ID',
|
|
72
100
|
movedToBin: 'Moved session “{title}” to the recycle bin',
|
|
73
101
|
currentSession: 'Current session',
|
|
74
102
|
session: 'Session',
|
|
75
103
|
moveFailed: 'Failed to move session to the recycle bin: {message}',
|
|
76
104
|
moveToBin: 'Move to Recycle Bin',
|
|
77
|
-
deleteSession: 'Delete Session',
|
|
78
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',
|
|
79
127
|
},
|
|
80
128
|
zh: {
|
|
81
129
|
undo: '撤销',
|
|
@@ -88,6 +136,7 @@ window.__ModuleLoader__.load({
|
|
|
88
136
|
deleteFailed: '删除失败: {message}',
|
|
89
137
|
unknownError: '未知错误',
|
|
90
138
|
deleted: '已彻底删除会话',
|
|
139
|
+
deleteSession: '删除会话',
|
|
91
140
|
deleteArchivedTitle: '彻底删除归档会话',
|
|
92
141
|
deleteWorkspaceBody: '确定要永久删除工作区「{title}」下的 {count} 个会话吗?此操作不可撤销。',
|
|
93
142
|
deletedSelected: '已彻底删除所选归档会话',
|
|
@@ -127,19 +176,49 @@ window.__ModuleLoader__.load({
|
|
|
127
176
|
assistant: '🤖 助手',
|
|
128
177
|
cancel: '取消',
|
|
129
178
|
confirmDelete: '确定删除',
|
|
130
|
-
|
|
179
|
+
storagePath: '存储路径: {path}',
|
|
131
180
|
noActiveSession: '无法识别当前活动会话 ID',
|
|
132
181
|
movedToBin: '已将会话「{title}」移入回收站',
|
|
133
182
|
currentSession: '当前会话',
|
|
134
183
|
session: '会话',
|
|
135
184
|
moveFailed: '移入回收站失败: {message}',
|
|
136
185
|
moveToBin: '移入回收站',
|
|
137
|
-
deleteSession: '删除会话',
|
|
138
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: '已保留',
|
|
139
208
|
},
|
|
140
209
|
};
|
|
141
210
|
|
|
142
|
-
|
|
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
|
+
*/
|
|
143
222
|
const LOCALE_NS = 'session-trash';
|
|
144
223
|
let hostLocale;
|
|
145
224
|
let hostTranslate;
|
|
@@ -317,6 +396,36 @@ window.__ModuleLoader__.load({
|
|
|
317
396
|
|
|
318
397
|
const TrashIcon = (props) => renderTrashIcon(props);
|
|
319
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
|
+
|
|
320
429
|
/**
|
|
321
430
|
* Inject the plugin stylesheet (external client.css) exactly once.
|
|
322
431
|
* The host serves it at GET /api/session-trash/client.css; all colors
|
|
@@ -332,8 +441,8 @@ window.__ModuleLoader__.load({
|
|
|
332
441
|
(document.head ?? document.documentElement).appendChild(link);
|
|
333
442
|
}
|
|
334
443
|
|
|
335
|
-
/** Show a
|
|
336
|
-
function showToastLayer(message, undoFn, actionText = t('undo')) {
|
|
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) {
|
|
337
446
|
if (typeof document === 'undefined') return;
|
|
338
447
|
|
|
339
448
|
let container = document.getElementById('dsh-session-manager-toast-container');
|
|
@@ -367,16 +476,11 @@ window.__ModuleLoader__.load({
|
|
|
367
476
|
}
|
|
368
477
|
|
|
369
478
|
container.appendChild(toastEl);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
setTimeout(() => {
|
|
376
|
-
toastEl.style.opacity = '0';
|
|
377
|
-
toastEl.style.transform = 'translateY(10px)';
|
|
378
|
-
setTimeout(() => toastEl.remove(), 200);
|
|
379
|
-
}, 4000);
|
|
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);
|
|
380
484
|
}
|
|
381
485
|
|
|
382
486
|
/** Formats file size in KB or MB */
|
|
@@ -399,6 +503,30 @@ window.__ModuleLoader__.load({
|
|
|
399
503
|
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
|
400
504
|
}
|
|
401
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
|
+
|
|
402
530
|
/** Make signed API requests. */
|
|
403
531
|
async function api(ctx, path, options = {}) {
|
|
404
532
|
const response = await fetch(path, {
|
|
@@ -413,6 +541,9 @@ window.__ModuleLoader__.load({
|
|
|
413
541
|
...(options.headers || {}),
|
|
414
542
|
},
|
|
415
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,
|
|
416
547
|
});
|
|
417
548
|
|
|
418
549
|
const payload = await response.json().catch(() => ({}));
|
|
@@ -425,6 +556,44 @@ window.__ModuleLoader__.load({
|
|
|
425
556
|
return payload.data;
|
|
426
557
|
}
|
|
427
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
|
+
|
|
428
597
|
/** 强制浏览器端 sessions / workspaces 服务从服务端重新拉取权威列表 */
|
|
429
598
|
function refreshSessionViews(ctx) {
|
|
430
599
|
try { ctx?.sessions?.refresh?.(); } catch {}
|
|
@@ -434,7 +603,11 @@ window.__ModuleLoader__.load({
|
|
|
434
603
|
} catch {}
|
|
435
604
|
}
|
|
436
605
|
|
|
437
|
-
/**
|
|
606
|
+
/** 同步浏览器端会话存储
|
|
607
|
+
* 注意:sessions.list 快照的真实结构是 { items, current, state, phase, error, ... },
|
|
608
|
+
* 并没有 byId / order 字段;原地改快照也无法触发 zustand 通知(是空操作)。
|
|
609
|
+
* 正确做法是调用 sessions.refresh() + workspaces.refresh() 从服务端重新拉取,
|
|
610
|
+
* 让已物理删除的会话消失、已还原的会话重新出现。 */
|
|
438
611
|
function purgeFromBrowserSessionStore(ctx, sessionIds) {
|
|
439
612
|
if (!sessionIds || sessionIds.length === 0) return;
|
|
440
613
|
refreshSessionViews(ctx);
|
|
@@ -665,11 +838,16 @@ window.__ModuleLoader__.load({
|
|
|
665
838
|
}
|
|
666
839
|
}
|
|
667
840
|
|
|
668
|
-
/**
|
|
841
|
+
/** 当前活动会话 ID:sessions.list 快照的 `current` 字段(0.1.1-rc.2 与 0.1.5-rc.1 均提供)。 */
|
|
669
842
|
function activeSessionId(ctx) {
|
|
670
|
-
|
|
843
|
+
try {
|
|
844
|
+
return ctx?.sessions?.list?.getSnapshot?.()?.current ?? undefined;
|
|
845
|
+
} catch {
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
671
848
|
}
|
|
672
849
|
|
|
850
|
+
/** Archive (move to recycle bin) an arbitrary session by id. */
|
|
673
851
|
async function archiveSession(ctx, sessionId, title) {
|
|
674
852
|
if (!sessionId) {
|
|
675
853
|
showToastLayer(t('noActiveSession'));
|
|
@@ -678,12 +856,12 @@ window.__ModuleLoader__.load({
|
|
|
678
856
|
try {
|
|
679
857
|
await api(ctx, `${API_PREFIX}/archive`, { method: 'POST', body: { sessionId, title } });
|
|
680
858
|
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
681
|
-
showToastLayer(t('movedToBin', { title: title || t('
|
|
859
|
+
showToastLayer(t('movedToBin', { title: title || t('currentSession') }), async () => {
|
|
682
860
|
try {
|
|
683
861
|
await api(ctx, `${API_PREFIX}/unarchive`, { method: 'POST', body: { sessionId } });
|
|
684
862
|
refreshSessionViews(ctx);
|
|
685
863
|
} catch (error) {
|
|
686
|
-
|
|
864
|
+
console.error(t('undoFailed'), error);
|
|
687
865
|
}
|
|
688
866
|
});
|
|
689
867
|
} catch (error) {
|
|
@@ -704,7 +882,7 @@ window.__ModuleLoader__.load({
|
|
|
704
882
|
}, t('deleteSession'));
|
|
705
883
|
}
|
|
706
884
|
|
|
707
|
-
|
|
885
|
+
/** TrashTab Component */
|
|
708
886
|
function TrashTab({ ctx }) {
|
|
709
887
|
const [items, setItems] = useState([]);
|
|
710
888
|
const [loading, setLoading] = useState(true);
|
|
@@ -720,6 +898,124 @@ window.__ModuleLoader__.load({
|
|
|
720
898
|
const [selectedSessionIds, setSelectedSessionIds] = useState(new Set());
|
|
721
899
|
const [hoverTooltip, setHoverTooltip] = useState(null);
|
|
722
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 });
|
|
723
1019
|
|
|
724
1020
|
const toggleSelectSession = (sessionId) => {
|
|
725
1021
|
setSelectedSessionIds((prev) => {
|
|
@@ -749,6 +1045,9 @@ window.__ModuleLoader__.load({
|
|
|
749
1045
|
try {
|
|
750
1046
|
const value = await api(ctx, `${API_PREFIX}/list`);
|
|
751
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);
|
|
752
1051
|
} catch (err) {
|
|
753
1052
|
setError(err.message || t('loadFailed'));
|
|
754
1053
|
} finally {
|
|
@@ -756,6 +1055,78 @@ window.__ModuleLoader__.load({
|
|
|
756
1055
|
}
|
|
757
1056
|
}, [ctx]);
|
|
758
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
|
+
|
|
759
1130
|
useEffect(() => {
|
|
760
1131
|
loadItems();
|
|
761
1132
|
const offEvent = ctx?.on?.('workspace/archived-sessions-changed', () => loadItems());
|
|
@@ -803,15 +1174,17 @@ window.__ModuleLoader__.load({
|
|
|
803
1174
|
onConfirm: async () => {
|
|
804
1175
|
try {
|
|
805
1176
|
const result = await api(ctx, `${API_PREFIX}/purge`, { method: 'POST', body: { sessionIds: [sessionId] } });
|
|
806
|
-
const
|
|
1177
|
+
const failures = purgeFailures(result);
|
|
1178
|
+
const failedIds = new Set(failures.map((e) => e.sessionId));
|
|
807
1179
|
if (failedIds.has(sessionId)) {
|
|
808
|
-
const errItem =
|
|
1180
|
+
const errItem = failures.find((e) => e.sessionId === sessionId);
|
|
809
1181
|
showToastLayer(t('deleteFailed', { message: localizedError(errItem) }));
|
|
810
1182
|
} else {
|
|
811
1183
|
purgeFromBrowserSessionStore(ctx, [sessionId]);
|
|
812
1184
|
showToastLayer(t('deleted'));
|
|
813
1185
|
}
|
|
814
1186
|
setConfirmModal(null);
|
|
1187
|
+
closeTooltip();
|
|
815
1188
|
loadItems();
|
|
816
1189
|
} catch (err) {
|
|
817
1190
|
showToastLayer(t('deleteFailed', { message: localizedError(err) }));
|
|
@@ -829,7 +1202,7 @@ window.__ModuleLoader__.load({
|
|
|
829
1202
|
try {
|
|
830
1203
|
const ids = groupItems.map((i) => i.sessionId);
|
|
831
1204
|
const result = await api(ctx, `${API_PREFIX}/purge-workspace`, { method: 'POST', body: { workspacePath, sessionIds: ids } });
|
|
832
|
-
const failedIds = new Set((result
|
|
1205
|
+
const failedIds = new Set(purgeFailures(result).map((e) => e.sessionId));
|
|
833
1206
|
const succeededIds = ids.filter((id) => !failedIds.has(id));
|
|
834
1207
|
purgeFromBrowserSessionStore(ctx, succeededIds);
|
|
835
1208
|
setConfirmModal(null);
|
|
@@ -879,6 +1252,279 @@ window.__ModuleLoader__.load({
|
|
|
879
1252
|
}
|
|
880
1253
|
};
|
|
881
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
|
+
|
|
882
1528
|
// 提取工作区下拉列表
|
|
883
1529
|
const workspaceOptions = React.useMemo(() => {
|
|
884
1530
|
const map = new Map();
|
|
@@ -1046,35 +1692,27 @@ window.__ModuleLoader__.load({
|
|
|
1046
1692
|
h('div', { className: 'dsh-trash-cards' },
|
|
1047
1693
|
group.items.map((item) => {
|
|
1048
1694
|
const displayTitle = currentTitle(item.sessionId, localizeServerFallback(item.title)) || item.sessionId;
|
|
1049
|
-
const path = item.cwd || item.workspacePath || '-';
|
|
1050
1695
|
const isChecked = selectedSessionIds.has(item.sessionId);
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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);
|
|
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',
|
|
1071
1707
|
},
|
|
1072
|
-
|
|
1073
|
-
//
|
|
1708
|
+
// 微圆复选框:只选这一行本身,不通配它的子会话(子行的
|
|
1709
|
+
// 勾选是另一件事——级联删除的范围),所以两者必须能一眼分辨。
|
|
1074
1710
|
h('input', {
|
|
1075
1711
|
type: 'checkbox',
|
|
1076
|
-
className: 'dsh-trash-checkbox',
|
|
1712
|
+
className: 'dsh-trash-checkbox dsh-trash-checkbox--card',
|
|
1077
1713
|
checked: isChecked,
|
|
1714
|
+
'aria-label': t('selectSessionOnly'),
|
|
1715
|
+
title: t('selectSessionOnly'),
|
|
1078
1716
|
onChange: (e) => {
|
|
1079
1717
|
e.stopPropagation();
|
|
1080
1718
|
toggleSelectSession(item.sessionId);
|
|
@@ -1082,53 +1720,153 @@ window.__ModuleLoader__.load({
|
|
|
1082
1720
|
}),
|
|
1083
1721
|
|
|
1084
1722
|
h('div', { className: 'dsh-trash-card-main' },
|
|
1085
|
-
// Title
|
|
1086
|
-
|
|
1087
|
-
//
|
|
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)
|
|
1088
1745
|
h('div', { className: 'dsh-trash-card-meta' },
|
|
1089
|
-
h('span', { className: 'dsh-trash-card-meta-item' }, formatTime(item.archivedAt)),
|
|
1090
|
-
|
|
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,
|
|
1091
1753
|
h('span', { className: 'dsh-trash-badge' },
|
|
1092
1754
|
renderDiskIcon({ className: 'dsh-trash-icon-badge' }),
|
|
1093
1755
|
formatFileSize(item.fileSize)
|
|
1094
|
-
)
|
|
1756
|
+
),
|
|
1757
|
+
renderChildBadge(item.sessionId, childCount)
|
|
1095
1758
|
)
|
|
1096
1759
|
),
|
|
1097
1760
|
|
|
1098
|
-
// Action Buttons (微型极简胶囊按钮,大幅留出左侧空间)
|
|
1761
|
+
// Action Buttons (微型极简胶囊按钮,大幅留出左侧空间)。
|
|
1762
|
+
// 文案包一层 span:窄容器里只隐藏文字、保留图标,因此每颗
|
|
1763
|
+
// 按钮都必须自带 aria-label/title,收起后仍有可访问名称。
|
|
1099
1764
|
h('div', { className: 'dsh-trash-card-actions' },
|
|
1100
1765
|
h('button', {
|
|
1101
1766
|
type: 'button',
|
|
1102
1767
|
className: 'dsh-trash-btn-ghost',
|
|
1768
|
+
'aria-label': t('view'),
|
|
1769
|
+
title: t('view'),
|
|
1103
1770
|
onClick: () => handleOpenPreview(item),
|
|
1104
1771
|
},
|
|
1105
1772
|
renderEyeIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1106
|
-
t('view')
|
|
1773
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('view'))
|
|
1107
1774
|
),
|
|
1108
1775
|
h('button', {
|
|
1109
1776
|
type: 'button',
|
|
1110
1777
|
className: 'dsh-trash-btn-ghost',
|
|
1778
|
+
'aria-label': t('restore'),
|
|
1779
|
+
title: t('restore'),
|
|
1111
1780
|
onClick: () => handleUnarchive(item),
|
|
1112
1781
|
},
|
|
1113
1782
|
renderRestoreIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1114
|
-
t('restore')
|
|
1783
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('restore'))
|
|
1115
1784
|
),
|
|
1116
1785
|
h('button', {
|
|
1117
1786
|
type: 'button',
|
|
1118
1787
|
className: 'dsh-trash-btn-danger-text',
|
|
1119
|
-
|
|
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
|
+
),
|
|
1120
1795
|
},
|
|
1121
1796
|
renderTrashIcon({ className: 'dsh-trash-icon-sm' }),
|
|
1122
|
-
t('permanentlyDelete')
|
|
1797
|
+
h('span', { className: 'dsh-trash-btn-label' }, t('permanentlyDelete'))
|
|
1123
1798
|
)
|
|
1124
1799
|
)
|
|
1125
|
-
|
|
1800
|
+
),
|
|
1801
|
+
childrenExpanded ? renderChildRegion(item.sessionId, children) : null,
|
|
1802
|
+
];
|
|
1126
1803
|
})
|
|
1127
1804
|
)
|
|
1128
1805
|
);
|
|
1129
1806
|
})
|
|
1130
1807
|
),
|
|
1131
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
|
+
|
|
1132
1870
|
// Footer Summary
|
|
1133
1871
|
items.length > 0 && h('div', { className: 'dsh-trash-footer' }, t('archivedCount', { count: items.length })),
|
|
1134
1872
|
|
|
@@ -1169,6 +1907,16 @@ window.__ModuleLoader__.load({
|
|
|
1169
1907
|
h('div', { className: 'dsh-trash-modal', onClick: (e) => e.stopPropagation() },
|
|
1170
1908
|
h('h3', { className: 'dsh-trash-modal-title' }, confirmModal.title),
|
|
1171
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
|
+
),
|
|
1172
1920
|
h('div', { className: 'dsh-trash-modal-actions' },
|
|
1173
1921
|
h('button', { className: 'dsh-trash-btn-cancel', onClick: () => setConfirmModal(null) }, t('cancel')),
|
|
1174
1922
|
h('button', { className: 'dsh-trash-btn-confirm', onClick: confirmModal.onConfirm }, t('confirmDelete'))
|
|
@@ -1176,34 +1924,129 @@ window.__ModuleLoader__.load({
|
|
|
1176
1924
|
)
|
|
1177
1925
|
),
|
|
1178
1926
|
|
|
1179
|
-
//
|
|
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.
|
|
1180
1930
|
hoverTooltip &&
|
|
1181
1931
|
h('div', {
|
|
1182
|
-
|
|
1183
|
-
|
|
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,
|
|
1184
1937
|
},
|
|
1185
1938
|
h('div', { className: 'dsh-trash-tooltip-title' }, hoverTooltip.title),
|
|
1186
1939
|
h('div', { className: 'dsh-trash-tooltip-sub' }, `ID: ${hoverTooltip.id}`),
|
|
1187
|
-
|
|
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
|
|
1188
1949
|
)
|
|
1189
1950
|
);
|
|
1190
1951
|
}
|
|
1191
1952
|
|
|
1192
|
-
/** Header
|
|
1953
|
+
/** Header Action component for active session header */
|
|
1193
1954
|
function DeleteSessionAction(props) {
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
+
);
|
|
1205
2047
|
}
|
|
1206
2048
|
|
|
2049
|
+
|
|
1207
2050
|
/** Inject Trash Icon into settings sidebar */
|
|
1208
2051
|
function injectSettingsMenuIcon() {
|
|
1209
2052
|
if (typeof document === 'undefined') return;
|
|
@@ -1242,15 +2085,29 @@ window.__ModuleLoader__.load({
|
|
|
1242
2085
|
}
|
|
1243
2086
|
}
|
|
1244
2087
|
|
|
1245
|
-
/**
|
|
1246
|
-
|
|
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() {
|
|
1247
2100
|
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
|
1248
2101
|
let raf = 0;
|
|
1249
2102
|
const schedule = () => {
|
|
1250
2103
|
if (raf) return;
|
|
1251
2104
|
raf = requestAnimationFrame(() => {
|
|
1252
2105
|
raf = 0;
|
|
1253
|
-
|
|
2106
|
+
try {
|
|
2107
|
+
injectSettingsMenuIcon();
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
console.error('session-trash: settings icon inject failed', error);
|
|
2110
|
+
}
|
|
1254
2111
|
});
|
|
1255
2112
|
};
|
|
1256
2113
|
const observer = new MutationObserver(schedule);
|
|
@@ -1299,7 +2156,14 @@ window.__ModuleLoader__.load({
|
|
|
1299
2156
|
name: 'conversation.session.header.actions',
|
|
1300
2157
|
id: 'delete-session',
|
|
1301
2158
|
order: 400,
|
|
1302
|
-
label
|
|
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'),
|
|
1303
2167
|
},
|
|
1304
2168
|
(props) => DeleteSessionAction({ ...props, ctx })
|
|
1305
2169
|
)
|
|
@@ -1313,11 +2177,12 @@ window.__ModuleLoader__.load({
|
|
|
1313
2177
|
label: () => t('deleteSession'),
|
|
1314
2178
|
}, (props) => DeleteSessionMenuItem({ ...props, ctx }))
|
|
1315
2179
|
);
|
|
1316
|
-
|
|
2180
|
+
|
|
2181
|
+
ctx.effect(() => startSettingsIconSync());
|
|
1317
2182
|
}
|
|
1318
2183
|
|
|
1319
2184
|
exports.apply = apply;
|
|
1320
|
-
exports.inject = ['slots', 'connection', 'sessions', 'workspaces'
|
|
2185
|
+
exports.inject = ['slots', 'connection', 'sessions', 'workspaces'];
|
|
1321
2186
|
|
|
1322
2187
|
return module.exports;
|
|
1323
2188
|
},
|