@xrn1997/dsh-session-delete 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +7 -25
  2. package/lib/client.js +972 -588
  3. package/lib/index.js +553 -286
  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 +6 -6
  8. package/lib/types/client/batch-confirm.d.ts +5 -3
  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/copy.d.ts +4 -1
  13. package/lib/types/client/delete-confirm.d.ts +2 -0
  14. package/lib/types/client/host-rows.d.ts +35 -0
  15. package/lib/types/client/main-view.d.ts +37 -0
  16. package/lib/types/client/receipt.d.ts +43 -0
  17. package/lib/types/client/remote.d.ts +16 -6
  18. package/lib/types/client/store.d.ts +17 -0
  19. package/lib/types/client/trash-panel.d.ts +1 -1
  20. package/lib/types/client/trash-store.d.ts +2 -0
  21. package/lib/types/client/ui/icons.d.ts +3 -3
  22. package/lib/types/client/undo-toast.d.ts +19 -3
  23. package/lib/types/client/use-column-resize.d.ts +2 -2
  24. package/lib/types/client/use-panel-width.d.ts +1 -1
  25. package/lib/types/client/use-trash-panel-controller.d.ts +55 -0
  26. package/lib/types/client/write-commit.d.ts +10 -0
  27. package/lib/types/deps.d.ts +14 -0
  28. package/lib/types/index.d.ts +6 -13
  29. package/lib/types/log/zstd-frames.d.ts +1 -1
  30. package/lib/types/ports/agent-handles.d.ts +25 -0
  31. package/lib/types/ports/home.d.ts +19 -0
  32. package/lib/types/ports/host-port.d.ts +58 -28
  33. package/lib/types/remote/map-error.d.ts +2 -2
  34. package/lib/types/shared/wire.d.ts +11 -9
  35. package/lib/types/trash/detail.d.ts +1 -1
  36. package/lib/types/trash/entry.d.ts +4 -4
  37. package/lib/types/trash/manifest.d.ts +2 -1
  38. package/lib/types/trash/reconcile.d.ts +2 -2
  39. package/lib/types/trash/storage-manifest.d.ts +12 -0
  40. package/lib/types/verbs/batch.d.ts +1 -1
  41. package/lib/types/verbs/delete-session.d.ts +8 -11
  42. package/lib/types/verbs/errors.d.ts +4 -0
  43. package/lib/types/verbs/member-journal.d.ts +4 -3
  44. package/lib/types/verbs/member-presence.d.ts +35 -0
  45. package/lib/types/verbs/orphan-subagents.d.ts +14 -38
  46. package/lib/types/verbs/permanent-removal.d.ts +113 -0
  47. package/lib/types/verbs/purge-trash.d.ts +9 -5
  48. package/lib/types/verbs/restore-session.d.ts +6 -6
  49. package/package.json +1 -1
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、`src/client/remote.ts` 的
99
+ * 幂等空值与取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
97
100
  *
98
101
  * ## 为什么不是散在各组件里的中文字面量
99
102
  *
@@ -431,6 +434,258 @@ 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
+ * 语义(四条,别在调用方另算一遍):
449
+ * - `set` / `update` 用 `Object.is` 判"变过":同一个引用不重渲染(React 的 snapshot 契约要求)。
450
+ * - `publish(make)` 递增**本实例**的 seq(从 1 起)并**总是**通告——seq 是组件重挂载的 key
451
+ * (同一段文案连续出现两次也要重新计一次停留),不是版本号。
452
+ * - `subscribe` 回退订函数;`use()` 就是 `useSyncExternalStore(subscribe, get, get)`。
453
+ * - `reset()` 只给测试收场:回最初那份引用、seq 清零,值没变就不通告。
454
+ */
455
+ function createStore(initial) {
456
+ let value = initial;
457
+ let seq = 0;
458
+ const listeners = /* @__PURE__ */ new Set();
459
+ const notify = () => {
460
+ for (const listener of listeners) listener();
461
+ };
462
+ const get = () => value;
463
+ const subscribe = (listener) => {
464
+ listeners.add(listener);
465
+ return () => {
466
+ listeners.delete(listener);
467
+ };
468
+ };
469
+ return {
470
+ get,
471
+ set(next) {
472
+ if (Object.is(next, value)) return;
473
+ value = next;
474
+ notify();
475
+ },
476
+ update(next) {
477
+ const candidate = next(value);
478
+ if (Object.is(candidate, value)) return false;
479
+ value = candidate;
480
+ notify();
481
+ return true;
482
+ },
483
+ publish(make) {
484
+ seq += 1;
485
+ value = make(seq);
486
+ notify();
487
+ },
488
+ subscribe,
489
+ use() {
490
+ return (0, react.useSyncExternalStore)(subscribe, get, get);
491
+ },
492
+ reset() {
493
+ seq = 0;
494
+ if (Object.is(initial, value)) return;
495
+ value = initial;
496
+ notify();
497
+ }
498
+ };
499
+ }
500
+ //#endregion
501
+ //#region src/client/batch-selection-store.ts
502
+ /**
503
+ * 宿主列表批量删除的**选中集现场**(2026-10-20):多选模式 + 选中名单,一份合并快照。
504
+ *
505
+ * 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
506
+ * 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
507
+ * 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
508
+ * `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
509
+ * 的 `createStore` 里,本文件不另写一份。
510
+ *
511
+ * **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
512
+ * (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
513
+ *
514
+ * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
515
+ * 「全选」追加的条目按 `host-rows.ts` 读到的 DOM 序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
516
+ */
517
+ const INITIAL = {
518
+ selecting: false,
519
+ picks: []
520
+ };
521
+ const selectionStore = createStore(INITIAL);
522
+ function getSelection() {
523
+ return selectionStore.get();
524
+ }
525
+ function getPicks() {
526
+ return getSelection().picks;
527
+ }
528
+ /** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
529
+ function useSelection() {
530
+ return selectionStore.use();
531
+ }
532
+ /** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
533
+ function enterSelection() {
534
+ selectionStore.update((current) => current.selecting ? current : {
535
+ ...current,
536
+ selecting: true
537
+ });
538
+ }
539
+ /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
540
+ function exitSelection() {
541
+ selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
542
+ }
543
+ /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
544
+ function togglePick(item) {
545
+ selectionStore.update((current) => {
546
+ const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
547
+ return {
548
+ ...current,
549
+ picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
550
+ };
551
+ });
552
+ }
553
+ /** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
554
+ function clearPicks() {
555
+ selectionStore.update((current) => current.picks.length === 0 ? current : {
556
+ ...current,
557
+ picks: []
558
+ });
559
+ }
560
+ /** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
561
+ function addPicks(items) {
562
+ selectionStore.update((current) => {
563
+ const chosen = new Set(current.picks.map((item) => item.sessionId));
564
+ const additions = [];
565
+ for (const item of items) {
566
+ if (chosen.has(item.sessionId)) continue;
567
+ chosen.add(item.sessionId);
568
+ additions.push(item);
569
+ }
570
+ return additions.length === 0 ? current : {
571
+ ...current,
572
+ picks: [...current.picks, ...additions]
573
+ };
574
+ });
575
+ }
576
+ /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
577
+ function shrinkPicksToFailures(failedSessionIds) {
578
+ const keep = new Set(failedSessionIds);
579
+ selectionStore.update((current) => ({
580
+ ...current,
581
+ picks: current.picks.filter((item) => keep.has(item.sessionId))
582
+ }));
583
+ }
584
+ //#endregion
585
+ //#region src/client/host-rows.ts
586
+ /**
587
+ * 宿主会话行的**DOM 适配**(2026-10-20):可见行解析、全选判定、模式内的整行点击守卫。
588
+ *
589
+ * 从 `batch-select.tsx` 拆出来的理由:选中集本身(模式 + 名单)只住 `batch-selection-store.ts`;
590
+ * 本文件只回答**页面上**的两件事——"现在 DOM 里有哪些可选的会话行"与"在这个行上点一下算不算选中"。
591
+ * 两边的唯一交界是 `data-row-key`(宿主会话行的稳定标记)和行尾兜底登记的那张 `rowInfo` 表。
592
+ *
593
+ * **为什么整行点击要抢在 document 捕获阶段**:宿主的会话行自己带 `onClick = open(sessionId)`,
594
+ * 第三方没有官方的"别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
595
+ */
596
+ /**
597
+ * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
598
+ * (它对每个非空行都渲染,哪怕 CSS 把整排藏起来)在 effect 里把这两件登记进这张表。
599
+ * 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
600
+ */
601
+ const rowInfo = /* @__PURE__ */ new Map();
602
+ /** 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。 */
603
+ function registerHostRowInfo(sessionId, info) {
604
+ rowInfo.set(sessionId, info);
605
+ }
606
+ /**
607
+ * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
608
+ * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
609
+ * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
610
+ */
611
+ function readVisibleSelectableRows() {
612
+ const rows = [];
613
+ const seen = /* @__PURE__ */ new Set();
614
+ for (const row of document.querySelectorAll("[data-row-key^=\"session:\"]")) {
615
+ const key = row.getAttribute("data-row-key") ?? "";
616
+ if (!key.startsWith("session:")) continue;
617
+ const sessionId = key.slice(8);
618
+ if (sessionId === "" || seen.has(sessionId)) continue;
619
+ const info = rowInfo.get(sessionId);
620
+ if (info === void 0 || info.live) continue;
621
+ seen.add(sessionId);
622
+ rows.push({
623
+ sessionId,
624
+ title: info.title
625
+ });
626
+ }
627
+ return rows;
628
+ }
629
+ /** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
630
+ function selectAllVisible() {
631
+ addPicks(readVisibleSelectableRows());
632
+ }
633
+ /**
634
+ * 同一个切换口该显示「全选」还是「取消全选」:
635
+ * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
636
+ * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
637
+ * - 其余(含 0 条)⇒ 全选。
638
+ */
639
+ function allVisibleSelected() {
640
+ const picks = getSelection().picks;
641
+ if (picks.length === 0) return false;
642
+ const rows = readVisibleSelectableRows();
643
+ if (rows.length === 0) return true;
644
+ const chosen = new Set(picks.map((item) => item.sessionId));
645
+ return rows.every((row) => chosen.has(row.sessionId));
646
+ }
647
+ /** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
648
+ const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
649
+ /**
650
+ * 多选态内的整行点击守卫(document 捕获阶段)。
651
+ *
652
+ * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
653
+ * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
654
+ * 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
655
+ * 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
656
+ */
657
+ function useSelectionRowClickGuard() {
658
+ const selecting = useSelection().selecting;
659
+ (0, react.useEffect)(() => {
660
+ if (!selecting) return;
661
+ const handle = (event) => {
662
+ if (!(event.target instanceof Element)) return;
663
+ const row = event.target.closest("[data-row-key^=\"session:\"]");
664
+ if (row === null) return;
665
+ if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
666
+ event.preventDefault();
667
+ event.stopPropagation();
668
+ if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
669
+ const key = row.getAttribute("data-row-key") ?? "";
670
+ if (!key.startsWith("session:")) return;
671
+ const sessionId = key.slice(8);
672
+ if (sessionId === "") return;
673
+ const info = rowInfo.get(sessionId);
674
+ if (info?.live === true) return;
675
+ togglePick({
676
+ sessionId,
677
+ title: info?.title ?? ""
678
+ });
679
+ };
680
+ document.addEventListener("click", handle, true);
681
+ document.addEventListener("dblclick", handle, true);
682
+ return () => {
683
+ document.removeEventListener("click", handle, true);
684
+ document.removeEventListener("dblclick", handle, true);
685
+ };
686
+ }, [selecting]);
687
+ }
688
+ //#endregion
434
689
  //#region src/shared/wire.ts
