@xrn1997/dsh-session-delete 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +10 -28
  2. package/lib/client.js +2012 -1117
  3. package/lib/index.js +1271 -893
  4. package/lib/types/api/dispatch.d.ts +2 -2
  5. package/lib/types/api/route-table.d.ts +1 -1
  6. package/lib/types/api/trash-rows.d.ts +13 -6
  7. package/lib/types/client/batch-bar.d.ts +7 -6
  8. package/lib/types/client/batch-confirm.d.ts +6 -4
  9. package/lib/types/client/batch-menu-item.d.ts +2 -2
  10. package/lib/types/client/batch-select.d.ts +7 -50
  11. package/lib/types/client/batch-selection-store.d.ts +30 -0
  12. package/lib/types/client/busy-gate.d.ts +6 -0
  13. package/lib/types/client/copy.d.ts +4 -1
  14. package/lib/types/client/delete-confirm.d.ts +2 -0
  15. package/lib/types/client/host-rows.d.ts +50 -0
  16. package/lib/types/client/main-view.d.ts +42 -0
  17. package/lib/types/client/orphan-maintenance.d.ts +27 -0
  18. package/lib/types/client/receipt.d.ts +73 -0
  19. package/lib/types/client/remote.d.ts +25 -19
  20. package/lib/types/client/store.d.ts +48 -0
  21. package/lib/types/client/trash-nav.d.ts +30 -0
  22. package/lib/types/client/trash-panel.d.ts +1 -1
  23. package/lib/types/client/trash-store.d.ts +2 -0
  24. package/lib/types/client/ui/icons.d.ts +3 -3
  25. package/lib/types/client/undo-toast.d.ts +30 -6
  26. package/lib/types/client/use-column-resize.d.ts +2 -2
  27. package/lib/types/client/use-panel-width.d.ts +1 -1
  28. package/lib/types/client/use-trash-panel-controller.d.ts +42 -0
  29. package/lib/types/client/use-trash-query.d.ts +20 -9
  30. package/lib/types/client/write-seats.d.ts +52 -0
  31. package/lib/types/client/write-settlement.d.ts +72 -0
  32. package/lib/types/client/write.d.ts +51 -0
  33. package/lib/types/deps.d.ts +14 -0
  34. package/lib/types/index.d.ts +7 -13
  35. package/lib/types/log/zstd-frames.d.ts +1 -1
  36. package/lib/types/ports/agent-handles.d.ts +25 -0
  37. package/lib/types/ports/home.d.ts +19 -0
  38. package/lib/types/ports/host-port.d.ts +60 -44
  39. package/lib/types/remote/map-error.d.ts +2 -2
  40. package/lib/types/shared/wire.d.ts +18 -9
  41. package/lib/types/trash/detail.d.ts +6 -3
  42. package/lib/types/trash/entry.d.ts +4 -4
  43. package/lib/types/trash/groups.d.ts +17 -6
  44. package/lib/types/trash/ledger.d.ts +20 -0
  45. package/lib/types/trash/manifest.d.ts +4 -2
  46. package/lib/types/trash/reconcile.d.ts +6 -5
  47. package/lib/types/trash/storage-manifest.d.ts +12 -0
  48. package/lib/types/verbs/batch-carried.d.ts +20 -0
  49. package/lib/types/verbs/batch.d.ts +1 -1
  50. package/lib/types/verbs/delete-session.d.ts +11 -13
  51. package/lib/types/verbs/errors.d.ts +2 -6
  52. package/lib/types/verbs/live-admission.d.ts +13 -0
  53. package/lib/types/verbs/member-journal.d.ts +26 -6
  54. package/lib/types/verbs/member-presence.d.ts +67 -0
  55. package/lib/types/verbs/move-group.d.ts +20 -0
  56. package/lib/types/verbs/orphan-subagents.d.ts +21 -40
  57. package/lib/types/verbs/permanent-removal.d.ts +114 -0
  58. package/lib/types/verbs/purge-trash.d.ts +21 -9
  59. package/lib/types/verbs/restore-session.d.ts +14 -12
  60. package/package.json +3 -2
