@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/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
- path: 'Path: {path}',
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
- path: '路径: {path}',
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
- // Use the host's selected language for both menu entries and notifications.
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 bottom-left floating toast prompt. */
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
- 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);
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
- /** Refresh authoritative lists after a successful archive/restore. */
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
- /** The current UI binding owns selection; the session list does not. */
841
+ /** 当前活动会话 ID:sessions.list 快照的 `current` 字段(0.1.1-rc.2 与 0.1.5-rc.1 均提供)。 */
669
842
  function activeSessionId(ctx) {
670
- return ctx.get('uiSession').adapter.current.value?.key;
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('session') }), async () => {
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
- showToastLayer(t('restoreFailed', { message: localizedError(error) }));
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
- /** TrashTab Component */
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 failedIds = new Set((result?.errors ?? []).map((e) => e.sessionId));
1177
+ const failures = purgeFailures(result);
1178
+ const failedIds = new Set(failures.map((e) => e.sessionId));
807
1179
  if (failedIds.has(sessionId)) {
808
- const errItem = result.errors.find((e) => e.sessionId === sessionId);
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?.errors ?? []).map((e) => e.sessionId));
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
- 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);
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
- h('div', { className: 'dsh-trash-card-title' }, displayTitle),
1087
- // Metadata Row (时间, 轮数, 容量 Badge)
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
- h('span', { className: 'dsh-trash-card-meta-item' }, t('turnCount', { count: item.turnCount ?? 0 })),
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
- onClick: () => handlePurgeSingle(item.sessionId, displayTitle),
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
- // Tooltip: 悬停展示全标题 & ID & 路径(left/top 由 JS 按鼠标位置内联给出)
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
- className: 'dsh-trash-tooltip',
1183
- style: { left: `${hoverTooltip.x}px`, top: `${hoverTooltip.y}px` },
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
- h('div', { className: 'dsh-trash-tooltip-sub' }, t('path', { path: hoverTooltip.path }))
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 action resolves selection when clicked, including after navigation. */
1953
+ /** Header Action component for active session header */
1193
1954
  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 }));
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
- /** Keep the settings icon in sync; session actions use the native slot below. */
1246
- function startSettingsIntegration() {
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
- injectSettingsMenuIcon();
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: () => t('deleteSession'),
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
- ctx.effect(() => startSettingsIntegration());
2180
+
2181
+ ctx.effect(() => startSettingsIconSync());
1317
2182
  }
1318
2183
 
1319
2184
  exports.apply = apply;
1320
- exports.inject = ['slots', 'connection', 'sessions', 'workspaces', 'uiSession', 'locale'];
2185
+ exports.inject = ['slots', 'connection', 'sessions', 'workspaces'];
1321
2186
 
1322
2187
  return module.exports;
1323
2188
  },