435
690
  /**
436
691
  * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
@@ -460,7 +715,7 @@ window.__ModuleLoader__.load({
460
715
  * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
461
716
  * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
462
717
  * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
463
- * - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
718
+ * - `POST /detail`:单条详情(用 POST 表达一个读:判据就写在 `src/api/route-table.ts` 那一行的上方)。
464
719
  * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
465
720
  * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
466
721
  * - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
@@ -524,6 +779,59 @@ window.__ModuleLoader__.load({
524
779
  return encoded.replace(/~([0-9A-F]{4})/g, (_whole, hex) => String.fromCharCode(parseInt(hex, 16)));
525
780
  }
526
781
  //#endregion
782
+ //#region src/client/receipt.ts
783
+ function missingOutcomeMessage(action, id) {
784
+ return `批量通道没回这一条的结果:${action} ${id}`;
785
+ }
786
+ function isFailureVerdict(verdict) {
787
+ return verdict.kind === "failed" || verdict.kind === "neutral" || verdict.kind === "missing";
788
+ }
789
+ /** failed / neutral 取服务端原文 `error.message`;missing 取通道自己写的那句。 */
790
+ function failureMessage(verdict) {
791
+ return verdict.kind === "failed" || verdict.kind === "neutral" ? verdict.error.message : verdict.message;
792
+ }
793
+ function decode$1(action, outcome) {
794
+ if (outcome.action !== action) return void 0;
795
+ const typed = outcome;
796
+ if (!typed.ok) return action === "orphan-clean" && typed.error.code === SESSION_DELETE_CODES["not-found"] ? {
797
+ kind: "neutral",
798
+ id: typed.id,
799
+ error: typed.error
800
+ } : {
801
+ kind: "failed",
802
+ id: typed.id,
803
+ error: typed.error
804
+ };
805
+ const value = typed.value;
806
+ return value === null ? {
807
+ kind: "already-gone",
808
+ id: typed.id
809
+ } : {
810
+ kind: "ok",
811
+ id: typed.id,
812
+ value
813
+ };
814
+ }
815
+ function readReceipt(action, requestedIds, outcomes) {
816
+ const wanted = [...new Set(requestedIds)];
817
+ const wantedSet = new Set(wanted);
818
+ const byId = /* @__PURE__ */ new Map();
819
+ for (const outcome of outcomes) {
820
+ if (!wantedSet.has(outcome.id) || byId.has(outcome.id)) continue;
821
+ const verdict = decode$1(action, outcome);
822
+ if (verdict !== void 0) byId.set(outcome.id, verdict);
823
+ }
824
+ const verdicts = wanted.map((id) => byId.get(id) ?? {
825
+ kind: "missing",
826
+ id,
827
+ message: missingOutcomeMessage(action, id)
828
+ });
829
+ return {
830
+ verdicts,
831
+ byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
832
+ };
833
+ }
834
+ //#endregion
527
835
  //#region src/client/remote.ts
528
836
  /**
529
837
  * 浏览器半的**取数通道**:同源 fetch 宿主 web server 上的 `/dsh-session-delete-api`,
@@ -667,9 +975,9 @@ window.__ModuleLoader__.load({
667
975
  *
668
976
  * 判据是**「拿到信封」而不是「操作成功」**:`{ok:false, error}` 也是一次成功的往返(路由在、依赖接上了、
669
977
  * 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
670
- * 一个槽位贡献都不注册(设计稿 §7「未接通态」)。
978
+ * 一个槽位贡献都不注册(docs/architecture.md §3「通道探测决定入口出现与否」)。
671
979
  *
672
- * 2026-10-17 第四批:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
980
+ * 2026-10-17:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
673
981
  * 而"信封到不到"这件事一次零成本的 GET 就答得出来。
674
982
  */