package/lib/client.js CHANGED
@@ -93,7 +93,10 @@ window.__ModuleLoader__.load({
93
93
  //#endregion
94
94
  //#region src/client/copy.ts
95
95
  /**
96
- * 可见文案(**全部**住在这里)+接上宿主 Client locale 服务的那一步。
96
+ * 可见文案(界面标签与提示)+接上宿主 Client locale 服务的那一步。
97
+ *
98
+ * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id 与单条幂等空值、
99
+ * `src/client/remote.ts` 的取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
97
100
  *
98
101
  * ## 为什么不是散在各组件里的中文字面量
99
102
  *
@@ -431,6 +434,369 @@ window.__ModuleLoader__.load({
431
434
  });
432
435
  }
433
436
  //#endregion
437
+ //#region src/client/store.ts
438
+ /**
439
+ * 客户端**模块级快照 store** 的唯一实现(2026-10-20)。
440
+ *
441
+ * 为什么要有它:浏览器半有一批"跨卸载的现场"——回收站修订号(`trash-store.ts`)、单删请求
442
+ * (`delete-confirm.tsx`)、批量删除请求(`batch-confirm.tsx`)、撤销提示(`undo-toast.tsx`)、
443
+ * 批量选中集(`batch-selection-store.ts`)等。它们都挂在 `shell.overlay` / `main` / 菜单行这些
444
+ * 互不相邻的座位上,模块级是唯一的共同点。
445
+ * 在这之前每个文件各写一遍「`let` 现场 + `Set<() => void>` + `notify` + `useSyncExternalStore`」,
446
+ * 各自的 seq / revision 也在分头维护。现在订阅/通告只住这一处。
447
+ *
448
+ * 另有一个**懒读快照**变体(`createLazyStore`):值由组件树之外的事实源(宿主 DOM)现读,
449
+ * 脏了才重读;订阅脚手架仍住这里,观察者生命周期由调用方挂 `onFirstSubscribe` / `onLastUnsubscribe`。
450
+ *
451
+ * 还有一个**最新票句柄**(`createLatestWins`):并发的异步读里"只有最新那次可以落地"原先在每个
452
+ * 调用方各写一遍自增序号,现在只住这一处。它只拥有 compare-and-set——防抖、轮询、焦点策略仍是
453
+ * 调用方的现场,不搬进来。
454
+ *
455
+ * 语义(四条,别在调用方另算一遍):
456
+ * - `set` / `update` 用 `Object.is` 判"变过":同一个引用不重渲染(React 的 snapshot 契约要求)。
457
+ * - `publish(make)` 递增**本实例**的 seq(从 1 起)并**总是**通告——seq 是组件重挂载的 key
458
+ * (同一段文案连续出现两次也要重新计一次停留),不是版本号。
459
+ * - `subscribe` 回退订函数;`use()` 就是 `useSyncExternalStore(subscribe, get, get)`。
460
+ * - `reset()` 只给测试收场:回最初那份引用、seq 清零,值没变就不通告。
461
+ */
462
+ function createStore(initial) {
463
+ let value = initial;
464
+ let seq = 0;
465
+ const listeners = /* @__PURE__ */ new Set();
466
+ const notify = () => {
467
+ for (const listener of listeners) listener();
468
+ };
469
+ const get = () => value;
470
+ const subscribe = (listener) => {
471
+ listeners.add(listener);
472
+ return () => {
473
+ listeners.delete(listener);
474
+ };
475
+ };
476
+ return {
477
+ get,
478
+ set(next) {
479
+ if (Object.is(next, value)) return;
480
+ value = next;
481
+ notify();
482
+ },
483
+ update(next) {
484
+ const candidate = next(value);
485
+ if (Object.is(candidate, value)) return false;
486
+ value = candidate;
487
+ notify();
488
+ return true;
489
+ },
490
+ publish(make) {
491
+ seq += 1;
492
+ value = make(seq);
493
+ notify();
494
+ },
495
+ subscribe,
496
+ use() {
497
+ return (0, react.useSyncExternalStore)(subscribe, get, get);
498
+ },
499
+ reset() {
500
+ seq = 0;
501
+ if (Object.is(initial, value)) return;
502
+ value = initial;
503
+ notify();
504
+ }
505
+ };
506
+ }
507
+ function createLazyStore(initial, read, lifecycle = {}) {
508
+ let cached = initial;
509
+ let dirty = true;
510
+ const listeners = /* @__PURE__ */ new Set();
511
+ const notify = () => {
512
+ for (const listener of listeners) listener();
513
+ };
514
+ return {
515
+ get() {
516
+ if (dirty) {
517
+ cached = read();
518
+ dirty = false;
519
+ }
520
+ return cached;
521
+ },
522
+ invalidate() {
523
+ dirty = true;
524
+ notify();
525
+ },
526
+ subscribe(listener) {
527
+ const reattaching = listeners.size === 0;
528
+ listeners.add(listener);
529
+ if (reattaching) {
530
+ dirty = true;
531
+ lifecycle.onFirstSubscribe?.();
532
+ }
533
+ return () => {
534
+ listeners.delete(listener);
535
+ if (listeners.size > 0) return;
536
+ lifecycle.onLastUnsubscribe?.();
537
+ dirty = true;
538
+ };
539
+ }
540
+ };
541
+ }
542
+ function createLatestWins() {
543
+ let seq = 0;
544
+ return {
545
+ issue() {
546
+ const token = ++seq;
547
+ return { get current() {
548
+ return token === seq;
549
+ } };
550
+ },
551
+ invalidate() {
552
+ seq += 1;
553
+ }
554
+ };
555
+ }
556
+ //#endregion
557
+ //#region src/client/batch-selection-store.ts
558
+ /**
559
+ * 宿主列表批量删除的**选中集现场**(2026-10-20):多选模式 + 选中名单,一份合并快照。
560
+ *
561
+ * 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
562
+ * 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
563
+ * 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
564
+ * `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
565
+ * 的 `createStore` 里,本文件不另写一份。
566
+ *
567
+ * **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
568
+ * (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
569
+ *
570
+ * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
571
+ * 「全选」追加的条目按 `host-rows.ts` 读到的 DOM 序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
572
+ */
573
+ const INITIAL = {
574
+ selecting: false,
575
+ picks: []
576
+ };
577
+ const selectionStore = createStore(INITIAL);
578
+ function getSelection() {
579
+ return selectionStore.get();
580
+ }
581
+ function getPicks() {
582
+ return getSelection().picks;
583
+ }
584
+ /** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
585
+ function useSelection() {
586
+ return selectionStore.use();
587
+ }
588
+ /** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
589
+ function enterSelection() {
590
+ selectionStore.update((current) => current.selecting ? current : {
591
+ ...current,
592
+ selecting: true
593
+ });
594
+ }
595
+ /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
596
+ function exitSelection() {
597
+ selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
598
+ }
599
+ /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
600
+ function togglePick(item) {
601
+ selectionStore.update((current) => {
602
+ const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
603
+ return {
604
+ ...current,
605
+ picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
606
+ };
607
+ });
608
+ }
609
+ /** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
610
+ function clearPicks() {
611
+ selectionStore.update((current) => current.picks.length === 0 ? current : {
612
+ ...current,
613
+ picks: []
614
+ });
615
+ }
616
+ /** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
617
+ function addPicks(items) {
618
+ selectionStore.update((current) => {
619
+ const chosen = new Set(current.picks.map((item) => item.sessionId));
620
+ const additions = [];
621
+ for (const item of items) {
622
+ if (chosen.has(item.sessionId)) continue;
623
+ chosen.add(item.sessionId);
624
+ additions.push(item);
625
+ }
626
+ return additions.length === 0 ? current : {
627
+ ...current,
628
+ picks: [...current.picks, ...additions]
629
+ };
630
+ });
631
+ }
632
+ /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
633
+ function shrinkPicksToFailures(failedSessionIds) {
634
+ const keep = new Set(failedSessionIds);
635
+ selectionStore.update((current) => ({
636
+ ...current,
637
+ picks: current.picks.filter((item) => keep.has(item.sessionId))
638
+ }));
639
+ }
640
+ //#endregion
641
+ //#region src/client/host-rows.ts
642
+ /**
643
+ * 宿主会话行的**DOM 适配**:可见行解析、全选判定、模式内的整行点击守卫。
644
+ *
645
+ * 从 `batch-select.tsx` 拆出来的理由:选中集本身(模式 + 名单)只住 `batch-selection-store.ts`;
646
+ * 本文件只回答**页面上**的两件事——"现在 DOM 里有哪些可选的会话行"与"在这个行上点一下算不算选中"。
647
+ * 两边的唯一交界是 `data-row-key`(宿主会话行的稳定标记)和行尾兜底登记的那张 `rowInfo` 表。
648
+ *
649
+ * **为什么渲染走快照**:可见行是组件树之外的状态(宿主的 DOM)。每个组件各自在渲染期查一遍,
650
+ * 同一帧看到什么就取决于副作用顺序,而且 DOM 变了也不会触发重渲染。这里交给 `useSyncExternalStore`:
651
+ * DOM 观察(`MutationObserver`)与行登记都留在本适配器里,只把缓存标脏并通告订阅者;组件读缓存快照,
652
+ * 干净时拿到同一个引用(React 的 snapshot 契约),脏了才重读。事件路径(全选)仍走 `readVisibleSelectableRows`
653
+ * 的当场读。裁决见 ADR-0015。
654
+ *
655
+ * **为什么整行点击要抢在 document 捕获阶段**:宿主的会话行自己带 `onClick = open(sessionId)`,
656
+ * 第三方没有官方的"别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
657
+ */
658
+ /**
659
+ * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
660
+ * (它对每个非空行都渲染,哪怕 CSS 把整排藏起来)在 effect 里把这两件登记进这张表。
661
+ * 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
662
+ */
663
+ const rowInfo = /* @__PURE__ */ new Map();
664
+ /**
665
+ * 渲染侧快照 + DOM 观察:订阅脚手架住 `store.ts` 的 `createLazyStore`;这里只提供读法与
666
+ * 观察者生命周期。宿主行的去留只体现在 `data-row-key` 节点上(增删、属性落定),文本变化
667
+ * 不算(标题只认 `rowInfo` 的登记值)。首个订阅者起开始观察,最后一个走了断开。
668
+ */
669
+ const EMPTY_ROWS = [];
670
+ let observer;
671
+ function startObserving() {
672
+ if (observer !== void 0 || typeof MutationObserver === "undefined") return;
673
+ observer = new MutationObserver(() => visibleRows.invalidate());
674
+ observer.observe(document.body, {
675
+ childList: true,
676
+ subtree: true,
677
+ attributes: true,
678
+ attributeFilter: ["data-row-key"]
679
+ });
680
+ }
681
+ function stopObserving() {
682
+ observer?.disconnect();
683
+ observer = void 0;
684
+ }
685
+ const visibleRows = createLazyStore(EMPTY_ROWS, readVisibleSelectableRows, {
686
+ onFirstSubscribe: startObserving,
687
+ onLastUnsubscribe: stopObserving
688
+ });
689
+ /**
690
+ * 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
691
+ * 只有标题/运行态**真的变了**才标脏:重渲染时的同值登记不该把快照翻新一遍。
692
+ */
693
+ function registerHostRowInfo(sessionId, info) {
694
+ const current = rowInfo.get(sessionId);
695
+ if (current?.title === info.title && current.live === info.live) return;
696
+ rowInfo.set(sessionId, info);
697
+ visibleRows.invalidate();
698
+ }
699
+ /**
700
+ * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
701
+ * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
702
+ * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
703
+ *
704
+ * 这是**当场读**:事件路径(点「全选」那一刻的页面)与测试用它;渲染侧读下面的快照。
705
+ */
706
+ function readVisibleSelectableRows() {
707
+ const rows = [];
708
+ const seen = /* @__PURE__ */ new Set();
709
+ for (const row of document.querySelectorAll("[data-row-key^=\"session:\"]")) {
710
+ const key = row.getAttribute("data-row-key") ?? "";
711
+ if (!key.startsWith("session:")) continue;
712
+ const sessionId = key.slice(8);
713
+ if (sessionId === "" || seen.has(sessionId)) continue;
714
+ const info = rowInfo.get(sessionId);
715
+ if (info === void 0 || info.live) continue;
716
+ seen.add(sessionId);
717
+ rows.push({
718
+ sessionId,
719
+ title: info.title
720
+ });
721
+ }
722
+ return rows;
723
+ }
724
+ /**
725
+ * `enabled === false` 时的稳定替身:同一个空表、不订阅、不读 DOM。
726
+ * 浮条是 `shell.overlay` 的常驻槽位,模式外若照常订阅,body 上每次变更(以及每次行登记)
727
+ * 都会白叫醒它重渲染一次。
728
+ */
729
+ function getEmptyRowsSnapshot() {
730
+ return EMPTY_ROWS;
731
+ }
732
+ function noopSubscribe() {
733
+ return () => {};
734
+ }
735
+ /**
736
+ * 渲染侧只读快照 + DOM 观察订阅;首个订阅者起开始观察,最后一个走了断开。
737
+ * `enabled === false` 不订阅也不读 DOM——行列表只随多选态存在,模式外不必付观察的成本。
738
+ */
739
+ function useVisibleSelectableRows(enabled) {
740
+ return (0, react.useSyncExternalStore)(enabled ? visibleRows.subscribe : noopSubscribe, enabled ? visibleRows.get : getEmptyRowsSnapshot, enabled ? visibleRows.get : getEmptyRowsSnapshot);
741
+ }
742
+ /** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
743
+ function selectAllVisible() {
744
+ addPicks(readVisibleSelectableRows());
745
+ }
746
+ /**
747
+ * 同一个切换口该显示「全选」还是「取消全选」:
748
+ * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
749
+ * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
750
+ * - 其余(含 0 条)⇒ 全选。
751
+ */
752
+ function areAllVisibleRowsSelected(rows, picks) {
753
+ if (picks.length === 0) return false;
754
+ if (rows.length === 0) return true;
755
+ const chosen = new Set(picks.map((item) => item.sessionId));
756
+ return rows.every((row) => chosen.has(row.sessionId));
757
+ }
758
+ /** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
759
+ const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
760
+ /**
761
+ * 多选态内的整行点击守卫(document 捕获阶段)。
762
+ *
763
+ * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
764
+ * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
765
+ * 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
766
+ * 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
767
+ */
768
+ function useSelectionRowClickGuard() {
769
+ const selecting = useSelection().selecting;
770
+ (0, react.useEffect)(() => {
771
+ if (!selecting) return;
772
+ const handle = (event) => {
773
+ if (!(event.target instanceof Element)) return;
774
+ const row = event.target.closest("[data-row-key^=\"session:\"]");
775
+ if (row === null) return;
776
+ if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
777
+ event.preventDefault();
778
+ event.stopPropagation();
779
+ if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
780
+ const key = row.getAttribute("data-row-key") ?? "";
781
+ if (!key.startsWith("session:")) return;
782
+ const sessionId = key.slice(8);
783
+ if (sessionId === "") return;
784
+ const info = rowInfo.get(sessionId);
785
+ if (info?.live === true) return;
786
+ togglePick({
787
+ sessionId,
788
+ title: info?.title ?? ""
789
+ });
790
+ };
791
+ document.addEventListener("click", handle, true);
792
+ document.addEventListener("dblclick", handle, true);
793
+ return () => {
794
+ document.removeEventListener("click", handle, true);
795
+ document.removeEventListener("dblclick", handle, true);
796
+ };
797
+ }, [selecting]);
798
+ }
799
+ //#endregion
434
800
  //#region src/shared/wire.ts
435
801
  /**
436
802
  * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
@@ -460,7 +826,7 @@ window.__ModuleLoader__.load({
460
826
  * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
461
827
  * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
462
828
  * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
463
- * - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
829
+ * - `POST /detail`:单条详情(用 POST 表达一个读:判据就写在 `src/api/route-table.ts` 那一行的上方)。
464
830
  * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
465
831
  * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
466
832
  * - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
@@ -524,6 +890,116 @@ window.__ModuleLoader__.load({
524
890
  return encoded.replace(/~([0-9A-F]{4})/g, (_whole, hex) => String.fromCharCode(parseInt(hex, 16)));
525
891
  }
526
892
  //#endregion
893
+ //#region src/client/receipt.ts
894
+ /** 浏览器半自己的未分类诊断码(通道打不通 / 非信封 / 单条协议错)。它不过线,线上失败码仍归 `shared/wire.ts`。 */
895
+ const CLIENT_UNKNOWN_CODE = "client/unknown";
896
+ function missingOutcomeMessage(action, id) {
897
+ return `批量通道没回这一条的结果:${action} ${id}`;
898
+ }
899
+ function singleNullMessage(action, id) {
900
+ return `单条动作回了幂等空值:${action} ${id}`;
901
+ }
902
+ function isFailureVerdict(verdict) {
903
+ return verdict.kind === "failed" || verdict.kind === "neutral" || verdict.kind === "missing";
904
+ }
905
+ /** failed / neutral 取服务端原文 `error.message`;missing 取通道自己写的那句。 */
906
+ function failureMessage(verdict) {
907
+ return verdict.kind === "failed" || verdict.kind === "neutral" ? verdict.error.message : verdict.message;
908
+ }
909
+ function decode$1(action, outcome, options) {
910
+ if (outcome.action !== action) return void 0;
911
+ const typed = outcome;
912
+ if (!typed.ok) return action === "orphan-clean" && typed.error.code === SESSION_DELETE_CODES["not-found"] ? {
913
+ kind: "neutral",
914
+ id: typed.id,
915
+ error: typed.error
916
+ } : {
917
+ kind: "failed",
918
+ id: typed.id,
919
+ error: typed.error
920
+ };
921
+ const value = typed.value;
922
+ if (value === null) return options.single === true ? {
923
+ kind: "failed",
924
+ id: typed.id,
925
+ error: {
926
+ code: CLIENT_UNKNOWN_CODE,
927
+ message: singleNullMessage(action, typed.id)
928
+ }
929
+ } : {
930
+ kind: "already-gone",
931
+ id: typed.id
932
+ };
933
+ return {
934
+ kind: "ok",
935
+ id: typed.id,
936
+ value
937
+ };
938
+ }
939
+ function readReceipt(action, requestedIds, outcomes, options = {}) {
940
+ const wanted = [...new Set(requestedIds)];
941
+ const wantedSet = new Set(wanted);
942
+ const byId = /* @__PURE__ */ new Map();
943
+ for (const outcome of outcomes) {
944
+ if (!wantedSet.has(outcome.id) || byId.has(outcome.id)) continue;
945
+ const verdict = decode$1(action, outcome, options);
946
+ if (verdict !== void 0) byId.set(outcome.id, verdict);
947
+ }
948
+ const verdicts = wanted.map((id) => byId.get(id) ?? {
949
+ kind: "missing",
950
+ id,
951
+ message: missingOutcomeMessage(action, id)
952
+ });
953
+ return {
954
+ verdicts,
955
+ byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
956
+ };
957
+ }
958
+ /**
959
+ * N=1 的读法:null 折成协议错,类型面因此不含 `already-gone`(下面那次过滤只服务类型收窄,
960
+ * single 读法运行期不会产出它)。`applyOne` 用它之后只剩「按 id 取一条 + 抛失败」。
961
+ */
962
+ function readSingleReceipt(action, id, outcomes) {
963
+ const verdicts = readReceipt(action, [id], outcomes, { single: true }).verdicts.filter((verdict) => verdict.kind !== "already-gone");
964
+ return {
965
+ verdicts,
966
+ byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
967
+ };
968
+ }
969
+ function isRestoreEntry(value) {
970
+ return typeof value === "object" && value !== null && "sessionId" in value;
971
+ }
972
+ function isEntryWithId(value) {
973
+ return typeof value === "object" && value !== null && "id" in value && typeof value.id === "string";
974
+ }
975
+ /**
976
+ * `/apply` 成功支回执里的条目 id(delete / restore 的 `value.id`);purge 的聚合值与 count 的数字值跳过。
977
+ * 撤销名单只收 `ok` 支的条目 id:already-gone 是「已被同批前一条带走」的幂等条,跟着组走,不单列。
978
+ */
979
+ function landedEntryIds(receipt) {
980
+ const ids = [];
981
+ for (const verdict of receipt.verdicts) if (verdict.kind === "ok" && isEntryWithId(verdict.value)) ids.push(verdict.value.id);
982
+ return ids;
983
+ }
984
+ /**
985
+ * restore 回执里真正回盘的会话 id:`ok` 支取条目上的 `sessionId`;`already-gone` 没有行,
986
+ * 由调用方给条目查找兜底。purge / count 的 `ok` 值没有 `sessionId`,会被跳过。
987
+ */
988
+ function restoredSessionIds(receipt, sessionIdOfEntry) {
989
+ const ids = [];
990
+ for (const verdict of receipt.verdicts) {
991
+ if (verdict.kind === "ok") {
992
+ if (isRestoreEntry(verdict.value)) ids.push(verdict.value.sessionId);
993
+ continue;
994
+ }
995
+ if (verdict.kind === "already-gone" && sessionIdOfEntry !== void 0) {
996
+ const sessionId = sessionIdOfEntry(verdict.id);
997
+ if (sessionId !== void 0) ids.push(sessionId);
998
+ }
999
+ }
1000
+ return ids;
1001
+ }
1002
+ //#endregion
527
1003
  //#region src/client/remote.ts
528
1004
  /**
529
1005
  * 浏览器半的**取数通道**:同源 fetch 宿主 web server 上的 `/dsh-session-delete-api`,
@@ -570,11 +1046,11 @@ window.__ModuleLoader__.load({
570
1046
  ...reason.details === void 0 ? {} : { details: reason.details }
571
1047
  };
572
1048
  if (reason instanceof Error) return {
573
- code: "client/unknown",
1049
+ code: CLIENT_UNKNOWN_CODE,
574
1050
  message: reason.message
575
1051
  };
576
1052
  return {
577
- code: "client/unknown",
1053
+ code: CLIENT_UNKNOWN_CODE,
578
1054
  message: String(reason)
579
1055
  };
580
1056
  }
@@ -667,9 +1143,9 @@ window.__ModuleLoader__.load({
667
1143
  *
668
1144
  * 判据是**「拿到信封」而不是「操作成功」**:`{ok:false, error}` 也是一次成功的往返(路由在、依赖接上了、
669
1145
  * 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
670
- * 一个槽位贡献都不注册(设计稿 §7「未接通态」)。
1146
+ * 一个槽位贡献都不注册(docs/architecture.md §3「通道探测决定入口出现与否」)。
671
1147
  *
672
- * 2026-10-17 第四批:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
1148
+ * 2026-10-17:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
673
1149
  * 而"信封到不到"这件事一次零成本的 GET 就答得出来。
674
1150
  */
675
1151
  async function probeChannel(raw) {
@@ -681,18 +1157,24 @@ window.__ModuleLoader__.load({
681
1157
  }
682
1158
  }
683
1159
  /**
684
- * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
1160
+ * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 必然非空)。
685
1161
  *
686
- * 三条判据都在这一处:
1162
+ * 三件事各归一个家:
687
1163
  * - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
688
- * - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
689
- * - `null` 是批量里"已被同批前一条带走"的幂等值——单条调用(N=1)永远不会命中它。
1164
+ * - 成/败/幂等/缺失与「单条 null 是协议错」的判读只走 {@link readSingleReceipt};
1165
+ * - 这里只把 verdict 折成「拿到值 / 抛失败」。
690
1166
  */
691
1167
  async function applyOne(deps, action, id) {
692
- const outcome = (await deps.applyOutcomes(action, [id])).find((candidate) => candidate.id === id);
693
- if (outcome === void 0) throw new Error(`批量通道没回这一条的结果:${action} ${id}`);
694
- if (!outcome.ok) throwRemoteFailure(outcome.error);
695
- return outcome;
1168
+ const verdict = readSingleReceipt(action, id, await deps.applyOutcomes(action, [id])).byId.get(id);
1169
+ if (verdict === void 0) throw new Error(missingOutcomeMessage(action, id));
1170
+ if (verdict.kind === "failed" || verdict.kind === "neutral") throwRemoteFailure(verdict.error);
1171
+ if (verdict.kind === "missing") throw new Error(verdict.message);
1172
+ return {
1173
+ id,
1174
+ action,
1175
+ ok: true,
1176
+ value: verdict.value
1177
+ };
696
1178
  }
697
1179
  /**
698
1180
  * 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
@@ -853,7 +1335,7 @@ window.__ModuleLoader__.load({
853
1335
  })]
854
1336
  });
855
1337
  }
856
- /** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17 第十三批加)。 */
1338
+ /** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17 加)。 */
857
1339
  function IconCheckOutlineRegular({ size }) {
858
1340
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFrame, {
859
1341
  size,
@@ -885,10 +1367,10 @@ window.__ModuleLoader__.load({
885
1367
  }
886
1368
  //#endregion
887
1369
  //#region src/client/ui/ui.css?dsh-css-text
888
- var ui_default = "/*\r\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\r\n *\r\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\r\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\r\n * `Toast.module.css`、`Menu.module.css`;2026-10-15 又加两张:`lib/Checkbox.module.css`、\r\n * `lib/DisclosureRow.module.css`,以及 `dsh-client-ui-conversation` 里的\r\n * `ConversationRoot.module.css` 的 `.widthHandle` 那一族——宿主自己那根可拖的宽度把手;\r\n * 2026-10-17 又加一张,也是首张来自 ui-primitives 之外的包:`dsh-client-ui-workspace` 的\r\n * `Rows.module.css` 的 `.iconButton` 那一族——会话行尾悬停排那颗按钮的基底(行尾兜底勾选开关\r\n * 要跟宿主自己的归档/置顶同形)。\r\n * 这是官方给第三方 UI 插件的处方:\r\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\r\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\r\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\r\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\r\n *\r\n * 三条改写规则:\r\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\r\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\r\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\r\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\r\n *\r\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\r\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\r\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)、Checkbox 的全部、DisclosureRow 的\r\n * 折叠头那一半(没有 `data-state` 那档状态点)、宽度把手的视觉与光标。**没抄进来的就是本插件不用的**,\r\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\r\n *\r\n * **五处不是抄本**:① `.sd-resizing`(拖动期间挂在面板根上、关掉文字选中)——宿主那份没有对应的类,\r\n * 它的把手由 JS 侧的 pointer capture 承担;② `[data-dsh-session-delete=\"trash-row\"]:focus-visible`\r\n * (清单那一行的键盘焦点描边,复用选中态那枚 `ghost-active-border`)——宿主那套表里行不是可聚焦\r\n * div,我们这一行是 `role=\"option\"` 且要吃 ↑↓,而这一代 `--dsw-focus-ring-color` 是 `transparent`;\r\n * ③ `.sd-rowaction-button` 的 `[aria-pressed='true']` 与 `:disabled` 两档(2026-10-17 第十三批)——\r\n * 官方那颗 `iconButton` 只有常态与 hover,勾选开关要有\"按下\"和\"这条删不动\"两档,token 与回收站\r\n * 选中行同源(`--dsw-alias-button-ghost-active-*`);④ `.sd-batch-bar`(同日同批)——宿主没有\"浮条\"\r\n * 这件控件,四件 token 自拼;⑤ 宿主行的批量选中底色与兜底隐藏(同日改版)——宿主的行选中态不归\r\n * 第三方,整行可见性靠 `data-row-key` + `:has`,token 与回收站选中行同源;行首格在场时行尾兜底退场。\r\n * 整张表里就这五条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n */\r\n\r\n/* ────────────────────────── Button ────────────────────────── */\r\n\r\n.sd-button {\r\n box-sizing: border-box;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 4px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n cursor: pointer;\r\n font-size: 14px;\r\n line-height: 22px;\r\n color: var(--dsw-alias-label-primary);\r\n background: transparent;\r\n padding: 0 14px;\r\n}\r\n\r\n.sd-button:disabled {\r\n cursor: not-allowed;\r\n opacity: 0.4;\r\n}\r\n\r\n.sd-button-md {\r\n height: 36px;\r\n}\r\n\r\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\r\n.sd-button-sm {\r\n height: 28px;\r\n font-size: 12px;\r\n line-height: 18px;\r\n padding: 0 10px;\r\n border-radius: var(--dsw-radius-sm);\r\n}\r\n\r\n.sd-button-primary {\r\n background: var(--dsw-alias-button-primary-fill);\r\n color: var(--dsw-alias-label-primary-foreground);\r\n}\r\n\r\n.sd-button-primary:hover:not(:disabled) {\r\n background: var(--dsw-alias-button-primary-hover);\r\n}\r\n\r\n.sd-button-ghost:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-ghost:active:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-active);\r\n}\r\n\r\n.sd-button-outline {\r\n border: 0.5px solid var(--dsw-alias-border-l3);\r\n background: transparent;\r\n}\r\n\r\n.sd-button-outline:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-icon {\r\n display: inline-flex;\r\n width: 16px;\r\n height: 16px;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* ────────────────────────── Tag ────────────────────────── */\r\n\r\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\r\n.sd-tag {\r\n display: inline-flex;\r\n align-items: center;\r\n border-radius: 999px;\r\n corner-shape: round;\r\n padding: 1px 8px;\r\n font-size: 11px;\r\n line-height: 17px;\r\n font-weight: 500;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\r\n.sd-tag-danger {\r\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n/* ────────────────────────── Modal ────────────────────────── */\r\n\r\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\r\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\r\n.sd-modal-root {\r\n pointer-events: auto;\r\n position: fixed;\r\n inset: 0;\r\n z-index: 1000;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\r\n}\r\n\r\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\r\n.sd-modal-mask {\r\n position: absolute;\r\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\r\n backdrop-filter: var(--dsw-mask-blur);\r\n}\r\n\r\n.sd-modal-mask::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n background: var(--dsw-alias-bg-mask-1);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-modal-dialog {\r\n box-sizing: border-box;\r\n position: relative;\r\n z-index: 1;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 20px;\r\n width: min(380px, 100%);\r\n padding: 0 0 24px;\r\n overflow: hidden;\r\n border: 0;\r\n border-radius: var(--dsw-radius-panel);\r\n background: var(--dsw-alias-bg-layer-2);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n@keyframes sd-modal-enter {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-modal-mask::after,\r\n .sd-modal-dialog { animation: none; }\r\n}\r\n\r\n.sd-modal-dialog:focus {\r\n outline: none;\r\n}\r\n\r\n.sd-modal-content {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n}\r\n\r\n/* 头行:标题左、关闭钮右。 */\r\n.sd-modal-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 8px;\r\n padding: 22px 14px 12px 24px;\r\n}\r\n\r\n.sd-modal-title {\r\n margin: 0;\r\n font-size: 16px;\r\n line-height: 24px;\r\n font-weight: 500;\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-modal-close {\r\n flex: none;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 28px;\r\n height: 28px;\r\n border: none;\r\n border-radius: var(--dsw-radius-sm);\r\n background: transparent;\r\n cursor: pointer;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-modal-close:hover {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-modal-body {\r\n display: flex;\r\n flex-direction: column;\r\n min-width: 0;\r\n margin-top: 20px;\r\n padding: 0 24px;\r\n}\r\n\r\n.sd-modal-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: flex-end;\r\n gap: 8px;\r\n padding: 0 24px;\r\n}\r\n\r\n/* ────────────────────────── Tooltip ────────────────────────── */\r\n\r\n.sd-tooltip-bubble {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n position: fixed;\r\n z-index: 100;\r\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\r\n `max-content` 只按文案定宽,再用半个视口收口。 */\r\n width: max-content;\r\n max-width: 50vw;\r\n padding: 3px 7px;\r\n border-radius: var(--dsw-radius-sm);\r\n background: var(--dsw-alias-tooltip-bg);\r\n color: var(--dsw-static-neutral-bluish-00);\r\n font-size: 13px;\r\n line-height: 20px;\r\n white-space: pre-line;\r\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\r\n overflow-wrap: break-word;\r\n pointer-events: none;\r\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\r\n.sd-tooltip-label { min-width: 0; }\r\n\r\n.sd-tooltip-bubble[data-side='right'] {\r\n transform: translateY(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='bottom'] {\r\n transform: translateX(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='top'] {\r\n transform: translate(-50%, -100%);\r\n}\r\n\r\n@keyframes sd-tooltip-in {\r\n from { opacity: 0; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-tooltip-bubble {\r\n animation: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── Toast ────────────────────────── */\r\n\r\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\r\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\r\n.sd-toast {\r\n position: fixed;\r\n top: 40px;\r\n left: 50%;\r\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\r\n z-index: 1100;\r\n /* 通告不截点击。 */\r\n pointer-events: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\r\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\r\n width: max-content;\r\n max-width: min(640px, calc(100vw - 48px));\r\n padding: 12px 16px;\r\n border-radius: var(--dsw-radius-lg);\r\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\r\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\r\n background: var(--dsw-alias-toast-bg);\r\n color: var(--dsw-alias-toast-label);\r\n font-size: 14px;\r\n line-height: 22px;\r\n box-shadow: var(--dsw-shadow-lv3);\r\n transform: translateX(-50%);\r\n animation:\r\n sd-toast-in 160ms ease-out,\r\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n}\r\n\r\n.sd-toast-icon {\r\n display: grid;\r\n place-items: center;\r\n flex: none;\r\n color: var(--dsw-alias-state-warn-label);\r\n}\r\n\r\n.sd-toast-icon-success {\r\n color: var(--dsw-alias-state-success-primary);\r\n}\r\n\r\n.sd-toast-text {\r\n min-width: 0;\r\n}\r\n\r\n.sd-toast-action {\r\n display: inline;\r\n padding: 0;\r\n border: 0;\r\n background: none;\r\n color: var(--dsw-static-deepseek-400);\r\n font: inherit;\r\n cursor: pointer;\r\n /* 动作文字与周围文案之间的呼吸位。 */\r\n margin-inline: 3px;\r\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\r\n pointer-events: auto;\r\n}\r\n\r\n.sd-toast-action:hover {\r\n opacity: 0.8;\r\n}\r\n\r\n@keyframes sd-toast-in {\r\n from {\r\n opacity: 0;\r\n transform: translate(-50%, -6px);\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n transform: translate(-50%, 0);\r\n }\r\n}\r\n\r\n@keyframes sd-toast-fade {\r\n to {\r\n opacity: 0;\r\n visibility: hidden;\r\n }\r\n}\r\n\r\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-toast {\r\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 菜单行 ────────────────────────── */\r\n\r\n.sd-menu-item-wrap {\r\n position: relative;\r\n}\r\n\r\n/* 菜单格:紧凑文字、图标与选中间距。 */\r\n.sd-menu-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n width: 100%;\r\n min-height: 34px;\r\n padding: 6px 8px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n background: transparent;\r\n cursor: pointer;\r\n font-size: 13px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n text-align: left;\r\n}\r\n\r\n.sd-menu-item:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\r\n 浏览器默认的 ring 再叠一层就重了。 */\r\n.sd-menu-item:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n outline: none;\r\n}\r\n\r\n.sd-menu-item:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n.sd-menu-item-icon {\r\n display: inline-flex;\r\n flex: none;\r\n width: 14px;\r\n height: 14px;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--dsw-alias-menu-icon);\r\n}\r\n\r\n.sd-menu-item-icon svg {\r\n width: 14px;\r\n height: 14px;\r\n}\r\n\r\n.sd-menu-item-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\r\n.sd-menu-item-danger {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger .sd-menu-item-icon {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n}\r\n\r\n.sd-menu-item-danger:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n outline: none;\r\n}\r\n\r\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\r\n.sd-menu-separator {\r\n height: 0.5px;\r\n margin: 3px 2px;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n/* ────────────────────────── Checkbox ────────────────────────── */\r\n/* 抄 `lib/Checkbox.module.css` 全部四条声明,逐字。勾选框本体是**原生 input** + `accent-color`,\r\n 不是自绘的圆框——回收站多选态用它(清单行左侧那一格)。 */\r\n\r\n.sd-checkbox {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-checkbox input {\r\n flex: 0 0 auto;\r\n width: 16px;\r\n height: 16px;\r\n margin: 0;\r\n accent-color: var(--dsw-alias-brand-primary);\r\n cursor: inherit;\r\n}\r\n\r\n.sd-checkbox input:focus-visible {\r\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\r\n outline-offset: 2px;\r\n}\r\n\r\n.sd-checkbox:has(input:disabled) {\r\n cursor: default;\r\n opacity: 0.5;\r\n}\r\n\r\n/* ────────────────────────── DisclosureRow(分组折叠头) ────────────────────────── */\r\n/* 抄 `lib/DisclosureRow.module.css` 的 `.root`/`.row`/`.leading`/`.title` 与那两枚 glyph 的\r\n 显隐。没抄进来的是 `StateDot`(`svg[data-state]`)那一档——它说\"这是状态标记不是文字装饰\",\r\n 我们的组头没有状态点。行高与 leading 盒走 `--dsh-content-font-delta` 那根轴,\r\n 标题字号走 secondary 档:宿主的\"设置里调正文字号\"会一起推着它走。 */\r\n\r\n.sd-disclosure {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n min-width: 0;\r\n}\r\n\r\n.sd-disclosure-row {\r\n position: relative;\r\n overflow: hidden;\r\n display: flex;\r\n align-items: center;\r\n height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n min-width: 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n transition: color 100ms ease;\r\n}\r\n\r\n.sd-disclosure-row[data-expandable] {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-row:hover {\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-disclosure-leading {\r\n position: relative;\r\n flex: none;\r\n width: calc(16px + var(--dsh-content-font-delta, 0px));\r\n height: calc(16px + var(--dsh-content-font-delta, 0px));\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n margin-right: 6px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n color: inherit;\r\n}\r\n\r\n.sd-disclosure-leading svg:not([data-state]) {\r\n width: calc(14px + var(--dsh-content-font-delta, 0px));\r\n height: calc(14px + var(--dsh-content-font-delta, 0px));\r\n}\r\n\r\nbutton.sd-disclosure-leading {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-title {\r\n flex: none;\r\n font-size: var(--dsh-content-font-size-secondary, 13px);\r\n line-height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n color: inherit;\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-disclosure-row {\r\n transition: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 宽度把手(可拖的分栏线) ────────────────────────── */\r\n/* 抄 `ConversationRoot.module.css` 的 `.widthHandle` 一族:10px 的命中区、`col-resize` 光标、\r\n 平时不画、悬停或拖动时才浮出一条 2px 的 `--dsw-alias-scrollbar-bg-l1` 短标(那条渐变里\r\n `--dsh-width-handle-pointer-y` 的兜底是 50%,宿主靠 JS 跟着指针走;我们不跟,\r\n 停在中间——少一条 pointermove 上的写字,视觉上是同一族东西)。\r\n 与宿主的差别只有位置:它绝对定位在会话栏两侧,我们这根住在两栏之间的那一格 flex 位里,\r\n 所以 `top/bottom/position` 那三样交给内联布局,这里只留视觉与光标。 */\r\n\r\n.sd-width-handle {\r\n position: relative;\r\n z-index: 0;\r\n cursor: col-resize;\r\n align-self: stretch;\r\n flex: 0 0 10px;\r\n width: 10px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n}\r\n\r\n.sd-width-handle::after {\r\n content: \"\";\r\n background: linear-gradient(\r\n to bottom,\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)\r\n );\r\n opacity: 0;\r\n pointer-events: none;\r\n border-radius: 2px;\r\n width: 2px;\r\n position: absolute;\r\n top: 0;\r\n bottom: 0;\r\n left: 4px;\r\n}\r\n\r\n.sd-width-handle:hover::after,\r\n.sd-width-handle[data-dragging]::after {\r\n opacity: 1;\r\n}\r\n\r\n.sd-width-handle:focus-visible {\r\n outline: none;\r\n}\r\n\r\n.sd-width-handle:focus-visible::after {\r\n opacity: 1;\r\n}\r\n\r\n/* 本插件自造的第一条(不是抄本,见文件头点名):拖动期间整块面板别把文字选上了。 */\r\n.sd-resizing {\r\n user-select: none;\r\n cursor: col-resize;\r\n}\r\n\r\n/* 本插件自造的第二条(不是抄本,见文件头点名):清单行是 `role=\"option\"` 的可聚焦 div,而这一代\r\n `--dsw-focus-ring-color` 逐字是 `transparent`(设计稿 2026-10-15 §1.3)——不自己描一条,键盘走\r\n ↑↓ 选会话时就没有任何焦点可见性。描边**复用选中态那枚** `ghost-active-border`,不新造颜色;\r\n 0.5px 与 `outline-offset: 1px` 是行的描边宽度与一档外移,不动布局(outline 不进滚动区)。 */\r\n[data-dsh-session-delete=\"trash-row\"]:focus-visible {\r\n outline: 0.5px solid var(--dsw-alias-button-ghost-active-border);\r\n outline-offset: 1px;\r\n}\r\n\r\n/* 会话行尾悬停排那颗**兜底**勾选开关(状态点行 / 归档行;行首主勾选是原生 Checkbox)。\r\n 基底**逐字抄** `dsh-client-ui-workspace` 的 `Rows.module.css` 的 `.iconButton` / `.iconButton:hover`,\r\n 只改类名——兜底开关要跟同排宿主自己的归档/置顶同形,尺寸与色档都不自己发明。 */\r\n.sd-rowaction-button {\r\n border-radius: var(--dsw-radius-xs);\r\n cursor: pointer;\r\n width: 16px;\r\n height: 16px;\r\n color: var(--dsw-alias-label-tertiary);\r\n background: 0 0;\r\n border: none;\r\n flex: none;\r\n justify-content: center;\r\n align-items: center;\r\n padding: 0;\r\n display: inline-flex;\r\n}\r\n.sd-rowaction-button:hover {\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n/* 本插件自造的第三条(不是抄本,见文件头点名):官方那颗只有常态与 hover,勾选开关还要有\r\n 「已按下」与「这条删不动」两档。按下档**复用回收站选中行那两枚** `ghost-active-*`,不新造颜色;\r\n 禁用档走 opacity。 */\r\n.sd-rowaction-button[aria-pressed='true'] {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n.sd-rowaction-button:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n/* 行首 16px 格里的勾选框(第十三批①改版,**只在多选模式内渲染**)。格本身由宿主居中(16×20),\r\n 这里只把包装层压成同样大小;勾选框本体是回收站多选同款原生 input(`.sd-checkbox`,见上)。 */\r\n.sd-batch-pick-leading {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* 本插件自造的第⑤条(不是抄本,见文件头点名):宿主行自己的选中态只归它自己,批量选择要有整行\r\n 底色;行首格在场时行尾兜底要退场(两条入口不许同屏)。两条都只认宿主行已有的\r\n `data-row-key`;将来这个属性改名,最差是底色不亮 / 兜底多露一颗,勾选本身仍在。 */\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-checked=\"true\"]) {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 0.5px var(--dsw-alias-button-ghost-active-border);\r\n}\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-seat=\"leading\"]) .sd-batch-pick-action {\r\n display: none;\r\n}\r\n\r\n/* 本插件自造的第四条(不是抄本,见文件头点名):批量删除的浮条。宿主没有\"浮条\"这件控件,形状照\r\n 本插件自己的浮层口径来——固定底部居中,表面/描边/圆角/高程四件全走 token(与单删那张目标卡同源)。\r\n **层级必须低于确认框(`.sd-modal-root` 1000)**:2026-10-17 真机发现浮条在 1100 时会盖住确认框、\r\n 还能抢它的按钮;撤销横幅与 Tooltip 是瞬时通告、保持 1100。列方向:清单在上、操作行在下;\r\n 清单限高可滚,默认收起(见 `batch-bar.tsx`)。**宽度按内容展开**(`max-content`,最多视口减 48):\r\n 只写 `left:50% + width:auto` 时 shrink-to-fit 只拿左半屏,新增「全选/取消全选」后会把「退出多选」\r\n 挤到第二行;窄到 max-width 还放不下才由 `.sd-batch-bar-main` 的 `flex-wrap` 兜底。 */\r\n.sd-batch-bar {\r\n position: fixed;\r\n bottom: 24px;\r\n left: 50%;\r\n transform: translateX(-50%);\r\n z-index: 900;\r\n width: max-content;\r\n max-width: calc(100vw - 48px);\r\n display: flex;\r\n flex-direction: column;\r\n align-items: stretch;\r\n gap: 8px;\r\n padding: 8px 12px;\r\n background: var(--dsw-alias-bg-layer-2);\r\n border: 0.5px solid var(--dsw-alias-border-l4);\r\n border-radius: var(--dsw-radius-lg);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n font-size: 12px;\r\n line-height: 18px;\r\n}\r\n\r\n.sd-batch-bar-main {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n.sd-batch-bar-hint {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list-head {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n/* 汇总的第二句(含 K 个子会话)比条数轻一档,和列表行里的 chip 同级。 */\r\n.sd-batch-bar-list-head span + span {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n max-height: 240px;\r\n overflow: auto;\r\n margin: 0;\r\n padding: 0 0 8px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l4);\r\n list-style: none;\r\n}\r\n\r\n.sd-batch-bar-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n min-width: 0;\r\n}\r\n\r\n.sd-batch-bar-title {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-batch-bar-meta {\r\n flex: none;\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-remove {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex: none;\r\n width: 16px;\r\n height: 16px;\r\n padding: 0;\r\n border: none;\r\n border-radius: var(--dsw-radius-xs);\r\n background: 0 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-batch-bar-remove:hover {\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n";
1370
+ var ui_default = "/*\r\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\r\n *\r\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\r\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\r\n * `Toast.module.css`、`Menu.module.css`;2026-10-15 又加两张:`lib/Checkbox.module.css`、\r\n * `lib/DisclosureRow.module.css`,以及 `dsh-client-ui-conversation` 里的\r\n * `ConversationRoot.module.css` 的 `.widthHandle` 那一族——宿主自己那根可拖的宽度把手;\r\n * 2026-10-17 又加一张,也是首张来自 ui-primitives 之外的包:`dsh-client-ui-workspace` 的\r\n * `Rows.module.css` 的 `.iconButton` 那一族——会话行尾悬停排那颗按钮的基底(行尾兜底勾选开关\r\n * 要跟宿主自己的归档/置顶同形)。\r\n * 这是官方给第三方 UI 插件的处方:\r\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\r\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\r\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\r\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\r\n *\r\n * 三条改写规则:\r\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\r\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\r\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\r\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\r\n *\r\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\r\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\r\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)、Checkbox 的全部、DisclosureRow 的\r\n * 折叠头那一半(没有 `data-state` 那档状态点)、宽度把手的视觉与光标。**没抄进来的就是本插件不用的**,\r\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\r\n *\r\n * **五处不是抄本**:① `.sd-resizing`(拖动期间挂在面板根上、关掉文字选中)——宿主那份没有对应的类,\r\n * 它的把手由 JS 侧的 pointer capture 承担;② `[data-dsh-session-delete=\"trash-row\"]:focus-visible`\r\n * (清单那一行的键盘焦点描边,复用选中态那枚 `ghost-active-border`)——宿主那套表里行不是可聚焦\r\n * div,我们这一行是 `role=\"option\"` 且要吃 ↑↓,而这一代 `--dsw-focus-ring-color` 是 `transparent`;\r\n * ③ `.sd-rowaction-button` 的 `[aria-pressed='true']` 与 `:disabled` 两档(2026-10-17)——\r\n * 官方那颗 `iconButton` 只有常态与 hover,勾选开关要有\"按下\"和\"这条删不动\"两档,token 与回收站\r\n * 选中行同源(`--dsw-alias-button-ghost-active-*`);④ `.sd-batch-bar`(同日)——宿主没有\"浮条\"\r\n * 这件控件,四件 token 自拼;⑤ 宿主行的批量选中底色与兜底隐藏(同日改版)——宿主的行选中态不归\r\n * 第三方,整行可见性靠 `data-row-key` + `:has`,token 与回收站选中行同源;行首格在场时行尾兜底退场。\r\n * 整张表里就这五条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的 `text-align: start`。宿主 `DisclosureRow` 那份\r\n * 是 `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表的 `text-align:center` 会渗进里面的\r\n * 文字(真机上就是项目名整条居中,2026-10-15 截图)。它写在 `src/client/trash-panel.tsx` 组头那一行的\r\n * inline 样式上——**所以「与官方逐条比对」时本表仍然只数上面那五条**,这条不算进抄本差异。\r\n */\r\n\r\n/* ────────────────────────── Button ────────────────────────── */\r\n\r\n.sd-button {\r\n box-sizing: border-box;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 4px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n cursor: pointer;\r\n font-size: 14px;\r\n line-height: 22px;\r\n color: var(--dsw-alias-label-primary);\r\n background: transparent;\r\n padding: 0 14px;\r\n}\r\n\r\n.sd-button:disabled {\r\n cursor: not-allowed;\r\n opacity: 0.4;\r\n}\r\n\r\n.sd-button-md {\r\n height: 36px;\r\n}\r\n\r\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\r\n.sd-button-sm {\r\n height: 28px;\r\n font-size: 12px;\r\n line-height: 18px;\r\n padding: 0 10px;\r\n border-radius: var(--dsw-radius-sm);\r\n}\r\n\r\n.sd-button-primary {\r\n background: var(--dsw-alias-button-primary-fill);\r\n color: var(--dsw-alias-label-primary-foreground);\r\n}\r\n\r\n.sd-button-primary:hover:not(:disabled) {\r\n background: var(--dsw-alias-button-primary-hover);\r\n}\r\n\r\n.sd-button-ghost:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-ghost:active:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-active);\r\n}\r\n\r\n.sd-button-outline {\r\n border: 0.5px solid var(--dsw-alias-border-l3);\r\n background: transparent;\r\n}\r\n\r\n.sd-button-outline:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-icon {\r\n display: inline-flex;\r\n width: 16px;\r\n height: 16px;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* ────────────────────────── Tag ────────────────────────── */\r\n\r\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\r\n.sd-tag {\r\n display: inline-flex;\r\n align-items: center;\r\n border-radius: 999px;\r\n corner-shape: round;\r\n padding: 1px 8px;\r\n font-size: 11px;\r\n line-height: 17px;\r\n font-weight: 500;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\r\n.sd-tag-danger {\r\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n/* ────────────────────────── Modal ────────────────────────── */\r\n\r\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\r\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\r\n.sd-modal-root {\r\n pointer-events: auto;\r\n position: fixed;\r\n inset: 0;\r\n z-index: 1000;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\r\n}\r\n\r\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\r\n.sd-modal-mask {\r\n position: absolute;\r\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\r\n backdrop-filter: var(--dsw-mask-blur);\r\n}\r\n\r\n.sd-modal-mask::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n background: var(--dsw-alias-bg-mask-1);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-modal-dialog {\r\n box-sizing: border-box;\r\n position: relative;\r\n z-index: 1;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 20px;\r\n width: min(380px, 100%);\r\n padding: 0 0 24px;\r\n overflow: hidden;\r\n border: 0;\r\n border-radius: var(--dsw-radius-panel);\r\n background: var(--dsw-alias-bg-layer-2);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n@keyframes sd-modal-enter {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-modal-mask::after,\r\n .sd-modal-dialog { animation: none; }\r\n}\r\n\r\n.sd-modal-dialog:focus {\r\n outline: none;\r\n}\r\n\r\n.sd-modal-content {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n}\r\n\r\n/* 头行:标题左、关闭钮右。 */\r\n.sd-modal-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 8px;\r\n padding: 22px 14px 12px 24px;\r\n}\r\n\r\n.sd-modal-title {\r\n margin: 0;\r\n font-size: 16px;\r\n line-height: 24px;\r\n font-weight: 500;\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-modal-close {\r\n flex: none;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 28px;\r\n height: 28px;\r\n border: none;\r\n border-radius: var(--dsw-radius-sm);\r\n background: transparent;\r\n cursor: pointer;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-modal-close:hover {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-modal-body {\r\n display: flex;\r\n flex-direction: column;\r\n min-width: 0;\r\n margin-top: 20px;\r\n padding: 0 24px;\r\n}\r\n\r\n.sd-modal-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: flex-end;\r\n gap: 8px;\r\n padding: 0 24px;\r\n}\r\n\r\n/* ────────────────────────── Tooltip ────────────────────────── */\r\n\r\n.sd-tooltip-bubble {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n position: fixed;\r\n z-index: 100;\r\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\r\n `max-content` 只按文案定宽,再用半个视口收口。 */\r\n width: max-content;\r\n max-width: 50vw;\r\n padding: 3px 7px;\r\n border-radius: var(--dsw-radius-sm);\r\n background: var(--dsw-alias-tooltip-bg);\r\n color: var(--dsw-static-neutral-bluish-00);\r\n font-size: 13px;\r\n line-height: 20px;\r\n white-space: pre-line;\r\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\r\n overflow-wrap: break-word;\r\n pointer-events: none;\r\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\r\n.sd-tooltip-label { min-width: 0; }\r\n\r\n.sd-tooltip-bubble[data-side='right'] {\r\n transform: translateY(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='bottom'] {\r\n transform: translateX(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='top'] {\r\n transform: translate(-50%, -100%);\r\n}\r\n\r\n@keyframes sd-tooltip-in {\r\n from { opacity: 0; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-tooltip-bubble {\r\n animation: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── Toast ────────────────────────── */\r\n\r\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\r\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\r\n.sd-toast {\r\n position: fixed;\r\n top: 40px;\r\n left: 50%;\r\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\r\n z-index: 1100;\r\n /* 通告不截点击。 */\r\n pointer-events: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\r\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\r\n width: max-content;\r\n max-width: min(640px, calc(100vw - 48px));\r\n padding: 12px 16px;\r\n border-radius: var(--dsw-radius-lg);\r\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\r\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\r\n background: var(--dsw-alias-toast-bg);\r\n color: var(--dsw-alias-toast-label);\r\n font-size: 14px;\r\n line-height: 22px;\r\n box-shadow: var(--dsw-shadow-lv3);\r\n transform: translateX(-50%);\r\n animation:\r\n sd-toast-in 160ms ease-out,\r\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n}\r\n\r\n.sd-toast-icon {\r\n display: grid;\r\n place-items: center;\r\n flex: none;\r\n color: var(--dsw-alias-state-warn-label);\r\n}\r\n\r\n.sd-toast-icon-success {\r\n color: var(--dsw-alias-state-success-primary);\r\n}\r\n\r\n.sd-toast-text {\r\n min-width: 0;\r\n}\r\n\r\n.sd-toast-action {\r\n display: inline;\r\n padding: 0;\r\n border: 0;\r\n background: none;\r\n color: var(--dsw-static-deepseek-400);\r\n font: inherit;\r\n cursor: pointer;\r\n /* 动作文字与周围文案之间的呼吸位。 */\r\n margin-inline: 3px;\r\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\r\n pointer-events: auto;\r\n}\r\n\r\n.sd-toast-action:hover {\r\n opacity: 0.8;\r\n}\r\n\r\n@keyframes sd-toast-in {\r\n from {\r\n opacity: 0;\r\n transform: translate(-50%, -6px);\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n transform: translate(-50%, 0);\r\n }\r\n}\r\n\r\n@keyframes sd-toast-fade {\r\n to {\r\n opacity: 0;\r\n visibility: hidden;\r\n }\r\n}\r\n\r\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-toast {\r\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 菜单行 ────────────────────────── */\r\n\r\n.sd-menu-item-wrap {\r\n position: relative;\r\n}\r\n\r\n/* 菜单格:紧凑文字、图标与选中间距。 */\r\n.sd-menu-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n width: 100%;\r\n min-height: 34px;\r\n padding: 6px 8px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n background: transparent;\r\n cursor: pointer;\r\n font-size: 13px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n text-align: left;\r\n}\r\n\r\n.sd-menu-item:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\r\n 浏览器默认的 ring 再叠一层就重了。 */\r\n.sd-menu-item:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n outline: none;\r\n}\r\n\r\n.sd-menu-item:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n.sd-menu-item-icon {\r\n display: inline-flex;\r\n flex: none;\r\n width: 14px;\r\n height: 14px;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--dsw-alias-menu-icon);\r\n}\r\n\r\n.sd-menu-item-icon svg {\r\n width: 14px;\r\n height: 14px;\r\n}\r\n\r\n.sd-menu-item-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\r\n.sd-menu-item-danger {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger .sd-menu-item-icon {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n}\r\n\r\n.sd-menu-item-danger:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n outline: none;\r\n}\r\n\r\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\r\n.sd-menu-separator {\r\n height: 0.5px;\r\n margin: 3px 2px;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n/* ────────────────────────── Checkbox ────────────────────────── */\r\n/* 抄 `lib/Checkbox.module.css` 全部四条声明,逐字。勾选框本体是**原生 input** + `accent-color`,\r\n 不是自绘的圆框——回收站多选态用它(清单行左侧那一格)。 */\r\n\r\n.sd-checkbox {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-checkbox input {\r\n flex: 0 0 auto;\r\n width: 16px;\r\n height: 16px;\r\n margin: 0;\r\n accent-color: var(--dsw-alias-brand-primary);\r\n cursor: inherit;\r\n}\r\n\r\n.sd-checkbox input:focus-visible {\r\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\r\n outline-offset: 2px;\r\n}\r\n\r\n.sd-checkbox:has(input:disabled) {\r\n cursor: default;\r\n opacity: 0.5;\r\n}\r\n\r\n/* ────────────────────────── DisclosureRow(分组折叠头) ────────────────────────── */\r\n/* 抄 `lib/DisclosureRow.module.css` 的 `.root`/`.row`/`.leading`/`.title` 与那两枚 glyph 的\r\n 显隐。没抄进来的是 `StateDot`(`svg[data-state]`)那一档——它说\"这是状态标记不是文字装饰\",\r\n 我们的组头没有状态点。行高与 leading 盒走 `--dsh-content-font-delta` 那根轴,\r\n 标题字号走 secondary 档:宿主的\"设置里调正文字号\"会一起推着它走。 */\r\n\r\n.sd-disclosure {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n min-width: 0;\r\n}\r\n\r\n.sd-disclosure-row {\r\n position: relative;\r\n overflow: hidden;\r\n display: flex;\r\n align-items: center;\r\n height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n min-width: 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n transition: color 100ms ease;\r\n}\r\n\r\n.sd-disclosure-row[data-expandable] {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-row:hover {\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-disclosure-leading {\r\n position: relative;\r\n flex: none;\r\n width: calc(16px + var(--dsh-content-font-delta, 0px));\r\n height: calc(16px + var(--dsh-content-font-delta, 0px));\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n margin-right: 6px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n color: inherit;\r\n}\r\n\r\n.sd-disclosure-leading svg:not([data-state]) {\r\n width: calc(14px + var(--dsh-content-font-delta, 0px));\r\n height: calc(14px + var(--dsh-content-font-delta, 0px));\r\n}\r\n\r\nbutton.sd-disclosure-leading {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-title {\r\n flex: none;\r\n font-size: var(--dsh-content-font-size-secondary, 13px);\r\n line-height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n color: inherit;\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-disclosure-row {\r\n transition: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 宽度把手(可拖的分栏线) ────────────────────────── */\r\n/* 抄 `ConversationRoot.module.css` 的 `.widthHandle` 一族:10px 的命中区、`col-resize` 光标、\r\n 平时不画、悬停或拖动时才浮出一条 2px 的 `--dsw-alias-scrollbar-bg-l1` 短标(那条渐变里\r\n `--dsh-width-handle-pointer-y` 的兜底是 50%,宿主靠 JS 跟着指针走;我们不跟,\r\n 停在中间——少一条 pointermove 上的写字,视觉上是同一族东西)。\r\n 与宿主的差别只有位置:它绝对定位在会话栏两侧,我们这根住在两栏之间的那一格 flex 位里,\r\n 所以 `top/bottom/position` 那三样交给内联布局,这里只留视觉与光标。 */\r\n\r\n.sd-width-handle {\r\n position: relative;\r\n z-index: 0;\r\n cursor: col-resize;\r\n align-self: stretch;\r\n flex: 0 0 10px;\r\n width: 10px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n}\r\n\r\n.sd-width-handle::after {\r\n content: \"\";\r\n background: linear-gradient(\r\n to bottom,\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)\r\n );\r\n opacity: 0;\r\n pointer-events: none;\r\n border-radius: 2px;\r\n width: 2px;\r\n position: absolute;\r\n top: 0;\r\n bottom: 0;\r\n left: 4px;\r\n}\r\n\r\n.sd-width-handle:hover::after,\r\n.sd-width-handle[data-dragging]::after {\r\n opacity: 1;\r\n}\r\n\r\n.sd-width-handle:focus-visible {\r\n outline: none;\r\n}\r\n\r\n.sd-width-handle:focus-visible::after {\r\n opacity: 1;\r\n}\r\n\r\n/* 本插件自造的第一条(不是抄本,见文件头点名):拖动期间整块面板别把文字选上了。 */\r\n.sd-resizing {\r\n user-select: none;\r\n cursor: col-resize;\r\n}\r\n\r\n/* 本插件自造的第二条(不是抄本,见文件头点名):清单行是 `role=\"option\"` 的可聚焦 div,而这一代\r\n `--dsw-focus-ring-color` 逐字是 `transparent`(2026-10-15 实测)——不自己描一条,键盘走\r\n ↑↓ 选会话时就没有任何焦点可见性。描边**复用选中态那枚** `ghost-active-border`,不新造颜色;\r\n 0.5px 与 `outline-offset: 1px` 是行的描边宽度与一档外移,不动布局(outline 不进滚动区)。 */\r\n[data-dsh-session-delete=\"trash-row\"]:focus-visible {\r\n outline: 0.5px solid var(--dsw-alias-button-ghost-active-border);\r\n outline-offset: 1px;\r\n}\r\n\r\n/* 会话行尾悬停排那颗**兜底**勾选开关(状态点行 / 归档行;行首主勾选是原生 Checkbox)。\r\n 基底**逐字抄** `dsh-client-ui-workspace` 的 `Rows.module.css` 的 `.iconButton` / `.iconButton:hover`,\r\n 只改类名——兜底开关要跟同排宿主自己的归档/置顶同形,尺寸与色档都不自己发明。 */\r\n.sd-rowaction-button {\r\n border-radius: var(--dsw-radius-xs);\r\n cursor: pointer;\r\n width: 16px;\r\n height: 16px;\r\n color: var(--dsw-alias-label-tertiary);\r\n background: 0 0;\r\n border: none;\r\n flex: none;\r\n justify-content: center;\r\n align-items: center;\r\n padding: 0;\r\n display: inline-flex;\r\n}\r\n.sd-rowaction-button:hover {\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n/* 本插件自造的第三条(不是抄本,见文件头点名):官方那颗只有常态与 hover,勾选开关还要有\r\n 「已按下」与「这条删不动」两档。按下档**复用回收站选中行那两枚** `ghost-active-*`,不新造颜色;\r\n 禁用档走 opacity。 */\r\n.sd-rowaction-button[aria-pressed='true'] {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n.sd-rowaction-button:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n/* 行首 16px 格里的勾选框(2026-10-17 改版,**只在多选模式内渲染**)。格本身由宿主居中(16×20),\r\n 这里只把包装层压成同样大小;勾选框本体是回收站多选同款原生 input(`.sd-checkbox`,见上)。 */\r\n.sd-batch-pick-leading {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* 本插件自造的第⑤条(不是抄本,见文件头点名):宿主行自己的选中态只归它自己,批量选择要有整行\r\n 底色;行首格在场时行尾兜底要退场(两条入口不许同屏)。两条都只认宿主行已有的\r\n `data-row-key`;将来这个属性改名,最差是底色不亮 / 兜底多露一颗,勾选本身仍在。 */\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-checked=\"true\"]) {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 0.5px var(--dsw-alias-button-ghost-active-border);\r\n}\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-seat=\"leading\"]) .sd-batch-pick-action {\r\n display: none;\r\n}\r\n\r\n/* 本插件自造的第四条(不是抄本,见文件头点名):批量删除的浮条。宿主没有\"浮条\"这件控件,形状照\r\n 本插件自己的浮层口径来——固定底部居中,表面/描边/圆角/高程四件全走 token(与单删那张目标卡同源)。\r\n **层级必须低于确认框(`.sd-modal-root` 1000)**:2026-10-17 真机发现浮条在 1100 时会盖住确认框、\r\n 还能抢它的按钮;撤销横幅与 Tooltip 是瞬时通告、保持 1100。列方向:清单在上、操作行在下;\r\n 清单限高可滚,默认收起(见 `batch-bar.tsx`)。**宽度按内容展开**(`max-content`,最多视口减 48):\r\n 只写 `left:50% + width:auto` 时 shrink-to-fit 只拿左半屏,新增「全选/取消全选」后会把「退出多选」\r\n 挤到第二行;窄到 max-width 还放不下才由 `.sd-batch-bar-main` 的 `flex-wrap` 兜底。 */\r\n.sd-batch-bar {\r\n position: fixed;\r\n bottom: 24px;\r\n left: 50%;\r\n transform: translateX(-50%);\r\n z-index: 900;\r\n width: max-content;\r\n max-width: calc(100vw - 48px);\r\n display: flex;\r\n flex-direction: column;\r\n align-items: stretch;\r\n gap: 8px;\r\n padding: 8px 12px;\r\n background: var(--dsw-alias-bg-layer-2);\r\n border: 0.5px solid var(--dsw-alias-border-l4);\r\n border-radius: var(--dsw-radius-lg);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n font-size: 12px;\r\n line-height: 18px;\r\n}\r\n\r\n.sd-batch-bar-main {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n.sd-batch-bar-hint {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list-head {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n/* 汇总的第二句(含 K 个子会话)比条数轻一档,和列表行里的 chip 同级。 */\r\n.sd-batch-bar-list-head span + span {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n max-height: 240px;\r\n overflow: auto;\r\n margin: 0;\r\n padding: 0 0 8px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l4);\r\n list-style: none;\r\n}\r\n\r\n.sd-batch-bar-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n min-width: 0;\r\n}\r\n\r\n.sd-batch-bar-title {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-batch-bar-meta {\r\n flex: none;\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-remove {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex: none;\r\n width: 16px;\r\n height: 16px;\r\n padding: 0;\r\n border: none;\r\n border-radius: var(--dsw-radius-xs);\r\n background: 0 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-batch-bar-remove:hover {\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n";
889
1371
  //#endregion
890
1372
  //#region src/client/ui/markdown.css?dsh-css-text
891
- var markdown_default = "/**\r\n * 回收站详情里**会话正文**的排版(2026-10-15 真机反馈后新增)。\r\n *\r\n * 为什么单独一个文件而不并进 `ui.css`:`ui.css` 是\"照官方 primitives 逐字抄来的那一份表\",\r\n * 那条 fidelity 核对(§8 门 4)要的是它与官方明文**逐属性零差异**,只许文件头点名的几类例外\r\n * (类名前缀、`@keyframes` 改名,外加它点名的两处自造);条数不钉在这里,钉了就会漂成假基线。这一份抄的是\r\n * **另一个来源**(宿主 markdown 那一张表),混进去就把那份核对弄脏了。\r\n *\r\n * **来源(逐字抄,只改选择器与下面点名的三处)**:\r\n * `app.asar` 里 `@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css`\r\n * (`.markdown` 那一支;`.compact` 一支我们不用)+ `lib/markdown/CodeBlock.module.css` 的 `pre`。\r\n * token 名一个没换:`--dsw-font-markdown-*` / `--dsw-alias-markdown-*` / `--dsh-content-font-*`\r\n * 全是宿主会话区自己吃的那组,所以宿主调内容字号时,这里跟着一起动。\r\n *\r\n * 三处**有意**的差异,每条都是因为我们的 DOM 长不出宿主那个节点:\r\n * ① 宿主给表包一层 `.tableScroll`(div)再横向滚动;react-markdown 不产这层 ⇒ 滚动挂在 `table`\r\n * 自己身上(`display:block` + 匿名表盒)。这条得真机量过才算成立。\r\n * ② 宿主的代码卡有 banner 行(语言名 + 复制),四个圆角被拆成\"上圆角在 banner、下圆角在 pre\";\r\n * 我们没有 banner ⇒ 圆角整块给 `pre`。\r\n * ③ 宿主的链接可点(点开外链/文件),我们不碰宿主导航,链接渲成不可点的文本 ⇒ 保留链接色与字重,\r\n * **去掉** hover 的下划线(那是\"点我\"的邀请)。\r\n *\r\n * 抄过来但用不上的选择器已删:`.fileLink` / `.fileMention` / `.linkIcon` / `.image` / `.imageAlt`\r\n * (图片渲成 `[图片 · alt]`)/ `.katex-display` / `:global(.md-code-block)` / `.shiki`(高亮由宿主\r\n * 的 highlighter 提供,我们没有)。\r\n */\r\n.sd-md {\r\n min-width: 0;\r\n overflow-wrap: anywhere;\r\n font: var(--dsw-font-markdown-base);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-md strong {\r\n font-weight: 600;\r\n}\r\n\r\n.sd-md h1 {\r\n font: var(--dsw-font-markdown-h1);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h2 {\r\n font: var(--dsw-font-markdown-h2);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h3 {\r\n font: var(--dsw-font-markdown-h3);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h4 {\r\n font: var(--dsw-font-markdown-h4);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h5, h6) {\r\n font: var(--dsw-font-markdown-base-strong);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) strong {\r\n font-weight: inherit;\r\n}\r\n\r\n.sd-md p {\r\n margin: 16px 0;\r\n}\r\n\r\n/* Tighten h4–h6 against a following list (design: 8px gap). */\r\n.sd-md :where(h4, h5, h6) + :where(ul, ol) {\r\n margin-top: 8px;\r\n}\r\n\r\n.sd-md :where(h4, h5, h6):has(+ :where(ul, ol)) {\r\n margin-bottom: 8px;\r\n}\r\n\r\n/* 见文件头差异③:颜色与字重照抄,hover 的 dotted 下划线不抄——这里没有可点的东西。 */\r\n.sd-md-link {\r\n color: var(--dsw-alias-link);\r\n font-weight: 500;\r\n}\r\n\r\n.sd-md :where(ul, ol) {\r\n margin: 16px 0;\r\n padding-left: 18px;\r\n}\r\n\r\n.sd-md li:not(:first-child) {\r\n margin-top: 6px;\r\n}\r\n\r\n.sd-md li > :where(ul, ol) {\r\n margin-top: 4px;\r\n}\r\n\r\n.sd-md li::marker {\r\n line-height: 24px;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */\r\n.sd-md :where(ul, ol) ol {\r\n list-style-position: inside;\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md :where(ul, ol) ol li p {\r\n display: inline;\r\n}\r\n\r\n.sd-md li > p {\r\n margin: 8px 0;\r\n}\r\n\r\n.sd-md li > *:first-child {\r\n margin-top: 0;\r\n}\r\n\r\n.sd-md li > *:last-child {\r\n margin-bottom: 0;\r\n}\r\n\r\n.sd-md hr {\r\n display: block;\r\n border: none;\r\n height: 0.5px;\r\n margin: 32px 0;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n.sd-md blockquote {\r\n border-left: 2px solid var(--dsw-alias-label-caption);\r\n margin: 16px 0 0;\r\n padding-left: 14px;\r\n}\r\n\r\n/* 代码卡:宿主的卡片由 `.block`(底色+圆角)与其内 `pre`(字号+内边距)两层拼成。\r\n 我们只有 `pre` 这一层,所以两层的声明合成在这里——见文件头差异②。 */\r\n.sd-md pre {\r\n font: var(--dsw-font-markdown-code-block);\r\n margin: 16px 0;\r\n padding: 16px;\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-markdown-code-block);\r\n border-radius: var(--dsw-radius-lg);\r\n overflow-x: auto;\r\n white-space: pre-wrap;\r\n word-break: break-all;\r\n}\r\n\r\n.sd-md pre code {\r\n font: inherit;\r\n background: none;\r\n padding: 0;\r\n}\r\n\r\n.sd-md :not(pre) > code {\r\n display: inline-flex;\r\n align-items: center;\r\n box-sizing: border-box;\r\n font: var(--dsw-font-markdown-code);\r\n font-family: var(--ds-font-family-code);\r\n font-size: 0.875em !important;\r\n background-color: var(--dsw-alias-markdown-inline-code);\r\n border: 0.5px solid var(--dsw-alias-border-l1);\r\n border-radius: var(--dsw-radius-sm);\r\n padding: 0 5px;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) code {\r\n font: inherit;\r\n font-family: var(--ds-font-family-code);\r\n}\r\n\r\n.sd-md input[type='checkbox'] {\r\n margin: 0 8px 0 0;\r\n accent-color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* 见文件头差异①:宿主是 `.tableScroll > table`,我们没有那层 div。 */\r\n.sd-md table {\r\n display: block;\r\n box-sizing: border-box;\r\n max-width: 100%;\r\n overflow-x: auto;\r\n overscroll-behavior-x: contain;\r\n border-collapse: collapse;\r\n width: max-content;\r\n}\r\n\r\n.sd-md th {\r\n text-align: start;\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l3);\r\n border-top: none;\r\n font: var(--dsw-font-markdown-table-head);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md td {\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l2);\r\n font: var(--dsw-font-markdown-table);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md th:first-child,\r\n.sd-md td:first-child {\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md td:last-child {\r\n padding-right: 0;\r\n}\r\n\r\n.sd-md table code {\r\n font-size: 11px;\r\n}\r\n\r\n.sd-md > *:first-child,\r\n.sd-md p:first-child {\r\n margin-top: 0 !important;\r\n}\r\n\r\n.sd-md > *:last-child,\r\n.sd-md p:last-child {\r\n margin-bottom: 0 !important;\r\n}\r\n\r\n/* 图片在回收站里读不到(路径属于会话当时的 cwd),渲成一句说明,字色用宿主的 tertiary。 */\r\n.sd-md-image {\r\n color: var(--dsw-alias-label-tertiary);\r\n font-style: italic;\r\n}\r\n";
1373
+ var markdown_default = "/**\r\n * 回收站详情里**会话正文**的排版(2026-10-15 真机反馈后新增)。\r\n *\r\n * 为什么单独一个文件而不并进 `ui.css`:`ui.css` 是\"照官方 primitives 逐字抄来的那一份表\",\r\n * 那条 fidelity 核对(`docs/development.md` 的界面保真复核门)要的是它与官方明文**逐属性零差异**,只许文件头点名的几类例外\r\n * (类名前缀、`@keyframes` 改名,外加它点名的自造与已声明的差别);条数不钉在这里,钉了就会漂成假基线。这一份抄的是\r\n * **另一个来源**(宿主 markdown 那一张表),混进去就把那份核对弄脏了。\r\n *\r\n * **来源(逐字抄,只改选择器与下面点名的三处)**:\r\n * `app.asar` 里 `@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css`\r\n * (`.markdown` 那一支;`.compact` 一支我们不用)+ `lib/markdown/CodeBlock.module.css` 的 `pre`。\r\n * token 名一个没换:`--dsw-font-markdown-*` / `--dsw-alias-markdown-*` / `--dsh-content-font-*`\r\n * 全是宿主会话区自己吃的那组,所以宿主调内容字号时,这里跟着一起动。\r\n *\r\n * 三处**有意**的差异,每条都是因为我们的 DOM 长不出宿主那个节点:\r\n * ① 宿主给表包一层 `.tableScroll`(div)再横向滚动;react-markdown 不产这层 ⇒ 滚动挂在 `table`\r\n * 自己身上(`display:block` + 匿名表盒)。这条得真机量过才算成立。\r\n * ② 宿主的代码卡有 banner 行(语言名 + 复制),四个圆角被拆成\"上圆角在 banner、下圆角在 pre\";\r\n * 我们没有 banner ⇒ 圆角整块给 `pre`。\r\n * ③ 宿主的链接可点(点开外链/文件),我们不碰宿主导航,链接渲成不可点的文本 ⇒ 保留链接色与字重,\r\n * **去掉** hover 的下划线(那是\"点我\"的邀请)。\r\n *\r\n * 抄过来但用不上的选择器已删:`.fileLink` / `.fileMention` / `.linkIcon` / `.image` / `.imageAlt`\r\n * (图片渲成 `[图片 · alt]`)/ `.katex-display` / `:global(.md-code-block)` / `.shiki`(高亮由宿主\r\n * 的 highlighter 提供,我们没有)。\r\n */\r\n.sd-md {\r\n min-width: 0;\r\n overflow-wrap: anywhere;\r\n font: var(--dsw-font-markdown-base);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-md strong {\r\n font-weight: 600;\r\n}\r\n\r\n.sd-md h1 {\r\n font: var(--dsw-font-markdown-h1);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h2 {\r\n font: var(--dsw-font-markdown-h2);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h3 {\r\n font: var(--dsw-font-markdown-h3);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h4 {\r\n font: var(--dsw-font-markdown-h4);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h5, h6) {\r\n font: var(--dsw-font-markdown-base-strong);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) strong {\r\n font-weight: inherit;\r\n}\r\n\r\n.sd-md p {\r\n margin: 16px 0;\r\n}\r\n\r\n/* Tighten h4–h6 against a following list (design: 8px gap). */\r\n.sd-md :where(h4, h5, h6) + :where(ul, ol) {\r\n margin-top: 8px;\r\n}\r\n\r\n.sd-md :where(h4, h5, h6):has(+ :where(ul, ol)) {\r\n margin-bottom: 8px;\r\n}\r\n\r\n/* 见文件头差异③:颜色与字重照抄,hover 的 dotted 下划线不抄——这里没有可点的东西。 */\r\n.sd-md-link {\r\n color: var(--dsw-alias-link);\r\n font-weight: 500;\r\n}\r\n\r\n.sd-md :where(ul, ol) {\r\n margin: 16px 0;\r\n padding-left: 18px;\r\n}\r\n\r\n.sd-md li:not(:first-child) {\r\n margin-top: 6px;\r\n}\r\n\r\n.sd-md li > :where(ul, ol) {\r\n margin-top: 4px;\r\n}\r\n\r\n.sd-md li::marker {\r\n line-height: 24px;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */\r\n.sd-md :where(ul, ol) ol {\r\n list-style-position: inside;\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md :where(ul, ol) ol li p {\r\n display: inline;\r\n}\r\n\r\n.sd-md li > p {\r\n margin: 8px 0;\r\n}\r\n\r\n.sd-md li > *:first-child {\r\n margin-top: 0;\r\n}\r\n\r\n.sd-md li > *:last-child {\r\n margin-bottom: 0;\r\n}\r\n\r\n.sd-md hr {\r\n display: block;\r\n border: none;\r\n height: 0.5px;\r\n margin: 32px 0;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n.sd-md blockquote {\r\n border-left: 2px solid var(--dsw-alias-label-caption);\r\n margin: 16px 0 0;\r\n padding-left: 14px;\r\n}\r\n\r\n/* 代码卡:宿主的卡片由 `.block`(底色+圆角)与其内 `pre`(字号+内边距)两层拼成。\r\n 我们只有 `pre` 这一层,所以两层的声明合成在这里——见文件头差异②。 */\r\n.sd-md pre {\r\n font: var(--dsw-font-markdown-code-block);\r\n margin: 16px 0;\r\n padding: 16px;\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-markdown-code-block);\r\n border-radius: var(--dsw-radius-lg);\r\n overflow-x: auto;\r\n white-space: pre-wrap;\r\n word-break: break-all;\r\n}\r\n\r\n.sd-md pre code {\r\n font: inherit;\r\n background: none;\r\n padding: 0;\r\n}\r\n\r\n.sd-md :not(pre) > code {\r\n display: inline-flex;\r\n align-items: center;\r\n box-sizing: border-box;\r\n font: var(--dsw-font-markdown-code);\r\n font-family: var(--ds-font-family-code);\r\n font-size: 0.875em !important;\r\n background-color: var(--dsw-alias-markdown-inline-code);\r\n border: 0.5px solid var(--dsw-alias-border-l1);\r\n border-radius: var(--dsw-radius-sm);\r\n padding: 0 5px;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) code {\r\n font: inherit;\r\n font-family: var(--ds-font-family-code);\r\n}\r\n\r\n.sd-md input[type='checkbox'] {\r\n margin: 0 8px 0 0;\r\n accent-color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* 见文件头差异①:宿主是 `.tableScroll > table`,我们没有那层 div。 */\r\n.sd-md table {\r\n display: block;\r\n box-sizing: border-box;\r\n max-width: 100%;\r\n overflow-x: auto;\r\n overscroll-behavior-x: contain;\r\n border-collapse: collapse;\r\n width: max-content;\r\n}\r\n\r\n.sd-md th {\r\n text-align: start;\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l3);\r\n border-top: none;\r\n font: var(--dsw-font-markdown-table-head);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md td {\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l2);\r\n font: var(--dsw-font-markdown-table);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md th:first-child,\r\n.sd-md td:first-child {\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md td:last-child {\r\n padding-right: 0;\r\n}\r\n\r\n.sd-md table code {\r\n font-size: 11px;\r\n}\r\n\r\n.sd-md > *:first-child,\r\n.sd-md p:first-child {\r\n margin-top: 0 !important;\r\n}\r\n\r\n.sd-md > *:last-child,\r\n.sd-md p:last-child {\r\n margin-bottom: 0 !important;\r\n}\r\n\r\n/* 图片在回收站里读不到(路径属于会话当时的 cwd),渲成一句说明,字色用宿主的 tertiary。 */\r\n.sd-md-image {\r\n color: var(--dsw-alias-label-tertiary);\r\n font-style: italic;\r\n}\r\n";
892
1374
  //#endregion
893
1375
  //#region src/client/ui/styles.ts
894
1376
  /**
@@ -1075,199 +1557,45 @@ window.__ModuleLoader__.load({
1075
1557
  //#endregion
1076
1558
  //#region src/client/batch-select.tsx
1077
1559
  /**
1078
- * 宿主列表批量删除的选择面(第十三批①):多选模式 + 选中集 + 行首勾选 + 行尾兜底 + 整行点击守卫。
1560
+ * 宿主列表批量删除的**控件面**(2026-10-17,2026-10-20 拆出状态与行适配):行首勾选框、行尾兜底开关,
1561
+ * 以及确认框/浮条读的那一份计数问句。
1079
1562
  *
1080
1563
  * 入口在每行的「⋯」菜单里(`batch-menu-item.tsx`):**按下才进入多选模式**,模式内才渲染勾选框、
1081
- * 才把整行点击变成"勾/取消";退出模式清空名单。选中集是**模块级现场**(本仓既有的客户端模式,见
1082
- * `delete-confirm.tsx` / `undo-toast.tsx`):主勾选框住在每行的 `sidebar.session.row.leading` 槽位里,
1083
- * 行尾兜底住在 `sidebar.workspaces.session.row.action`,浮条与批量确认框住在 `shell.overlay` 上——
1084
- * 四处隔着槽位边界,只有模块级 store 能让他们看见同一份名单。状态源用 React 自带的
1085
- * `useSyncExternalStore`,不为此引新的状态库。
1564
+ * 才把整行点击变成"勾/取消";退出模式清空名单。选中集只住 `batch-selection-store.ts`(模式 + 名单一份
1565
+ * 合并快照);"哪些行现在可见、点行算不算选中"只住 `host-rows.ts`。四处隔着槽位边界,只有模块级
1566
+ * store 能让他们看见同一份名单;本文件不再持有任何现场。
1086
1567
  *
1087
1568
  * **为什么主勾选在行首**:宿主对行尾 `.rowActions` 是 `display:none`,只有 `row:hover` / 菜单打开
1088
1569
  * 才出现(app.asar 0.2.0-rc.2 的 Rows 渲染);行首 16px 格在无状态点时是空的,模式内可见。
1089
1570
  * 宿主在「有状态点(运行中/待确认/未读完成)」与归档行不渲染行首格 ⇒ 这些行走行尾悬停兜底,
1090
- * 但模式内**点整行**也能选(`useSelectionRowClickGuard`),所以不依赖那颗小框。
1571
+ * 但模式内**点整行**也能选(`host-rows.ts` 的 `useSelectionRowClickGuard`),所以不依赖那颗小框。
1091
1572
  * 两条勾选不会同时出现(`ui.css` 的 `:has` 兜底隐藏规则,靠宿主行的 `data-row-key`)。
1092
- *
1093
- * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id),**顺序 = 勾选顺序**:
1094
- * 宿主列表的 DOM 序不归我们读,`/apply` 那条「保持输入顺序」吃到的因此就是勾选序。
1095
1573
  */
1096
- let picks = [];
1097
- /** 多选模式:入口在每一行的「⋯」菜单里;只有模式内才渲染勾选框、才吃整行点击。 */
1098
- let selecting = false;
1099
- const listeners$4 = /* @__PURE__ */ new Set();
1100
- const emit$3 = () => {
1101
- for (const listener of listeners$4) listener();
1102
- };
1103
- function getPicks() {
1104
- return picks;
1105
- }
1106
- function isSelecting() {
1107
- return selecting;
1108
- }
1109
- /** 「⋯ → 多选」按下时进入;重复进入是幂等的。 */
1110
- function enterSelection() {
1111
- if (selecting) return;
1112
- selecting = true;
1113
- emit$3();
1114
- }
1115
- /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
1116
- function exitSelection() {
1117
- if (!selecting && picks.length === 0) return;
1118
- selecting = false;
1119
- picks = [];
1120
- emit$3();
1121
- }
1122
- function useSelecting() {
1123
- return (0, react.useSyncExternalStore)(subscribe$3, isSelecting, isSelecting);
1124
- }
1125
- /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
1126
- function togglePick(item) {
1127
- picks = picks.findIndex((candidate) => candidate.sessionId === item.sessionId) === -1 ? [...picks, item] : picks.filter((candidate) => candidate.sessionId !== item.sessionId);
1128
- emit$3();
1129
- }
1130
- function clearPicks() {
1131
- if (picks.length === 0) return;
1132
- picks = [];
1133
- emit$3();
1134
- }
1135
1574
  /**
1136
- * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
1137
- * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
1138
- * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
1575
+ * 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
1576
+ *
1577
+ * **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
1578
+ * 缺一条就是假数;「不许拿 0 冒充没有」。
1139
1579
  */
1140
- function visibleSelectableRows() {
1141
- const rows = [];
1142
- const seen = /* @__PURE__ */ new Set();
1143
- for (const row of document.querySelectorAll("[data-row-key^=\"session:\"]")) {
1144
- const key = row.getAttribute("data-row-key") ?? "";
1145
- if (!key.startsWith("session:")) continue;
1146
- const sessionId = key.slice(8);
1147
- if (sessionId === "" || seen.has(sessionId)) continue;
1148
- const info = rowInfo.get(sessionId);
1149
- if (info === void 0 || info.live) continue;
1150
- seen.add(sessionId);
1151
- rows.push({
1152
- sessionId,
1153
- title: info.title
1154
- });
1155
- }
1156
- return rows;
1157
- }
1158
- /** 全选:把当前可见可选行里还没选的按 DOM 序追加;原有选中保持在前。 */
1159
- function selectAllVisible() {
1160
- const chosen = new Set(picks.map((item) => item.sessionId));
1161
- const additions = visibleSelectableRows().filter((row) => !chosen.has(row.sessionId));
1162
- if (additions.length === 0) return;
1163
- picks = [...picks, ...additions];
1164
- emit$3();
1580
+ async function fetchCounts(deps, ids) {
1581
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
1582
+ const outcome = await callRemote(() => deps.applyOutcomes("count", ids));
1583
+ if (!outcome.ok) return null;
1584
+ const receipt = readReceipt("count", ids, outcome.value);
1585
+ if (receipt.verdicts.some((verdict) => verdict.kind !== "ok")) return null;
1586
+ return new Map(receipt.verdicts.map((verdict) => [verdict.id, verdict.kind === "ok" ? verdict.value : 0]));
1165
1587
  }
1166
1588
  /**
1167
- * 同一个切换口该显示「全选」还是「取消全选」:
1168
- * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
1169
- * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
1170
- * - 其余(含 0 条)⇒ 全选。
1171
- */
1172
- function allVisibleSelected() {
1173
- if (picks.length === 0) return false;
1174
- const rows = visibleSelectableRows();
1175
- if (rows.length === 0) return true;
1176
- const chosen = new Set(picks.map((item) => item.sessionId));
1177
- return rows.every((row) => chosen.has(row.sessionId));
1178
- }
1179
- /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
1180
- function shrinkPicksToFailures(failedSessionIds) {
1181
- const keep = new Set(failedSessionIds);
1182
- picks = picks.filter((candidate) => keep.has(candidate.sessionId));
1183
- emit$3();
1184
- }
1185
- function subscribe$3(listener) {
1186
- listeners$4.add(listener);
1187
- return () => {
1188
- listeners$4.delete(listener);
1189
- };
1190
- }
1191
- function usePicks() {
1192
- return (0, react.useSyncExternalStore)(subscribe$3, getPicks, getPicks);
1193
- }
1194
- /**
1195
- * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
1196
- * (它对每个非空行都渲染,哪怕 CSS 把整排藏起来)在 effect 里把这两件登记进这张表。
1197
- * 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
1198
- */
1199
- const rowInfo = /* @__PURE__ */ new Map();
1200
- /** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
1201
- const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
1202
- /**
1203
- * 多选态内的整行点击守卫(document 捕获阶段)。
1204
- *
1205
- * 为什么必须在这里拦:宿主的会话行自己带 `onClick = open(sessionId)`,第三方没有官方的
1206
- * "别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
1207
- * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
1208
- * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
1209
- * 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
1210
- * 勾选框本身仍在。
1211
- */
1212
- function useSelectionRowClickGuard() {
1213
- const selectingNow = useSelecting();
1214
- (0, react.useEffect)(() => {
1215
- if (!selectingNow) return;
1216
- const handle = (event) => {
1217
- if (!(event.target instanceof Element)) return;
1218
- const row = event.target.closest("[data-row-key^=\"session:\"]");
1219
- if (row === null) return;
1220
- if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
1221
- event.preventDefault();
1222
- event.stopPropagation();
1223
- if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
1224
- const key = row.getAttribute("data-row-key") ?? "";
1225
- if (!key.startsWith("session:")) return;
1226
- const sessionId = key.slice(8);
1227
- if (sessionId === "") return;
1228
- const info = rowInfo.get(sessionId);
1229
- if (info?.live === true) return;
1230
- togglePick({
1231
- sessionId,
1232
- title: info?.title ?? ""
1233
- });
1234
- };
1235
- document.addEventListener("click", handle, true);
1236
- document.addEventListener("dblclick", handle, true);
1237
- return () => {
1238
- document.removeEventListener("click", handle, true);
1239
- document.removeEventListener("dblclick", handle, true);
1240
- };
1241
- }, [selectingNow]);
1242
- }
1243
- /**
1244
- * 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
1245
- *
1246
- * **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
1247
- * 缺一条就是假数;「不许拿 0 冒充没有」(10-15 稿 §10)。
1248
- */
1249
- async function fetchCounts(deps, ids) {
1250
- if (ids.length === 0) return /* @__PURE__ */ new Map();
1251
- const outcome = await callRemote(() => deps.applyOutcomes("count", ids));
1252
- if (!outcome.ok) return null;
1253
- const counts = /* @__PURE__ */ new Map();
1254
- for (const item of outcome.value) {
1255
- if (item.action !== "count" || !item.ok) return null;
1256
- counts.set(item.id, item.value);
1257
- }
1258
- return counts.size === ids.length ? counts : null;
1259
- }
1260
- /**
1261
- * 行首 16px 格里的主勾选框:**只在多选模式内渲染**(模式入口在「⋯ → 多选」,不常驻)。
1262
- *
1263
- * 用的是回收站多选同款原生 `Checkbox`:它自己会 `stopPropagation`,所以点框不会顺带打开会话
1264
- * (行首格在宿主行那条 `onClick` 里面)。宿主对「有状态点」和归档行不渲染这一格,
1265
- * 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
1589
+ * 行首 16px 格里的主勾选框:**只在多选模式内渲染**(模式入口在「⋯ → 多选」,不常驻)。
1590
+ *
1591
+ * 用的是回收站多选同款原生 `Checkbox`:它自己会 `stopPropagation`,所以点框不会顺带打开会话
1592
+ * (行首格在宿主行那条 `onClick` 里面)。宿主对「有状态点」和归档行不渲染这一格,
1593
+ * 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
1266
1594
  */
1267
1595
  function BatchPickCheckbox({ sessionId, title, t = zhTranslate }) {
1268
- const selectingNow = useSelecting();
1269
- const on = usePicks().some((candidate) => candidate.sessionId === sessionId);
1270
- if (!selectingNow) return null;
1596
+ const { selecting, picks } = useSelection();
1597
+ const on = picks.some((candidate) => candidate.sessionId === sessionId);
1598
+ if (!selecting) return null;
1271
1599
  const name = title === "" ? t("common.untitled") : title;
1272
1600
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1273
1601
  className: "sd-batch-pick-leading",
@@ -1292,10 +1620,10 @@ window.__ModuleLoader__.load({
1292
1620
  * 自己 `stopPropagation`。行首那颗与这颗共用同一份选中集和同一句 aria 名字;模式内点整行也能选。
1293
1621
  */
1294
1622
  function BatchPickButton({ sessionId, title, live = false, t = zhTranslate }) {
1295
- const selectingNow = useSelecting();
1296
- const on = usePicks().some((candidate) => candidate.sessionId === sessionId);
1623
+ const { selecting, picks } = useSelection();
1624
+ const on = picks.some((candidate) => candidate.sessionId === sessionId);
1297
1625
  (0, react.useEffect)(() => {
1298
- rowInfo.set(sessionId, {
1626
+ registerHostRowInfo(sessionId, {
1299
1627
  title,
1300
1628
  live
1301
1629
  });
@@ -1304,7 +1632,7 @@ window.__ModuleLoader__.load({
1304
1632
  title,
1305
1633
  live
1306
1634
  ]);
1307
- if (!selectingNow) return null;
1635
+ if (!selecting) return null;
1308
1636
  const name = title === "" ? t("common.untitled") : title;
1309
1637
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tooltip, {
1310
1638
  label: t("menu.live"),
@@ -1332,41 +1660,30 @@ window.__ModuleLoader__.load({
1332
1660
  });
1333
1661
  }
1334
1662
  //#endregion
1335
- //#region src/client/trash-store.ts
1663
+ //#region src/client/busy-gate.ts
1336
1664
  /**
1337
- * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
1338
- *
1339
- * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
1340
- * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1341
- * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
1342
- * 同理。
1665
+ * 「这一趟还算忙」的一枚闸——面板与两个确认框共用的一份实现(各面各持一枚实例)。
1343
1666
  *
1344
- * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场 +
1345
- * `useSyncExternalStore`**,不为此引状态库、也不引入上下文——三样东西都挂在 `shell.overlay`/`main`
1346
- * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1667
+ * 为什么收回一处:三个面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有一条:
1668
+ * 任务落地或抛错都把 busy 放回 false;不做引用计数——同一实例同一时刻只跑一趟写动作,多一层计数
1669
+ * 只会把「这一趟什么时候算完」变成第二个判据。
1347
1670
  *
1348
- * 语义只有"变过"这一件事:一个单调递增的修订号。面板把它写进 effect 的依赖里,戳一次重读一次;
1349
- * 不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
1671
+ * 边界:闸只拥有 busy 的生命周期,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
1350
1672
  */
1351
- let revision = 0;
1352
- const listeners$3 = /* @__PURE__ */ new Set();
1353
- /** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
1354
- function invalidateTrash() {
1355
- revision += 1;
1356
- for (const listener of listeners$3) listener();
1357
- }
1358
- function getTrashRevision() {
1359
- return revision;
1360
- }
1361
- function subscribeTrash(listener) {
1362
- listeners$3.add(listener);
1363
- return () => {
1364
- listeners$3.delete(listener);
1365
- };
1366
- }
1367
- /** 供面板订阅。 */
1368
- function useTrashRevision() {
1369
- return (0, react.useSyncExternalStore)(subscribeTrash, getTrashRevision, getTrashRevision);
1673
+ function useBusyGate() {
1674
+ const [busy, setBusy] = (0, react.useState)(false);
1675
+ const around = (0, react.useCallback)(async (task) => {
1676
+ setBusy(true);
1677
+ try {
1678
+ return await task();
1679
+ } finally {
1680
+ setBusy(false);
1681
+ }
1682
+ }, []);
1683
+ return (0, react.useMemo)(() => ({
1684
+ busy,
1685
+ around
1686
+ }), [busy, around]);
1370
1687
  }
1371
1688
  //#endregion
1372
1689
  //#region src/client/ui/Button.tsx
@@ -1543,6 +1860,32 @@ window.__ModuleLoader__.load({
1543
1860
  });
1544
1861
  }
1545
1862
  //#endregion
1863
+ //#region src/client/trash-store.ts
1864
+ /**
1865
+ * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
1866
+ *
1867
+ * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(refreshAll)`),而删除发生在
1868
+ * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1869
+ * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
1870
+ * 同理。
1871
+ *
1872
+ * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场**,
1873
+ * 订阅/通告那套只住在 `store.ts` 的 `createStore` 里——这些现场都挂在 `shell.overlay`/`main`
1874
+ * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1875
+ *
1876
+ * 语义只有"变过"这一件事:一个递增的修订号(测试收场时归零)。面板把它写进 effect 的依赖里,
1877
+ * 戳一次重读一次;不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
1878
+ */
1879
+ const revisionStore = createStore(0);
1880
+ /** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
1881
+ function invalidateTrash() {
1882
+ revisionStore.update((revision) => revision + 1);
1883
+ }
1884
+ /** 供面板订阅。 */
1885
+ function useTrashRevision() {
1886
+ return revisionStore.use();
1887
+ }
1888
+ //#endregion
1546
1889
  //#region src/client/ui/Toast.tsx
1547
1890
  /**
1548
1891
  * `Toast`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Toast,出处与三条改写规则
@@ -1603,7 +1946,7 @@ window.__ModuleLoader__.load({
1603
1946
  //#endregion
1604
1947
  //#region src/client/undo-toast.tsx
1605
1948
  /**
1606
- * 撤销提示(设计稿 §7 状态 ③):删除成功后一条可撤销的横幅。
1949
+ * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
1607
1950
  *
1608
1951
  * 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
1609
1952
  * (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
@@ -1613,71 +1956,84 @@ window.__ModuleLoader__.load({
1613
1956
  * `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
1614
1957
  * 本文件就是那份 store + 那个常驻组件。
1615
1958
  *
1616
- * 状态源用 React 自带的 `useSyncExternalStore`——不引 `@deepseek-ai/dsh-client-store`
1617
- * (它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
1959
+ * 状态源走 `store.ts` 的 `createStore`(内部用 React 自带的 `useSyncExternalStore`)——
1960
+ * 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
1618
1961
  */
1619
- let notice = null;
1620
- let nextSeq$2 = 0;
1621
- const listeners$2 = /* @__PURE__ */ new Set();
1622
- const emit$2 = () => {
1623
- for (const listener of listeners$2) listener();
1624
- };
1962
+ const noticeStore = createStore(null);
1625
1963
  /** 删除成功后发布一条撤销提示(单条)。 */
1626
1964
  function publishUndoNotice(input) {
1627
- notice = {
1628
- seq: nextSeq$2 += 1,
1965
+ noticeStore.publish((seq) => ({
1966
+ seq,
1629
1967
  kind: "one",
1630
1968
  entryId: input.entryId,
1631
1969
  title: input.title
1632
- };
1633
- emit$2();
1970
+ }));
1634
1971
  }
1635
- /** 批量删除成功后发布一条撤销提示(第十三批②):覆盖上一条,只报这一次移走的条数。 */
1972
+ /** 批量删除成功后发布一条撤销提示(2026-10-17):覆盖上一条,只报这一次移走的条数。 */
1636
1973
  function publishUndoBatchNotice(input) {
1637
- notice = {
1638
- seq: nextSeq$2 += 1,
1974
+ noticeStore.publish((seq) => ({
1975
+ seq,
1639
1976
  kind: "batch",
1640
1977
  entryIds: input.entryIds,
1641
1978
  n: input.n
1642
- };
1643
- emit$2();
1979
+ }));
1644
1980
  }
1645
- /** 撤销成功后把提示收掉(用户点「撤销」、或横幅自己走完停留时间)。 */
1981
+ /** 整条横幅不再留:撤销成了、停留时间走完,或清空回收站把所有落点一起清了。
1982
+ * 与下面 `dismissUndoNotice` 的分别是「整条不再留」与「按条目匹配」——清空之后没有条目还能撤销,
1983
+ * 所以那一路不需要名单。 */
1646
1984
  function clearUndoNotice() {
1647
- if (notice === null) return;
1648
- notice = null;
1649
- emit$2();
1650
- }
1651
- function getUndoNotice() {
1652
- return notice;
1985
+ noticeStore.set(null);
1653
1986
  }
1654
- function subscribe$2(listener) {
1655
- listeners$2.add(listener);
1656
- return () => {
1657
- listeners$2.delete(listener);
1658
- };
1987
+ /**
1988
+ * 某个条目已经在别处恢复:把指向它的横幅熄掉。
1989
+ * 批量里任一命中就清整条——名单少了、计数没变,留着就是一条会骗人的「撤销」动作。
1990
+ */
1991
+ function dismissUndoNotice(entryIds) {
1992
+ const wanted = new Set(entryIds);
1993
+ noticeStore.update((current) => {
1994
+ if (current === null) return current;
1995
+ if (current.kind === "one") return wanted.has(current.entryId) ? null : current;
1996
+ return current.entryIds.some((id) => wanted.has(id)) ? null : current;
1997
+ });
1659
1998
  }
1660
1999
  /** 供组件订阅。 */
1661
2000
  function useUndoNotice() {
1662
- return (0, react.useSyncExternalStore)(subscribe$2, getUndoNotice, getUndoNotice);
2001
+ return noticeStore.use();
1663
2002
  }
1664
- /** 提示停留时长。设计稿 §7 定死:**6 秒**(Toast 自己的默认是 3 秒)。 */
2003
+ /** 提示停留时长。**6 秒** 定死在本行(比 Toast 默认的 3 秒长)——`docs/ui-spec.md` §2 状态 ③ 只指到这里,不抄这个数。 */
1665
2004
  const UNDO_HOLD_MS = 6e3;
1666
- /** 撤销:单条 `restore(entryId)`、批量**一次** `applyOutcomes('restore', entryIds)`;失败就把原因换到同一条横幅上,不静默。 */
2005
+ /** 这次撤销开始时的那条横幅还在吗——期间被新删除覆盖、或由面板恢复熄灭时,旧请求不许改写它。 */
2006
+ function ownsNotice(current) {
2007
+ return noticeStore.get() === current;
2008
+ }
2009
+ /** 撤销失败:把原因换到同一条横幅上(seq 递增 ⇒ Toast 重挂载、重新计停留)。 */
2010
+ function failNotice(current, message) {
2011
+ if (!ownsNotice(current)) return;
2012
+ noticeStore.publish((seq) => ({
2013
+ ...current,
2014
+ seq,
2015
+ failure: message
2016
+ }));
2017
+ }
2018
+ /** 撤销:单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
2019
+ * null 是协议错);失败就把原因换到同一条横幅上,不静默。 */
1667
2020
  async function undo(deps, current) {
2021
+ const ids = current.kind === "one" ? [current.entryId] : current.entryIds;
1668
2022
  if (current.kind === "one" && current.entryId === "") return;
1669
- const outcome = current.kind === "one" ? await callRemote(() => applyOne(deps, "restore", current.entryId).then((item) => item.value)) : await callRemote(() => deps.applyOutcomes("restore", current.entryIds));
1670
- if (outcome.ok) {
1671
- invalidateTrash();
1672
- clearUndoNotice();
2023
+ const outcome = await restoreFromUndo(deps, {
2024
+ ids,
2025
+ single: current.kind === "one"
2026
+ });
2027
+ if (!outcome.ok) {
2028
+ failNotice(current, outcome.failure.message);
1673
2029
  return;
1674
2030
  }
1675
- notice = {
1676
- ...current,
1677
- seq: nextSeq$2 += 1,
1678
- failure: outcome.failure.message
1679
- };
1680
- emit$2();
2031
+ const failure = outcome.failures.values().next().value;
2032
+ if (failure !== void 0) {
2033
+ failNotice(current, failure);
2034
+ return;
2035
+ }
2036
+ if (ownsNotice(current)) clearUndoNotice();
1681
2037
  }
1682
2038
  /**
1683
2039
  * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
@@ -1706,6 +2062,299 @@ window.__ModuleLoader__.load({
1706
2062
  }, seq);
1707
2063
  }
1708
2064
  //#endregion
2065
+ //#region src/client/main-view.ts
2066
+ let client;
2067
+ /** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开;面板恢复用 `forgetMainView` 清掉。 */
2068
+ let closedByUs = /* @__PURE__ */ new Set();
2069
+ /** 重开排队的最新票:后一次删除/恢复(或重开)签发新票,前一次的等待随之作废(只开最新那条)。 */
2070
+ const reopen = createLatestWins();
2071
+ /** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
2072
+ function bindMainView(ctx) {
2073
+ client = ctx;
2074
+ }
2075
+ function uiWorkspaceOf() {
2076
+ try {
2077
+ const candidate = client?.get?.("uiWorkspace");
2078
+ if (candidate === void 0 || candidate === null) return void 0;
2079
+ if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return void 0;
2080
+ if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return void 0;
2081
+ return candidate;
2082
+ } catch {
2083
+ return;
2084
+ }
2085
+ }
2086
+ function currentSessionOf(ui) {
2087
+ try {
2088
+ return ui.selection?.getSnapshot()?.sessionId;
2089
+ } catch {
2090
+ return;
2091
+ }
2092
+ }
2093
+ /**
2094
+ * 删除成功后调用:当前主视图若在这批里,把它关掉并记下"这一条是我们关掉的"(供撤销重开)。
2095
+ * 关不掉/不是当前会话都什么都不做——这只是界面收尾,绝不让删除结果变红。
2096
+ */
2097
+ function afterDelete(sessionIds) {
2098
+ if (sessionIds.length === 0) return;
2099
+ reopen.invalidate();
2100
+ const ui = uiWorkspaceOf();
2101
+ if (ui === void 0) return;
2102
+ const current = currentSessionOf(ui);
2103
+ if (current === void 0 || !sessionIds.includes(current)) return;
2104
+ try {
2105
+ ui.clearMain();
2106
+ closedByUs.add(current);
2107
+ } catch {}
2108
+ }
2109
+ /**
2110
+ * 撤销恢复成功后调用:把**我们自己关掉的**那几个会话重新打开(只认 `afterDelete` 记过的)。
2111
+ *
2112
+ * 一批里只有最后一条会开(`targets[targets.length - 1]`):主视图一次只装一个会话,而撤销恢复
2113
+ * 的成功条数可能多于一条;`openWhenListed` 的最新票守卫保证前一条的等待不会后落地抢焦点。
2114
+ * 任何更新的删除/恢复都会先作废旧等待;这里只负责「这一次要开哪一条」。
2115
+ */
2116
+ function afterRestore(sessionIds) {
2117
+ if (sessionIds.length === 0) return;
2118
+ reopen.invalidate();
2119
+ const targets = sessionIds.filter((id) => closedByUs.has(id));
2120
+ if (targets.length === 0) return;
2121
+ for (const id of targets) closedByUs.delete(id);
2122
+ openWhenListed(targets[targets.length - 1]);
2123
+ }
2124
+ /**
2125
+ * 面板恢复已经替这些会话完成了“回来”的结算:只忘掉我们关过的那几条,不重开、不抢焦点。
2126
+ * 它不动 `reopen`:同一会话不会既在等重开、又躺在回收站里等面板恢复;在等的重开属于另一条动作。
2127
+ */
2128
+ function forgetMainView(sessionIds) {
2129
+ for (const id of sessionIds) closedByUs.delete(id);
2130
+ }
2131
+ /**
2132
+ * 把会话重新打开:`api-session/added` 从宿主发出到客户端摘要表落地有一拍(WS 与 HTTP 响应各走
2133
+ * 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——所以先等它出现,
2134
+ * 最多 2 秒;期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
2135
+ *
2136
+ * **最新票守卫**(`reopen`):开始等待时签一张票,立即开与每次 tick 都核对;后一次删除/
2137
+ * 恢复(或 `resetMainView`)签发新票或作废当前票,前一次的等待就不再打开、也不再续时。
2138
+ */
2139
+ function openWhenListed(sessionId) {
2140
+ const ui = uiWorkspaceOf();
2141
+ if (ui === void 0) return;
2142
+ const ticket = reopen.issue();
2143
+ const listReady = () => {
2144
+ try {
2145
+ return ui.sessions?.list?.getSnapshot()?.byId?.[sessionId] !== void 0;
2146
+ } catch {
2147
+ return false;
2148
+ }
2149
+ };
2150
+ const canTakeFocus = () => {
2151
+ const current = currentSessionOf(ui);
2152
+ return current === void 0 || current === sessionId;
2153
+ };
2154
+ const open = () => {
2155
+ if (!ticket.current) return;
2156
+ if (!canTakeFocus()) return;
2157
+ try {
2158
+ ui.openSession?.(sessionId);
2159
+ } catch {}
2160
+ };
2161
+ if (listReady()) {
2162
+ open();
2163
+ return;
2164
+ }
2165
+ let attempts = 0;
2166
+ const tick = () => {
2167
+ if (!ticket.current) return;
2168
+ attempts += 1;
2169
+ if (listReady()) {
2170
+ open();
2171
+ return;
2172
+ }
2173
+ if (attempts < 20) setTimeout(tick, 100);
2174
+ };
2175
+ setTimeout(tick, 100);
2176
+ }
2177
+ //#endregion
2178
+ //#region src/client/write-settlement.ts
2179
+ const NO_PRESENTATION = {
2180
+ delete: {},
2181
+ restore: {},
2182
+ purge: {},
2183
+ count: {},
2184
+ "orphan-clean": {}
2185
+ };
2186
+ /** 没有呈现材质的座位用它:抛掉按 action 的联合,调用点不用各自造一个空对象字面量。 */
2187
+ function noPresentation(action) {
2188
+ return NO_PRESENTATION[action];
2189
+ }
2190
+ function settleWrite(success, presentation) {
2191
+ switch (success.action) {
2192
+ case "delete": {
2193
+ const seat = presentation;
2194
+ afterDelete(success.successIds);
2195
+ seat.invalidate?.();
2196
+ if (success.anySuccess) {
2197
+ const [first, ...rest] = landedEntryIds(success.receipt);
2198
+ if (first !== void 0) {
2199
+ const entryIds = [first, ...rest];
2200
+ seat.publishUndo?.({
2201
+ entryIds,
2202
+ n: success.successIds.length
2203
+ });
2204
+ }
2205
+ }
2206
+ return;
2207
+ }
2208
+ case "restore": {
2209
+ const seat = presentation;
2210
+ const restored = restoredSessionIds(success.receipt, seat.sessionIdOfEntry);
2211
+ if (seat.panelSeat !== true) afterRestore(restored);
2212
+ seat.invalidate?.();
2213
+ if (!success.anySuccess) return;
2214
+ seat.dismissUndo?.(success.successIds);
2215
+ if (seat.panelSeat === true) forgetMainView(restored);
2216
+ seat.clearDetailCache?.(success.successIds);
2217
+ return;
2218
+ }
2219
+ case "purge": {
2220
+ const seat = presentation;
2221
+ if (success.anySuccess) {
2222
+ seat.dismissUndo?.(success.successIds);
2223
+ seat.clearDetailCache?.(success.successIds);
2224
+ }
2225
+ return;
2226
+ }
2227
+ case "count":
2228
+ case "orphan-clean": return;
2229
+ default: return success.action;
2230
+ }
2231
+ }
2232
+ //#endregion
2233
+ //#region src/client/write.ts
2234
+ async function runWrite(input) {
2235
+ const lifecycle = async () => {
2236
+ const ids = [...new Set(input.ids)];
2237
+ const outcome = await callRemote(() => input.deps.applyOutcomes(input.action, ids));
2238
+ if (!outcome.ok) {
2239
+ const failures = new Map(ids.map((id) => [id, outcome.failure.message]));
2240
+ const failed = {
2241
+ ok: false,
2242
+ action: input.action,
2243
+ ids,
2244
+ failure: outcome.failure,
2245
+ failures
2246
+ };
2247
+ await input.refresh?.(failed);
2248
+ return failed;
2249
+ }
2250
+ const single = input.single === true && ids.length === 1;
2251
+ const receipt = readReceipt(input.action, ids, outcome.value, { single });
2252
+ const failures = /* @__PURE__ */ new Map();
2253
+ for (const verdict of receipt.verdicts) if (isFailureVerdict(verdict)) failures.set(verdict.id, failureMessage(verdict));
2254
+ const successIds = receipt.verdicts.filter((verdict) => verdict.kind === "ok" || verdict.kind === "already-gone").map((verdict) => verdict.id);
2255
+ const success = {
2256
+ ok: true,
2257
+ action: input.action,
2258
+ ids,
2259
+ receipt,
2260
+ failures,
2261
+ successIds,
2262
+ anySuccess: successIds.length > 0
2263
+ };
2264
+ settleWrite(success, input.presentation ?? noPresentation(input.action));
2265
+ await input.refresh?.(success);
2266
+ return success;
2267
+ };
2268
+ return input.gate === void 0 ? lifecycle() : input.gate.around(lifecycle);
2269
+ }
2270
+ //#endregion
2271
+ //#region src/client/write-seats.ts
2272
+ /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。 */
2273
+ async function deleteSessionFromMenu(deps, input, gate) {
2274
+ return (await runWrite({
2275
+ deps,
2276
+ action: "delete",
2277
+ ids: [input.sessionId],
2278
+ single: true,
2279
+ presentation: {
2280
+ invalidate: invalidateTrash,
2281
+ publishUndo: ({ entryIds }) => publishUndoNotice({
2282
+ entryId: entryIds[0],
2283
+ title: input.title
2284
+ })
2285
+ },
2286
+ gate
2287
+ })).failures.get(input.sessionId) ?? null;
2288
+ }
2289
+ /** 批量删除座位(宿主会话列表的多选 → 批量确认框):一次 `/apply`,撤销名单只收 ok 支的条目 id(跟着组走的幂等条不单列)。
2290
+ * 只收 id:标题是浮条的呈现,不参与这一次写入。 */
2291
+ async function deleteSessionsFromBatch(deps, ids, gate) {
2292
+ return (await runWrite({
2293
+ deps,
2294
+ action: "delete",
2295
+ ids,
2296
+ presentation: {
2297
+ invalidate: invalidateTrash,
2298
+ publishUndo: ({ entryIds, n }) => publishUndoBatchNotice({
2299
+ entryIds,
2300
+ n
2301
+ })
2302
+ },
2303
+ gate
2304
+ })).failures;
2305
+ }
2306
+ /** 外部恢复座位(撤销横幅):没有 busy 面,不传闸;主视图重开归 settlement,这里只让清单失效。 */
2307
+ function restoreFromUndo(deps, input) {
2308
+ return runWrite({
2309
+ deps,
2310
+ action: "restore",
2311
+ ids: input.ids,
2312
+ single: input.single,
2313
+ presentation: { invalidate: invalidateTrash }
2314
+ });
2315
+ }
2316
+ /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。 */
2317
+ function restoreFromPanel(input) {
2318
+ return runWrite({
2319
+ deps: input.deps,
2320
+ action: "restore",
2321
+ ids: input.ids,
2322
+ single: input.single,
2323
+ presentation: {
2324
+ panelSeat: true,
2325
+ dismissUndo: dismissUndoNotice,
2326
+ clearDetailCache: input.clearDetailCache,
2327
+ sessionIdOfEntry: (entryId) => input.entries?.find((entry) => entry.id === entryId)?.sessionId
2328
+ },
2329
+ gate: input.gate,
2330
+ refresh: input.refreshAfterWrite
2331
+ });
2332
+ }
2333
+ /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。 */
2334
+ function purgeFromPanel(input) {
2335
+ return runWrite({
2336
+ deps: input.deps,
2337
+ action: "purge",
2338
+ ids: input.ids,
2339
+ presentation: {
2340
+ dismissUndo: dismissUndoNotice,
2341
+ clearDetailCache: input.clearDetailCache
2342
+ },
2343
+ gate: input.gate,
2344
+ refresh: input.refreshAfterWrite
2345
+ });
2346
+ }
2347
+ /** 无主子会话清理座位:不碰回收站失效与撤销,回执判读仍归 `use-trash-query.ts`。 */
2348
+ function cleanOrphansFromPanel(input) {
2349
+ return runWrite({
2350
+ deps: input.deps,
2351
+ action: "orphan-clean",
2352
+ ids: input.ids,
2353
+ gate: input.gate,
2354
+ refresh: input.refreshAfterWrite
2355
+ });
2356
+ }
2357
+ //#endregion
1709
2358
  //#region src/client/delete-confirm.tsx
1710
2359
  /**
1711
2360
  * 「删除」的确认框——**挂在 `shell.overlay` 上的宿主组件**,加它读的那个模块级现场。
@@ -1725,11 +2374,12 @@ window.__ModuleLoader__.load({
1725
2374
  *
1726
2375
  * - **菜单行只发请求**:`DeleteMenuItem` 把 `{ sessionId, title }` 写进本文件的模块级现场;
1727
2376
  * - **确认框由本文件的宿主渲染与提交**:`DeleteConfirmHost` 挂在 `shell.overlay` 上,从现场读请求,
1728
- * 自己持有 busy / failure。于是「提交删除 → 失败原文上屏 → 再点一次重试 → 成功后发撤销提示」
2377
+ * 自己持有这趟的闸与 failure。于是「提交删除 → 失败原文上屏 → 再点一次重试 → 成功后发撤销提示」
1729
2378
  * 整条链路都在菜单子树之外,菜单关不关都不影响它。
1730
2379
  *
1731
2380
  * 现场是**模块级**的(本仓既有的客户端模式:视图内路由与跨卸载的现场都住在模块级 store,
1732
- * 见 `undo-toast.tsx`),状态源用 React 自带的 `useSyncExternalStore`——不为此引新的状态库。
2381
+ * 见 `undo-toast.tsx`),状态源是 `store.ts` 的 `createStore`(内部用 React 自带的
2382
+ * `useSyncExternalStore`)——不为此引新的状态库。
1733
2383
  */
1734
2384
  /**
1735
2385
  * 被操作对象那张卡。`Modal`(`ui/Modal.tsx`,照官方抄的)的 `.sd-modal-body` 不设字号也不给层级 ⇒ 裸摆一行会话标题会和下面那句
@@ -1743,39 +2393,22 @@ window.__ModuleLoader__.load({
1743
2393
  border: "0.5px solid var(--dsw-alias-border-l4)",
1744
2394
  borderRadius: "var(--dsw-radius-lg)"
1745
2395
  };
1746
- let request$1 = null;
1747
- let nextSeq$1 = 0;
1748
- const listeners$1 = /* @__PURE__ */ new Set();
1749
- const emit$1 = () => {
1750
- for (const listener of listeners$1) listener();
1751
- };
2396
+ const requestStore$1 = createStore(null);
1752
2397
  /** 菜单行调用:请求删除这个会话(确认框由 overlay 上的宿主渲染)。 */
1753
2398
  function requestDelete(input) {
1754
- request$1 = {
1755
- seq: nextSeq$1 += 1,
2399
+ requestStore$1.publish((seq) => ({
2400
+ seq,
1756
2401
  sessionId: input.sessionId,
1757
2402
  title: input.title
1758
- };
1759
- emit$1();
2403
+ }));
1760
2404
  }
1761
2405
  /** 收起确认框(用户点「取消」/「关闭」、或删除成功后收工)。 */
1762
2406
  function clearDeleteRequest() {
1763
- if (request$1 === null) return;
1764
- request$1 = null;
1765
- emit$1();
1766
- }
1767
- function getDeleteRequest() {
1768
- return request$1;
1769
- }
1770
- function subscribe$1(listener) {
1771
- listeners$1.add(listener);
1772
- return () => {
1773
- listeners$1.delete(listener);
1774
- };
2407
+ requestStore$1.set(null);
1775
2408
  }
1776
2409
  /** 供宿主组件订阅。 */
1777
2410
  function useDeleteRequest() {
1778
- return (0, react.useSyncExternalStore)(subscribe$1, getDeleteRequest, getDeleteRequest);
2411
+ return requestStore$1.use();
1779
2412
  }
1780
2413
  /**
1781
2414
  * 常驻的确认框宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
@@ -1791,16 +2424,17 @@ window.__ModuleLoader__.load({
1791
2424
  }, current.seq);
1792
2425
  }
1793
2426
  /**
1794
- * 确认框本体。视图与文案与设计稿 §7 一致(`Modal` + `variant="primary"` 主按钮「移入回收站」;
2427
+ * 确认框本体。视图与文案与 docs/ui-spec.md 一致(`Modal` + `variant="primary"` 主按钮「移入回收站」;
1795
2428
  * 那个 primary 在宿主浅色下是近黑、深色下近白,**从来不是蓝**)。
1796
2429
  * 失败面**不吞**:原文以 `Tag tone="danger"` 摆进对话框,且**确认失败后不关框**,
1797
2430
  * 用户可以再点一次(不是一次失败就永久锁死)。
1798
2431
  */
1799
2432
  function ConfirmDialog({ request: current, deps, t }) {
1800
- const [busy, setBusy] = (0, react.useState)(false);
2433
+ /** 这一趟的闸:写入座位把 apply → 收尾 → 重读整趟包在里面,`busy` 期间主按钮按住。 */
2434
+ const gate = useBusyGate();
1801
2435
  const [failure, setFailure] = (0, react.useState)(null);
1802
2436
  /**
1803
- * 这条会话在盘上带着多少个后代。删除是按组走的(10-15 稿 §11),而宿主的客户端本来就把孩子那几行
2437
+ * 这条会话在盘上带着多少个后代。删除是按组走的(组语义见 `src/trash/groups.ts`),而宿主的客户端本来就把孩子那几行
1804
2438
  * 整排藏了 ⇒ 不说就没人知道点下去侧栏会少一片(issue #3)。那一刻它还没进回收站,清单里没有它,
1805
2439
  * 所以只能现问一次。
1806
2440
  */
@@ -1816,20 +2450,16 @@ window.__ModuleLoader__.load({
1816
2450
  };
1817
2451
  }, [deps, current.sessionId]);
1818
2452
  const confirm = async () => {
1819
- setBusy(true);
1820
2453
  setFailure(null);
1821
- const outcome = await callRemote(() => applyOne(deps, "delete", current.sessionId).then((item) => item.value));
1822
- setBusy(false);
1823
- if (!outcome.ok) {
1824
- setFailure(outcome.failure.message);
2454
+ const failure = await deleteSessionFromMenu(deps, {
2455
+ sessionId: current.sessionId,
2456
+ title: current.title
2457
+ }, gate);
2458
+ if (failure !== null) {
2459
+ setFailure(failure);
1825
2460
  return;
1826
2461
  }
1827
2462
  clearDeleteRequest();
1828
- invalidateTrash();
1829
- if (outcome.value !== null) publishUndoNotice({
1830
- entryId: outcome.value.id,
1831
- title: current.title
1832
- });
1833
2463
  };
1834
2464
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
1835
2465
  open: true,
@@ -1842,7 +2472,7 @@ window.__ModuleLoader__.load({
1842
2472
  children: t("common.cancel")
1843
2473
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
1844
2474
  variant: "primary",
1845
- disabled: busy,
2475
+ disabled: gate.busy,
1846
2476
  onClick: () => void confirm(),
1847
2477
  children: t("dialog.confirm")
1848
2478
  })] }),
@@ -1874,9 +2504,9 @@ window.__ModuleLoader__.load({
1874
2504
  //#endregion
1875
2505
  //#region src/client/batch-confirm.tsx
1876
2506
  /**
1877
- * 批量删除的确认框(第十三批②)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
2507
+ * 批量删除的确认框(2026-10-17)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
1878
2508
  *
1879
- * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(设计稿 §7 状态 ②,
2509
+ * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(docs/ui-spec.md 状态 ②,
1880
2510
  * 用例钉着),批量形与它只有三件不同——标题随条数、目标卡逐条列、执行是一次批量 `apply`。共用一个壳
1881
2511
  * 意味着单删的 `busy` / 失败面要跟着学批量形,那是拿已交付的行为去换省一个文件。卡片样式(`TARGET_CARD`)
1882
2512
  * 与 hint、子会话那两句**复用**单删的,一句不造第二个键。
@@ -1884,48 +2514,32 @@ window.__ModuleLoader__.load({
1884
2514
  * 现场是模块级的(同 `delete-confirm.tsx` / `undo-toast.tsx`):请求从浮条那颗按钮发来,确认框住在
1885
2515
  * `shell.overlay` 上——它必须活在行开关那棵子树之外(槽位行随宿主重排而卸载,见 `delete-confirm.tsx` 头注)。
1886
2516
  */
1887
- let request = null;
1888
- let nextSeq = 0;
1889
- const listeners = /* @__PURE__ */ new Set();
1890
- const emit = () => {
1891
- for (const listener of listeners) listener();
1892
- };
2517
+ const requestStore = createStore(null);
1893
2518
  /** 浮条那颗按钮调用:请求确认这一批(缺省 = 当前选中集)。空名单不发框。 */
1894
2519
  function requestDeleteBatch(items = getPicks()) {
1895
2520
  if (items.length === 0) return;
1896
- request = {
1897
- seq: nextSeq += 1,
2521
+ requestStore.publish((seq) => ({
2522
+ seq,
1898
2523
  items: [...items]
1899
- };
1900
- emit();
2524
+ }));
1901
2525
  }
1902
2526
  function clearDeleteBatchRequest() {
1903
- if (request === null) return;
1904
- request = null;
1905
- emit();
2527
+ requestStore.set(null);
1906
2528
  }
1907
2529
  /** 重试轮之后把现场收缩到失败名单(框里只剩还没办成的那几条)。 */
1908
2530
  function shrinkRequestToFailures(failedSessionIds) {
1909
- if (request === null) return;
1910
- const keep = new Set(failedSessionIds);
1911
- request = {
1912
- ...request,
1913
- items: request.items.filter((item) => keep.has(item.sessionId))
1914
- };
1915
- emit();
1916
- }
1917
- function getBatchRequest() {
1918
- return request;
1919
- }
1920
- function subscribe(listener) {
1921
- listeners.add(listener);
1922
- return () => {
1923
- listeners.delete(listener);
1924
- };
2531
+ requestStore.update((current) => {
2532
+ if (current === null) return current;
2533
+ const keep = new Set(failedSessionIds);
2534
+ return {
2535
+ ...current,
2536
+ items: current.items.filter((item) => keep.has(item.sessionId))
2537
+ };
2538
+ });
1925
2539
  }
1926
2540
  /** 供浮条与装配面订阅。 */
1927
2541
  function useDeleteBatchRequest() {
1928
- return (0, react.useSyncExternalStore)(subscribe, getBatchRequest, getBatchRequest);
2542
+ return requestStore.use();
1929
2543
  }
1930
2544
  /** 常驻的批量确认框宿主。没有待确认的批量删除时什么都不渲染。 */
1931
2545
  function BatchConfirmHost({ deps, t = zhTranslate }) {
@@ -1968,15 +2582,14 @@ window.__ModuleLoader__.load({
1968
2582
  overflow: "auto"
1969
2583
  };
1970
2584
  function BatchConfirmDialog({ request: current, deps, t }) {
1971
- const [busy, setBusy] = (0, react.useState)(false);
2585
+ /** 这一趟的闸:写入座位把 apply → 收尾 → 重读整趟包在里面,`busy` 期间主按钮按住。 */
2586
+ const gate = useBusyGate();
1972
2587
  /** 逐条失败原文:`sessionId → 信封的 message`。失败**不关框**(与单删同款)。 */
1973
2588
  const [failures, setFailures] = (0, react.useState)(/* @__PURE__ */ new Map());
1974
2589
  /** 开框那一刻现问的子会话计数;`null` = 有一条读不到 ⇒ 那两半句整句不说(不许拿 0 冒充"没有")。 */
1975
2590
  const [counts, setCounts] = (0, react.useState)(null);
1976
2591
  const ids = current.items.map((item) => item.sessionId);
1977
2592
  const failedIds = [...failures.keys()];
1978
- /** 首轮发全部;重试轮只发失败的那些(成功的已经不在名单里了)。 */
1979
- const pendingIds = failedIds.length > 0 ? failedIds : ids;
1980
2593
  const totalKids = counts === null ? 0 : [...counts.values()].reduce((sum, n) => sum + n, 0);
1981
2594
  (0, react.useEffect)(() => {
1982
2595
  let alive = true;
@@ -1988,37 +2601,8 @@ window.__ModuleLoader__.load({
1988
2601
  };
1989
2602
  }, [deps, current.seq]);
1990
2603
  const run = async () => {
1991
- setBusy(true);
1992
2604
  setFailures(/* @__PURE__ */ new Map());
1993
- const outcome = await callRemote(() => deps.applyOutcomes("delete", pendingIds));
1994
- setBusy(false);
1995
- if (!outcome.ok) {
1996
- setFailures(new Map(pendingIds.map((id) => [id, outcome.failure.message])));
1997
- return;
1998
- }
1999
- const byId = /* @__PURE__ */ new Map();
2000
- for (const item of outcome.value) if (!byId.has(item.id)) byId.set(item.id, item);
2001
- const moved = [];
2002
- const failed = /* @__PURE__ */ new Map();
2003
- let okCount = 0;
2004
- for (const id of pendingIds) {
2005
- const item = byId.get(id);
2006
- if (item === void 0) {
2007
- failed.set(id, `批量通道没回这一条的结果:delete ${id}`);
2008
- continue;
2009
- }
2010
- if (!item.ok) {
2011
- failed.set(id, item.error.message);
2012
- continue;
2013
- }
2014
- okCount += 1;
2015
- if (item.action === "delete" && item.value !== null) moved.push(item.value.id);
2016
- }
2017
- if (okCount > 0) publishUndoBatchNotice({
2018
- entryIds: moved,
2019
- n: okCount
2020
- });
2021
- invalidateTrash();
2605
+ const failed = await deleteSessionsFromBatch(deps, current.items.map((item) => item.sessionId), gate);
2022
2606
  shrinkPicksToFailures([...failed.keys()]);
2023
2607
  if (failed.size === 0) {
2024
2608
  clearDeleteBatchRequest();
@@ -2038,7 +2622,7 @@ window.__ModuleLoader__.load({
2038
2622
  children: t("common.cancel")
2039
2623
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2040
2624
  variant: "primary",
2041
- disabled: busy,
2625
+ disabled: gate.busy,
2042
2626
  onClick: () => void run(),
2043
2627
  children: failedIds.length > 0 ? t("dialog.batch.retry", { n: failedIds.length }) : t("batch.run", { n: ids.length })
2044
2628
  })] }),
@@ -2075,45 +2659,50 @@ window.__ModuleLoader__.load({
2075
2659
  //#endregion
2076
2660
  //#region src/client/batch-bar.tsx
2077
2661
  /**
2078
- * 批量删除的浮条(第十三批①):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2662
+ * 批量删除的浮条(2026-10-17 `6f24fd4`):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2079
2663
  * 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
2080
2664
  * (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
2081
- * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `batch-select.tsx` 的 `useSelectionRowClickGuard`)。
2665
+ * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `host-rows.ts` 的 `useSelectionRowClickGuard`)。
2082
2666
  *
2083
- * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"选中集 + 行开关"(被确认框 import),
2084
- * 而浮条要点开确认框(要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
2667
+ * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"行开关控件",而浮条要点开确认框
2668
+ * (要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
2085
2669
  * `shell.overlay` 上,与单删的撤销提示/确认框同类(槽位原文:"a toast stack or a status pill
2086
2670
  * all belong here")。
2087
2671
  *
2088
2672
  * 那个 K 是**勾选变化后防抖 300ms 的一次批量 count**(`/apply` 的 `action:'count'`,一组 id 一次),
2089
- * 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与第十二批那条 `reloadSeq` 同一个道理)。
2090
- * 读不到就整句不说——K 是合计,缺一条就是假数(10-15 稿 §10「不许拿 0 冒充没有」)。
2673
+ * 并且带**最新票守卫**:在飞的旧响应不许盖掉新名单的读数(compare-and-set 住 `createLatestWins`,
2674
+ * 防抖本身仍在下面这个 effect 里)。
2675
+ * 读不到就整句不说——K 是合计,缺一条就是假数(「不许拿 0 冒充没有」)。
2091
2676
  */
2092
2677
  /** 勾选变化后到发问之间的等待:连勾十条不该发十次 count。 */
2093
2678
  const COUNT_DEBOUNCE_MS = 300;
2094
2679
  function BatchBar({ deps, t = zhTranslate }) {
2095
- const selecting = useSelecting();
2096
- const picks = usePicks();
2680
+ const { selecting, picks } = useSelection();
2097
2681
  useSelectionRowClickGuard();
2682
+ const visibleRows = useVisibleSelectableRows(selecting);
2098
2683
  const [counts, setCounts] = (0, react.useState)(null);
2099
2684
  const [expanded, setExpanded] = (0, react.useState)(false);
2100
- const seqRef = (0, react.useRef)(0);
2685
+ const [latest] = (0, react.useState)(createLatestWins);
2101
2686
  (0, react.useEffect)(() => {
2102
2687
  const ids = picks.map((item) => item.sessionId);
2103
- const seq = seqRef.current += 1;
2688
+ const ticket = latest.issue();
2104
2689
  const timer = setTimeout(() => {
2105
2690
  fetchCounts(deps, ids).then((result) => {
2106
- if (seq === seqRef.current) setCounts(result);
2691
+ if (ticket.current) setCounts(result);
2107
2692
  });
2108
2693
  }, COUNT_DEBOUNCE_MS);
2109
2694
  return () => clearTimeout(timer);
2110
- }, [deps, picks]);
2695
+ }, [
2696
+ deps,
2697
+ latest,
2698
+ picks
2699
+ ]);
2111
2700
  (0, react.useEffect)(() => {
2112
2701
  if (!selecting || picks.length === 0) setExpanded(false);
2113
2702
  }, [selecting, picks.length]);
2114
2703
  if (!selecting) return null;
2115
2704
  const kids = counts === null ? 0 : picks.reduce((sum, item) => sum + (counts.get(item.sessionId) ?? 0), 0);
2116
- const allPicked = allVisibleSelected();
2705
+ const allPicked = areAllVisibleRowsSelected(visibleRows, picks);
2117
2706
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2118
2707
  className: "sd-batch-bar",
2119
2708
  "data-dsh-session-delete": "batch-bar",
@@ -2211,7 +2800,7 @@ window.__ModuleLoader__.load({
2211
2800
  //#endregion
2212
2801
  //#region src/client/batch-menu-item.tsx
2213
2802
  function BatchSelectMenuItem({ dismissMenu, t = zhTranslate }) {
2214
- const selecting = useSelecting();
2803
+ const selecting = useSelection().selecting;
2215
2804
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MenuItemButton, {
2216
2805
  separatorBefore: true,
2217
2806
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCheckOutlineRegular, { size: 14 }),
@@ -2226,10 +2815,10 @@ window.__ModuleLoader__.load({
2226
2815
  //#endregion
2227
2816
  //#region src/client/delete-menu-item.tsx
2228
2817
  /**
2229
- * 会话条目「…」菜单里的「删除」行(设计稿 §7 状态 ①)。
2818
+ * 会话条目「…」菜单里的「删除」行(docs/ui-spec.md 状态 ①)。
2230
2819
  *
2231
2820
  * 这一行**只做两件事**:把「要删这个会话」写进 `delete-confirm.tsx` 的模块级现场,然后让菜单
2232
- * owner 把菜单收起来(确认框不许叠在收起的菜单上——设计稿 §7 状态 ② 画的就是菜单已经收起之后
2821
+ * owner 把菜单收起来(确认框不许叠在收起的菜单上——docs/ui-spec.md 状态 ② 说的就是菜单已经收起之后
2233
2822
  * 的画面)。**确认框不在这棵子树里**——
2234
2823
  * 原因见 `delete-confirm.tsx` 的头注:宿主的菜单行随菜单一起卸载(真产物 `Menu` 是
2235
2824
  * `open && jsxs(MenuSurface, { … children: [items.map(renderEntry), children] })`,`open === false`
@@ -2245,7 +2834,7 @@ window.__ModuleLoader__.load({
2245
2834
  * 菜单的**开合归 owner**:槽位把 `useMenuOpenState` 交给这一行(`index.tsx` 的 `DeleteRow` 接住后
2246
2835
  * 当 `dismissMenu` 传下来),按下时先发请求、再收菜单——与官方 README 的示例同序
2247
2836
  * (`onSelect={() => { setMenuOpen(false); … }}`)。
2248
- * 2. **运行中置灰**(设计稿 §7「运行中」)。置灰用**原生 `disabled`**:本平台的菜单行组件
2837
+ * 2. **运行中置灰**(docs/ui-spec.md「运行中」)。置灰用**原生 `disabled`**:本平台的菜单行组件
2249
2838
  * `MenuItemButton` 只把 `disabled` 落在原生按钮属性上(真产物原文 `<button type="button"
2250
2839
  * role="menuitem" … disabled={disabled}>`),jest-dom 的 `toBeDisabled()` 也只认这个属性;
2251
2840
  * `aria-disabled` 在这条链上既渲染不出来也不算数。原因文案用 `ui/Tooltip.tsx`(照官方 Tooltip 抄的,
@@ -2278,7 +2867,7 @@ window.__ModuleLoader__.load({
2278
2867
  //#endregion
2279
2868
  //#region src/client/trash-entry.tsx
2280
2869
  /**
2281
- * 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;设计稿 §7 状态 ④ 的入口那一行)。
2870
+ * 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;docs/ui-spec.md 状态 ④ 的入口那一行)。
2282
2871
  *
2283
2872
  * **只出 glyph**——行本体由宿主 `PanelRow` 画:`<button class=panelRow aria-current
2284
2873
  * onClick=selectPanel(id)>` 里一个 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18,
@@ -2300,10 +2889,417 @@ window.__ModuleLoader__.load({
2300
2889
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size });
2301
2890
  }
2302
2891
  //#endregion
2303
- //#region node_modules/.pnpm/comma-separated-tokens@2.0.3/node_modules/comma-separated-tokens/index.js
2892
+ //#region src/client/ui/recipe.ts
2304
2893
  /**
2305
- * Serialize an array of strings or numbers to comma-separated tokens.
2306
- *
2894
+ * 安静的红:**只给字色、不给底**。宿主控件的 `.sd-button-ghost:hover` / `:active` 是普通类,
2895
+ * 而作者态的内联 `background` 永远赢过类规则 ⇒ 铺了底的红按钮在点错之前不给任何视觉回应。
2896
+ * 整页唯一那块实心红另有写法(`variant="primary"` + 就地覆盖 primary 那对 token),不在这里。
2897
+ */
2898
+ const QUIET_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
2899
+ /** 单行截断。宽档那一列与可拖的分栏天然会挤文字,逐处手写这三行就会各写各的。 */
2900
+ const ELLIPSIS = {
2901
+ overflow: "hidden",
2902
+ textOverflow: "ellipsis",
2903
+ whiteSpace: "nowrap"
2904
+ };
2905
+ //#endregion
2906
+ //#region src/client/trash-view.ts
2907
+ /** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
2908
+ const CARD = {
2909
+ display: "flex",
2910
+ alignItems: "center",
2911
+ gap: 14,
2912
+ minWidth: 0,
2913
+ padding: "9px 14px",
2914
+ background: "var(--dsw-alias-bg-layer-2)",
2915
+ border: "0.5px solid var(--dsw-alias-border-l4)",
2916
+ borderRadius: "var(--dsw-radius-lg)"
2917
+ };
2918
+ /** 选中行:底色与描边都换成宿主"选中"那一档(`trash-panel.tsx` 头注里有这两枚 token 的实测取值)。 */
2919
+ const CARD_SELECTED = {
2920
+ ...CARD,
2921
+ background: "var(--dsw-alias-button-ghost-active-fill)",
2922
+ border: "0.5px solid var(--dsw-alias-button-ghost-active-border)"
2923
+ };
2924
+ /**
2925
+ * **整页唯一那块实心红**的写法:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
2926
+ * 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
2927
+ */
2928
+ const BULK_DANGER = {
2929
+ "--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
2930
+ "--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
2931
+ };
2932
+ /**
2933
+ * 项目名怎么念:宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、
2934
+ * 拆不回真实路径;但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。
2935
+ * 摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
2936
+ * 字母表只有一份:还原走 `shared/wire.ts`。
2937
+ */
2938
+ function projectLabel(projectDir, t) {
2939
+ const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
2940
+ return bare === "" || bare === "_no-cwd" ? t("trash.project.unknown") : bare;
2941
+ }
2942
+ /** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
2943
+ function groupByProject(entries, t) {
2944
+ const byProject = /* @__PURE__ */ new Map();
2945
+ for (const entry of [...entries].sort((a, b) => b.deletedAt - a.deletedAt)) {
2946
+ const list = byProject.get(entry.projectDir);
2947
+ if (list === void 0) byProject.set(entry.projectDir, [entry]);
2948
+ else list.push(entry);
2949
+ }
2950
+ return [...byProject].map(([projectDir, list]) => ({
2951
+ projectDir,
2952
+ label: projectLabel(projectDir, t),
2953
+ latest: list[0].deletedAt,
2954
+ entries: list
2955
+ })).sort((a, b) => b.latest - a.latest);
2956
+ }
2957
+ //#endregion
2958
+ //#region src/client/trash-confirm.tsx
2959
+ /** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
2960
+ const CARD_LIST = {
2961
+ display: "flex",
2962
+ flexDirection: "column",
2963
+ gap: 6,
2964
+ maxHeight: 280,
2965
+ overflow: "auto",
2966
+ marginBlockEnd: 10
2967
+ };
2968
+ /**
2969
+ * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
2970
+ * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
2971
+ * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
2972
+ * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
2973
+ */
2974
+ function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
2975
+ if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
2976
+ open: true,
2977
+ onClose: () => {
2978
+ if (!busy) onCancel();
2979
+ },
2980
+ title: t("trash.confirm.restore", { count: pending.ids.length }),
2981
+ closeLabel: t("common.close"),
2982
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2983
+ variant: "outline",
2984
+ disabled: busy,
2985
+ onClick: onCancel,
2986
+ children: t("common.cancel")
2987
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2988
+ variant: "primary",
2989
+ disabled: busy,
2990
+ onClick: () => onConfirmRestore(pending.ids),
2991
+ children: t("trash.confirm.restoreNow")
2992
+ })] }),
2993
+ children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2994
+ style: CARD_LIST,
2995
+ children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2996
+ style: {
2997
+ ...CARD,
2998
+ flexDirection: "column",
2999
+ alignItems: "stretch",
3000
+ gap: 2,
3001
+ padding: "9px 12px"
3002
+ },
3003
+ children: [
3004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3005
+ style: {
3006
+ ...ELLIPSIS,
3007
+ fontSize: 13.5,
3008
+ lineHeight: "19px"
3009
+ },
3010
+ children: entry.title === "" ? t("common.untitled") : entry.title
3011
+ }),
3012
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3013
+ style: {
3014
+ ...ELLIPSIS,
3015
+ fontSize: 12,
3016
+ lineHeight: "16px",
3017
+ color: "var(--dsw-alias-label-tertiary)"
3018
+ },
3019
+ children: projectLabel(entry.projectDir, t)
3020
+ }),
3021
+ entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3022
+ style: {
3023
+ fontSize: 12,
3024
+ lineHeight: "16px",
3025
+ color: "var(--dsw-alias-label-tertiary)"
3026
+ },
3027
+ children: t("trash.row.workspaceGone")
3028
+ }) : null
3029
+ ]
3030
+ }, entry.id))
3031
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
3032
+ });
3033
+ const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
3034
+ const strong = t("trash.confirm.body.strong");
3035
+ const body = splitOn(t("trash.confirm.body", { strong }), strong);
3036
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3037
+ open: true,
3038
+ onClose: () => {
3039
+ if (!busy) onCancel();
3040
+ },
3041
+ title,
3042
+ closeLabel: t("common.close"),
3043
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3044
+ variant: "outline",
3045
+ disabled: busy,
3046
+ onClick: onCancel,
3047
+ children: t("common.cancel")
3048
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3049
+ variant: "primary",
3050
+ style: BULK_DANGER,
3051
+ disabled: busy,
3052
+ onClick: onConfirmPurge,
3053
+ children: t("trash.confirm.purge")
3054
+ })] }),
3055
+ children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3056
+ style: {
3057
+ ...CARD,
3058
+ flexDirection: "column",
3059
+ alignItems: "stretch",
3060
+ gap: 2,
3061
+ padding: "10px 12px",
3062
+ marginBlockEnd: 10
3063
+ },
3064
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3065
+ style: {
3066
+ ...ELLIPSIS,
3067
+ fontSize: 13.5,
3068
+ lineHeight: "19px"
3069
+ },
3070
+ children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
3071
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3072
+ style: {
3073
+ ...ELLIPSIS,
3074
+ fontSize: 12,
3075
+ lineHeight: "16px",
3076
+ color: "var(--dsw-alias-label-tertiary)"
3077
+ },
3078
+ children: t("trash.confirm.one.meta", {
3079
+ project: projectLabel(pending.entry.projectDir, t),
3080
+ at: absoluteText(pending.entry.deletedAt),
3081
+ size: fileSizeText(pending.entry.releaseBytes)
3082
+ })
3083
+ })]
3084
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
3085
+ body[0],
3086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
3087
+ style: { color: "var(--dsw-alias-state-error-primary)" },
3088
+ children: body[1]
3089
+ }),
3090
+ body[2]
3091
+ ] })]
3092
+ });
3093
+ }
3094
+ /** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
3095
+ function OrphanConfirm({ report, busy, t, onCancel, onConfirm }) {
3096
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3097
+ open: true,
3098
+ onClose: () => {
3099
+ if (!busy) onCancel();
3100
+ },
3101
+ title: t("trash.orphans.confirm", { count: report.totalSessions }),
3102
+ closeLabel: t("common.close"),
3103
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3104
+ variant: "outline",
3105
+ disabled: busy,
3106
+ onClick: onCancel,
3107
+ children: t("common.cancel")
3108
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3109
+ variant: "primary",
3110
+ style: BULK_DANGER,
3111
+ disabled: busy,
3112
+ onClick: onConfirm,
3113
+ children: t("trash.orphans.clean")
3114
+ })] }),
3115
+ children: [
3116
+ report.groups.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3117
+ style: CARD_LIST,
3118
+ children: report.groups.map((group) => {
3119
+ const text = t("trash.orphans.group", {
3120
+ project: projectLabel(group.projectDir, t),
3121
+ count: group.sessions,
3122
+ size: fileSizeText(group.bytes)
3123
+ });
3124
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3125
+ title: text,
3126
+ style: {
3127
+ ...CARD,
3128
+ padding: "9px 12px",
3129
+ fontSize: 13,
3130
+ lineHeight: "19px"
3131
+ },
3132
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3133
+ style: {
3134
+ ...ELLIPSIS,
3135
+ minWidth: 0,
3136
+ flex: "1 1 auto"
3137
+ },
3138
+ children: text
3139
+ })
3140
+ }, group.projectDir);
3141
+ })
3142
+ }),
3143
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.orphans.body") }),
3144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3145
+ style: {
3146
+ fontSize: 12,
3147
+ lineHeight: "17px",
3148
+ color: "var(--dsw-alias-label-tertiary)"
3149
+ },
3150
+ children: t("trash.orphans.cacheNote")
3151
+ })
3152
+ ]
3153
+ });
3154
+ }
3155
+ //#endregion
3156
+ //#region src/client/orphan-maintenance.tsx
3157
+ function OrphanMaintenance({ query, busy, t }) {
3158
+ const { orphanReport, orphanFailure, orphanNotice, orphanConfirm } = query;
3159
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3160
+ orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3161
+ "data-dsh-session-delete": "orphan-banner",
3162
+ style: {
3163
+ ...CARD,
3164
+ alignItems: "flex-start",
3165
+ gap: 12,
3166
+ marginBottom: 10
3167
+ },
3168
+ children: [
3169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3170
+ style: {
3171
+ display: "inline-flex",
3172
+ flex: "none",
3173
+ paddingTop: 2,
3174
+ color: "var(--dsw-alias-state-warn-primary)"
3175
+ },
3176
+ "aria-hidden": "true",
3177
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
3178
+ }),
3179
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3180
+ style: {
3181
+ display: "flex",
3182
+ flexDirection: "column",
3183
+ gap: 2,
3184
+ flex: "1 1 auto",
3185
+ minWidth: 0
3186
+ },
3187
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3188
+ style: {
3189
+ fontSize: 13,
3190
+ lineHeight: "19px"
3191
+ },
3192
+ children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
3193
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3194
+ style: {
3195
+ fontSize: 12,
3196
+ lineHeight: "16px",
3197
+ color: "var(--dsw-alias-label-tertiary)"
3198
+ },
3199
+ children: t("trash.orphans.meta", {
3200
+ size: fileSizeText(orphanReport.totalBytes),
3201
+ parents: orphanReport.missingParents
3202
+ })
3203
+ })]
3204
+ }),
3205
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3206
+ variant: "outline",
3207
+ size: "sm",
3208
+ style: { flex: "none" },
3209
+ disabled: busy,
3210
+ onClick: () => {
3211
+ if (busy) return;
3212
+ query.openOrphanPreview();
3213
+ },
3214
+ children: t("trash.orphans.open")
3215
+ })
3216
+ ]
3217
+ }) : null,
3218
+ orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3219
+ role: "status",
3220
+ style: {
3221
+ ...CARD,
3222
+ alignItems: "flex-start",
3223
+ gap: 10,
3224
+ marginBottom: 10,
3225
+ padding: "10px 12px"
3226
+ },
3227
+ children: [
3228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3229
+ style: {
3230
+ display: "inline-flex",
3231
+ flex: "none",
3232
+ color: "var(--dsw-alias-state-error-primary)"
3233
+ },
3234
+ "aria-hidden": "true",
3235
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3236
+ }),
3237
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3238
+ style: {
3239
+ flex: "1 1 auto",
3240
+ minWidth: 0,
3241
+ wordBreak: "break-word",
3242
+ fontSize: 12,
3243
+ lineHeight: "17px",
3244
+ color: "var(--dsw-alias-state-error-primary)"
3245
+ },
3246
+ children: orphanFailure
3247
+ }),
3248
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3249
+ variant: "outline",
3250
+ size: "sm",
3251
+ style: { flex: "none" },
3252
+ disabled: busy,
3253
+ onClick: () => void query.refreshAll(),
3254
+ children: t("common.retry")
3255
+ })
3256
+ ]
3257
+ }) : null,
3258
+ orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3259
+ "data-dsh-session-delete": "orphan-notice",
3260
+ role: "status",
3261
+ style: {
3262
+ ...CARD,
3263
+ alignItems: "flex-start",
3264
+ gap: 10,
3265
+ marginBottom: 10,
3266
+ padding: "10px 12px"
3267
+ },
3268
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3269
+ style: {
3270
+ display: "inline-flex",
3271
+ flex: "none",
3272
+ paddingTop: 2,
3273
+ color: "var(--dsw-alias-label-tertiary)"
3274
+ },
3275
+ "aria-hidden": "true",
3276
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3277
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3278
+ style: {
3279
+ flex: "1 1 auto",
3280
+ minWidth: 0,
3281
+ wordBreak: "break-word",
3282
+ fontSize: 12,
3283
+ lineHeight: "17px",
3284
+ color: "var(--dsw-alias-label-secondary)"
3285
+ },
3286
+ children: orphanNotice
3287
+ })]
3288
+ }) : null,
3289
+ orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3290
+ report: orphanReport,
3291
+ busy,
3292
+ t,
3293
+ onCancel: () => query.setOrphanConfirm(false),
3294
+ onConfirm: () => void query.cleanOrphans()
3295
+ }) : null
3296
+ ] });
3297
+ }
3298
+ //#endregion
3299
+ //#region node_modules/.pnpm/comma-separated-tokens@2.0.3/node_modules/comma-separated-tokens/index.js
3300
+ /**
3301
+ * Serialize an array of strings or numbers to comma-separated tokens.
3302
+ *
2307
3303
  * @param {Array<string|number>} values
2308
3304
  * List of tokens.
2309
3305
  * @param {Options} [options]
@@ -24255,21 +25251,29 @@ window.__ModuleLoader__.load({
24255
25251
  });
24256
25252
  });
24257
25253
  //#endregion
24258
- //#region src/client/ui/recipe.ts
25254
+ //#region src/client/trash-detail.tsx
24259
25255
  /**
24260
- * 安静的红:**只给字色、不给底**。宿主控件的 `.sd-button-ghost:hover` / `:active` 是普通类,
24261
- * 而作者态的内联 `background` 永远赢过类规则 ⇒ 铺了底的红按钮在点错之前不给任何视觉回应。
24262
- * 整页唯一那块实心红另有写法(`variant="primary"` + 就地覆盖 primary 那对 token),不在这里。
25256
+ * 回收站的**详情块**(docs/ui-spec.md 的四态)。
25257
+ *
25258
+ * 它**不自己取数**:选中、缓存、失败与重读全在面板控制器(`use-trash-panel-controller.ts`)手里,
25259
+ * 这里只把一个状态画出来——
25260
+ * 所以它能被单独测穿(喂四种状态,断四张形状)。
25261
+ *
25262
+ * 三个不能让步的点:
25263
+ * ① **动作不住在这里**(2026-10-15 真机反馈后定,与 2026-10-01 那版同形):「恢复」「彻底删除…」
25264
+ * 在清单行右侧。搬进详情那一阵子,左栏为了只放标题被压到 316px,读起来就是"左侧太窄"——
25265
+ * 动作回行内之后左栏需要的宽度也回来了(420px,理由写在面板常量处)。
25266
+ * ② **标题优先用清册那一份**:保证「行里叫 A、点开也叫 A」,清册标题是空串才回落到日志里的
25267
+ * `session/title`。
25268
+ * ③ **注入条数与截断条数必须上屏**:正文比日志里的 `user/message` 少一大截是**过滤的结果**,
25269
+ * 页面上要说清少掉的是什么、多少条,不能让它读起来像"这条会话就这么点内容"。
25270
+ *
25271
+ * 皮肤**只由 token 决定**,且 token 与消息行的声明都来自宿主自己那两张表(`MessageItem.module.css` /
25272
+ * `AssistantMarkdown.module.css` / `MarkdownText.module.css`,逐字抄,来源与差异记在 `ui/markdown.css`
25273
+ * 文件头)——字号一律走 `--dsh-content-font-*` 那根轴,不再写宿主同类面的字面量 px。
25274
+ * **助手正文走 Markdown,用户提问走原话**(2026-10-15 真机反馈后改;见 `markdown.tsx` 那三行收口):
25275
+ * 助手的 `text` 段本身就是 markdown 源,用 `pre-wrap` 摆出来就是用户说的"对话内容没渲染、是一整块纯文本"。
24263
25276
  */
24264
- const QUIET_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
24265
- /** 单行截断。宽档那一列与可拖的分栏天然会挤文字,逐处手写这三行就会各写各的。 */
24266
- const ELLIPSIS = {
24267
- overflow: "hidden",
24268
- textOverflow: "ellipsis",
24269
- whiteSpace: "nowrap"
24270
- };
24271
- //#endregion
24272
- //#region src/client/trash-detail.tsx
24273
25277
  const SHELL = {
24274
25278
  display: "flex",
24275
25279
  flexDirection: "column",
@@ -24624,13 +25628,13 @@ window.__ModuleLoader__.load({
24624
25628
  //#endregion
24625
25629
  //#region src/client/use-column-resize.ts
24626
25630
  /**
24627
- * 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15 加,设计稿 §7 状态④)。
25631
+ * 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15 加,`docs/ui-spec.md` 的「面板与清单」)。
24628
25632
  *
24629
25633
  * 三件事住在这里,因为它们互相咬合、分开测各自都不成立:
24630
25634
  * ① **宽度夹在两个界之间**。下限由调用方给(回收站那条的来历写在 `trash-panel.tsx`:真 Chromium
24631
25635
  * 上扫出来的,约束不是"两颗按钮放得下"——它们 `flex:none`,再窄也是文字列缩;而是「删除于 … ·
24632
25636
  * 大小」那一行不再被省略号截断),上限 = 面板宽 − `reserve`。**`reserve` 的数值与来历都住在调用方**
24633
- * (`DETAIL_RESERVE`,那三件东西怎么加出来的见设计稿 2026-10-15 §7.1 与现状稿 §8 的几何读数):
25637
+ * (`DETAIL_RESERVE`,那三件东西怎么加出来的见 `trash-panel.tsx` 该常量的注释与几何用例):
24634
25638
  * 这一层不复制那份算式——上一版在这里钉过一个"480 ⇒ 900 那档正好回 420",调用方改到 489 之后
24635
25639
  * 它就成了假数,而 900 那档的真实读数是**清单被夹到 411**(比默认窄 9px,还能往里拖)。
24636
25640
  * ② **夹在"读"的时候也做一次**。窗口变窄不触发任何写入,若只在写入时夹,之前拖出来的超宽
@@ -24711,7 +25715,7 @@ window.__ModuleLoader__.load({
24711
25715
  //#endregion
24712
25716
  //#region src/client/use-panel-width.ts
24713
25717
  /**
24714
- * 观测**面板自己那一块**的宽度(回收站的断点全靠它,设计稿 2026-10-15 §2.1)。
25718
+ * 观测**面板自己那一块**的宽度(回收站的断点全靠它,docs/ui-spec.md)。
24715
25719
  *
24716
25720
  * 为什么不是 `window.innerWidth`:这块住在宿主的中央列里,**侧栏折叠/展开、拖动分栏**都会改它的宽度
24717
25721
  * 而 window 一点不动——只听 window 的 resize 会在侧栏折叠后拿着旧宽度画错形态。
@@ -24741,262 +25745,360 @@ window.__ModuleLoader__.load({
24741
25745
  return width;
24742
25746
  }
24743
25747
  //#endregion
24744
- //#region src/client/trash-view.ts
24745
- /** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
24746
- const CARD = {
24747
- display: "flex",
24748
- alignItems: "center",
24749
- gap: 14,
24750
- minWidth: 0,
24751
- padding: "9px 14px",
24752
- background: "var(--dsw-alias-bg-layer-2)",
24753
- border: "0.5px solid var(--dsw-alias-border-l4)",
24754
- borderRadius: "var(--dsw-radius-lg)"
24755
- };
24756
- /** 选中行:底色与描边都换成宿主"选中"那一档(`trash-panel.tsx` 头注里有这两枚 token 的实测取值)。 */
24757
- const CARD_SELECTED = {
24758
- ...CARD,
24759
- background: "var(--dsw-alias-button-ghost-active-fill)",
24760
- border: "0.5px solid var(--dsw-alias-button-ghost-active-border)"
24761
- };
24762
- /**
24763
- * **整页唯一那块实心红**的写法:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
24764
- * 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
24765
- */
24766
- const BULK_DANGER = {
24767
- "--dsw-alias-button-primary-fill": "var(--dsw-alias-state-error-primary)",
24768
- "--dsw-alias-button-primary-hover": "var(--dsw-alias-state-error-primary)"
24769
- };
24770
- /**
24771
- * 项目名怎么念:宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、
24772
- * 拆不回真实路径;但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。
24773
- * 摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
24774
- * 字母表只有一份:还原走 `shared/wire.ts`。
24775
- */
24776
- function projectLabel(projectDir, t) {
24777
- const bare = unescapeProjectKey(projectDir.replace(/^--/, "").replace(/--$/, ""));
24778
- return bare === "" || bare === "_no-cwd" ? t("trash.project.unknown") : bare;
24779
- }
24780
- /** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
24781
- function groupByProject(entries, t) {
24782
- const byProject = /* @__PURE__ */ new Map();
24783
- for (const entry of [...entries].sort((a, b) => b.deletedAt - a.deletedAt)) {
24784
- const list = byProject.get(entry.projectDir);
24785
- if (list === void 0) byProject.set(entry.projectDir, [entry]);
24786
- else list.push(entry);
24787
- }
24788
- return [...byProject].map(([projectDir, list]) => ({
24789
- projectDir,
24790
- label: projectLabel(projectDir, t),
24791
- latest: list[0].deletedAt,
24792
- entries: list
24793
- })).sort((a, b) => b.latest - a.latest);
24794
- }
24795
- //#endregion
24796
- //#region src/client/trash-confirm.tsx
24797
- /** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
24798
- const CARD_LIST = {
24799
- display: "flex",
24800
- flexDirection: "column",
24801
- gap: 6,
24802
- maxHeight: 280,
24803
- overflow: "auto",
24804
- marginBlockEnd: 10
24805
- };
24806
- /**
24807
- * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
24808
- * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
24809
- * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
24810
- * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
24811
- */
24812
- function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
24813
- if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
24814
- open: true,
24815
- onClose: () => {
24816
- if (!busy) onCancel();
24817
- },
24818
- title: t("trash.confirm.restore", { count: pending.ids.length }),
24819
- closeLabel: t("common.close"),
24820
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24821
- variant: "outline",
24822
- disabled: busy,
24823
- onClick: onCancel,
24824
- children: t("common.cancel")
24825
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24826
- variant: "primary",
24827
- disabled: busy,
24828
- onClick: () => onConfirmRestore(pending.ids),
24829
- children: t("trash.confirm.restoreNow")
24830
- })] }),
24831
- children: [restoreEntries.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24832
- style: CARD_LIST,
24833
- children: restoreEntries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24834
- style: {
24835
- ...CARD,
24836
- flexDirection: "column",
24837
- alignItems: "stretch",
24838
- gap: 2,
24839
- padding: "9px 12px"
24840
- },
24841
- children: [
24842
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24843
- style: {
24844
- ...ELLIPSIS,
24845
- fontSize: 13.5,
24846
- lineHeight: "19px"
24847
- },
24848
- children: entry.title === "" ? t("common.untitled") : entry.title
24849
- }),
24850
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24851
- style: {
24852
- ...ELLIPSIS,
24853
- fontSize: 12,
24854
- lineHeight: "16px",
24855
- color: "var(--dsw-alias-label-tertiary)"
24856
- },
24857
- children: projectLabel(entry.projectDir, t)
24858
- }),
24859
- entry.workspaceMissing === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24860
- style: {
24861
- fontSize: 12,
24862
- lineHeight: "16px",
24863
- color: "var(--dsw-alias-label-tertiary)"
24864
- },
24865
- children: t("trash.row.workspaceGone")
24866
- }) : null
24867
- ]
24868
- }, entry.id))
24869
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.confirm.restore.body") })]
24870
- });
24871
- const title = pending.kind === "all" ? t("trash.confirm.all") : pending.kind === "entries" ? t("trash.confirm.bulk", { count: pending.ids.length }) : t("trash.confirm.one");
24872
- const strong = t("trash.confirm.body.strong");
24873
- const body = splitOn(t("trash.confirm.body", { strong }), strong);
24874
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
24875
- open: true,
24876
- onClose: () => {
24877
- if (!busy) onCancel();
24878
- },
24879
- title,
24880
- closeLabel: t("common.close"),
24881
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24882
- variant: "outline",
24883
- disabled: busy,
24884
- onClick: onCancel,
24885
- children: t("common.cancel")
24886
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24887
- variant: "primary",
24888
- style: BULK_DANGER,
24889
- disabled: busy,
24890
- onClick: onConfirmPurge,
24891
- children: t("trash.confirm.purge")
24892
- })] }),
24893
- children: [pending.kind === "entry" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24894
- style: {
24895
- ...CARD,
24896
- flexDirection: "column",
24897
- alignItems: "stretch",
24898
- gap: 2,
24899
- padding: "10px 12px",
24900
- marginBlockEnd: 10
24901
- },
24902
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24903
- style: {
24904
- ...ELLIPSIS,
24905
- fontSize: 13.5,
24906
- lineHeight: "19px"
24907
- },
24908
- children: pending.entry.title === "" ? t("common.untitled") : pending.entry.title
24909
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24910
- style: {
24911
- ...ELLIPSIS,
24912
- fontSize: 12,
24913
- lineHeight: "16px",
24914
- color: "var(--dsw-alias-label-tertiary)"
24915
- },
24916
- children: t("trash.confirm.one.meta", {
24917
- project: projectLabel(pending.entry.projectDir, t),
24918
- at: absoluteText(pending.entry.deletedAt),
24919
- size: fileSizeText(pending.entry.releaseBytes)
24920
- })
24921
- })]
24922
- }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
24923
- body[0],
24924
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
24925
- style: { color: "var(--dsw-alias-state-error-primary)" },
24926
- children: body[1]
24927
- }),
24928
- body[2]
24929
- ] })]
24930
- });
25748
+ //#region src/client/trash-nav.ts
25749
+ /**
25750
+ * 清单的**键盘导航面**:方向键走环、Enter / Space 的双重语义、roving tabindex 的落点,
25751
+ * 以及窄档下钻里按 Esc 退回清单并把焦点交还给那一行。
25752
+ *
25753
+ * 为什么收回一处:这几条判据此前劈在 `trash-panel.tsx`(键盘分支与 `activeDescendantId` /
25754
+ * `tabStopId` 两枚只读)与 `use-trash-panel-controller.ts`(Esc 的两个 effect 与那笔焦点账)之间。
25755
+ * 它们说的其实是同一件事——"屏幕上从上到下的那一串行里,谁是光标、谁拿 tab 落点、Esc 之后焦点
25756
+ * 还给谁"——而那份顺序只在面板手里(`ordered` 只含展开的组)。收进一个 module 之后面板只交输入
25757
+ * 与四个动作,控制器不再知道焦点这回事。
25758
+ *
25759
+ * 它**不查文档、不认选择器**:焦点动作由调用方注入 `focusRow`,DOM 查询仍留在面板——行是在那儿
25760
+ * 渲染出来的,选择器只在那儿有权威。它只读事件的 `target` 来判断这一下该不该归清单:从行内按钮 /
25761
+ * 勾选框起来的 Enter / Space 是人家的激活键,不是清单的下钻或勾选。
25762
+ *
25763
+ * 三条原样搬来的判据(界面现状住 `docs/ui-spec.md` 的「键盘」那条;这里只留「为什么这么定」):
25764
+ * - **方向键走环**:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷,不是撞墙。
25765
+ * - **`aria-activedescendant` 只指向在 DOM 里的行**:选中那行被折叠起来时它就不存在,指过去是
25766
+ * 假引用。tab 落点(清单里唯一的 tab 巢)走同一条可见性规则,但**永远有一行拿着**——否则一次
25767
+ * 没点过时全行都是 `-1`,键盘根本进不来清单。焦点所在的行就是激活目标:选中行被折叠时,
25768
+ * Enter / Space 落在拿 tab 落点的那一条上,而不是一声不响。
25769
+ * - **Esc 只在下钻期间生效**:监听挂 `document`,因为窄档下钻时清单**不在 DOM 里**;退回后把焦点
25770
+ * 交还选中那一行**一次**,交完就清账,后续重渲染不再重复抢焦点。
25771
+ */
25772
+ /** 事件是不是清单自己的:从行(`role="option"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
25773
+ * 归它们自己。测试里的窄事件没有 `target`,按清单自己的处理。 */
25774
+ function isListOwnedEvent(event) {
25775
+ const target = event.target;
25776
+ if (target == null || target === event.currentTarget) return true;
25777
+ return typeof target.getAttribute === "function" && target.getAttribute("role") === "option";
25778
+ }
25779
+ function useTrashNav(input) {
25780
+ const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, exitDrill, focusRow } = input;
25781
+ /** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
25782
+ const refocusRow = (0, react.useRef)(null);
25783
+ /** 折叠起来的组只是收掉那一列,选中的 id 还留着——所以这条可见性得逐条对账,不能只看非空。 */
25784
+ const activeDescendantId = ordered.some((entry) => entry.id === selectedId) ? selectedId : null;
25785
+ const tabStopId = activeDescendantId ?? ordered[0]?.id ?? null;
25786
+ const onListKeyDown = (0, react.useCallback)((event) => {
25787
+ if (ordered.length === 0 || !isListOwnedEvent(event)) return;
25788
+ const index = ordered.findIndex((entry) => entry.id === selectedId);
25789
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
25790
+ event.preventDefault();
25791
+ const step = event.key === "ArrowDown" ? 1 : -1;
25792
+ const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
25793
+ const target = ordered[next];
25794
+ select(target);
25795
+ focusRow(target.id);
25796
+ return;
25797
+ }
25798
+ if (event.key === "Enter" || event.key === " ") {
25799
+ const target = ordered[index >= 0 ? index : 0];
25800
+ if (target === void 0) return;
25801
+ event.preventDefault();
25802
+ if (selectMode) {
25803
+ toggleCheck(target.id);
25804
+ return;
25805
+ }
25806
+ if (drillEnabled) select(target, { drill: true });
25807
+ else select(target);
25808
+ }
25809
+ }, [
25810
+ drillEnabled,
25811
+ focusRow,
25812
+ ordered,
25813
+ select,
25814
+ selectMode,
25815
+ selectedId,
25816
+ toggleCheck
25817
+ ]);
25818
+ (0, react.useEffect)(() => {
25819
+ if (!drilled || !drillEnabled) return;
25820
+ const onKey = (event) => {
25821
+ if (event.key !== "Escape") return;
25822
+ refocusRow.current = selectedId;
25823
+ exitDrill();
25824
+ };
25825
+ document.addEventListener("keydown", onKey);
25826
+ return () => {
25827
+ document.removeEventListener("keydown", onKey);
25828
+ };
25829
+ }, [
25830
+ drillEnabled,
25831
+ drilled,
25832
+ exitDrill,
25833
+ selectedId
25834
+ ]);
25835
+ (0, react.useEffect)(() => {
25836
+ if (drilled || refocusRow.current === null) return;
25837
+ focusRow(refocusRow.current);
25838
+ refocusRow.current = null;
25839
+ }, [drilled, focusRow]);
25840
+ return {
25841
+ activeDescendantId,
25842
+ tabStopId,
25843
+ onListKeyDown
25844
+ };
24931
25845
  }