675
983
  async function probeChannel(raw) {
@@ -686,13 +994,24 @@ window.__ModuleLoader__.load({
686
994
  * 三条判据都在这一处:
687
995
  * - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
688
996
  * - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
689
- * - `null` 是批量里"已被同批前一条带走"的幂等值——单条调用(N=1)永远不会命中它。
997
+ * - `null` 是批量里"已被同批前一条带走"的幂等值——N=1 永远不会命中它,回 `null` 只能是通道答错了,
998
+ * 所以同样按通道错抛,不冒充成功。
999
+ *
1000
+ * 成/败/幂等/缺失的判读只走 {@link readReceipt}(一个家)。
690
1001
  */
691
1002
  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;
1003
+ const outcomes = await deps.applyOutcomes(action, [id]);
1004
+ const verdict = readReceipt(action, [id], outcomes).byId.get(id);
1005
+ if (verdict === void 0) throw new Error(missingOutcomeMessage(action, id));
1006
+ if (verdict.kind === "failed" || verdict.kind === "neutral") throwRemoteFailure(verdict.error);
1007
+ if (verdict.kind === "missing") throw new Error(verdict.message);
1008
+ if (verdict.kind === "already-gone") throw new Error(`单条动作回了幂等空值:${action} ${id}`);
1009
+ return {
1010
+ id,
1011
+ action,
1012
+ ok: true,
1013
+ value: verdict.value
1014
+ };
696
1015
  }
697
1016
  /**
698
1017
  * 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
@@ -853,7 +1172,7 @@ window.__ModuleLoader__.load({
853
1172
  })]
854
1173
  });
855
1174
  }
856
- /** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17 第十三批加)。 */
1175
+ /** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17 加)。 */
857
1176
  function IconCheckOutlineRegular({ size }) {
858
1177
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFrame, {
859
1178
  size,
@@ -885,10 +1204,10 @@ window.__ModuleLoader__.load({
885
1204
  }
886
1205
  //#endregion
887
1206
  //#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";
1207
+ 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
1208
  //#endregion
890
1209
  //#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";
1210
+ 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
1211
  //#endregion
893
1212
  //#region src/client/ui/styles.ts
894
1213
  /**
@@ -1075,187 +1394,33 @@ window.__ModuleLoader__.load({
1075
1394
  //#endregion
1076
1395
  //#region src/client/batch-select.tsx
1077
1396
  /**
1078
- * 宿主列表批量删除的选择面(第十三批①):多选模式 + 选中集 + 行首勾选 + 行尾兜底 + 整行点击守卫。
1397
+ * 宿主列表批量删除的**控件面**(2026-10-17,2026-10-20 拆出状态与行适配):行首勾选框、行尾兜底开关,
1398
+ * 以及确认框/浮条读的那一份计数问句。
1079
1399
  *
1080
1400
  * 入口在每行的「⋯」菜单里(`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`,不为此引新的状态库。
1401
+ * 才把整行点击变成"勾/取消";退出模式清空名单。选中集只住 `batch-selection-store.ts`(模式 + 名单一份
1402
+ * 合并快照);"哪些行现在可见、点行算不算选中"只住 `host-rows.ts`。四处隔着槽位边界,只有模块级
1403
+ * store 能让他们看见同一份名单;本文件不再持有任何现场。
1086
1404
  *
1087
1405
  * **为什么主勾选在行首**:宿主对行尾 `.rowActions` 是 `display:none`,只有 `row:hover` / 菜单打开
1088
1406
  * 才出现(app.asar 0.2.0-rc.2 的 Rows 渲染);行首 16px 格在无状态点时是空的,模式内可见。
1089
1407
  * 宿主在「有状态点(运行中/待确认/未读完成)」与归档行不渲染行首格 ⇒ 这些行走行尾悬停兜底,
1090
- * 但模式内**点整行**也能选(`useSelectionRowClickGuard`),所以不依赖那颗小框。
1408
+ * 但模式内**点整行**也能选(`host-rows.ts` 的 `useSelectionRowClickGuard`),所以不依赖那颗小框。
1091
1409
  * 两条勾选不会同时出现(`ui.css` 的 `:has` 兜底隐藏规则,靠宿主行的 `data-row-key`)。
1092
- *
1093
- * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id),**顺序 = 勾选顺序**:
1094
- * 宿主列表的 DOM 序不归我们读,`/apply` 那条「保持输入顺序」吃到的因此就是勾选序。
1095
- */
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
- /**
1136
- * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
1137
- * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
1138
- * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
1139
- */
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();
1165
- }
1166
- /**
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
1410
  */
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
1411
  /**
1244
1412
  * 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
1245
1413
  *
1246
1414
  * **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
1247
- * 缺一条就是假数;「不许拿 0 冒充没有」(10-15 稿 §10)。
1415
+ * 缺一条就是假数;「不许拿 0 冒充没有」。
1248
1416
  */
1249
1417
  async function fetchCounts(deps, ids) {
1250
1418
  if (ids.length === 0) return /* @__PURE__ */ new Map();
1251
1419
  const outcome = await callRemote(() => deps.applyOutcomes("count", ids));
1252
1420
  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;
1421
+ const receipt = readReceipt("count", ids, outcome.value);
1422
+ if (receipt.verdicts.some((verdict) => verdict.kind !== "ok")) return null;
1423
+ return new Map(receipt.verdicts.map((verdict) => [verdict.id, verdict.kind === "ok" ? verdict.value : 0]));
1259
1424
  }
1260
1425
  /**
1261
1426
  * 行首 16px 格里的主勾选框:**只在多选模式内渲染**(模式入口在「⋯ → 多选」,不常驻)。
@@ -1265,9 +1430,9 @@ window.__ModuleLoader__.load({
1265
1430
  * 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
1266
1431
  */
1267
1432
  function BatchPickCheckbox({ sessionId, title, t = zhTranslate }) {
1268
- const selectingNow = useSelecting();
1269
- const on = usePicks().some((candidate) => candidate.sessionId === sessionId);
1270
- if (!selectingNow) return null;
1433
+ const { selecting, picks } = useSelection();
1434
+ const on = picks.some((candidate) => candidate.sessionId === sessionId);
1435
+ if (!selecting) return null;
1271
1436
  const name = title === "" ? t("common.untitled") : title;
1272
1437
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1273
1438
  className: "sd-batch-pick-leading",
@@ -1292,10 +1457,10 @@ window.__ModuleLoader__.load({
1292
1457
  * 自己 `stopPropagation`。行首那颗与这颗共用同一份选中集和同一句 aria 名字;模式内点整行也能选。
1293
1458
  */
1294
1459
  function BatchPickButton({ sessionId, title, live = false, t = zhTranslate }) {
1295
- const selectingNow = useSelecting();
1296
- const on = usePicks().some((candidate) => candidate.sessionId === sessionId);
1460
+ const { selecting, picks } = useSelection();
1461
+ const on = picks.some((candidate) => candidate.sessionId === sessionId);
1297
1462
  (0, react.useEffect)(() => {
1298
- rowInfo.set(sessionId, {
1463
+ registerHostRowInfo(sessionId, {
1299
1464
  title,
1300
1465
  live
1301
1466
  });
@@ -1304,7 +1469,7 @@ window.__ModuleLoader__.load({
1304
1469
  title,
1305
1470
  live
1306
1471
  ]);
1307
- if (!selectingNow) return null;
1472
+ if (!selecting) return null;
1308
1473
  const name = title === "" ? t("common.untitled") : title;
1309
1474
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tooltip, {
1310
1475
  label: t("menu.live"),
@@ -1332,43 +1497,6 @@ window.__ModuleLoader__.load({
1332
1497
  });
1333
1498
  }
1334
1499
  //#endregion
1335
- //#region src/client/trash-store.ts
1336
- /**
1337
- * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
1338
- *
1339
- * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
1340
- * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1341
- * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
1342
- * 同理。
1343
- *
1344
- * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场 +
1345
- * `useSyncExternalStore`**,不为此引状态库、也不引入上下文——三样东西都挂在 `shell.overlay`/`main`
1346
- * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1347
- *
1348
- * 语义只有"变过"这一件事:一个单调递增的修订号。面板把它写进 effect 的依赖里,戳一次重读一次;
1349
- * 不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
1350
- */
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);
1370
- }
1371
- //#endregion
1372
1500
  //#region src/client/ui/Button.tsx
1373
1501
  /**
1374
1502
  * `Button`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Button,出处与三条改写规则
@@ -1601,9 +1729,157 @@ window.__ModuleLoader__.load({
1601
1729
  }), document.body);
1602
1730
  }
1603
1731
  //#endregion
1732
+ //#region src/client/main-view.ts
1733
+ let client;
1734
+ /** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开。 */
1735
+ let closedByUs = /* @__PURE__ */ new Set();
1736
+ /** 重开排队的最新序号:后一次删除/恢复(或重开)让前一次的等待作废(只开最新那条)。 */
1737
+ let openToken = 0;
1738
+ /** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
1739
+ function bindMainView(ctx) {
1740
+ client = ctx;
1741
+ }
1742
+ function uiWorkspaceOf() {
1743
+ try {
1744
+ const candidate = client?.get?.("uiWorkspace");
1745
+ if (candidate === void 0 || candidate === null) return void 0;
1746
+ if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return void 0;
1747
+ if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return void 0;
1748
+ return candidate;
1749
+ } catch {
1750
+ return;
1751
+ }
1752
+ }
1753
+ function currentSessionOf(ui) {
1754
+ try {
1755
+ return ui.selection?.getSnapshot()?.sessionId;
1756
+ } catch {
1757
+ return;
1758
+ }
1759
+ }
1760
+ /**
1761
+ * 删除成功后调用:当前主视图若在这批里,把它关掉并记下"这一条是我们关掉的"(供撤销重开)。
1762
+ * 关不掉/不是当前会话都什么都不做——这只是界面收尾,绝不让删除结果变红。
1763
+ */
1764
+ function afterDelete(sessionIds) {
1765
+ if (sessionIds.length === 0) return;
1766
+ openToken += 1;
1767
+ const ui = uiWorkspaceOf();
1768
+ if (ui === void 0) return;
1769
+ const current = currentSessionOf(ui);
1770
+ if (current === void 0 || !sessionIds.includes(current)) return;
1771
+ try {
1772
+ ui.clearMain();
1773
+ closedByUs.add(current);
1774
+ } catch {}
1775
+ }
1776
+ /**
1777
+ * 撤销恢复成功后调用:把**我们自己关掉的**那几个会话重新打开(只认 `afterDelete` 记过的)。
1778
+ *
1779
+ * 一批里只有最后一条会开(`targets[targets.length - 1]`):主视图一次只装一个会话,而撤销恢复
1780
+ * 的成功条数可能多于一条;`openWhenListed` 的 token 守卫保证前一条的等待不会后落地抢焦点。
1781
+ * 任何更新的删除/恢复都会先作废旧等待;这里只负责「这一次要开哪一条」。
1782
+ */
1783
+ function afterRestore(sessionIds) {
1784
+ if (sessionIds.length === 0) return;
1785
+ openToken += 1;
1786
+ const targets = sessionIds.filter((id) => closedByUs.has(id));
1787
+ if (targets.length === 0) return;
1788
+ for (const id of targets) closedByUs.delete(id);
1789
+ openWhenListed(targets[targets.length - 1]);
1790
+ }
1791
+ /**
1792
+ * 把会话重新打开:`api-session/added` 从宿主发出到客户端摘要表落地有一拍(WS 与 HTTP 响应各走
1793
+ * 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——所以先等它出现,
1794
+ * 最多 2 秒;期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
1795
+ *
1796
+ * **token 守卫**(`openToken`):开始等待时取一个序号,立即开与每次 tick 都核对;后一次删除/
1797
+ * 恢复(或 `resetMainView`)推进序号,前一次的等待就不再打开、也不再续时。
1798
+ */
1799
+ function openWhenListed(sessionId) {
1800
+ const ui = uiWorkspaceOf();
1801
+ if (ui === void 0) return;
1802
+ const token = ++openToken;
1803
+ const listReady = () => {
1804
+ try {
1805
+ return ui.sessions?.list?.getSnapshot()?.byId?.[sessionId] !== void 0;
1806
+ } catch {
1807
+ return false;
1808
+ }
1809
+ };
1810
+ const canTakeFocus = () => {
1811
+ const current = currentSessionOf(ui);
1812
+ return current === void 0 || current === sessionId;
1813
+ };
1814
+ const open = () => {
1815
+ if (token !== openToken) return;
1816
+ if (!canTakeFocus()) return;
1817
+ try {
1818
+ ui.openSession?.(sessionId);
1819
+ } catch {}
1820
+ };
1821
+ if (listReady()) {
1822
+ open();
1823
+ return;
1824
+ }
1825
+ let attempts = 0;
1826
+ const tick = () => {
1827
+ if (token !== openToken) return;
1828
+ attempts += 1;
1829
+ if (listReady()) {
1830
+ open();
1831
+ return;
1832
+ }
1833
+ if (attempts < 20) setTimeout(tick, 100);
1834
+ };
1835
+ setTimeout(tick, 100);
1836
+ }
1837
+ //#endregion
1838
+ //#region src/client/trash-store.ts
1839
+ /**
1840
+ * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
1841
+ *
1842
+ * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
1843
+ * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1844
+ * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
1845
+ * 同理。
1846
+ *
1847
+ * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场**,
1848
+ * 订阅/通告那套只住在 `store.ts` 的 `createStore` 里——这些现场都挂在 `shell.overlay`/`main`
1849
+ * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1850
+ *
1851
+ * 语义只有"变过"这一件事:一个递增的修订号(测试收场时归零)。面板把它写进 effect 的依赖里,
1852
+ * 戳一次重读一次;不需要知道是谁动的、动了什么(清单本体永远是问宿主要)。
1853
+ */
1854
+ const revisionStore = createStore(0);
1855
+ /** 通告一次"回收站变过了"(删除成功、撤销成功;面板自己的恢复/彻底删除则就地重读)。 */
1856
+ function invalidateTrash() {
1857
+ revisionStore.update((revision) => revision + 1);
1858
+ }
1859
+ /** 供面板订阅。 */
1860
+ function useTrashRevision() {
1861
+ return revisionStore.use();
1862
+ }
1863
+ //#endregion
1864
+ //#region src/client/write-commit.ts
1865
+ /**
1866
+ * 回收站面板之外发生的写入(菜单删除、批量删除、撤销恢复)的**跨槽位收尾顺序**:
1867
+ * 主视图收尾 → 失效回收站 → 撤销通告。面板自己的写入,本地 refresh / 缓存的
1868
+ * 生命周期仍住在 controller 与 query 里。
1869
+ */
1870
+ function commitExternalDelete(input) {
1871
+ afterDelete(input.sessionIds);
1872
+ invalidateTrash();
1873
+ input.publishUndo();
1874
+ }
1875
+ function commitExternalRestore(sessionIds) {
1876
+ afterRestore(sessionIds);
1877
+ invalidateTrash();
1878
+ }
1879
+ //#endregion
1604
1880
  //#region src/client/undo-toast.tsx
1605
1881
  /**
1606
- * 撤销提示(设计稿 §7 状态 ③):删除成功后一条可撤销的横幅。
1882
+ * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
1607
1883
  *
1608
1884
  * 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
1609
1885
  * (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
@@ -1613,71 +1889,71 @@ window.__ModuleLoader__.load({
1613
1889
  * `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
1614
1890
  * 本文件就是那份 store + 那个常驻组件。
1615
1891
  *
1616
- * 状态源用 React 自带的 `useSyncExternalStore`——不引 `@deepseek-ai/dsh-client-store`
1617
- * (它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
1892
+ * 状态源走 `store.ts` 的 `createStore`(内部用 React 自带的 `useSyncExternalStore`)——
1893
+ * 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
1618
1894
  */
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
- };
1895
+ const noticeStore = createStore(null);
1625
1896
  /** 删除成功后发布一条撤销提示(单条)。 */
1626
1897
  function publishUndoNotice(input) {
1627
- notice = {
1628
- seq: nextSeq$2 += 1,
1898
+ noticeStore.publish((seq) => ({
1899
+ seq,
1629
1900
  kind: "one",
1630
1901
  entryId: input.entryId,
1631
1902
  title: input.title
1632
- };
1633
- emit$2();
1903
+ }));
1634
1904
  }
1635
- /** 批量删除成功后发布一条撤销提示(第十三批②):覆盖上一条,只报这一次移走的条数。 */
1905
+ /** 批量删除成功后发布一条撤销提示(2026-10-17):覆盖上一条,只报这一次移走的条数。 */
1636
1906
  function publishUndoBatchNotice(input) {
1637
- notice = {
1638
- seq: nextSeq$2 += 1,
1907
+ noticeStore.publish((seq) => ({
1908
+ seq,
1639
1909
  kind: "batch",
1640
1910
  entryIds: input.entryIds,
1641
1911
  n: input.n
1642
- };
1643
- emit$2();
1912
+ }));
1644
1913
  }
1645
1914
  /** 撤销成功后把提示收掉(用户点「撤销」、或横幅自己走完停留时间)。 */
1646
1915
  function clearUndoNotice() {
1647
- if (notice === null) return;
1648
- notice = null;
1649
- emit$2();
1650
- }
1651
- function getUndoNotice() {
1652
- return notice;
1653
- }
1654
- function subscribe$2(listener) {
1655
- listeners$2.add(listener);
1656
- return () => {
1657
- listeners$2.delete(listener);
1658
- };
1916
+ noticeStore.set(null);
1659
1917
  }
1660
1918
  /** 供组件订阅。 */
1661
1919
  function useUndoNotice() {
1662
- return (0, react.useSyncExternalStore)(subscribe$2, getUndoNotice, getUndoNotice);
1920
+ return noticeStore.use();
1663
1921
  }
1664
- /** 提示停留时长。设计稿 §7 定死:**6 秒**(Toast 自己的默认是 3 秒)。 */
1922
+ /** 提示停留时长。**6 秒** 定死在本行(比 Toast 默认的 3 秒长)——`docs/ui-spec.md` §2 状态 ③ 只指到这里,不抄这个数。 */
1665
1923
  const UNDO_HOLD_MS = 6e3;
1924
+ /** 撤销失败:把原因换到同一条横幅上(seq 递增 ⇒ Toast 重挂载、重新计停留)。 */
1925
+ function failNotice(current, message) {
1926
+ noticeStore.publish((seq) => ({
1927
+ ...current,
1928
+ seq,
1929
+ failure: message
1930
+ }));
1931
+ }
1666
1932
  /** 撤销:单条 `restore(entryId)`、批量**一次** `applyOutcomes('restore', entryIds)`;失败就把原因换到同一条横幅上,不静默。 */
1667
1933
  async function undo(deps, current) {
1668
1934
  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();
1673
- return;
1935
+ if (current.kind === "one") {
1936
+ const outcome = await callRemote(() => applyOne(deps, "restore", current.entryId));
1937
+ if (!outcome.ok) {
1938
+ failNotice(current, outcome.failure.message);
1939
+ return;
1940
+ }
1941
+ commitExternalRestore([outcome.value.value.sessionId]);
1942
+ } else {
1943
+ const outcome = await callRemote(() => deps.applyOutcomes("restore", current.entryIds));
1944
+ if (!outcome.ok) {
1945
+ failNotice(current, outcome.failure.message);
1946
+ return;
1947
+ }
1948
+ const receipt = readReceipt("restore", current.entryIds, outcome.value);
1949
+ commitExternalRestore(receipt.verdicts.flatMap((verdict) => verdict.kind === "ok" && verdict.value !== null ? [verdict.value.sessionId] : []));
1950
+ const failure = receipt.verdicts.find(isFailureVerdict);
1951
+ if (failure !== void 0) {
1952
+ failNotice(current, failureMessage(failure));
1953
+ return;
1954
+ }
1674
1955
  }
1675
- notice = {
1676
- ...current,
1677
- seq: nextSeq$2 += 1,
1678
- failure: outcome.failure.message
1679
- };
1680
- emit$2();
1956
+ clearUndoNotice();
1681
1957
  }
1682
1958
  /**
1683
1959
  * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
@@ -1729,7 +2005,8 @@ window.__ModuleLoader__.load({
1729
2005
  * 整条链路都在菜单子树之外,菜单关不关都不影响它。
1730
2006
  *
1731
2007
  * 现场是**模块级**的(本仓既有的客户端模式:视图内路由与跨卸载的现场都住在模块级 store,
1732
- * 见 `undo-toast.tsx`),状态源用 React 自带的 `useSyncExternalStore`——不为此引新的状态库。
2008
+ * 见 `undo-toast.tsx`),状态源是 `store.ts` 的 `createStore`(内部用 React 自带的
2009
+ * `useSyncExternalStore`)——不为此引新的状态库。
1733
2010
  */
1734
2011
  /**
1735
2012
  * 被操作对象那张卡。`Modal`(`ui/Modal.tsx`,照官方抄的)的 `.sd-modal-body` 不设字号也不给层级 ⇒ 裸摆一行会话标题会和下面那句
@@ -1743,39 +2020,22 @@ window.__ModuleLoader__.load({
1743
2020
  border: "0.5px solid var(--dsw-alias-border-l4)",
1744
2021
  borderRadius: "var(--dsw-radius-lg)"
1745
2022
  };
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
- };
2023
+ const requestStore$1 = createStore(null);
1752
2024
  /** 菜单行调用:请求删除这个会话(确认框由 overlay 上的宿主渲染)。 */
1753
2025
  function requestDelete(input) {
1754
- request$1 = {
1755
- seq: nextSeq$1 += 1,
2026
+ requestStore$1.publish((seq) => ({
2027
+ seq,
1756
2028
  sessionId: input.sessionId,
1757
2029
  title: input.title
1758
- };
1759
- emit$1();
2030
+ }));
1760
2031
  }
1761
2032
  /** 收起确认框(用户点「取消」/「关闭」、或删除成功后收工)。 */
1762
2033
  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
- };
2034
+ requestStore$1.set(null);
1775
2035
  }
1776
2036
  /** 供宿主组件订阅。 */
1777
2037
  function useDeleteRequest() {
1778
- return (0, react.useSyncExternalStore)(subscribe$1, getDeleteRequest, getDeleteRequest);
2038
+ return requestStore$1.use();
1779
2039
  }
1780
2040
  /**
1781
2041
  * 常驻的确认框宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
@@ -1791,7 +2051,7 @@ window.__ModuleLoader__.load({
1791
2051
  }, current.seq);
1792
2052
  }
1793
2053
  /**
1794
- * 确认框本体。视图与文案与设计稿 §7 一致(`Modal` + `variant="primary"` 主按钮「移入回收站」;
2054
+ * 确认框本体。视图与文案与 docs/ui-spec.md 一致(`Modal` + `variant="primary"` 主按钮「移入回收站」;
1795
2055
  * 那个 primary 在宿主浅色下是近黑、深色下近白,**从来不是蓝**)。
1796
2056
  * 失败面**不吞**:原文以 `Tag tone="danger"` 摆进对话框,且**确认失败后不关框**,
1797
2057
  * 用户可以再点一次(不是一次失败就永久锁死)。
@@ -1800,7 +2060,7 @@ window.__ModuleLoader__.load({
1800
2060
  const [busy, setBusy] = (0, react.useState)(false);
1801
2061
  const [failure, setFailure] = (0, react.useState)(null);
1802
2062
  /**
1803
- * 这条会话在盘上带着多少个后代。删除是按组走的(10-15 稿 §11),而宿主的客户端本来就把孩子那几行
2063
+ * 这条会话在盘上带着多少个后代。删除是按组走的(组语义见 `src/trash/groups.ts`),而宿主的客户端本来就把孩子那几行
1804
2064
  * 整排藏了 ⇒ 不说就没人知道点下去侧栏会少一片(issue #3)。那一刻它还没进回收站,清单里没有它,
1805
2065
  * 所以只能现问一次。
1806
2066
  */
@@ -1824,12 +2084,15 @@ window.__ModuleLoader__.load({
1824
2084
  setFailure(outcome.failure.message);
1825
2085
  return;
1826
2086
  }
1827
- clearDeleteRequest();
1828
- invalidateTrash();
1829
- if (outcome.value !== null) publishUndoNotice({
1830
- entryId: outcome.value.id,
1831
- title: current.title
2087
+ const entry = outcome.value;
2088
+ commitExternalDelete({
2089
+ sessionIds: [current.sessionId],
2090
+ publishUndo: () => publishUndoNotice({
2091
+ entryId: entry.id,
2092
+ title: current.title
2093
+ })
1832
2094
  });
2095
+ clearDeleteRequest();
1833
2096
  };
1834
2097
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
1835
2098
  open: true,
@@ -1874,9 +2137,9 @@ window.__ModuleLoader__.load({
1874
2137
  //#endregion
1875
2138
  //#region src/client/batch-confirm.tsx
1876
2139
  /**
1877
- * 批量删除的确认框(第十三批②)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
2140
+ * 批量删除的确认框(2026-10-17)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
1878
2141
  *
1879
- * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(设计稿 §7 状态 ②,
2142
+ * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(docs/ui-spec.md 状态 ②,
1880
2143
  * 用例钉着),批量形与它只有三件不同——标题随条数、目标卡逐条列、执行是一次批量 `apply`。共用一个壳
1881
2144
  * 意味着单删的 `busy` / 失败面要跟着学批量形,那是拿已交付的行为去换省一个文件。卡片样式(`TARGET_CARD`)
1882
2145
  * 与 hint、子会话那两句**复用**单删的,一句不造第二个键。
@@ -1884,48 +2147,32 @@ window.__ModuleLoader__.load({
1884
2147
  * 现场是模块级的(同 `delete-confirm.tsx` / `undo-toast.tsx`):请求从浮条那颗按钮发来,确认框住在
1885
2148
  * `shell.overlay` 上——它必须活在行开关那棵子树之外(槽位行随宿主重排而卸载,见 `delete-confirm.tsx` 头注)。
1886
2149
  */
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
- };
2150
+ const requestStore = createStore(null);
1893
2151
  /** 浮条那颗按钮调用:请求确认这一批(缺省 = 当前选中集)。空名单不发框。 */
1894
2152
  function requestDeleteBatch(items = getPicks()) {
1895
2153
  if (items.length === 0) return;
1896
- request = {
1897
- seq: nextSeq += 1,
2154
+ requestStore.publish((seq) => ({
2155
+ seq,
1898
2156
  items: [...items]
1899
- };
1900
- emit();
2157
+ }));
1901
2158
  }
1902
2159
  function clearDeleteBatchRequest() {
1903
- if (request === null) return;
1904
- request = null;
1905
- emit();
2160
+ requestStore.set(null);
1906
2161
  }
1907
2162
  /** 重试轮之后把现场收缩到失败名单(框里只剩还没办成的那几条)。 */
1908
2163
  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
- };
2164
+ requestStore.update((current) => {
2165
+ if (current === null) return current;
2166
+ const keep = new Set(failedSessionIds);
2167
+ return {
2168
+ ...current,
2169
+ items: current.items.filter((item) => keep.has(item.sessionId))
2170
+ };
2171
+ });
1925
2172
  }
1926
2173
  /** 供浮条与装配面订阅。 */
1927
2174
  function useDeleteBatchRequest() {
1928
- return (0, react.useSyncExternalStore)(subscribe, getBatchRequest, getBatchRequest);
2175
+ return requestStore.use();
1929
2176
  }
1930
2177
  /** 常驻的批量确认框宿主。没有待确认的批量删除时什么都不渲染。 */
1931
2178
  function BatchConfirmHost({ deps, t = zhTranslate }) {
@@ -1996,29 +2243,35 @@ window.__ModuleLoader__.load({
1996
2243
  setFailures(new Map(pendingIds.map((id) => [id, outcome.failure.message])));
1997
2244
  return;
1998
2245
  }
1999
- const byId = /* @__PURE__ */ new Map();
2000
- for (const item of outcome.value) if (!byId.has(item.id)) byId.set(item.id, item);
2246
+ const receipt = readReceipt("delete", pendingIds, outcome.value);
2001
2247
  const moved = [];
2248
+ const deletedSessionIds = [];
2002
2249
  const failed = /* @__PURE__ */ new Map();
2003
2250
  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;
2251
+ for (const verdict of receipt.verdicts) switch (verdict.kind) {
2252
+ case "failed":
2253
+ case "neutral":
2254
+ case "missing":
2255
+ failed.set(verdict.id, failureMessage(verdict));
2256
+ break;
2257
+ case "ok":
2258
+ okCount += 1;
2259
+ deletedSessionIds.push(verdict.id);
2260
+ if (verdict.value !== null) moved.push(verdict.value.id);
2261
+ break;
2262
+ case "already-gone":
2263
+ okCount += 1;
2264
+ deletedSessionIds.push(verdict.id);
2265
+ }
2266
+ commitExternalDelete({
2267
+ sessionIds: deletedSessionIds,
2268
+ publishUndo: () => {
2269
+ if (okCount > 0) publishUndoBatchNotice({
2270
+ entryIds: moved,
2271
+ n: okCount
2272
+ });
2013
2273
  }
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
2274
  });
2021
- invalidateTrash();
2022
2275
  shrinkPicksToFailures([...failed.keys()]);
2023
2276
  if (failed.size === 0) {
2024
2277
  clearDeleteBatchRequest();
@@ -2075,25 +2328,24 @@ window.__ModuleLoader__.load({
2075
2328
  //#endregion
2076
2329
  //#region src/client/batch-bar.tsx
2077
2330
  /**
2078
- * 批量删除的浮条(第十三批①):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2331
+ * 批量删除的浮条(2026-10-17 `6f24fd4`):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2079
2332
  * 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
2080
2333
  * (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
2081
- * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `batch-select.tsx` 的 `useSelectionRowClickGuard`)。
2334
+ * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `host-rows.ts` 的 `useSelectionRowClickGuard`)。
2082
2335
  *
2083
- * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"选中集 + 行开关"(被确认框 import),
2084
- * 而浮条要点开确认框(要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
2336
+ * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"行开关控件",而浮条要点开确认框
2337
+ * (要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
2085
2338
  * `shell.overlay` 上,与单删的撤销提示/确认框同类(槽位原文:"a toast stack or a status pill
2086
2339
  * all belong here")。
2087
2340
  *
2088
2341
  * 那个 K 是**勾选变化后防抖 300ms 的一次批量 count**(`/apply` 的 `action:'count'`,一组 id 一次),
2089
- * 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与第十二批那条 `reloadSeq` 同一个道理)。
2090
- * 读不到就整句不说——K 是合计,缺一条就是假数(10-15 稿 §10「不许拿 0 冒充没有」)。
2342
+ * 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与 2026-10-17 `c66f082` 那条 `reloadSeq` 同一个道理)。
2343
+ * 读不到就整句不说——K 是合计,缺一条就是假数(「不许拿 0 冒充没有」)。
2091
2344
  */
2092
2345
  /** 勾选变化后到发问之间的等待:连勾十条不该发十次 count。 */
2093
2346
  const COUNT_DEBOUNCE_MS = 300;
2094
2347
  function BatchBar({ deps, t = zhTranslate }) {
2095
- const selecting = useSelecting();
2096
- const picks = usePicks();
2348
+ const { selecting, picks } = useSelection();
2097
2349
  useSelectionRowClickGuard();
2098
2350
  const [counts, setCounts] = (0, react.useState)(null);
2099
2351
  const [expanded, setExpanded] = (0, react.useState)(false);
@@ -2211,7 +2463,7 @@ window.__ModuleLoader__.load({
2211
2463
  //#endregion
2212
2464
  //#region src/client/batch-menu-item.tsx
2213
2465
  function BatchSelectMenuItem({ dismissMenu, t = zhTranslate }) {
2214
- const selecting = useSelecting();
2466
+ const selecting = useSelection().selecting;
2215
2467
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MenuItemButton, {
2216
2468
  separatorBefore: true,
2217
2469
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCheckOutlineRegular, { size: 14 }),
@@ -2226,10 +2478,10 @@ window.__ModuleLoader__.load({
2226
2478
  //#endregion
2227
2479
  //#region src/client/delete-menu-item.tsx
2228
2480
  /**
2229
- * 会话条目「…」菜单里的「删除」行(设计稿 §7 状态 ①)。
2481
+ * 会话条目「…」菜单里的「删除」行(docs/ui-spec.md 状态 ①)。
2230
2482
  *
2231
2483
  * 这一行**只做两件事**:把「要删这个会话」写进 `delete-confirm.tsx` 的模块级现场,然后让菜单
2232
- * owner 把菜单收起来(确认框不许叠在收起的菜单上——设计稿 §7 状态 ② 画的就是菜单已经收起之后
2484
+ * owner 把菜单收起来(确认框不许叠在收起的菜单上——docs/ui-spec.md 状态 ② 说的就是菜单已经收起之后
2233
2485
  * 的画面)。**确认框不在这棵子树里**——
2234
2486
  * 原因见 `delete-confirm.tsx` 的头注:宿主的菜单行随菜单一起卸载(真产物 `Menu` 是
2235
2487
  * `open && jsxs(MenuSurface, { … children: [items.map(renderEntry), children] })`,`open === false`
@@ -2245,7 +2497,7 @@ window.__ModuleLoader__.load({
2245
2497
  * 菜单的**开合归 owner**:槽位把 `useMenuOpenState` 交给这一行(`index.tsx` 的 `DeleteRow` 接住后
2246
2498
  * 当 `dismissMenu` 传下来),按下时先发请求、再收菜单——与官方 README 的示例同序
2247
2499
  * (`onSelect={() => { setMenuOpen(false); … }}`)。
2248
- * 2. **运行中置灰**(设计稿 §7「运行中」)。置灰用**原生 `disabled`**:本平台的菜单行组件
2500
+ * 2. **运行中置灰**(docs/ui-spec.md「运行中」)。置灰用**原生 `disabled`**:本平台的菜单行组件
2249
2501
  * `MenuItemButton` 只把 `disabled` 落在原生按钮属性上(真产物原文 `<button type="button"
2250
2502
  * role="menuitem" … disabled={disabled}>`),jest-dom 的 `toBeDisabled()` 也只认这个属性;
2251
2503
  * `aria-disabled` 在这条链上既渲染不出来也不算数。原因文案用 `ui/Tooltip.tsx`(照官方 Tooltip 抄的,
@@ -2278,7 +2530,7 @@ window.__ModuleLoader__.load({
2278
2530
  //#endregion
2279
2531
  //#region src/client/trash-entry.tsx
2280
2532
  /**
2281
- * 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;设计稿 §7 状态 ④ 的入口那一行)。
2533
+ * 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;docs/ui-spec.md 状态 ④ 的入口那一行)。
2282
2534
  *
2283
2535
  * **只出 glyph**——行本体由宿主 `PanelRow` 画:`<button class=panelRow aria-current
2284
2536
  * onClick=selectPanel(id)>` 里一个 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18,
@@ -24624,13 +24876,13 @@ window.__ModuleLoader__.load({
24624
24876
  //#endregion
24625
24877
  //#region src/client/use-column-resize.ts
24626
24878
  /**
24627
- * 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15 加,设计稿 §7 状态④)。
24879
+ * 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15 加,`docs/ui-spec.md` 的「面板与清单」)。
24628
24880
  *
24629
24881
  * 三件事住在这里,因为它们互相咬合、分开测各自都不成立:
24630
24882
  * ① **宽度夹在两个界之间**。下限由调用方给(回收站那条的来历写在 `trash-panel.tsx`:真 Chromium
24631
24883
  * 上扫出来的,约束不是"两颗按钮放得下"——它们 `flex:none`,再窄也是文字列缩;而是「删除于 … ·
24632
24884
  * 大小」那一行不再被省略号截断),上限 = 面板宽 − `reserve`。**`reserve` 的数值与来历都住在调用方**
24633
- * (`DETAIL_RESERVE`,那三件东西怎么加出来的见设计稿 2026-10-15 §7.1 与现状稿 §8 的几何读数):
24885
+ * (`DETAIL_RESERVE`,那三件东西怎么加出来的见 `trash-panel.tsx` 该常量的注释与几何用例):
24634
24886
  * 这一层不复制那份算式——上一版在这里钉过一个"480 ⇒ 900 那档正好回 420",调用方改到 489 之后
24635
24887
  * 它就成了假数,而 900 那档的真实读数是**清单被夹到 411**(比默认窄 9px,还能往里拖)。
24636
24888
  * ② **夹在"读"的时候也做一次**。窗口变窄不触发任何写入,若只在写入时夹,之前拖出来的超宽
@@ -24711,7 +24963,7 @@ window.__ModuleLoader__.load({
24711
24963
  //#endregion
24712
24964
  //#region src/client/use-panel-width.ts
24713
24965
  /**
24714
- * 观测**面板自己那一块**的宽度(回收站的断点全靠它,设计稿 2026-10-15 §2.1)。
24966
+ * 观测**面板自己那一块**的宽度(回收站的断点全靠它,docs/ui-spec.md)。
24715
24967
  *
24716
24968
  * 为什么不是 `window.innerWidth`:这块住在宿主的中央列里,**侧栏折叠/展开、拖动分栏**都会改它的宽度
24717
24969
  * 而 window 一点不动——只听 window 的 resize 会在侧栏折叠后拿着旧宽度画错形态。
@@ -24991,9 +25243,298 @@ window.__ModuleLoader__.load({
24991
25243
  });
24992
25244
  }
24993
25245
  //#endregion
25246
+ //#region src/client/use-trash-panel-controller.ts
25247
+ /**
25248
+ * 回收站面板的**状态机**:谁被选中、详情缓存、窄档下钻与 Esc 交还焦点、清单换版后的清场、
25249
+ * 宽档自动选中、"别处动过回收站"的失效重读,以及确认后的写动作(恢复/批量/清空)与失败落点。
25250
+ *
25251
+ * 面板接入后,这套语义只住这里;面板自己只留布局与行内动作。搬过来的判据一条不改:
25252
+ *
25253
+ * - **选中从全量条目里找**(`query.entries`,不是键盘序列 `ordered`):把某一组折叠起来只是收掉
25254
+ * 清单那一列,右栏读的那条不该跟着消失。
25255
+ * - **一条只读一次**:面板存活期内按条目 id 缓存成功读数;**失败不缓存**——「重试」与切走再切回
25256
+ * 都会重新读那一条(缓存里存一次失败,界面就再也等不到它自己好了)。
25257
+ * - **清单换版就让位**:选中的 id 不在新清单里 ⇒ 丢它的缓存、清选中、退出下钻。清单读不到
25258
+ * (`entries === null`)**不算"没了"**:那一拍只是不知道,选中 id 留着,详情回 idle。
25259
+ * - **自动选中只认 `selectedId === null`**:同一拍里选中的那条消失时,自动选中看到的是旧选中
25260
+ * (非 null)⇒ 不抢;清场落地后的下一拍才接住新清单的第一条。判据是这道 null 门,不是两个
25261
+ * effect 的声明顺序(顺序反过来也拦得住)。
25262
+ * - **失效通告只戳重读**:`useTrashRevision` 变了就 `query.refresh()`,与挂载时同一条路;它不碰
25263
+ * 选中、不碰详情缓存(清单换了自会有上面的清场来收尾)。
25264
+ * - **动作成功就丢缓存**:恢复/批量彻底删除成功的条目、清空成功的全部条目,详情缓存必须在同一趟里
25265
+ * 失效——否则这条会话再被删回来时会摆着上一世的正文。失败只按 `actionFailure[id]` 留原文,不碰缓存。
25266
+ * - **Esc 只在 drilled 期间挂 document 监听**:记下 `selectedId` 再退出下钻,渲染落地后把焦点交还
25267
+ * 那一行**一次**;`back()`(「← 返回清单」)只收下钻、不动焦点。控制器自己不查 DOM——焦点动作
25268
+ * 走注入的 `focusRow`。
25269
+ *
25270
+ * **失败原文不翻译**:`detailFailure.message` 与 `actionFailure[id]` 都是 Node 半给的原文
25271
+ * (`callRemote` 的 `failure.message`),原样交给详情块与清单行上屏。
25272
+ *
25273
+ * `busy` 是面板那枚全局闸(写动作在飞)的读数:闸本体也由本 module 导出(`useTrashPanelGate`),
25274
+ * `restore` / `confirmApply` 用同一个 `gate.around` 把「动作 + 清单重读」整个包住;闸开着时谁该
25275
+ * 禁用由面板决定。`kind:'all'` 的清空**不再套一层** `around`——`query.emptyTrash` 自己已经包了
25276
+ * 同一枚闸。
25277
+ */
25278
+ /**
25279
+ * 闸本体(从 `trash-panel.tsx` 的函数体原样收进来):任务落地或抛错都把 `busy` 放回 false。
25280
+ * 不做引用计数:面板同一时刻只允许一趟写动作(按钮在 `busy` 期间全被按住),多一层计数只会
25281
+ * 把"这一趟什么时候算完"变成第二个判据。
25282
+ *
25283
+ * 返回值经 `useMemo` 稳定:`gate` 进 `restore` / `confirmApply` 的 `useCallback` 依赖,每次渲染
25284
+ * 换个新对象会让那两个回调跟着失效,进而把引用它们的 effect 也带着重跑。
25285
+ */
25286
+ function useTrashPanelGate() {
25287
+ const [busy, setBusy] = (0, react.useState)(false);
25288
+ const around = (0, react.useCallback)(async (task) => {
25289
+ setBusy(true);
25290
+ try {
25291
+ return await task();
25292
+ } finally {
25293
+ setBusy(false);
25294
+ }
25295
+ }, []);
25296
+ return (0, react.useMemo)(() => ({
25297
+ busy,
25298
+ around
25299
+ }), [busy, around]);
25300
+ }
25301
+ function useTrashPanelController(input) {
25302
+ const { deps, gate, query, ordered, canAutoSelect, focusRow } = input;
25303
+ const { entries, refresh, emptyTrash } = query;
25304
+ const [selectedId, setSelectedId] = (0, react.useState)(null);
25305
+ const [drilled, setDrilled] = (0, react.useState)(false);
25306
+ const [loadingId, setLoadingId] = (0, react.useState)(null);
25307
+ const [detailFailure, setDetailFailure] = (0, react.useState)(null);
25308
+ const [pending, setPending] = (0, react.useState)(null);
25309
+ const [actionFailure, setActionFailure] = (0, react.useState)({});
25310
+ /** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
25311
+ const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
25312
+ /** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
25313
+ const refocusRow = (0, react.useRef)(null);
25314
+ const revision = useTrashRevision();
25315
+ (0, react.useEffect)(() => {
25316
+ refresh();
25317
+ }, [refresh, revision]);
25318
+ /**
25319
+ * 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
25320
+ * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
25321
+ */
25322
+ const select = (0, react.useCallback)(async (entry, options = {}) => {
25323
+ setSelectedId(entry.id);
25324
+ if (options.drill === true) setDrilled(true);
25325
+ if (detailCache.current.has(entry.id)) {
25326
+ setLoadingId(null);
25327
+ setDetailFailure(null);
25328
+ return;
25329
+ }
25330
+ setLoadingId(entry.id);
25331
+ setDetailFailure(null);
25332
+ const outcome = await callRemote(() => deps.detail(entry.id));
25333
+ setLoadingId((current) => current === entry.id ? null : current);
25334
+ if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
25335
+ else setDetailFailure({
25336
+ entryId: entry.id,
25337
+ message: outcome.failure.message
25338
+ });
25339
+ }, [deps]);
25340
+ (0, react.useEffect)(() => {
25341
+ if (entries === null || selectedId === null) return;
25342
+ if (entries.some((entry) => entry.id === selectedId)) return;
25343
+ detailCache.current.delete(selectedId);
25344
+ setSelectedId(null);
25345
+ setDrilled(false);
25346
+ }, [entries, selectedId]);
25347
+ (0, react.useEffect)(() => {
25348
+ if (canAutoSelect && selectedId === null && ordered.length > 0) select(ordered[0]);
25349
+ }, [
25350
+ canAutoSelect,
25351
+ selectedId,
25352
+ ordered,
25353
+ select
25354
+ ]);
25355
+ /**
25356
+ * 下钻里按 Esc 退回清单:监听挂 `document`,因为窄档下钻时清单**不在 DOM 里**——挂在容器上的
25357
+ * Esc 是一条永远收不到的死路径。退回后把焦点交还给选中的那一行(行还没进 DOM,记一笔等这一拍
25358
+ * 渲染完再交,见下一个 effect)。
25359
+ */
25360
+ (0, react.useEffect)(() => {
25361
+ if (!drilled) return;
25362
+ const onKey = (event) => {
25363
+ if (event.key !== "Escape") return;
25364
+ refocusRow.current = selectedId;
25365
+ setDrilled(false);
25366
+ };
25367
+ document.addEventListener("keydown", onKey);
25368
+ return () => {
25369
+ document.removeEventListener("keydown", onKey);
25370
+ };
25371
+ }, [drilled, selectedId]);
25372
+ (0, react.useEffect)(() => {
25373
+ if (drilled || refocusRow.current === null) return;
25374
+ focusRow(refocusRow.current);
25375
+ refocusRow.current = null;
25376
+ }, [drilled, focusRow]);
25377
+ /**
25378
+ * 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:折叠组只是把那一列收掉,
25379
+ * 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
25380
+ */
25381
+ const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
25382
+ const detail = (0, react.useMemo)(() => {
25383
+ if (selected === null) return { kind: "idle" };
25384
+ if (loadingId === selected.id) return {
25385
+ kind: "loading",
25386
+ entry: selected
25387
+ };
25388
+ const cached = detailCache.current.get(selected.id);
25389
+ if (cached !== void 0) return {
25390
+ kind: "ready",
25391
+ entry: selected,
25392
+ detail: cached
25393
+ };
25394
+ if (detailFailure?.entryId === selected.id) return {
25395
+ kind: "failed",
25396
+ entry: selected,
25397
+ message: detailFailure.message
25398
+ };
25399
+ return {
25400
+ kind: "loading",
25401
+ entry: selected
25402
+ };
25403
+ }, [
25404
+ selected,
25405
+ loadingId,
25406
+ detailFailure
25407
+ ]);
25408
+ /** 「重试」只重读当前这一条,不牵清单。 */
25409
+ const retry = (0, react.useCallback)(() => {
25410
+ if (selected !== null) select(selected);
25411
+ }, [selected, select]);
25412
+ /** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(只有 Esc 会交还焦点)。 */
25413
+ const back = (0, react.useCallback)(() => {
25414
+ setDrilled(false);
25415
+ }, []);
25416
+ /** 一次动作的失败原文落回它自己那一行(按条目 id 存,别条目的成败不会串台)。 */
25417
+ const failRow = (0, react.useCallback)((entryId, message) => {
25418
+ setActionFailure((current) => ({
25419
+ ...current,
25420
+ [entryId]: message
25421
+ }));
25422
+ }, []);
25423
+ /**
25424
+ * 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把手势包进 `around`。
25425
+ * 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
25426
+ * `refresh` 留在 `around` 里:否则旧清单还在屏上,同一个动作能被再点一次。
25427
+ */
25428
+ const restore = (0, react.useCallback)(async (entry) => {
25429
+ setActionFailure((current) => {
25430
+ const next = { ...current };
25431
+ delete next[entry.id];
25432
+ return next;
25433
+ });
25434
+ await gate.around(async () => {
25435
+ const outcome = await callRemote(() => applyOne(deps, "restore", entry.id));
25436
+ if (!outcome.ok) {
25437
+ failRow(entry.id, outcome.failure.message);
25438
+ return;
25439
+ }
25440
+ detailCache.current.delete(entry.id);
25441
+ await refresh();
25442
+ });
25443
+ }, [
25444
+ deps,
25445
+ failRow,
25446
+ gate,
25447
+ refresh
25448
+ ]);
25449
+ /**
25450
+ * 一段批量 → 组件状态:成功的丢详情缓存,失败的原文落回它自己那一行。整批一次上屏。
25451
+ * **请求了但没回的 id 也是失败**(通道的错),落 missing 原文,不静默当成成功。
25452
+ */
25453
+ const settleOutcomes = (0, react.useCallback)((action, ids, outcomes) => {
25454
+ const receipt = readReceipt(action, ids, outcomes);
25455
+ const failures = [];
25456
+ for (const verdict of receipt.verdicts) switch (verdict.kind) {
25457
+ case "ok":
25458
+ case "already-gone":
25459
+ detailCache.current.delete(verdict.id);
25460
+ break;
25461
+ case "failed":
25462
+ case "neutral":
25463
+ case "missing": failures.push({
25464
+ id: verdict.id,
25465
+ message: failureMessage(verdict)
25466
+ });
25467
+ }
25468
+ if (failures.length === 0) return;
25469
+ setActionFailure((current) => {
25470
+ const next = { ...current };
25471
+ for (const failure of failures) next[failure.id] = failure.message;
25472
+ return next;
25473
+ });
25474
+ }, []);
25475
+ /**
25476
+ * 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
25477
+ * `ApplyOutcome` 里 ⇒ 不再"每条一次全量重读"。顶层失败(路由自检 / 通道断)是**这一批一条都没落地**
25478
+ * ——原文要落到每一个 id 上,别让清单看起来"删成功了"。
25479
+ */
25480
+ const confirmApply = (0, react.useCallback)(async (action, ids) => {
25481
+ await gate.around(async () => {
25482
+ const outcome = await callRemote(() => deps.applyOutcomes(action, ids));
25483
+ setPending(null);
25484
+ if (!outcome.ok) setActionFailure((current) => {
25485
+ const next = { ...current };
25486
+ for (const id of ids) next[id] = outcome.failure.message;
25487
+ return next;
25488
+ });
25489
+ else settleOutcomes(action, ids, outcome.value);
25490
+ await refresh();
25491
+ });
25492
+ }, [
25493
+ deps,
25494
+ gate,
25495
+ refresh,
25496
+ settleOutcomes
25497
+ ]);
25498
+ /**
25499
+ * 确认框上的「确认」按 `pending` 的形状分派:三条按条目标都委托 `confirmApply`(一段 `/apply`);
25500
+ * 清空是**聚合回执**、形状不同,走 `query.emptyTrash`——它自己已经包了同一枚闸与重读,这里
25501
+ * **不再套第二层 `around`**(双套会让外层 busy 提前落下)。
25502
+ */
25503
+ const confirmPurge = (0, react.useCallback)(async () => {
25504
+ const target = pending;
25505
+ if (target === null) return;
25506
+ if (target.kind === "entry") return confirmApply("purge", [target.entry.id]);
25507
+ if (target.kind === "entries") return confirmApply("purge", target.ids);
25508
+ if (target.kind === "restore") return confirmApply("restore", target.ids);
25509
+ const emptied = await emptyTrash();
25510
+ setPending(null);
25511
+ if (emptied) detailCache.current.clear();
25512
+ }, [
25513
+ confirmApply,
25514
+ emptyTrash,
25515
+ pending
25516
+ ]);
25517
+ return {
25518
+ busy: gate.busy,
25519
+ selected,
25520
+ selectedId,
25521
+ drilled,
25522
+ detail,
25523
+ pending,
25524
+ actionFailure,
25525
+ select,
25526
+ retry,
25527
+ back,
25528
+ setPending,
25529
+ restore,
25530
+ confirmPurge,
25531
+ confirmApply
25532
+ };
25533
+ }
25534
+ //#endregion
24994
25535
  //#region src/client/use-trash-query.ts
24995
25536
  /**
24996
- * 清册读数与无主子会话的**取数编排**(架构复核候选 3 的第二道内部 seam)。
25537
+ * 清册读数与无主子会话的**取数编排**。
24997
25538
  *
24998
25539
  * 它从 `trash-panel.tsx` 的函数体里整块搬出来,连同三条承重规矩:
24999
25540
  * ① **到达序号**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
@@ -25097,16 +25638,17 @@ window.__ModuleLoader__.load({
25097
25638
  setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
25098
25639
  return;
25099
25640
  }
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);
25641
+ const failures = readReceipt("orphan-clean", orphanReport.roots, outcome.value).verdicts.filter(isFailureVerdict);
25642
+ const gone = failures.filter((verdict) => verdict.kind === "neutral");
25643
+ const failed = failures.filter((verdict) => verdict.kind !== "neutral");
25644
+ const partialRemoved = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "removed"), 0);
25645
+ const partialBytes = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "freedBytes"), 0);
25105
25646
  if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
25106
25647
  if (failed.length > 0) {
25648
+ const message = failureMessage(failed[0]);
25107
25649
  const base = t("trash.orphans.cleanFailed", {
25108
25650
  count: failed.length,
25109
- message: failed[0].error.message
25651
+ message
25110
25652
  });
25111
25653
  const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
25112
25654
  removed: partialRemoved,
@@ -25133,7 +25675,7 @@ window.__ModuleLoader__.load({
25133
25675
  //#endregion
25134
25676
  //#region src/client/use-trash-selection.ts
25135
25677
  /**
25136
- * 清单上的**选择面**(架构复核候选 3 的第三道内部 seam):多选态、折叠起来的组、勾选集合,
25678
+ * 清单上的**选择面**:多选态、折叠起来的组、勾选集合,
25137
25679
  * 以及由它们派生的三样只读(`checkedIds` / `ordered` / 两种三态判定)。
25138
25680
  *
25139
25681
  * 从 `trash-panel.tsx` 的函数体里整块搬出来的两条承重规矩:
@@ -25221,16 +25763,15 @@ window.__ModuleLoader__.load({
25221
25763
  //#endregion
25222
25764
  //#region src/client/trash-panel.tsx
25223
25765
  /**
25224
- * 回收站清单面板(设计稿 §7 状态 ④⑤ + 2026-10-15 §2 的两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
25766
+ * 回收站清单面板(`docs/ui-spec.md` 的状态 ④⑤ + 两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
25225
25767
  *
25226
25768
  * 面板自带页面骨架(标题 / 条数与合计 / 工具排 / 内容区 / 页脚),因为宿主给中央列的那个盒子
25227
25769
  * (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
25228
25770
  * `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
25229
25771
  * 如此。回收站可以很长(本机 125 个会话合计 85M),不自己收滚动就会把整列顶长。
25230
25772
  *
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 之后各批会改写它):
25773
+ * **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(现行界面规格住
25774
+ * `docs/ui-spec.md`,本文件只记这三条与实现约束):
25234
25775
  * ① 动机:一条只有一个标题,判断不了该恢复还是该彻底删除 ⇒ 详情要摆出这条会话自己日志里的元信息
25235
25776
  * 与正文,而详情只能我们自己画(宿主读不到回收站目录里的文件);
25236
25777
  * ② 断点 900px(盒子宽,不是窗口宽):中央列宽由 `usePanelWidth`(ResizeObserver)观测,**只听
@@ -25268,18 +25809,19 @@ window.__ModuleLoader__.load({
25268
25809
  * + 就地覆盖 `--dsw-alias-button-primary-fill` / `-hover`)照抄宿主插件管理页 @21658274。
25269
25810
  *
25270
25811
  * 布局只用**结构**内联样式,**配色与圆角一律走 token**,**字号是字面量 px**(宿主同类面也这么写)。
25271
- * 结构与层级归设计稿 §7 与 2026-10-15 §2,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
25812
+ * 结构与层级归 docs/ui-spec.md,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
25272
25813
  * 由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——这是宿主给客户端插件的一等公民通道。
25273
25814
  *
25274
25815
  * **浅色主题已在真机看过(2026-10-01)**:浅色下 `bg-base` 与 `bg-layer-2` 同值(都是 `bluish-00`)
25275
25816
  * ⇒ 卡片只剩一根 `border-l4` 描边与页面分隔,分组靠组头 + 间距站住。**选中态在两档下都要另量**
25276
- * (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`),量法见 2026-10-15 §4 第 5 条。
25817
+ * (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`,2026-10-01/10-15 真机量的);那两档取自同一个
25818
+ * token,来源表见 `src/client/ui/ui.css` 文件头。
25277
25819
  */
25278
25820
  /** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。
25279
25821
  * 字面量住 `shared/wire.ts`(`TRASH_DIR_LABEL`)——Node 半拼的就是同一个名字(ADR-0003)。 */
25280
25822
  const TRASH_DIR = TRASH_DIR_LABEL;
25281
25823
  /**
25282
- * 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(设计稿 2026-10-15 §2.1,2026-10-15 真机后定值)。
25824
+ * 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(docs/ui-spec.md,2026-10-15 真机后定值)。
25283
25825
  *
25284
25826
  * 为什么是 900 而不是当初那个 760:760 是从"正文列限宽"那条关心得搬来的,**不是两栏所需宽度**。
25285
25827
  * 两栏要成立,详情那一栏得真的读得下去:那一格固定要吃掉 90(两道 gap 32 + 把手 10 + 页内边距 48),
@@ -25401,37 +25943,20 @@ window.__ModuleLoader__.load({
25401
25943
  });
25402
25944
  }
25403
25945
  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
- }, []);
25946
+ /** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住控制器 module,查询侧的写动作也吃它。 */
25947
+ const gate = useTrashPanelGate();
25414
25948
  const query = useTrashQuery({
25415
25949
  deps,
25416
25950
  t,
25417
- around
25951
+ around: gate.around
25418
25952
  });
25419
25953
  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());
25429
25954
  const listRef = (0, react.useRef)(null);
25430
25955
  const rootRef = (0, react.useRef)(null);
25431
25956
  const width = usePanelWidth(rootRef);
25432
25957
  /**
25433
25958
  * 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
25434
- * 可选项也没有动作,白占一半宽度(设计稿 2026-10-15 §2.3 第 1 条)。失败那块自己的「重试」
25959
+ * 可选项也没有动作,白占一半宽度(docs/ui-spec.md)。失败那块自己的「重试」
25435
25960
  * 负责把清单读回来,读回来之后右栏跟着回来。
25436
25961
  */
25437
25962
  const split = width >= SPLIT_MIN_WIDTH && entries !== null && entries.length > 0;
@@ -25444,88 +25969,37 @@ window.__ModuleLoader__.load({
25444
25969
  step: HANDLE_STEP,
25445
25970
  label: t("trash.divider.label")
25446
25971
  });
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
25972
  const groups = (0, react.useMemo)(() => groupByProject(entries ?? [], t), [entries, t]);
25474
25973
  const { selectMode, checked, checkedIds, ordered, allCollapsed, isCollapsed, enterSelect, exitSelect, toggleGroup, toggleAllGroups, toggleCheck, toggleGroupCheck, groupFullyChecked, groupPartiallyChecked } = useTrashSelection({
25475
25974
  entries,
25476
25975
  groups
25477
25976
  });
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
- ]);
25977
+ /** 行的 DOM 查询留给面板;控制器只拿"把焦点还给哪一行"这个动作(不认选择器)。 */
25493
25978
  const focusRow = (0, react.useCallback)((entryId) => {
25494
25979
  (listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
25495
25980
  }, []);
25496
25981
  /**
25497
- * 下钻里按 Esc 退回清单(设计稿 2026-10-15 §2.4)。
25498
- *
25499
- * 监听挂在 `document` 而不是清单容器:窄档下钻时清单**不在 DOM 里**,挂在容器上的 Esc 是一条
25500
- * 永远收不到的死路径。退回后焦点交还给选中的那一行(行还没进 DOM,所以记一笔等这一拍渲染完再做)。
25982
+ * 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / Esc 交还焦点 / 写动作与失败落点,整块住在
25983
+ * `use-trash-panel-controller.ts`。布局常量不搬进控制器:面板只交两样从布局算出来的面——
25984
+ * 屏幕上的顺序 `ordered`(只含展开的组)与"这一档要不要自动选中"(`canAutoSelect: split`)。
25501
25985
  */
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]);
25986
+ const controller = useTrashPanelController({
25987
+ deps,
25988
+ gate,
25989
+ query,
25990
+ ordered,
25991
+ canAutoSelect: split,
25992
+ focusRow
25993
+ });
25520
25994
  const onListKeyDown = (event) => {
25521
25995
  if (ordered.length === 0) return;
25522
- const index = ordered.findIndex((entry) => entry.id === selectedId);
25996
+ const index = ordered.findIndex((entry) => entry.id === controller.selectedId);
25523
25997
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
25524
25998
  event.preventDefault();
25525
25999
  const step = event.key === "ArrowDown" ? 1 : -1;
25526
26000
  const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
25527
26001
  const target = ordered[next];
25528
- select(target);
26002
+ controller.select(target);
25529
26003
  focusRow(target.id);
25530
26004
  return;
25531
26005
  }
@@ -25536,68 +26010,9 @@ window.__ModuleLoader__.load({
25536
26010
  toggleCheck(ordered[index].id);
25537
26011
  return;
25538
26012
  }
25539
- select(ordered[index], { drill: true });
26013
+ controller.select(ordered[index], { drill: true });
25540
26014
  }
25541
26015
  };
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
- };
25578
- /**
25579
- * 批量彻底删除 / 批量恢复**共用这一条**(2026-10-17 批量重构):一次 `apply` 把整批交给 Node 半,
25580
- * 逐条成败住在 `ApplyOutcome` 里 ⇒ 组件不再自己拼循环、也不再"每条一次全量重读"。
25581
- */
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
26016
  /** 清理那批无主子会话:整块住在 `useTrashQuery.cleanOrphans`(扫描的报告、两种失败的分流、先刷新后落提示
25602
26017
  * 都在那一条里);这里只负责把确认框收起来。 */
25603
26018
  const confirmOrphans = async () => {
@@ -25609,6 +26024,7 @@ window.__ModuleLoader__.load({
25609
26024
  * (重读会把它从勾选里也剔掉)。
25610
26025
  */
25611
26026
  const byIdEntries = (0, react.useMemo)(() => new Map((entries ?? []).map((entry) => [entry.id, entry])), [entries]);
26027
+ const pending = controller.pending;
25612
26028
  const restoreEntries = pending?.kind === "restore" ? pending.ids.map((id) => byIdEntries.get(id)).filter((entry) => entry !== void 0) : [];
25613
26029
  const totalBytes = (entries ?? []).reduce((sum, entry) => sum + entry.releaseBytes, 0);
25614
26030
  const now = Date.now();
@@ -25618,45 +26034,14 @@ window.__ModuleLoader__.load({
25618
26034
  * 在准备批量恢复时是假话——那类具体警告归各自的确认框,这里只留两件事:回收站在哪、不自动过期。
25619
26035
  */
25620
26036
  const noteParts = splitOn(t("trash.footer.note", { dir: TRASH_DIR }), TRASH_DIR);
25621
- /**
25622
- * 选中那条从**全量条目**里找,不从 `ordered`(只含展开的组)里找:把选中那条所在的组折叠起来
25623
- * 只是把那一列收掉,右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
25624
- */
25625
- const selected = (entries ?? []).find((entry) => entry.id === selectedId) ?? null;
25626
26037
  /** `aria-activedescendant` 只能指向**在 DOM 里**的那一行:折叠起来之后它就是不存在,指过去是假引用。 */
25627
- const activeDescendantId = ordered.some((entry) => entry.id === selectedId) ? selectedId : null;
26038
+ const activeDescendantId = ordered.some((entry) => entry.id === controller.selectedId) ? controller.selectedId : null;
25628
26039
  /**
25629
26040
  * 清单里那个唯一的 tab 落点(roving tabindex)。它必须**永远有一行拿着**:窄档不自动选中,
25630
26041
  * 只按"选中那行拿 0"来给的话,一次没点过时全行都是 `-1`,键盘根本进不来清单。
25631
26042
  * 落点与 `aria-activedescendant` 同一条可见性规则——选中那行被折叠起来时落到第一条可见行。
25632
26043
  */
25633
26044
  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
26045
  /**
25661
26046
  * 清单容器(roving tabindex 那个唯一的 tab 巢)。`flex:'1 1 auto'` 是**空态居中链**的一节:
25662
26047
  * 空态块自己有 `flex:1` + `justifyContent:center`,但要有**有高度的**父链才居得起来——本层是
@@ -25864,7 +26249,7 @@ window.__ModuleLoader__.load({
25864
26249
  ]
25865
26250
  })]
25866
26251
  }), isCollapsed(group.projectDir) ? null : group.entries.map((entry) => {
25867
- const isSelected = entry.id === selectedId;
26252
+ const isSelected = entry.id === controller.selectedId;
25868
26253
  const isChecked = checked.has(entry.id);
25869
26254
  const title = entry.title === "" ? t("common.untitled") : entry.title;
25870
26255
  const kids = entry.subagentCount ?? 0;
@@ -25882,7 +26267,7 @@ window.__ModuleLoader__.load({
25882
26267
  toggleCheck(entry.id);
25883
26268
  return;
25884
26269
  }
25885
- select(entry, { drill: !split });
26270
+ controller.select(entry, { drill: !split });
25886
26271
  },
25887
26272
  children: [
25888
26273
  selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
@@ -25934,7 +26319,7 @@ window.__ModuleLoader__.load({
25934
26319
  },
25935
26320
  children: t("trash.row.workspaceGone")
25936
26321
  }) : null,
25937
- actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26322
+ controller.actionFailure[entry.id] === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25938
26323
  role: "status",
25939
26324
  style: {
25940
26325
  display: "flex",
@@ -25952,7 +26337,7 @@ window.__ModuleLoader__.load({
25952
26337
  },
25953
26338
  "aria-hidden": "true",
25954
26339
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 13 })
25955
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: actionFailure[entry.id] })]
26340
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: controller.actionFailure[entry.id] })]
25956
26341
  })
25957
26342
  ]
25958
26343
  }),
@@ -25965,16 +26350,16 @@ window.__ModuleLoader__.load({
25965
26350
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25966
26351
  variant: "outline",
25967
26352
  size: "sm",
25968
- disabled: busy,
25969
- onClick: () => void restore(entry),
26353
+ disabled: controller.busy,
26354
+ onClick: () => void controller.restore(entry),
25970
26355
  children: t("trash.row.restore")
25971
26356
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25972
26357
  variant: "ghost",
25973
26358
  size: "sm",
25974
26359
  style: QUIET_DANGER,
25975
26360
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
25976
- disabled: busy,
25977
- onClick: () => setPending({
26361
+ disabled: controller.busy,
26362
+ onClick: () => controller.setPending({
25978
26363
  kind: "entry",
25979
26364
  entry
25980
26365
  }),
@@ -25992,13 +26377,11 @@ window.__ModuleLoader__.load({
25992
26377
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25993
26378
  style: DETAIL_INNER,
25994
26379
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashDetail, {
25995
- state: detailState,
25996
- busy,
26380
+ state: controller.detail,
26381
+ busy: controller.busy,
25997
26382
  wide: split,
25998
- onRetry: () => {
25999
- if (selected !== null) select(selected);
26000
- },
26001
- onBack: () => setDrilled(false),
26383
+ onRetry: () => controller.retry(),
26384
+ onBack: () => controller.back(),
26002
26385
  t
26003
26386
  })
26004
26387
  })
@@ -26007,7 +26390,7 @@ window.__ModuleLoader__.load({
26007
26390
  * 那一排**常驻面板体顶端**,不住清单列里:它原先在滚动容器内,长清单滚到底会把开关滚走。
26008
26391
  * 宽度上限跟着清单列走,所以它在视觉上仍然"属于清单"。
26009
26392
  *
26010
- * 这里曾有一颗「刷新」图标(2026-10-15 第三批收进那一排、第四批降级成图标,**2026-10-16
26393
+ * 这里曾有一颗「刷新」图标(2026-10-15 收进那一排、同日降级成图标,**2026-10-16
26011
26394
  * 按用户裁定删除**):清单现在是"每次读都对账"(见 `reconcile.ts`),开面板、切走再切回、
26012
26395
  * 重启——任何一次读都会先同步,那颗按钮不再承担别人替代不了的活。
26013
26396
  */
@@ -26134,9 +26517,9 @@ window.__ModuleLoader__.load({
26134
26517
  variant: "outline",
26135
26518
  size: "sm",
26136
26519
  style: { flex: "none" },
26137
- disabled: busy,
26520
+ disabled: controller.busy,
26138
26521
  onClick: () => {
26139
- if (busy) return;
26522
+ if (controller.busy) return;
26140
26523
  query.openOrphanPreview();
26141
26524
  },
26142
26525
  children: t("trash.orphans.open")
@@ -26177,7 +26560,7 @@ window.__ModuleLoader__.load({
26177
26560
  variant: "outline",
26178
26561
  size: "sm",
26179
26562
  style: { flex: "none" },
26180
- disabled: busy,
26563
+ disabled: controller.busy,
26181
26564
  onClick: () => void refresh(),
26182
26565
  children: t("common.retry")
26183
26566
  })
@@ -26228,7 +26611,7 @@ window.__ModuleLoader__.load({
26228
26611
  ...resize.handleProps
26229
26612
  }),
26230
26613
  detailPane
26231
- ] }) : drilled && selected !== null ? detailPane : listPane({
26614
+ ] }) : controller.drilled && controller.selected !== null ? detailPane : listPane({
26232
26615
  ...LIST_COLUMN,
26233
26616
  flex: "1 1 auto"
26234
26617
  })
@@ -26275,8 +26658,8 @@ window.__ModuleLoader__.load({
26275
26658
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26276
26659
  variant: "outline",
26277
26660
  size: "sm",
26278
- disabled: checkedIds.length === 0 || busy,
26279
- onClick: () => setPending({
26661
+ disabled: checkedIds.length === 0 || controller.busy,
26662
+ onClick: () => controller.setPending({
26280
26663
  kind: "restore",
26281
26664
  ids: checkedIds
26282
26665
  }),
@@ -26292,8 +26675,8 @@ window.__ModuleLoader__.load({
26292
26675
  size: "sm",
26293
26676
  style: BULK_DANGER,
26294
26677
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
26295
- disabled: checkedIds.length === 0 || busy,
26296
- onClick: () => setPending({
26678
+ disabled: checkedIds.length === 0 || controller.busy,
26679
+ onClick: () => controller.setPending({
26297
26680
  kind: "entries",
26298
26681
  ids: checkedIds
26299
26682
  }),
@@ -26304,8 +26687,8 @@ window.__ModuleLoader__.load({
26304
26687
  size: "sm",
26305
26688
  style: BULK_DANGER,
26306
26689
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
26307
- disabled: busy,
26308
- onClick: () => setPending({ kind: "all" }),
26690
+ disabled: controller.busy,
26691
+ onClick: () => controller.setPending({ kind: "all" }),
26309
26692
  children: t("trash.emptyAll")
26310
26693
  })
26311
26694
  })]
@@ -26313,15 +26696,15 @@ window.__ModuleLoader__.load({
26313
26696
  pending === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashConfirm, {
26314
26697
  pending,
26315
26698
  restoreEntries,
26316
- busy,
26699
+ busy: controller.busy,
26317
26700
  t,
26318
- onCancel: () => setPending(null),
26319
- onConfirmPurge: () => void confirmPurge(),
26320
- onConfirmRestore: (ids) => void confirmApply("restore", ids)
26701
+ onCancel: () => controller.setPending(null),
26702
+ onConfirmPurge: () => void controller.confirmPurge(),
26703
+ onConfirmRestore: (ids) => void controller.confirmApply("restore", ids)
26321
26704
  }),
26322
26705
  orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
26323
26706
  report: orphanReport,
26324
- busy,
26707
+ busy: controller.busy,
26325
26708
  t,
26326
26709
  onCancel: () => setOrphanConfirm(false),
26327
26710
  onConfirm: () => void confirmOrphans()
@@ -26334,19 +26717,19 @@ window.__ModuleLoader__.load({
26334
26717
  /**
26335
26718
  * 浏览器半入口(CJS 单文件闭包工厂的 `factory` 体,构建契约见 tsdown.config.ts)。
26336
26719
  *
26337
- * 共交付**十个**槽位贡献(第十三批起,批量删除再加五件),分四类:
26720
+ * 共交付**十个**槽位贡献(2026-10-17 起,批量删除再加五件),分四类:
26338
26721
  *
26339
26722
  * - `sidebar.workspaces.session.menu.item` —— 两行:「多选」入口(`BatchSelectMenuItem`,order 850)
26340
26723
  * 与「删除」菜单行(`DeleteMenuItem`,order 900)。多选入口按下后进入多选模式(再按一次退出)
26341
26724
  * 并收菜单;删除行**只发请求**(把 `{sessionId, title}` 写进 `delete-confirm.tsx` 的模块级现场)。
26342
- * - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox`,第十三批①)。
26725
+ * - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox`,2026-10-17)。
26343
26726
  * **只在多选模式内渲染**(入口在「⋯ → 多选」,不常驻)。宿主在「有状态点(运行中/待确认/未读完成)」
26344
26727
  * 与归档行不渲染这一格 ⇒ 那些行由下面行尾那颗兜底;槽位只投 `sessionId`,标题
26345
26728
  * (`displayTitle`)从 root hook 的 `byId[id].title` 读。
26346
26729
  * - `sidebar.workspaces.session.row.action` —— 行尾悬停排的**兜底勾选开关**(`BatchPickButton`)。
26347
26730
  * 座位原文:"一行一颗图标按钮(动作不适用于这一行时可以不渲染)、动作归自己",且**排内点击不误开
26348
26731
  * 会话** ⇒ 兜底开关不需要处理事件传播。两条与前一条不会同时出现(`ui.css` 的 `:has` 隐藏规则)。
26349
- * 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`batch-select.tsx`)。
26732
+ * 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`host-rows.ts`)。
26350
26733
  * 与 overlay 上的**浮条**(`BatchBar`)、**批量确认框**(`BatchConfirmHost`)合起来才是批量删除那一套。
26351
26734
  * 模式内点整行会拦截宿主的会话切换(document 捕获守卫);执行仍走 `POST /apply`,单删那三件一律不动。
26352
26735
  * - `shell.overlay` —— 菜单之外的**四个**常驻宿主(各一次 `slots.inject`,与宿主自己对
@@ -26362,7 +26745,7 @@ window.__ModuleLoader__.load({
26362
26745
  * !this.hasMainPanel(panelId)) throw new Error(...)`(asar @21199710)——只有行、没有面板时,
26363
26746
  * 用户点一下就是运行期抛错;反过来先有面板也没有入口。
26364
26747
  *
26365
- * **为什么不是底栏(2026-09-30 实测裁决,设计稿 §10)**:`sidebar.footer.action` 的**惯用形**是
26748
+ * **为什么不是底栏(2026-09-30 实测裁决,判据全在下面三行)**:`sidebar.footer.action` 的**惯用形**是
26366
26749
  * 「42px 整行 + 图标 + 文字 + 右对齐 12px 计数」——官方 `client-ui-cordis` 的占用者 CSS 是
26367
26750
  * `.layer{flex:none;width:100%;height:42px}`,而座位容器只有 `.footerActions{display:flex}`
26368
26751
  * (无 gap、无 wrap)⇒ 我们交的 `Button ghost/sm` 小药丸贴底栏,与下方 42px 的「设置」行不同形。
@@ -26509,7 +26892,7 @@ window.__ModuleLoader__.load({
26509
26892
  * **探测 → 注册**:取数通道在不在,只有真发一次请求才知道。
26510
26893
  *
26511
26894
  * - 探测不过(打不通 / 回的不是信封)⇒ **早返回**:一个槽位贡献都不注册。页面上的表现是"本插件
26512
- * 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(设计稿 §7「未接通态」)。
26895
+ * 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(docs/architecture.md §3「通道探测决定入口出现与否」)。
26513
26896
  * - 探测过(哪怕 `ping` 回的是 `{ok:false,error}`)⇒ 注册:那次往返证明路由在、依赖接上了、
26514
26897
  * 错误映射也生效了,失败该做的是把原文交给用户看。
26515
26898
  *
@@ -26520,6 +26903,7 @@ window.__ModuleLoader__.load({
26520
26903
  * 漏一半的代价是**宿主那句响亮的抛错**,不是静默降级。
26521
26904
  */
26522
26905
  async function activate(client) {
26906
+ bindMainView(client);
26523
26907
  const raw = createHttpRemote();
26524
26908
  if (!await probeChannel(raw)) return;
26525
26909
  const deps = buildDeps(raw);