24932
- /** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
24933
- function OrphanConfirm({ report, busy, t, onCancel, onConfirm }) {
24934
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
24935
- open: true,
24936
- onClose: () => {
24937
- if (!busy) onCancel();
24938
- },
24939
- title: t("trash.orphans.confirm", { count: report.totalSessions }),
24940
- closeLabel: t("common.close"),
24941
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24942
- variant: "outline",
24943
- disabled: busy,
24944
- onClick: onCancel,
24945
- children: t("common.cancel")
24946
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24947
- variant: "primary",
24948
- style: BULK_DANGER,
24949
- disabled: busy,
24950
- onClick: onConfirm,
24951
- children: t("trash.orphans.clean")
24952
- })] }),
24953
- children: [
24954
- report.groups.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24955
- style: CARD_LIST,
24956
- children: report.groups.map((group) => {
24957
- const text = t("trash.orphans.group", {
24958
- project: projectLabel(group.projectDir, t),
24959
- count: group.sessions,
24960
- size: fileSizeText(group.bytes)
24961
- });
24962
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24963
- title: text,
24964
- style: {
24965
- ...CARD,
24966
- padding: "9px 12px",
24967
- fontSize: 13,
24968
- lineHeight: "19px"
24969
- },
24970
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24971
- style: {
24972
- ...ELLIPSIS,
24973
- minWidth: 0,
24974
- flex: "1 1 auto"
24975
- },
24976
- children: text
24977
- })
24978
- }, group.projectDir);
24979
- })
24980
- }),
24981
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("trash.orphans.body") }),
24982
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
24983
- style: {
24984
- fontSize: 12,
24985
- lineHeight: "17px",
24986
- color: "var(--dsw-alias-label-tertiary)"
24987
- },
24988
- children: t("trash.orphans.cacheNote")
24989
- })
24990
- ]
24991
- });
25846
+ //#endregion
25847
+ //#region src/client/use-trash-panel-controller.ts
25848
+ /**
25849
+ * 回收站面板的**状态机**:谁被选中、详情缓存、窄档下钻、清单换版后的清场、
25850
+ * 宽档自动选中、"别处动过回收站"的失效重读,以及确认后的写动作(恢复/批量/清空)与失败落点。
25851
+ *
25852
+ * 面板接入后,这套语义只住这里;面板自己只留布局与行内动作。搬过来的判据一条不改:
25853
+ *
25854
+ * - **选中从全量条目里找**(`query.entries`,不是键盘序列 `ordered`):把某一组折叠起来只是收掉
25855
+ * 清单那一列,右栏读的那条不该跟着消失。
25856
+ * - **一条只读一次**:面板存活期内按条目 id 缓存成功读数;**失败不缓存**——「重试」与切走再切回
25857
+ * 都会重新读那一条(缓存里存一次失败,界面就再也等不到它自己好了)。
25858
+ * - **清单换版就让位**:选中的 id 不在新清单里 ⇒ 丢它的缓存、清选中、退出下钻。清单读不到
25859
+ * (`entries === null`)**不算"没了"**:那一拍只是不知道,选中 id 留着,详情回 idle。
25860
+ * - **自动选中只认 `selectedId === null`**:同一拍里选中的那条消失时,自动选中看到的是旧选中
25861
+ * (非 null)⇒ 不抢;清场落地后的下一拍才接住新清单的第一条。判据是这道 null 门,不是两个
25862
+ * effect 的声明顺序(顺序反过来也拦得住)。
25863
+ * - **失效通告只戳重读**:`useTrashRevision` 变了就 `query.refreshAfterWrite()`;挂载那拍才走
25864
+ * `refreshAll()`。它不碰选中、不碰详情缓存(清单换了自会有上面的清场来收尾)。
25865
+ * - **动作成功就丢缓存**:恢复/批量彻底删除成功的条目、清空成功的全部条目,详情缓存必须在同一趟里
25866
+ * 失效——否则这条会话再被删回来时会摆着上一世的正文。失败只按 `actionFailure[id]` 留原文,不碰缓存。
25867
+ * - **`back()` 只收下钻、不动焦点**:Esc 那条(下钻期间挂 document 监听、退回后把焦点交还那一行
25868
+ * 一次)住 `trash-nav.ts`;控制器不查 DOM、也不认识焦点。
25869
+ *
25870
+ * **失败原文不翻译**:`detailFailure.message` 与 `actionFailure[id]` 都是 Node 半给的原文
25871
+ * (`callRemote` 的 `failure.message`),原样交给详情块与清单行上屏。
25872
+ *
25873
+ * `busy` 是面板那枚全局闸(写动作在飞)的读数,闸本体住 `busy-gate.ts`;`restore` / `confirmApply`
25874
+ * 把闸交给写入座位(`write-seats.ts`),由 `runWrite` 把「动作 + 收尾 + 清单重读」整趟包住——控制器
25875
+ * 不再自己 `around`;闸开着时谁该禁用由面板决定。`kind:'all'` 的清空不是 `/apply` 座位:
25876
+ * `query.emptyTrash` 自己包同一枚闸。
25877
+ */
25878
+ function useTrashPanelController(input) {
25879
+ const { deps, gate, query, ordered, canAutoSelect } = input;
25880
+ const { entries, refreshAll, refreshAfterWrite, emptyTrash } = query;
25881
+ const [selectedId, setSelectedId] = (0, react.useState)(null);
25882
+ const [drilled, setDrilled] = (0, react.useState)(false);
25883
+ const [loadingId, setLoadingId] = (0, react.useState)(null);
25884
+ const [detailFailure, setDetailFailure] = (0, react.useState)(null);
25885
+ const [pending, setPending] = (0, react.useState)(null);
25886
+ const [actionFailure, setActionFailure] = (0, react.useState)({});
25887
+ /** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
25888
+ const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
25889
+ /** 挂载那一拍做完整刷新(清单 + 残留),之后失效通告只走写后刷新。 */
25890
+ const firstRefresh = (0, react.useRef)(true);
25891
+ const revision = useTrashRevision();
25892
+ (0, react.useEffect)(() => {
25893
+ if (firstRefresh.current) {
25894
+ firstRefresh.current = false;
25895
+ refreshAll();
25896
+ return;
25897
+ }
25898
+ refreshAfterWrite();
25899
+ }, [
25900
+ refreshAll,
25901
+ refreshAfterWrite,
25902
+ revision
25903
+ ]);
25904
+ /**
25905
+ * 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
25906
+ * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
25907
+ */
25908
+ const select = (0, react.useCallback)(async (entry, options = {}) => {
25909
+ setSelectedId(entry.id);
25910
+ if (options.drill === true) setDrilled(true);
25911
+ if (detailCache.current.has(entry.id)) {
25912
+ setLoadingId(null);
25913
+ setDetailFailure(null);
25914
+ return;
25915
+ }
25916
+ setLoadingId(entry.id);
25917
+ setDetailFailure(null);
25918
+ const outcome = await callRemote(() => deps.detail(entry.id));
25919
+ setLoadingId((current) => current === entry.id ? null : current);
25920
+ if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
25921
+ else setDetailFailure({
25922
+ entryId: entry.id,
25923
+ message: outcome.failure.message
25924
+ });
25925
+ }, [deps]);
25926
+ (0, react.useEffect)(() => {
25927
+ if (entries === null || selectedId === null) return;
25928
+ if (entries.some((entry) => entry.id === selectedId)) return;
25929
+ detailCache.current.delete(selectedId);
25930
+ setSelectedId(null);
25931
+ setDrilled(false);
25932
+ }, [entries, selectedId]);
25933
+ (0, react.useEffect)(() => {
25934
+ if (canAutoSelect && drilled) setDrilled(false);
25935
+ }, [canAutoSelect, drilled]);
25936
+ (0, react.useEffect)(() => {
25937
+ if (canAutoSelect && selectedId === null && ordered.length > 0) select(ordered[0]);
25938
+ }, [
25939
+ canAutoSelect,
25940
+ selectedId,
25941
+ ordered,
25942
+ select
25943
+ ]);
25944
+ /**
25945
+ * 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:折叠组只是把那一列收掉,
25946
+ * 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
25947
+ */
25948
+ const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
25949
+ const detail = (0, react.useMemo)(() => {
25950
+ if (selected === null) return { kind: "idle" };
25951
+ if (loadingId === selected.id) return {
25952
+ kind: "loading",
25953
+ entry: selected
25954
+ };
25955
+ const cached = detailCache.current.get(selected.id);
25956
+ if (cached !== void 0) return {
25957
+ kind: "ready",
25958
+ entry: selected,
25959
+ detail: cached
25960
+ };
25961
+ if (detailFailure?.entryId === selected.id) return {
25962
+ kind: "failed",
25963
+ entry: selected,
25964
+ message: detailFailure.message
25965
+ };
25966
+ return {
25967
+ kind: "loading",
25968
+ entry: selected
25969
+ };
25970
+ }, [
25971
+ selected,
25972
+ loadingId,
25973
+ detailFailure
25974
+ ]);
25975
+ /** 「重试」只重读当前这一条,不牵清单。 */
25976
+ const retry = (0, react.useCallback)(() => {
25977
+ if (selected !== null) select(selected);
25978
+ }, [selected, select]);
25979
+ /** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
25980
+ const back = (0, react.useCallback)(() => {
25981
+ setDrilled(false);
25982
+ }, []);
25983
+ /** 一次动作的失败原文落回它自己那一行(按条目 id 存,别条目的成败不会串台)。 */
25984
+ const failRow = (0, react.useCallback)((entryId, message) => {
25985
+ setActionFailure((current) => ({
25986
+ ...current,
25987
+ [entryId]: message
25988
+ }));
25989
+ }, []);
25990
+ /** 成功支(含 already-gone)的条目丢详情缓存。 */
25991
+ const clearDetailCache = (0, react.useCallback)((successIds) => {
25992
+ for (const id of successIds) detailCache.current.delete(id);
25993
+ }, []);
25994
+ /**
25995
+ * 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把整趟交给面板恢复座位。
25996
+ * 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
25997
+ * 失败也重读:组回滚可能自己失败(`details.rollbackFailures`),清单不能继续摆旧行。
25998
+ * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次。
25999
+ * 单条恢复走 `single` 读法,`already-gone` 不可能出现,座位里那条查找兜底在此不会命中。
26000
+ */
26001
+ const restore = (0, react.useCallback)(async (entry) => {
26002
+ setActionFailure((current) => {
26003
+ const next = { ...current };
26004
+ delete next[entry.id];
26005
+ return next;
26006
+ });
26007
+ const failure = (await restoreFromPanel({
26008
+ deps,
26009
+ ids: [entry.id],
26010
+ single: true,
26011
+ entries,
26012
+ gate,
26013
+ refreshAfterWrite,
26014
+ clearDetailCache
26015
+ })).failures.get(entry.id);
26016
+ if (failure !== void 0) failRow(entry.id, failure);
26017
+ }, [
26018
+ clearDetailCache,
26019
+ deps,
26020
+ entries,
26021
+ failRow,
26022
+ gate,
26023
+ refreshAfterWrite
26024
+ ]);
26025
+ /**
26026
+ * 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
26027
+ * `WriteOutcome` 里 ⇒ 不再"每条一次全量重读"。顶层失败(路由自检 / 通道断)只说明没拿到信封,
26028
+ * 不能断言哪几条落地:原文保守地铺到每一个 id(别让清单看起来"删成功了"),清单照样重读。
26029
+ */
26030
+ const confirmApply = (0, react.useCallback)(async (action, ids) => {
26031
+ const settle = (outcome) => {
26032
+ setPending(null);
26033
+ return refreshAfterWrite(outcome);
26034
+ };
26035
+ const seat = {
26036
+ deps,
26037
+ ids,
26038
+ gate,
26039
+ refreshAfterWrite: settle,
26040
+ clearDetailCache
26041
+ };
26042
+ const outcome = action === "purge" ? await purgeFromPanel(seat) : await restoreFromPanel({
26043
+ ...seat,
26044
+ entries
26045
+ });
26046
+ if (outcome.failures.size === 0) return;
26047
+ setActionFailure((current) => {
26048
+ const next = { ...current };
26049
+ for (const [id, message] of outcome.failures) next[id] = message;
26050
+ return next;
26051
+ });
26052
+ }, [
26053
+ clearDetailCache,
26054
+ deps,
26055
+ entries,
26056
+ gate,
26057
+ refreshAfterWrite
26058
+ ]);
26059
+ /**
26060
+ * 确认框上的「确认」按 `pending` 的形状分派:三条按条目标都委托 `confirmApply`(一段 `/apply`);
26061
+ * 清空是**聚合回执**、形状不同,走 `query.emptyTrash`——`emptyTrash` 自己包闸;这条不是 `/apply`
26062
+ * 座位,别在外面再包一层(双套会让外层 busy 提前落下)。
26063
+ */
26064
+ const confirmPurge = (0, react.useCallback)(async () => {
26065
+ const target = pending;
26066
+ if (target === null) return;
26067
+ if (target.kind === "entry") return confirmApply("purge", [target.entry.id]);
26068
+ if (target.kind === "entries") return confirmApply("purge", target.ids);
26069
+ if (target.kind === "restore") return confirmApply("restore", target.ids);
26070
+ const emptied = await emptyTrash();
26071
+ setPending(null);
26072
+ if (emptied) detailCache.current.clear();
26073
+ }, [
26074
+ confirmApply,
26075
+ emptyTrash,
26076
+ pending
26077
+ ]);
26078
+ return {
26079
+ busy: gate.busy,
26080
+ selected,
26081
+ selectedId,
26082
+ drilled,
26083
+ detail,
26084
+ pending,
26085
+ actionFailure,
26086
+ select,
26087
+ retry,
26088
+ back,
26089
+ setPending,
26090
+ restore,
26091
+ confirmPurge,
26092
+ confirmApply
26093
+ };
24992
26094
  }
24993
26095
  //#endregion
24994
26096
  //#region src/client/use-trash-query.ts
24995
26097
  /**
24996
- * 清册读数与无主子会话的**取数编排**(架构复核候选 3 的第二道内部 seam)。
26098
+ * 清册读数与无主子会话的**取数编排**。
24997
26099
  *
24998
26100
  * 它从 `trash-panel.tsx` 的函数体里整块搬出来,连同三条承重规矩:
24999
- * ① **到达序号**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
26101
+ * ① **最新票**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
25000
26102
  * 后到的旧响应不许盖掉刚发生的动作带来的新状态;
25001
26103
  * ② **先清单后扫描**:`GET /orphans` 判"父没了"吃的是清册,而清册要由 `/list` 那次对账才与
25002
26104
  * `session-trash/` 的目录持平——反过来接,刚被手工搬进回收站的父会被报成失踪父;
@@ -25004,67 +26106,133 @@ window.__ModuleLoader__.load({
25004
26106
  *
25005
26107
  * 清理(`/apply` 的 `orphan-clean`)也住这里:它与扫描共用同一份报告与同一组提示,"读"与"写"分家
25006
26108
  * 会让 `busy` 与重读顺序那条判据劈成两半。`busy` 本身不归它管——那是面板的一枚全局闸,通过
25007
- * `around` 传进来(`around` 里跑的就是"这一趟还算忙")。
26109
+ * `gate` 传进来(闸里跑的就是"这一趟还算忙")。
25008
26110
  */
25009
- function useTrashQuery({ deps, t, around }) {
26111
+ function useTrashQuery({ deps, t, gate }) {
25010
26112
  const [entries, setEntries] = (0, react.useState)(null);
25011
26113
  const [listFailure, setListFailure] = (0, react.useState)(null);
25012
26114
  const [orphanReport, setOrphanReport] = (0, react.useState)(null);
25013
26115
  const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
25014
26116
  const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
25015
26117
  const [orphanConfirm, setOrphanConfirm] = (0, react.useState)(false);
25016
- const reloadSeq = (0, react.useRef)(0);
25017
- const reload = (0, react.useCallback)(async () => {
25018
- const seq = ++reloadSeq.current;
26118
+ /** 报告的同步只读镜像:`refreshAfterWrite` 需要区分「未知」与「已有读数」,又不把 identity 绑在 state 上。 */
26119
+ const orphanReportRef = (0, react.useRef)(null);
26120
+ const setReport = (0, react.useCallback)((report) => {
26121
+ orphanReportRef.current = report;
26122
+ setOrphanReport(report);
26123
+ }, []);
26124
+ /** 清单的同步只读镜像:`refreshAfterWrite` 要在发下一次读**之前**按"当前清单"判要不要强制重扫,
26125
+ * 又不能让回调跟着渲染期 state 换代。 */
26126
+ const entriesRef = (0, react.useRef)(null);
26127
+ const [reloadLatest] = (0, react.useState)(createLatestWins);
26128
+ const reloadRows = (0, react.useCallback)(async () => {
26129
+ const ticket = reloadLatest.issue();
25019
26130
  const outcome = await callRemote(() => deps.list());
25020
- if (seq !== reloadSeq.current) return false;
26131
+ if (!ticket.current) return "stale";
25021
26132
  if (!outcome.ok) {
25022
26133
  setListFailure(outcome.failure.message);
26134
+ entriesRef.current = null;
25023
26135
  setEntries(null);
25024
- return false;
26136
+ return "failed";
25025
26137
  }
25026
26138
  setListFailure(null);
26139
+ entriesRef.current = outcome.value;
25027
26140
  setEntries(outcome.value);
25028
- return true;
25029
- }, [deps]);
25030
- const orphanScanSeq = (0, react.useRef)(0);
25031
- const scanOrphans = (0, react.useCallback)(async () => {
25032
- const seq = ++orphanScanSeq.current;
26141
+ return "ok";
26142
+ }, [deps, reloadLatest]);
26143
+ const [orphanLatest] = (0, react.useState)(createLatestWins);
26144
+ /** 强制重扫标记:先记再读清单;这次读输给后发读时,由赢家消费,强制不丢。 */
26145
+ const pendingForcedRescan = (0, react.useRef)(false);
26146
+ /** 清单这一拍读失败:清掉报告与提示、作废在飞的旧扫描(晚到的读数不能把报告写回来),
26147
+ * 并丢掉挂起的强制重扫(报告已回 null,下一次成功刷新本来就会重扫)。 */
26148
+ const clearOrphanState = (0, react.useCallback)(() => {
26149
+ orphanLatest.invalidate();
26150
+ pendingForcedRescan.current = false;
26151
+ setReport(null);
26152
+ setOrphanFailure(null);
26153
+ setOrphanNotice(null);
26154
+ setOrphanConfirm(false);
26155
+ }, [orphanLatest, setReport]);
26156
+ const rescanOrphans = (0, react.useCallback)(async () => {
26157
+ const ticket = orphanLatest.issue();
25033
26158
  const outcome = await callRemote(() => deps.scanOrphans());
25034
- if (seq !== orphanScanSeq.current) return;
26159
+ if (!ticket.current) return;
25035
26160
  if (!outcome.ok) {
25036
- setOrphanReport(null);
26161
+ setReport(null);
25037
26162
  setOrphanNotice(null);
25038
26163
  setOrphanConfirm(false);
25039
26164
  setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
25040
26165
  return;
25041
26166
  }
25042
- setOrphanReport(outcome.value);
26167
+ setReport(outcome.value);
25043
26168
  setOrphanFailure(null);
25044
26169
  setOrphanNotice(null);
25045
- }, [deps, t]);
25046
- const refresh = (0, react.useCallback)(async () => {
25047
- if (!await reload()) {
25048
- setOrphanReport(null);
25049
- setOrphanFailure(null);
25050
- setOrphanNotice(null);
25051
- setOrphanConfirm(false);
26170
+ }, [
26171
+ deps,
26172
+ orphanLatest,
26173
+ setReport,
26174
+ t
26175
+ ]);
26176
+ /** 显式全量刷新:清单读失败时连残留报告一起清掉(这一拍不知道盘上长什么样);被顶掉则让位。
26177
+ * 它总要扫残留,所以顺手消费掉之前挂起的强制重扫。 */
26178
+ const refreshAll = (0, react.useCallback)(async () => {
26179
+ const listed = await reloadRows();
26180
+ if (listed === "stale") return;
26181
+ if (listed === "failed") {
26182
+ clearOrphanState();
26183
+ return;
26184
+ }
26185
+ pendingForcedRescan.current = false;
26186
+ await rescanOrphans();
26187
+ }, [
26188
+ clearOrphanState,
26189
+ reloadRows,
26190
+ rescanOrphans
26191
+ ]);
26192
+ /**
26193
+ * 写后与失效通告:清单必重读;要不要顺带重扫残留由 `needsOrphanRescan` 从这次的 outcome 派生:
26194
+ * purge 与 orphan-clean 总要扫(撤掉的父、被带走的孩子都可能留下新孤儿,报告为空也只是这一拍为空);
26195
+ * delete 与组根恢复可以省——孩子跟着父走,报告为空时扫了也只会得到空报告。**报告 `null` 不是"空的"**:
26196
+ * 它表示从没扫到(挂载刷新被顶掉 / 扫描失败),这时重扫是唯一能把未知变成已知的路;失效通告没有
26197
+ * outcome,仍按报告未知或非空才扫。
26198
+ * 强制重扫先记标记再读清单:若这次读输给后发的 refresh,赢家会看到标记并补扫,强制不丢。
26199
+ * 清单读失败时清空报告与提示:这一拍不知道盘上长什么样,旧读数不能继续当现状。
26200
+ */
26201
+ const refreshAfterWriteInternal = (0, react.useCallback)(async (forceRescan) => {
26202
+ if (forceRescan) pendingForcedRescan.current = true;
26203
+ const listed = await reloadRows();
26204
+ if (listed === "stale") return;
26205
+ if (listed === "failed") {
26206
+ clearOrphanState();
26207
+ return;
26208
+ }
26209
+ const pending = pendingForcedRescan.current;
26210
+ pendingForcedRescan.current = false;
26211
+ if (pending) {
26212
+ await rescanOrphans();
25052
26213
  return;
25053
26214
  }
25054
- await scanOrphans();
25055
- }, [reload, scanOrphans]);
25056
- const emptyTrash = (0, react.useCallback)(() => around(async () => {
26215
+ const current = orphanReportRef.current;
26216
+ if (current === null || current.totalSessions > 0) await rescanOrphans();
26217
+ }, [
26218
+ clearOrphanState,
26219
+ reloadRows,
26220
+ rescanOrphans
26221
+ ]);
26222
+ const refreshAfterWrite = (0, react.useCallback)((outcome) => refreshAfterWriteInternal(outcome !== void 0 && needsOrphanRescan(outcome, entriesRef.current)), [refreshAfterWriteInternal]);
26223
+ const emptyTrash = (0, react.useCallback)(() => gate.around(async () => {
25057
26224
  const outcome = await callRemote(() => deps.emptyTrash());
25058
26225
  if (!outcome.ok) {
25059
26226
  setListFailure(outcome.failure.message);
25060
26227
  return false;
25061
26228
  }
25062
- await refresh();
26229
+ clearUndoNotice();
26230
+ await refreshAfterWriteInternal(true);
25063
26231
  return true;
25064
26232
  }), [
25065
- around,
26233
+ gate,
25066
26234
  deps,
25067
- refresh
26235
+ refreshAfterWriteInternal
25068
26236
  ]);
25069
26237
  return {
25070
26238
  entries,
@@ -25079,47 +26247,54 @@ window.__ModuleLoader__.load({
25079
26247
  setOrphanNotice(null);
25080
26248
  setOrphanConfirm(true);
25081
26249
  },
25082
- reload,
25083
- scanOrphans,
25084
- refresh,
25085
- cleanOrphans: (0, react.useCallback)(() => {
26250
+ reloadRows,
26251
+ rescanOrphans,
26252
+ refreshAll,
26253
+ refreshAfterWrite,
26254
+ cleanOrphans: (0, react.useCallback)(async () => {
25086
26255
  if (orphanReport === null || orphanReport.roots.length === 0) {
25087
26256
  setOrphanConfirm(false);
25088
- return Promise.resolve();
26257
+ return;
25089
26258
  }
25090
- return around(async () => {
25091
- setOrphanFailure(null);
25092
- setOrphanNotice(null);
25093
- const outcome = await callRemote(() => deps.applyOutcomes("orphan-clean", orphanReport.roots));
26259
+ setOrphanFailure(null);
26260
+ setOrphanNotice(null);
26261
+ const settle = (outcome) => {
25094
26262
  setOrphanConfirm(false);
25095
- await refresh();
25096
- if (!outcome.ok) {
25097
- setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
25098
- return;
25099
- }
25100
- const failures = outcome.value.filter((item) => !item.ok);
25101
- const gone = failures.filter((item) => item.error.code === SESSION_DELETE_CODES["not-found"]);
25102
- const failed = failures.filter((item) => item.error.code !== SESSION_DELETE_CODES["not-found"]);
25103
- const partialRemoved = failed.reduce((sum, item) => sum + detailNumber(item.error.details, "removed"), 0);
25104
- const partialBytes = failed.reduce((sum, item) => sum + detailNumber(item.error.details, "freedBytes"), 0);
25105
- if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
25106
- if (failed.length > 0) {
25107
- const base = t("trash.orphans.cleanFailed", {
25108
- count: failed.length,
25109
- message: failed[0].error.message
25110
- });
25111
- const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
25112
- removed: partialRemoved,
25113
- size: fileSizeText(partialBytes)
25114
- })}` : "";
25115
- setOrphanFailure(`${base}${partial}`);
25116
- }
26263
+ return refreshAfterWrite(outcome);
26264
+ };
26265
+ const outcome = await cleanOrphansFromPanel({
26266
+ deps,
26267
+ ids: orphanReport.roots,
26268
+ gate,
26269
+ refreshAfterWrite: settle
25117
26270
  });
26271
+ if (!outcome.ok) {
26272
+ setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
26273
+ return;
26274
+ }
26275
+ const failures = outcome.receipt.verdicts.filter(isFailureVerdict);
26276
+ const gone = failures.filter((verdict) => verdict.kind === "neutral");
26277
+ const failed = failures.filter((verdict) => verdict.kind !== "neutral");
26278
+ const partialRemoved = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "removed"), 0);
26279
+ const partialBytes = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "freedBytes"), 0);
26280
+ if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
26281
+ if (failed.length > 0) {
26282
+ const message = failureMessage(failed[0]);
26283
+ const base = t("trash.orphans.cleanFailed", {
26284
+ count: failed.length,
26285
+ message
26286
+ });
26287
+ const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
26288
+ removed: partialRemoved,
26289
+ size: fileSizeText(partialBytes)
26290
+ })}` : "";
26291
+ setOrphanFailure(`${base}${partial}`);
26292
+ }
25118
26293
  }, [
25119
- around,
25120
26294
  deps,
26295
+ gate,
25121
26296
  orphanReport,
25122
- refresh,
26297
+ refreshAfterWrite,
25123
26298
  t
25124
26299
  ]),
25125
26300
  emptyTrash
@@ -25130,10 +26305,34 @@ window.__ModuleLoader__.load({
25130
26305
  const value = details?.[key];
25131
26306
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
25132
26307
  }
26308
+ /**
26309
+ * 写后要不要强制重扫残留:只看这次写的动作、它请求的 id 与**发读前**的清单。
26310
+ * - purge / orphan-clean 总要扫:语料缺席时故意留下的盘上后代、被带走父行留下的孩子,报告为空也可能是新孤儿。
26311
+ * - restore 只在清单未知(不知道恢复的是什么)、清单里已经找不到这个 id(这一拍不能断言它不是孩子),
26312
+ * 或恢复的是露面的孩子(父行已摘,回到语料后可能无主)时扫;组根恢复把孩子一起带回,不新增孤儿。
26313
+ * - delete / count 不扫。新动作必须在这里选一条,别静默落到"不扫"。裁决见 ADR-0016。
26314
+ */
26315
+ function needsOrphanRescan(outcome, entries) {
26316
+ switch (outcome.action) {
26317
+ case "purge":
26318
+ case "orphan-clean": return true;
26319
+ case "restore": {
26320
+ if (entries === null) return true;
26321
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
26322
+ return outcome.ids.some((id) => {
26323
+ const entry = byId.get(id);
26324
+ return entry === void 0 || entry.parentEntryId !== void 0;
26325
+ });
26326
+ }
26327
+ case "delete":
26328
+ case "count": return false;
26329
+ default: return outcome.action;
26330
+ }
26331
+ }
25133
26332
  //#endregion
25134
26333
  //#region src/client/use-trash-selection.ts
25135
26334
  /**
25136
- * 清单上的**选择面**(架构复核候选 3 的第三道内部 seam):多选态、折叠起来的组、勾选集合,
26335
+ * 清单上的**选择面**:多选态、折叠起来的组、勾选集合,
25137
26336
  * 以及由它们派生的三样只读(`checkedIds` / `ordered` / 两种三态判定)。
25138
26337
  *
25139
26338
  * 从 `trash-panel.tsx` 的函数体里整块搬出来的两条承重规矩:
@@ -25221,16 +26420,15 @@ window.__ModuleLoader__.load({
25221
26420
  //#endregion
25222
26421
  //#region src/client/trash-panel.tsx
25223
26422
  /**
25224
- * 回收站清单面板(设计稿 §7 状态 ④⑤ + 2026-10-15 §2 的两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
26423
+ * 回收站清单面板(`docs/ui-spec.md` 的状态 ④⑤ + 两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
25225
26424
  *
25226
26425
  * 面板自带页面骨架(标题 / 条数与合计 / 工具排 / 内容区 / 页脚),因为宿主给中央列的那个盒子
25227
26426
  * (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
25228
26427
  * `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
25229
26428
  * 如此。回收站可以很长(本机 125 个会话合计 85M),不自己收滚动就会把整列顶长。
25230
26429
  *
25231
- * **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(判据全在
25232
- * `docs/design/2026-10-15-trash-detail-design.md` §6,本节只记结论;**现行界面规格住 `docs/design/
25233
- * 2026-09-30-session-delete-design.md` §7**——那份是批次日志,§7 之后各批会改写它):
26430
+ * **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(现行界面规格住
26431
+ * `docs/ui-spec.md`,本文件只记这三条与实现约束):
25234
26432
  * ① 动机:一条只有一个标题,判断不了该恢复还是该彻底删除 ⇒ 详情要摆出这条会话自己日志里的元信息
25235
26433
  * 与正文,而详情只能我们自己画(宿主读不到回收站目录里的文件);
25236
26434
  * ② 断点 900px(盒子宽,不是窗口宽):中央列宽由 `usePanelWidth`(ResizeObserver)观测,**只听
@@ -25268,18 +26466,19 @@ window.__ModuleLoader__.load({
25268
26466
  * + 就地覆盖 `--dsw-alias-button-primary-fill` / `-hover`)照抄宿主插件管理页 @21658274。
25269
26467
  *
25270
26468
  * 布局只用**结构**内联样式,**配色与圆角一律走 token**,**字号是字面量 px**(宿主同类面也这么写)。
25271
- * 结构与层级归设计稿 §7 与 2026-10-15 §2,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
26469
+ * 结构与层级归 docs/ui-spec.md,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
25272
26470
  * 由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——这是宿主给客户端插件的一等公民通道。
25273
26471
  *
25274
26472
  * **浅色主题已在真机看过(2026-10-01)**:浅色下 `bg-base` 与 `bg-layer-2` 同值(都是 `bluish-00`)
25275
26473
  * ⇒ 卡片只剩一根 `border-l4` 描边与页面分隔,分组靠组头 + 间距站住。**选中态在两档下都要另量**
25276
- * (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`),量法见 2026-10-15 §4 第 5 条。
26474
+ * (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`,2026-10-01/10-15 真机量的);那两档取自同一个
26475
+ * token,来源表见 `src/client/ui/ui.css` 文件头。
25277
26476
  */
25278
26477
  /** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。
25279
26478
  * 字面量住 `shared/wire.ts`(`TRASH_DIR_LABEL`)——Node 半拼的就是同一个名字(ADR-0003)。 */
25280
26479
  const TRASH_DIR = TRASH_DIR_LABEL;
25281
26480
  /**
25282
- * 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(设计稿 2026-10-15 §2.1,2026-10-15 真机后定值)。
26481
+ * 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(docs/ui-spec.md,2026-10-15 真机后定值)。
25283
26482
  *
25284
26483
  * 为什么是 900 而不是当初那个 760:760 是从"正文列限宽"那条关心得搬来的,**不是两栏所需宽度**。
25285
26484
  * 两栏要成立,详情那一栏得真的读得下去:那一格固定要吃掉 90(两道 gap 32 + 把手 10 + 页内边距 48),
@@ -25401,37 +26600,20 @@ window.__ModuleLoader__.load({
25401
26600
  });
25402
26601
  }
25403
26602
  function TrashPanel({ deps, t = zhTranslate }) {
25404
- /** 一枚全局闸:写动作在飞时按住这一排按钮(清单行那颗按 `busy` 置灰),并挡住"旧清单那一拍再点一次"。 */
25405
- const [busy, setBusy] = (0, react.useState)(false);
25406
- const around = (0, react.useCallback)(async (task) => {
25407
- setBusy(true);
25408
- try {
25409
- return await task();
25410
- } finally {
25411
- setBusy(false);
25412
- }
25413
- }, []);
26603
+ /** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住 `busy-gate.ts`,查询侧的写动作也吃它。 */
26604
+ const gate = useBusyGate();
25414
26605
  const query = useTrashQuery({
25415
26606
  deps,
25416
26607
  t,
25417
- around
26608
+ gate
25418
26609
  });
25419
- const { entries, listFailure, orphanReport, orphanFailure, orphanNotice, orphanConfirm, setOrphanConfirm, refresh } = query;
25420
- /** 恢复/彻底删除 的失败原文,按条目 id 存:切走再回来还在,不是一次性的。 */
25421
- const [actionFailure, setActionFailure] = (0, react.useState)({});
25422
- const [pending, setPending] = (0, react.useState)(null);
25423
- const [selectedId, setSelectedId] = (0, react.useState)(null);
25424
- const [loadingId, setLoadingId] = (0, react.useState)(null);
25425
- const [detailFailure, setDetailFailure] = (0, react.useState)(null);
25426
- const [drilled, setDrilled] = (0, react.useState)(false);
25427
- /** 面板存活期内每条只读一次(本机量级:最大一条 135ms,125 条全读 2.0s ⇒ 不预读、也不重复读)。 */
25428
- const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
26610
+ const { entries, listFailure, refreshAll } = query;
25429
26611
  const listRef = (0, react.useRef)(null);
25430
26612
  const rootRef = (0, react.useRef)(null);
25431
26613
  const width = usePanelWidth(rootRef);
25432
26614
  /**
25433
26615
  * 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
25434
- * 可选项也没有动作,白占一半宽度(设计稿 2026-10-15 §2.3 第 1 条)。失败那块自己的「重试」
26616
+ * 可选项也没有动作,白占一半宽度(docs/ui-spec.md)。失败那块自己的「重试」
25435
26617
  * 负责把清单读回来,读回来之后右栏跟着回来。
25436
26618
  */
25437
26619
  const split = width >= SPLIT_MIN_WIDTH && entries !== null && entries.length > 0;
@@ -25444,171 +26626,49 @@ window.__ModuleLoader__.load({
25444
26626
  step: HANDLE_STEP,
25445
26627
  label: t("trash.divider.label")
25446
26628
  });
25447
- const revision = useTrashRevision();
25448
- (0, react.useEffect)(() => {
25449
- refresh();
25450
- }, [refresh, revision]);
25451
- /**
25452
- * 选一条:命中缓存就直接摆,没命中才去读那条会话自己的日志。
25453
- * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
25454
- */
25455
- const select = (0, react.useCallback)(async (entry, options = {}) => {
25456
- setSelectedId(entry.id);
25457
- if (options.drill === true) setDrilled(true);
25458
- if (detailCache.current.has(entry.id)) {
25459
- setLoadingId(null);
25460
- setDetailFailure(null);
25461
- return;
25462
- }
25463
- setLoadingId(entry.id);
25464
- setDetailFailure(null);
25465
- const outcome = await callRemote(() => deps.detail(entry.id));
25466
- setLoadingId((current) => current === entry.id ? null : current);
25467
- if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
25468
- else setDetailFailure({
25469
- entryId: entry.id,
25470
- message: outcome.failure.message
25471
- });
25472
- }, [deps]);
25473
26629
  const groups = (0, react.useMemo)(() => groupByProject(entries ?? [], t), [entries, t]);
25474
26630
  const { selectMode, checked, checkedIds, ordered, allCollapsed, isCollapsed, enterSelect, exitSelect, toggleGroup, toggleAllGroups, toggleCheck, toggleGroupCheck, groupFullyChecked, groupPartiallyChecked } = useTrashSelection({
25475
26631
  entries,
25476
26632
  groups
25477
26633
  });
25478
- (0, react.useEffect)(() => {
25479
- if (entries === null || selectedId === null) return;
25480
- if (entries.some((entry) => entry.id === selectedId)) return;
25481
- detailCache.current.delete(selectedId);
25482
- setSelectedId(null);
25483
- setDrilled(false);
25484
- }, [entries, selectedId]);
25485
- (0, react.useEffect)(() => {
25486
- if (split && selectedId === null && ordered.length > 0) select(ordered[0]);
25487
- }, [
25488
- split,
25489
- selectedId,
25490
- ordered,
25491
- select
25492
- ]);
26634
+ /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认选择器)。 */
25493
26635
  const focusRow = (0, react.useCallback)((entryId) => {
25494
26636
  (listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
25495
26637
  }, []);
25496
26638
  /**
25497
- * 下钻里按 Esc 退回清单(设计稿 2026-10-15 §2.4)。
25498
- *
25499
- * 监听挂在 `document` 而不是清单容器:窄档下钻时清单**不在 DOM 里**,挂在容器上的 Esc 是一条
25500
- * 永远收不到的死路径。退回后焦点交还给选中的那一行(行还没进 DOM,所以记一笔等这一拍渲染完再做)。
26639
+ * 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / 写动作与失败落点,整块住在
26640
+ * `use-trash-panel-controller.ts`。布局常量不搬进控制器:面板只交两样从布局算出来的面——
26641
+ * 屏幕上的顺序 `ordered`(只含展开的组)与"这一档要不要自动选中"(`canAutoSelect: split`)。
25501
26642
  */
25502
- const refocusRow = (0, react.useRef)(null);
25503
- (0, react.useEffect)(() => {
25504
- if (!drilled) return;
25505
- const onKey = (event) => {
25506
- if (event.key !== "Escape") return;
25507
- refocusRow.current = selectedId;
25508
- setDrilled(false);
25509
- };
25510
- document.addEventListener("keydown", onKey);
25511
- return () => {
25512
- document.removeEventListener("keydown", onKey);
25513
- };
25514
- }, [drilled, selectedId]);
25515
- (0, react.useEffect)(() => {
25516
- if (drilled || refocusRow.current === null) return;
25517
- focusRow(refocusRow.current);
25518
- refocusRow.current = null;
25519
- }, [drilled, focusRow]);
25520
- const onListKeyDown = (event) => {
25521
- if (ordered.length === 0) return;
25522
- const index = ordered.findIndex((entry) => entry.id === selectedId);
25523
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
25524
- event.preventDefault();
25525
- const step = event.key === "ArrowDown" ? 1 : -1;
25526
- const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
25527
- const target = ordered[next];
25528
- select(target);
25529
- focusRow(target.id);
25530
- return;
25531
- }
25532
- if (event.key === "Enter" || event.key === " ") {
25533
- if (index < 0) return;
25534
- event.preventDefault();
25535
- if (selectMode) {
25536
- toggleCheck(ordered[index].id);
25537
- return;
25538
- }
25539
- select(ordered[index], { drill: true });
25540
- }
25541
- };
25542
- const failRow = (entryId, message) => {
25543
- setActionFailure((current) => ({
25544
- ...current,
25545
- [entryId]: message
25546
- }));
25547
- };
25548
- const restore = async (entry) => {
25549
- setActionFailure((current) => {
25550
- const next = { ...current };
25551
- delete next[entry.id];
25552
- return next;
25553
- });
25554
- await around(async () => {
25555
- const outcome = await callRemote(() => applyOne(deps, "restore", entry.id).then((item) => item.value));
25556
- if (!outcome.ok) {
25557
- failRow(entry.id, outcome.failure.message);
25558
- return;
25559
- }
25560
- detailCache.current.delete(entry.id);
25561
- await refresh();
25562
- });
25563
- };
25564
- /** 一段批量 → 组件状态:成功的丢详情缓存,失败的原文落回它自己那一行。整批一次上屏、一次重读。 */
25565
- const settleOutcomes = (outcomes) => {
25566
- const failures = [];
25567
- for (const outcome of outcomes) if (outcome.ok) detailCache.current.delete(outcome.id);
25568
- else failures.push({
25569
- id: outcome.id,
25570
- message: outcome.error.message
25571
- });
25572
- if (failures.length > 0) setActionFailure((current) => {
25573
- const next = { ...current };
25574
- for (const failure of failures) next[failure.id] = failure.message;
25575
- return next;
25576
- });
25577
- };
26643
+ const controller = useTrashPanelController({
26644
+ deps,
26645
+ gate,
26646
+ query,
26647
+ ordered,
26648
+ canAutoSelect: split
26649
+ });
25578
26650
  /**
25579
- * 批量彻底删除 / 批量恢复**共用这一条**(2026-10-17 批量重构):一次 `apply` 把整批交给 Node 半,
25580
- * 逐条成败住在 `ApplyOutcome` 里 ⇒ 组件不再自己拼循环、也不再"每条一次全量重读"。
26651
+ * 键盘导航(↑↓ 走环、Enter/Space、tab 落点、Esc 交还焦点)整块住在 `trash-nav.ts`。
26652
+ * 屏幕顺序与四个动作从这里进去;它不查 DOM——`focusRow` 是面板交出去的那一动作。
25581
26653
  */
25582
- const confirmApply = async (action, ids) => {
25583
- await around(async () => {
25584
- const outcome = await callRemote(() => deps.applyOutcomes(action, ids));
25585
- setPending(null);
25586
- if (!outcome.ok) for (const id of ids) failRow(id, outcome.failure.message);
25587
- else settleOutcomes(outcome.value);
25588
- await refresh();
25589
- });
25590
- };
25591
- const confirmPurge = async () => {
25592
- const target = pending;
25593
- if (target === null) return;
25594
- if (target.kind === "entries") return confirmApply("purge", target.ids);
25595
- if (target.kind === "restore") return confirmApply("restore", target.ids);
25596
- if (target.kind === "entry") return confirmApply("purge", [target.entry.id]);
25597
- const emptied = await query.emptyTrash();
25598
- setPending(null);
25599
- if (emptied) detailCache.current.clear();
25600
- };
25601
- /** 清理那批无主子会话:整块住在 `useTrashQuery.cleanOrphans`(扫描的报告、两种失败的分流、先刷新后落提示
25602
- * 都在那一条里);这里只负责把确认框收起来。 */
25603
- const confirmOrphans = async () => {
25604
- await query.cleanOrphans();
25605
- };
26654
+ const nav = useTrashNav({
26655
+ ordered,
26656
+ selectedId: controller.selectedId,
26657
+ selectMode,
26658
+ drillEnabled: !split,
26659
+ drilled: controller.drilled,
26660
+ select: controller.select,
26661
+ toggleCheck,
26662
+ exitDrill: controller.back,
26663
+ focusRow
26664
+ });
25606
26665
  /**
25607
26666
  * 详细信息那块的选中集合:确认框要把"这一批有哪些、要去哪"摆出来,靠的全量条目是从勾选 id 反查
25608
26667
  * (顺序已经是"清单从上往下")——清单里找不到的 id 是别的窗口刚动过,滤掉不等于丢动作
25609
26668
  * (重读会把它从勾选里也剔掉)。
25610
26669
  */
25611
26670
  const byIdEntries = (0, react.useMemo)(() => new Map((entries ?? []).map((entry) => [entry.id, entry])), [entries]);
26671
+ const pending = controller.pending;
25612
26672
  const restoreEntries = pending?.kind === "restore" ? pending.ids.map((id) => byIdEntries.get(id)).filter((entry) => entry !== void 0) : [];
25613
26673
  const totalBytes = (entries ?? []).reduce((sum, entry) => sum + entry.releaseBytes, 0);
25614
26674
  const now = Date.now();
@@ -25619,45 +26679,6 @@ window.__ModuleLoader__.load({
25619
26679
  */
25620
26680
  const noteParts = splitOn(t("trash.footer.note", { dir: TRASH_DIR }), TRASH_DIR);
25621
26681
  /**
25622
- * 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:把选中那条所在的组折叠起来
25623
- * 只是把那一列收掉,右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
25624
- */
25625
- const selected = (entries ?? []).find((entry) => entry.id === selectedId) ?? null;
25626
- /** `aria-activedescendant` 只能指向**在 DOM 里**的那一行:折叠起来之后它就是不存在,指过去是假引用。 */
25627
- const activeDescendantId = ordered.some((entry) => entry.id === selectedId) ? selectedId : null;
25628
- /**
25629
- * 清单里那个唯一的 tab 落点(roving tabindex)。它必须**永远有一行拿着**:窄档不自动选中,
25630
- * 只按"选中那行拿 0"来给的话,一次没点过时全行都是 `-1`,键盘根本进不来清单。
25631
- * 落点与 `aria-activedescendant` 同一条可见性规则——选中那行被折叠起来时落到第一条可见行。
25632
- */
25633
- const tabStopId = activeDescendantId ?? ordered[0]?.id ?? null;
25634
- const detailState = (0, react.useMemo)(() => {
25635
- if (selected === null) return { kind: "idle" };
25636
- if (loadingId === selected.id) return {
25637
- kind: "loading",
25638
- entry: selected
25639
- };
25640
- const cached = detailCache.current.get(selected.id);
25641
- if (cached !== void 0) return {
25642
- kind: "ready",
25643
- entry: selected,
25644
- detail: cached
25645
- };
25646
- if (detailFailure?.entryId === selected.id) return {
25647
- kind: "failed",
25648
- entry: selected,
25649
- message: detailFailure.message
25650
- };
25651
- return {
25652
- kind: "loading",
25653
- entry: selected
25654
- };
25655
- }, [
25656
- selected,
25657
- loadingId,
25658
- detailFailure
25659
- ]);
25660
- /**
25661
26682
  * 清单容器(roving tabindex 那个唯一的 tab 巢)。`flex:'1 1 auto'` 是**空态居中链**的一节:
25662
26683
  * 空态块自己有 `flex:1` + `justifyContent:center`,但要有**有高度的**父链才居得起来——本层是
25663
26684
  * 两栏改造(2026-10-15)新插进来的 div,缺这条 flex 就把链断在"内容高"上、空态图标贴左上角
@@ -25668,10 +26689,10 @@ window.__ModuleLoader__.load({
25668
26689
  "data-dsh-session-delete": "trash-list",
25669
26690
  role: "listbox",
25670
26691
  "aria-label": t("detail.list.label"),
25671
- "aria-activedescendant": activeDescendantId === null ? void 0 : `sd-trash-${activeDescendantId}`,
26692
+ "aria-activedescendant": nav.activeDescendantId === null ? void 0 : `sd-trash-${nav.activeDescendantId}`,
25672
26693
  "aria-multiselectable": selectMode ? true : void 0,
25673
26694
  tabIndex: -1,
25674
- onKeyDown: onListKeyDown,
26695
+ onKeyDown: nav.onListKeyDown,
25675
26696
  style: {
25676
26697
  display: "flex",
25677
26698
  flexDirection: "column",
@@ -25726,7 +26747,7 @@ window.__ModuleLoader__.load({
25726
26747
  variant: "outline",
25727
26748
  size: "sm",
25728
26749
  style: { flex: "none" },
25729
- onClick: () => void refresh(),
26750
+ onClick: () => void refreshAll(),
25730
26751
  children: t("common.retry")
25731
26752
  })
25732
26753
  ]
@@ -25864,7 +26885,7 @@ window.__ModuleLoader__.load({
25864
26885
  ]
25865
26886
  })]
25866
26887
  }), isCollapsed(group.projectDir) ? null : group.entries.map((entry) => {
25867
- const isSelected = entry.id === selectedId;
26888
+ const isSelected = entry.id === controller.selectedId;
25868
26889
  const isChecked = checked.has(entry.id);
25869
26890
  const title = entry.title === "" ? t("common.untitled") : entry.title;
25870
26891
  const kids = entry.subagentCount ?? 0;
@@ -25873,7 +26894,7 @@ window.__ModuleLoader__.load({
25873
26894
  id: `sd-trash-${entry.id}`,
25874
26895
  role: "option",
25875
26896
  "aria-selected": selectMode ? isChecked : isSelected,
25876
- tabIndex: entry.id === tabStopId ? 0 : -1,
26897
+ tabIndex: entry.id === nav.tabStopId ? 0 : -1,
25877
26898
  "data-dsh-session-delete": "trash-row",
25878
26899
  "data-entry-id": entry.id,
25879
26900
  style: isSelected ? CARD_SELECTED : CARD,
@@ -25882,7 +26903,7 @@ window.__ModuleLoader__.load({
25882
26903
  toggleCheck(entry.id);
25883
26904
  return;
25884
26905
  }
25885
- select(entry, { drill: !split });
26906
+ controller.select(entry, { drill: !split });
25886
26907
  },
25887
26908
  children: [
25888
26909
  selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
@@ -25934,7 +26955,7 @@ window.__ModuleLoader__.load({
25934
26955
  },
25935
26956
  children: t("trash.row.workspaceGone")
25936
26957
  }) : null,
25937
- actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26958
+ controller.actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25938
26959
  role: "status",
25939
26960
  style: {
25940
26961
  display: "flex",
@@ -25952,7 +26973,7 @@ window.__ModuleLoader__.load({
25952
26973
  },
25953
26974
  "aria-hidden": "true",
25954
26975
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 13 })
25955
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: actionFailure[entry.id] })]
26976
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: controller.actionFailure[entry.id] })]
25956
26977
  })
25957
26978
  ]
25958
26979
  }),
@@ -25965,19 +26986,25 @@ window.__ModuleLoader__.load({
25965
26986
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25966
26987
  variant: "outline",
25967
26988
  size: "sm",
25968
- disabled: busy,
25969
- onClick: () => void restore(entry),
26989
+ disabled: controller.busy,
26990
+ onClick: (event) => {
26991
+ event.stopPropagation();
26992
+ controller.restore(entry);
26993
+ },
25970
26994
  children: t("trash.row.restore")
25971
26995
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25972
26996
  variant: "ghost",
25973
26997
  size: "sm",
25974
26998
  style: QUIET_DANGER,
25975
26999
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
25976
- disabled: busy,
25977
- onClick: () => setPending({
25978
- kind: "entry",
25979
- entry
25980
- }),
27000
+ disabled: controller.busy,
27001
+ onClick: (event) => {
27002
+ event.stopPropagation();
27003
+ controller.setPending({
27004
+ kind: "entry",
27005
+ entry
27006
+ });
27007
+ },
25981
27008
  children: t("trash.row.purge")
25982
27009
  })]
25983
27010
  })
@@ -25992,13 +27019,11 @@ window.__ModuleLoader__.load({
25992
27019
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25993
27020
  style: DETAIL_INNER,
25994
27021
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashDetail, {
25995
- state: detailState,
25996
- busy,
27022
+ state: controller.detail,
27023
+ busy: controller.busy,
25997
27024
  wide: split,
25998
- onRetry: () => {
25999
- if (selected !== null) select(selected);
26000
- },
26001
- onBack: () => setDrilled(false),
27025
+ onRetry: () => controller.retry(),
27026
+ onBack: () => controller.back(),
26002
27027
  t
26003
27028
  })
26004
27029
  })
@@ -26007,7 +27032,7 @@ window.__ModuleLoader__.load({
26007
27032
  * 那一排**常驻面板体顶端**,不住清单列里:它原先在滚动容器内,长清单滚到底会把开关滚走。
26008
27033
  * 宽度上限跟着清单列走,所以它在视觉上仍然"属于清单"。
26009
27034
  *
26010
- * 这里曾有一颗「刷新」图标(2026-10-15 第三批收进那一排、第四批降级成图标,**2026-10-16
27035
+ * 这里曾有一颗「刷新」图标(2026-10-15 收进那一排、同日降级成图标,**2026-10-16
26011
27036
  * 按用户裁定删除**):清单现在是"每次读都对账"(见 `reconcile.ts`),开面板、切走再切回、
26012
27037
  * 重启——任何一次读都会先同步,那颗按钮不再承担别人替代不了的活。
26013
27038
  */
@@ -26085,135 +27110,11 @@ window.__ModuleLoader__.load({
26085
27110
  }) : null]
26086
27111
  })
26087
27112
  }),
26088
- orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26089
- "data-dsh-session-delete": "orphan-banner",
26090
- style: {
26091
- ...CARD,
26092
- alignItems: "flex-start",
26093
- gap: 12,
26094
- marginBottom: 10
26095
- },
26096
- children: [
26097
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26098
- style: {
26099
- display: "inline-flex",
26100
- flex: "none",
26101
- paddingTop: 2,
26102
- color: "var(--dsw-alias-state-warn-primary)"
26103
- },
26104
- "aria-hidden": "true",
26105
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
26106
- }),
26107
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26108
- style: {
26109
- display: "flex",
26110
- flexDirection: "column",
26111
- gap: 2,
26112
- flex: "1 1 auto",
26113
- minWidth: 0
26114
- },
26115
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26116
- style: {
26117
- fontSize: 13,
26118
- lineHeight: "19px"
26119
- },
26120
- children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
26121
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26122
- style: {
26123
- fontSize: 12,
26124
- lineHeight: "16px",
26125
- color: "var(--dsw-alias-label-tertiary)"
26126
- },
26127
- children: t("trash.orphans.meta", {
26128
- size: fileSizeText(orphanReport.totalBytes),
26129
- parents: orphanReport.missingParents
26130
- })
26131
- })]
26132
- }),
26133
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26134
- variant: "outline",
26135
- size: "sm",
26136
- style: { flex: "none" },
26137
- disabled: busy,
26138
- onClick: () => {
26139
- if (busy) return;
26140
- query.openOrphanPreview();
26141
- },
26142
- children: t("trash.orphans.open")
26143
- })
26144
- ]
26145
- }) : null,
26146
- orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26147
- role: "status",
26148
- style: {
26149
- ...CARD,
26150
- alignItems: "flex-start",
26151
- gap: 10,
26152
- marginBottom: 10,
26153
- padding: "10px 12px"
26154
- },
26155
- children: [
26156
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26157
- style: {
26158
- display: "inline-flex",
26159
- flex: "none",
26160
- color: "var(--dsw-alias-state-error-primary)"
26161
- },
26162
- "aria-hidden": "true",
26163
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
26164
- }),
26165
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26166
- style: {
26167
- flex: "1 1 auto",
26168
- minWidth: 0,
26169
- wordBreak: "break-word",
26170
- fontSize: 12,
26171
- lineHeight: "17px",
26172
- color: "var(--dsw-alias-state-error-primary)"
26173
- },
26174
- children: orphanFailure
26175
- }),
26176
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26177
- variant: "outline",
26178
- size: "sm",
26179
- style: { flex: "none" },
26180
- disabled: busy,
26181
- onClick: () => void refresh(),
26182
- children: t("common.retry")
26183
- })
26184
- ]
26185
- }) : null,
26186
- orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26187
- "data-dsh-session-delete": "orphan-notice",
26188
- role: "status",
26189
- style: {
26190
- ...CARD,
26191
- alignItems: "flex-start",
26192
- gap: 10,
26193
- marginBottom: 10,
26194
- padding: "10px 12px"
26195
- },
26196
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26197
- style: {
26198
- display: "inline-flex",
26199
- flex: "none",
26200
- paddingTop: 2,
26201
- color: "var(--dsw-alias-label-tertiary)"
26202
- },
26203
- "aria-hidden": "true",
26204
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
26205
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26206
- style: {
26207
- flex: "1 1 auto",
26208
- minWidth: 0,
26209
- wordBreak: "break-word",
26210
- fontSize: 12,
26211
- lineHeight: "17px",
26212
- color: "var(--dsw-alias-label-secondary)"
26213
- },
26214
- children: orphanNotice
26215
- })]
26216
- }) : null,
27113
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
27114
+ query,
27115
+ busy: controller.busy,
27116
+ t
27117
+ }),
26217
27118
  toolbar,
26218
27119
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26219
27120
  style: BODY,
@@ -26228,7 +27129,7 @@ window.__ModuleLoader__.load({
26228
27129
  ...resize.handleProps
26229
27130
  }),
26230
27131
  detailPane
26231
- ] }) : drilled && selected !== null ? detailPane : listPane({
27132
+ ] }) : controller.drilled && controller.selected !== null ? detailPane : listPane({
26232
27133
  ...LIST_COLUMN,
26233
27134
  flex: "1 1 auto"
26234
27135
  })
@@ -26275,8 +27176,8 @@ window.__ModuleLoader__.load({
26275
27176
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26276
27177
  variant: "outline",
26277
27178
  size: "sm",
26278
- disabled: checkedIds.length === 0 || busy,
26279
- onClick: () => setPending({
27179
+ disabled: checkedIds.length === 0 || controller.busy,
27180
+ onClick: () => controller.setPending({
26280
27181
  kind: "restore",
26281
27182
  ids: checkedIds
26282
27183
  }),
@@ -26292,8 +27193,8 @@ window.__ModuleLoader__.load({
26292
27193
  size: "sm",
26293
27194
  style: BULK_DANGER,
26294
27195
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
26295
- disabled: checkedIds.length === 0 || busy,
26296
- onClick: () => setPending({
27196
+ disabled: checkedIds.length === 0 || controller.busy,
27197
+ onClick: () => controller.setPending({
26297
27198
  kind: "entries",
26298
27199
  ids: checkedIds
26299
27200
  }),
@@ -26304,8 +27205,8 @@ window.__ModuleLoader__.load({
26304
27205
  size: "sm",
26305
27206
  style: BULK_DANGER,
26306
27207
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
26307
- disabled: busy,
26308
- onClick: () => setPending({ kind: "all" }),
27208
+ disabled: controller.busy,
27209
+ onClick: () => controller.setPending({ kind: "all" }),
26309
27210
  children: t("trash.emptyAll")
26310
27211
  })
26311
27212
  })]
@@ -26313,19 +27214,12 @@ window.__ModuleLoader__.load({
26313
27214
  pending === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashConfirm, {
26314
27215
  pending,
26315
27216
  restoreEntries,
26316
- busy,
26317
- t,
26318
- onCancel: () => setPending(null),
26319
- onConfirmPurge: () => void confirmPurge(),
26320
- onConfirmRestore: (ids) => void confirmApply("restore", ids)
26321
- }),
26322
- orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
26323
- report: orphanReport,
26324
- busy,
27217
+ busy: controller.busy,
26325
27218
  t,
26326
- onCancel: () => setOrphanConfirm(false),
26327
- onConfirm: () => void confirmOrphans()
26328
- }) : null
27219
+ onCancel: () => controller.setPending(null),
27220
+ onConfirmPurge: () => void controller.confirmPurge(),
27221
+ onConfirmRestore: (ids) => void controller.confirmApply("restore", ids)
27222
+ })
26329
27223
  ]
26330
27224
  });
26331
27225
  }
@@ -26334,19 +27228,19 @@ window.__ModuleLoader__.load({
26334
27228
  /**
26335
27229
  * 浏览器半入口(CJS 单文件闭包工厂的 `factory` 体,构建契约见 tsdown.config.ts)。
26336
27230
  *
26337
- * 共交付**十个**槽位贡献(第十三批起,批量删除再加五件),分四类:
27231
+ * 共交付**十个**槽位贡献(2026-10-17 起,批量删除再加五件),分四类:
26338
27232
  *
26339
27233
  * - `sidebar.workspaces.session.menu.item` —— 两行:「多选」入口(`BatchSelectMenuItem`,order 850)
26340
27234
  * 与「删除」菜单行(`DeleteMenuItem`,order 900)。多选入口按下后进入多选模式(再按一次退出)
26341
27235
  * 并收菜单;删除行**只发请求**(把 `{sessionId, title}` 写进 `delete-confirm.tsx` 的模块级现场)。
26342
- * - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox`,第十三批①)。
27236
+ * - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox`,2026-10-17)。
26343
27237
  * **只在多选模式内渲染**(入口在「⋯ → 多选」,不常驻)。宿主在「有状态点(运行中/待确认/未读完成)」
26344
27238
  * 与归档行不渲染这一格 ⇒ 那些行由下面行尾那颗兜底;槽位只投 `sessionId`,标题
26345
27239
  * (`displayTitle`)从 root hook 的 `byId[id].title` 读。
26346
27240
  * - `sidebar.workspaces.session.row.action` —— 行尾悬停排的**兜底勾选开关**(`BatchPickButton`)。
26347
27241
  * 座位原文:"一行一颗图标按钮(动作不适用于这一行时可以不渲染)、动作归自己",且**排内点击不误开
26348
27242
  * 会话** ⇒ 兜底开关不需要处理事件传播。两条与前一条不会同时出现(`ui.css` 的 `:has` 隐藏规则)。
26349
- * 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`batch-select.tsx`)。
27243
+ * 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`host-rows.ts`)。
26350
27244
  * 与 overlay 上的**浮条**(`BatchBar`)、**批量确认框**(`BatchConfirmHost`)合起来才是批量删除那一套。
26351
27245
  * 模式内点整行会拦截宿主的会话切换(document 捕获守卫);执行仍走 `POST /apply`,单删那三件一律不动。
26352
27246
  * - `shell.overlay` —— 菜单之外的**四个**常驻宿主(各一次 `slots.inject`,与宿主自己对
@@ -26362,7 +27256,7 @@ window.__ModuleLoader__.load({
26362
27256
  * !this.hasMainPanel(panelId)) throw new Error(...)`(asar @21199710)——只有行、没有面板时,
26363
27257
  * 用户点一下就是运行期抛错;反过来先有面板也没有入口。
26364
27258
  *
26365
- * **为什么不是底栏(2026-09-30 实测裁决,设计稿 §10)**:`sidebar.footer.action` 的**惯用形**是
27259
+ * **为什么不是底栏(2026-09-30 实测裁决,判据全在下面三行)**:`sidebar.footer.action` 的**惯用形**是
26366
27260
  * 「42px 整行 + 图标 + 文字 + 右对齐 12px 计数」——官方 `client-ui-cordis` 的占用者 CSS 是
26367
27261
  * `.layer{flex:none;width:100%;height:42px}`,而座位容器只有 `.footerActions{display:flex}`
26368
27262
  * (无 gap、无 wrap)⇒ 我们交的 `Button ghost/sm` 小药丸贴底栏,与下方 42px 的「设置」行不同形。
@@ -26509,7 +27403,7 @@ window.__ModuleLoader__.load({
26509
27403
  * **探测 → 注册**:取数通道在不在,只有真发一次请求才知道。
26510
27404
  *
26511
27405
  * - 探测不过(打不通 / 回的不是信封)⇒ **早返回**:一个槽位贡献都不注册。页面上的表现是"本插件
26512
- * 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(设计稿 §7「未接通态」)。
27406
+ * 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(docs/architecture.md §3「通道探测决定入口出现与否」)。
26513
27407
  * - 探测过(哪怕 `ping` 回的是 `{ok:false,error}`)⇒ 注册:那次往返证明路由在、依赖接上了、
26514
27408
  * 错误映射也生效了,失败该做的是把原文交给用户看。
26515
27409
  *
@@ -26520,6 +27414,7 @@ window.__ModuleLoader__.load({
26520
27414
  * 漏一半的代价是**宿主那句响亮的抛错**,不是静默降级。
26521
27415
  */
26522
27416
  async function activate(client) {
27417
+ bindMainView(client);
26523
27418
  const raw = createHttpRemote();
26524
27419
  if (!await probeChannel(raw)) return;
26525
27420
  const deps = buildDeps(raw);