@xrn1997/dsh-session-delete 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +39 -10
  2. package/cordis.patch.yml +4 -0
  3. package/icon.svg +18 -18
  4. package/lib/client.js +1897 -859
  5. package/lib/index.js +1803 -1058
  6. package/lib/types/api/attachment-orphans.d.ts +9 -3
  7. package/lib/types/api/attachment.d.ts +17 -7
  8. package/lib/types/api/connection-route.d.ts +23 -0
  9. package/lib/types/api/dispatch.d.ts +25 -14
  10. package/lib/types/api/route-table.d.ts +47 -11
  11. package/lib/types/api/trash-rows.d.ts +3 -3
  12. package/lib/types/client/batch-bar.d.ts +1 -1
  13. package/lib/types/client/batch-pick.d.ts +19 -0
  14. package/lib/types/client/batch-select.d.ts +7 -5
  15. package/lib/types/client/busy-gate.d.ts +6 -2
  16. package/lib/types/client/confirm-close.d.ts +4 -1
  17. package/lib/types/client/context.d.ts +3 -2
  18. package/lib/types/client/copy.d.ts +54 -4
  19. package/lib/types/client/delete-confirm.d.ts +1 -1
  20. package/lib/types/client/delete-menu-item.d.ts +1 -1
  21. package/lib/types/client/host-rows.d.ts +29 -30
  22. package/lib/types/client/main-view.d.ts +2 -1
  23. package/lib/types/client/markdown.d.ts +6 -7
  24. package/lib/types/client/orphan-maintenance.d.ts +10 -25
  25. package/lib/types/client/pending-open.d.ts +17 -0
  26. package/lib/types/client/receipt.d.ts +3 -1
  27. package/lib/types/client/remote.d.ts +21 -18
  28. package/lib/types/client/store.d.ts +15 -10
  29. package/lib/types/client/trash-detail.d.ts +48 -6
  30. package/lib/types/client/trash-entry.d.ts +1 -1
  31. package/lib/types/client/trash-nav.d.ts +12 -8
  32. package/lib/types/client/ui/Toast.d.ts +3 -1
  33. package/lib/types/client/ui/icons.d.ts +1 -1
  34. package/lib/types/client/undo-toast.d.ts +2 -1
  35. package/lib/types/client/use-maintenance-section.d.ts +52 -0
  36. package/lib/types/client/use-maintenance.d.ts +7 -35
  37. package/lib/types/client/use-trash-panel-controller.d.ts +35 -14
  38. package/lib/types/client/use-trash-query.d.ts +10 -8
  39. package/lib/types/client/use-trash-selection.d.ts +15 -12
  40. package/lib/types/client/write-seats.d.ts +32 -16
  41. package/lib/types/client/write-settlement.d.ts +1 -1
  42. package/lib/types/client/write.d.ts +13 -3
  43. package/lib/types/config.d.ts +45 -0
  44. package/lib/types/deps.d.ts +8 -3
  45. package/lib/types/domain/closure.d.ts +47 -0
  46. package/lib/types/index.d.ts +13 -12
  47. package/lib/types/log/zstd-frames.d.ts +2 -3
  48. package/lib/types/ports/attachments-port.d.ts +40 -0
  49. package/lib/types/ports/fs-port.d.ts +15 -12
  50. package/lib/types/ports/host-port.d.ts +4 -5
  51. package/lib/types/ports/presence-port.d.ts +9 -0
  52. package/lib/types/shared/wire.d.ts +137 -31
  53. package/lib/types/trash/attachments.d.ts +10 -1
  54. package/lib/types/trash/detail.d.ts +32 -22
  55. package/lib/types/trash/entry.d.ts +7 -1
  56. package/lib/types/trash/groups.d.ts +6 -1
  57. package/lib/types/trash/manifest.d.ts +4 -4
  58. package/lib/types/verbs/batch-carried.d.ts +1 -3
  59. package/lib/types/verbs/live-admission.d.ts +1 -1
  60. package/lib/types/verbs/member-presence.d.ts +3 -1
  61. package/lib/types/verbs/move-group.d.ts +2 -1
  62. package/lib/types/verbs/orphan-subagents.d.ts +12 -5
  63. package/lib/types/verbs/permanent-removal.d.ts +39 -10
  64. package/lib/types/verbs/trash-scope.d.ts +36 -0
  65. package/locale/en.json +6 -6
  66. package/locale/zh.json +6 -6
  67. package/package.json +13 -3
  68. package/lib/types/api/request-trust.d.ts +0 -45
  69. package/lib/types/ports/connection-port.d.ts +0 -33
  70. package/lib/types/verbs/purge-trash.d.ts +0 -35
package/lib/client.js CHANGED
@@ -196,7 +196,7 @@ window.__ModuleLoader__.load({
196
196
  "detail.notSelected": "选中一条会话,看它讲了什么",
197
197
  "detail.loading": "正在读这条会话…",
198
198
  "detail.readFailed": "这条会话的正文读不出来",
199
- "detail.noBody": "这条会话没有可显示的文字",
199
+ "detail.noBody": "这条会话没有可显示的内容",
200
200
  "detail.field.created": "创建",
201
201
  "detail.field.lastActivity": "最后活动",
202
202
  "detail.field.deletedAt": "删除于",
@@ -205,8 +205,9 @@ window.__ModuleLoader__.load({
205
205
  "detail.field.size": "大小",
206
206
  "detail.field.turns": "轮数",
207
207
  "detail.field.userMessages": "提问",
208
- "detail.field.assistantMessages": "回复",
208
+ "detail.field.assistantMessages": "助手消息",
209
209
  "detail.field.toolCalls": "工具调用",
210
+ "detail.field.injections": "注入上下文",
210
211
  "detail.field.models": "模型",
211
212
  "detail.field.project": "项目",
212
213
  "detail.field.sessionId": "会话 id",
@@ -214,10 +215,36 @@ window.__ModuleLoader__.load({
214
215
  "detail.field.wasArchived": "原本归档",
215
216
  "detail.value.yes": "是",
216
217
  "detail.value.no": "否",
218
+ "detail.value.spanLessThanHour": "不足 1 小时",
217
219
  "detail.value.spanHours": "{n} 小时",
218
220
  "detail.value.spanDays": "{n} 天",
219
- "detail.messages.hidden": "另有 {count} 条宿主注入的上下文未显示",
220
- "detail.messages.omitted": "还有 {count} 条未显示",
221
+ "detail.block.reasoning": "思考",
222
+ "detail.block.tool": "工具调用",
223
+ "detail.block.injection": "上下文注入",
224
+ "detail.block.system": "系统消息",
225
+ "detail.block.compaction": "上下文已压缩",
226
+ "detail.block.args": "参数",
227
+ "detail.block.result": "结果",
228
+ "detail.block.noResult": "日志在调用结束前结束,没有结果",
229
+ "detail.block.status.ok": "成功",
230
+ "detail.block.status.error": "失败",
231
+ "detail.block.status.unfinished": "未完成",
232
+ "detail.block.truncated": "已截断",
233
+ "detail.loadOlder": "加载更早",
234
+ "detail.loadOlder.loading": "正在加载更早…",
235
+ "detail.expandAll": "全部展开",
236
+ "detail.collapseAll": "全部折叠",
237
+ "detail.injection.runtimeContext": "运行上下文",
238
+ "detail.injection.timeContext": "时间上下文",
239
+ "detail.injection.agentInstructions": "Agent 指令",
240
+ "detail.injection.skillCatalog": "技能目录",
241
+ "detail.injection.agentMessage": "子会话消息",
242
+ "detail.injection.subagentSettled": "子会话结束",
243
+ "detail.injection.toolJobs": "后台任务",
244
+ "detail.injection.skillInvocation": "技能调用",
245
+ "detail.injection.userApproval": "用户批准",
246
+ "detail.injection.goal": "目标",
247
+ "detail.injection.other": "其他上下文",
221
248
  "detail.image": "[图片]",
222
249
  "detail.image.alt": "[图片 · {alt}]",
223
250
  "detail.attachment.file": "文件",
@@ -229,6 +256,9 @@ window.__ModuleLoader__.load({
229
256
  "detail.attachment.saving": "保存中…",
230
257
  "detail.attachment.saveFailed": "保存失败:{message}",
231
258
  "detail.back": "← 返回清单",
259
+ "detail.subagents.header": "子会话({n}) · {size}",
260
+ "detail.subagents.truncated": "仅列出前 {shown} 条,共 {total} 条",
261
+ "detail.backToParent": "← 返回父会话",
232
262
  "detail.list.label": "回收站清单",
233
263
  "detail.role.user": "你",
234
264
  "detail.role.assistant": "助手",
@@ -253,7 +283,8 @@ window.__ModuleLoader__.load({
253
283
  "undo.movedBatch": "{n} 条会话已移入回收站",
254
284
  "undo.moved": "{title} 已移入回收站",
255
285
  "undo.session": "会话",
256
- "undo.undo": "撤销"
286
+ "undo.undo": "撤销",
287
+ "undo.failedBatch": "{n} 条会话未能撤销:{reasons}"
257
288
  };
258
289
  /** en:与 `ZH` 逐键镜像(键集合由 `Record<CopyKey, string>` 盯住,漏一个就编译不过)。
259
290
  * 单位词与宿主 en 侧 `relativeTime` 的写法对齐(宿主 en 的桶就是 `5min` / `3h` / `2d` / `4mo` / `1y`)。 */
@@ -348,7 +379,7 @@ window.__ModuleLoader__.load({
348
379
  "detail.notSelected": "Select a session to see what it was about",
349
380
  "detail.loading": "Reading this session…",
350
381
  "detail.readFailed": "Could not read this session’s log",
351
- "detail.noBody": "This session has no readable text",
382
+ "detail.noBody": "This session has nothing to show",
352
383
  "detail.field.created": "Created",
353
384
  "detail.field.lastActivity": "Last activity",
354
385
  "detail.field.deletedAt": "Deleted",
@@ -357,8 +388,9 @@ window.__ModuleLoader__.load({
357
388
  "detail.field.size": "Size",
358
389
  "detail.field.turns": "Turns",
359
390
  "detail.field.userMessages": "Prompts",
360
- "detail.field.assistantMessages": "Replies",
391
+ "detail.field.assistantMessages": "Assistant messages",
361
392
  "detail.field.toolCalls": "Tool calls",
393
+ "detail.field.injections": "Injected contexts",
362
394
  "detail.field.models": "Model",
363
395
  "detail.field.project": "Project",
364
396
  "detail.field.sessionId": "Session id",
@@ -366,10 +398,36 @@ window.__ModuleLoader__.load({
366
398
  "detail.field.wasArchived": "Was archived",
367
399
  "detail.value.yes": "Yes",
368
400
  "detail.value.no": "No",
401
+ "detail.value.spanLessThanHour": "< 1 h",
369
402
  "detail.value.spanHours": "{n} h",
370
403
  "detail.value.spanDays": "{n} d",
371
- "detail.messages.hidden": "{count} injected context records not shown",
372
- "detail.messages.omitted": "{count} more messages not shown",
404
+ "detail.block.reasoning": "Thinking",
405
+ "detail.block.tool": "Tool call",
406
+ "detail.block.injection": "Context injection",
407
+ "detail.block.system": "System message",
408
+ "detail.block.compaction": "Context compacted",
409
+ "detail.block.args": "Arguments",
410
+ "detail.block.result": "Result",
411
+ "detail.block.noResult": "The log ended before this call returned a result",
412
+ "detail.block.status.ok": "OK",
413
+ "detail.block.status.error": "Failed",
414
+ "detail.block.status.unfinished": "Unfinished",
415
+ "detail.block.truncated": "Truncated",
416
+ "detail.loadOlder": "Load earlier",
417
+ "detail.loadOlder.loading": "Loading earlier…",
418
+ "detail.expandAll": "Expand all",
419
+ "detail.collapseAll": "Collapse all",
420
+ "detail.injection.runtimeContext": "Runtime context",
421
+ "detail.injection.timeContext": "Time context",
422
+ "detail.injection.agentInstructions": "Agent instructions",
423
+ "detail.injection.skillCatalog": "Skill catalog",
424
+ "detail.injection.agentMessage": "Subagent message",
425
+ "detail.injection.subagentSettled": "Subagent finished",
426
+ "detail.injection.toolJobs": "Background jobs",
427
+ "detail.injection.skillInvocation": "Skill invocation",
428
+ "detail.injection.userApproval": "User approval",
429
+ "detail.injection.goal": "Goal",
430
+ "detail.injection.other": "Other context",
373
431
  "detail.image": "[Image]",
374
432
  "detail.image.alt": "[Image · {alt}]",
375
433
  "detail.attachment.file": "File",
@@ -381,6 +439,9 @@ window.__ModuleLoader__.load({
381
439
  "detail.attachment.saving": "Saving…",
382
440
  "detail.attachment.saveFailed": "Save failed: {message}",
383
441
  "detail.back": "← Back to list",
442
+ "detail.subagents.header": "Subagent sessions ({n}) · {size}",
443
+ "detail.subagents.truncated": "Showing the first {shown} of {total} subagent sessions",
444
+ "detail.backToParent": "← Back to parent session",
384
445
  "detail.list.label": "Trash list",
385
446
  "detail.role.user": "You",
386
447
  "detail.role.assistant": "Assistant",
@@ -405,7 +466,8 @@ window.__ModuleLoader__.load({
405
466
  "undo.movedBatch": "{n} sessions were moved to the trash",
406
467
  "undo.moved": "{title} was moved to the trash",
407
468
  "undo.session": "the session",
408
- "undo.undo": "Undo"
469
+ "undo.undo": "Undo",
470
+ "undo.failedBatch": "{n} sessions could not be undone: {reasons}"
409
471
  };
410
472
  /** 与宿主 `translate` 同一条替换规则(`{name}`,参数缺席时原样留下占位符)。 */
411
473
  function interpolate(template, params) {
@@ -432,6 +494,27 @@ window.__ModuleLoader__.load({
432
494
  return (key, params) => bound(key, params);
433
495
  }
434
496
  /**
497
+ * `MarkdownBody`(`markdown.tsx`)memo comparator 的**语言探针**:借一句固定键的译文,当
498
+ * 「当前是哪种语言」的读数。为什么借译文而不问 locale 服务:宿主 locale 服务的窄镜像不暴露
499
+ * 当前语言(受窄镜像形状约束,不做 `locale()` 探针),当前语言只能从 `t` 的输出里读出来。
500
+ *
501
+ * 为什么必须在**渲染时**现算、由调用方存进 props:宿主 `t` 读的是**当前** locale,若在
502
+ * comparator 里现调,prev/next 那一拍都取到新值、恒等,换语言就漏了——只有渲染那一刻算好的
503
+ * 那份才是换语言那一拍的读数。
504
+ *
505
+ * 为什么拿 `t` 的输出当读数、而不是拿 `t` 的身份:宿主 `t` 是 `bind` 出来的**稳定身份**
506
+ * (`createTranslate` 一次客户端激活只造一个),换语言后同一个 `t` 吐另一套词——身份不变、
507
+ * 词变了,所以判据必须是这个词(探针值)而不是身份本身。
508
+ *
509
+ * 「借哪句」的知识只住这里:调用方与测试一律传 `probe={localeProbe(t)}`,不各自记得借哪句。
510
+ *
511
+ * **失效模式**:借的那句若在两种语言下译成同一个字面,探针就分不出换语言——memo 恒等、旧语言滞留。
512
+ * 这条由 `tests/client/markdown.test.tsx` 的换语言用例兜住;换借句或改那句译文时先看它红不红。
513
+ */
514
+ function localeProbe(t) {
515
+ return t("common.close");
516
+ }
517
+ /**
435
518
  * 把一句话里内嵌的那一段拆出来:`'…文件,之后没有任何副本可以恢复。'` 交给调用面用 `<b>` 包中间那段。
436
519
  *
437
520
  * 为什么不留成"前段 / 内嵌段 / 后段"三个键:那等于把英文和中文的语序各钉一次,两段翻译各自
@@ -489,16 +572,19 @@ window.__ModuleLoader__.load({
489
572
  * (`delete-confirm.tsx`)、批量删除请求(`batch-confirm.tsx`)、撤销提示(`undo-notices.ts`)、
490
573
  * 批量选中集(`batch-selection-store.ts`)等。它们都挂在 `shell.overlay` / `main` / 菜单行这些
491
574
  * 互不相邻的座位上,模块级是唯一的共同点。
492
- * 在这之前每个文件各写一遍「`let` 现场 + `Set<() => void>` + `notify` + `useSyncExternalStore`」,
575
+ * 在这之前每个文件各写一遍「`let` 现场 + 监听集合 + `notify` + `useSyncExternalStore`」,
493
576
  * 各自的 seq / revision 也在分头维护。现在订阅/通告只住这一处。
494
577
  *
495
- * 另有一个**懒读快照**变体(`createLazyStore`):值由组件树之外的事实源(宿主 DOM)现读,
496
- * 脏了才重读;订阅脚手架仍住这里,观察者生命周期由调用方挂 `onFirstSubscribe` / `onLastUnsubscribe`。
578
+ * 另有一个**懒读快照**变体(`createLazyStore`):值由组件树之外的事实源(如宿主行槽位登记)现读,
579
+ * 脏了才重读;订阅脚手架仍住这里。
497
580
  *
498
581
  * 还有一个**最新票句柄**(`createLatestWins`):并发的异步读里"只有最新那次可以落地"原先在每个
499
582
  * 调用方各写一遍自增序号,现在只住这一处。它只拥有 compare-and-set——防抖、轮询、焦点策略仍是
500
583
  * 调用方的现场,不搬进来。
501
584
  *
585
+ * 以及一枚**引用归一 hook**(`useLatest`):输入逐渲染换新对象时,回调经它只读最新那一份,
586
+ * 身份恒稳、可安全进 effect 依赖。这一形状也只住这一处,调用方不再各写一遍「ref + 每渲染写回」。
587
+ *
502
588
  * 语义(四条,别在调用方另算一遍):
503
589
  * - `set` / `update` 用 `Object.is` 判"变过":同一个引用不重渲染(React 的 snapshot 契约要求)。
504
590
  * - `publish(make)` 递增**本实例**的 seq(从 1 起)并**总是**通告——seq 是组件重挂载的 key
@@ -551,7 +637,7 @@ window.__ModuleLoader__.load({
551
637
  }
552
638
  };
553
639
  }
554
- function createLazyStore(initial, read, lifecycle = {}) {
640
+ function createLazyStore(initial, read) {
555
641
  let cached = initial;
556
642
  let dirty = true;
557
643
  const listeners = /* @__PURE__ */ new Set();
@@ -571,17 +657,11 @@ window.__ModuleLoader__.load({
571
657
  notify();
572
658
  },
573
659
  subscribe(listener) {
574
- const reattaching = listeners.size === 0;
660
+ if (listeners.size === 0) dirty = true;
575
661
  listeners.add(listener);
576
- if (reattaching) {
577
- dirty = true;
578
- lifecycle.onFirstSubscribe?.();
579
- }
580
662
  return () => {
581
663
  listeners.delete(listener);
582
- if (listeners.size > 0) return;
583
- lifecycle.onLastUnsubscribe?.();
584
- dirty = true;
664
+ if (listeners.size === 0) dirty = true;
585
665
  };
586
666
  }
587
667
  };
@@ -600,170 +680,155 @@ window.__ModuleLoader__.load({
600
680
  }
601
681
  };
602
682
  }
603
- //#endregion
604
- //#region src/client/batch-selection-store.ts
605
683
  /**
606
- * 宿主列表批量删除的**选中集现场**:多选模式 + 选中名单,一份合并快照。
607
- *
608
- * 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
609
- * 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
610
- * 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
611
- * `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
612
- * 的 `createStore` 里,本文件不另写一份。
613
- *
614
- * **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
615
- * (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
616
- *
617
- * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
618
- * 「全选」追加的条目按 `host-rows.ts` 读到的 DOM 序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
684
+ * 引用归一:输入允许调用方逐渲染换新对象,回调只经这份 ref 读**最新**那一份,于是依赖它的回调
685
+ * 身份恒稳、调用方的 effect 可以放心把它们当依赖——「输入必须是稳定引用」的隐式契约就此不存在
686
+ * (issue #8 第 3 条)。写回在 effect 里(提交后),与 React 的渲染期只读约定一致。
687
+ * 这一形状的唯一实现:别在调用方再手写一份「useRef + 每渲染写回」。
619
688
  */
620
- const INITIAL = {
621
- selecting: false,
622
- picks: []
623
- };
624
- const selectionStore = createStore(INITIAL);
625
- function getSelection() {
626
- return selectionStore.get();
627
- }
628
- function getPicks() {
629
- return getSelection().picks;
630
- }
631
- /** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
632
- function useSelection() {
633
- return selectionStore.use();
634
- }
635
- /** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
636
- function enterSelection() {
637
- selectionStore.update((current) => current.selecting ? current : {
638
- ...current,
639
- selecting: true
640
- });
641
- }
642
- /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
643
- function exitSelection() {
644
- selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
645
- }
646
- /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
647
- function togglePick(item) {
648
- selectionStore.update((current) => {
649
- const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
650
- return {
651
- ...current,
652
- picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
653
- };
654
- });
655
- }
656
- /** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
657
- function clearPicks() {
658
- selectionStore.update((current) => current.picks.length === 0 ? current : {
659
- ...current,
660
- picks: []
661
- });
662
- }
663
- /** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
664
- function addPicks(items) {
665
- selectionStore.update((current) => {
666
- const chosen = new Set(current.picks.map((item) => item.sessionId));
667
- const additions = [];
668
- for (const item of items) {
669
- if (chosen.has(item.sessionId)) continue;
670
- chosen.add(item.sessionId);
671
- additions.push(item);
672
- }
673
- return additions.length === 0 ? current : {
674
- ...current,
675
- picks: [...current.picks, ...additions]
676
- };
689
+ function useLatest(value) {
690
+ const ref = (0, react.useRef)(value);
691
+ (0, react.useEffect)(() => {
692
+ ref.current = value;
677
693
  });
678
- }
679
- /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
680
- function shrinkPicksToFailures(failedSessionIds) {
681
- const keep = new Set(failedSessionIds);
682
- selectionStore.update((current) => ({
683
- ...current,
684
- picks: current.picks.filter((item) => keep.has(item.sessionId))
685
- }));
694
+ return ref;
686
695
  }
687
696
  //#endregion
688
697
  //#region src/client/host-rows.ts
689
698
  /**
690
- * 宿主会话行的 DOM 适配:可见行解析、全选判定、模式内整行点击守卫。
699
+ * 宿主会话行的适配:**可见行由槽位组件登记**;选择动作与模式内整行点击守卫住 `batch-pick.ts`。
700
+ *
701
+ * 选中集(模式 + 名单)只住 `batch-selection-store.ts`;本文件只回答页面上的「当前有哪些可勾选的
702
+ * 会话行」:行槽位开关的挂载登记、渲染快照与按 id 的行查找。「这一行可不可勾」的 live 判据
703
+ * 也只住这里(`pickableRowOf` / `readVisibleSelectableRows`)。DOM 只用于守卫认行,且那处 DOM
704
+ * 读取随守卫住在 `batch-pick.ts`(ADR-0034 记下的受约束偏离)。
691
705
  *
692
- * 选中集(模式 + 名单)只住 batch-selection-store.ts;本文件只回答页面上的两件事:
693
- * 当前 DOM 里有哪些可选行,以及在某一行上点击是否算选中。交界是 data-row-key 与行尾登记的
694
- * rowInfo。渲染走 useSyncExternalStore 快照(ADR-0015);事件路径(全选)走当场读。
695
- * 模式内整行点击在 document 捕获阶段拦截,避免触发宿主的会话切换。
706
+ * 为什么不再扫 DOM:宿主的行槽位只在行可见时挂载,行首/行尾两颗开关(`BatchPickCheckbox` /
707
+ * `BatchPickButton`)都会登记 `{title, live}`;挂载/卸载就是「这一行在不在可见集合」的契约,
708
+ * 不需要 MutationObserver。
696
709
  */
697
710
  /**
698
- * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
699
- * (它对每个非空行都渲染,哪怕 CSS 把整排藏起来)在 effect 里把这两件登记进这张表。
700
- * 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
711
+ * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行首/行尾开关
712
+ * 在 effect 里把这两件登记进这张表。生命周期跟着**行槽位组件的挂载**走:卸载即注销
713
+ * ({@link unregisterHostRowInfo}),表的大小被"当前渲染的行"钉住,而不是随用过的会话只增不减;
714
+ * 行重挂载时 effect 会重新登记。**这张表就是可见行的事实源**(ADR-0034),不再由 DOM 行列表反查。
715
+ *
716
+ * **按挂载实例计数**:同一个会话在 DOM 里可能有两行(重复行),两行各挂一组开关;
717
+ * 其中一颗卸载时不能把另一颗还在用的登记一起摘掉——那会让这一行从"全选"里消失、标题也变空。
701
718
  */
702
719
  const rowInfo = /* @__PURE__ */ new Map();
703
720
  /**
704
- * 渲染侧快照 + DOM 观察:订阅脚手架住 `store.ts` 的 `createLazyStore`;这里只提供读法与
705
- * 观察者生命周期。宿主行的去留只体现在 `data-row-key` 节点上(增删、属性落定),文本变化
706
- * 不算(标题只认 `rowInfo` 的登记值)。首个订阅者起开始观察,最后一个走了断开。
721
+ * 渲染侧快照:`rowInfo` 变了才标脏;订阅脚手架住 `store.ts` 的 `createLazyStore`。
722
+ * `readVisibleSelectableRows` 按登记顺序(宿主渲染顺序)返回,不再观察 DOM。
707
723
  */
708
724
  const EMPTY_ROWS = [];
709
- let observer;
710
- function startObserving() {
711
- if (observer !== void 0 || typeof MutationObserver === "undefined") return;
712
- observer = new MutationObserver(() => visibleRows.invalidate());
713
- observer.observe(document.body, {
714
- childList: true,
715
- subtree: true,
716
- attributes: true,
717
- attributeFilter: ["data-row-key"]
718
- });
725
+ const visibleRows = createLazyStore(EMPTY_ROWS, readVisibleSelectableRows);
726
+ const EMPTY_ROW_INFO = {
727
+ title: "",
728
+ live: false
729
+ };
730
+ /**
731
+ * 行槽位开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
732
+ * 每次登记算**多一个挂载实例**;不给 info 时只占位,随后由 {@link updateHostRowInfo} 填值。
733
+ */
734
+ function registerHostRowInfo(sessionId, info) {
735
+ const current = rowInfo.get(sessionId);
736
+ if (current === void 0) {
737
+ rowInfo.set(sessionId, {
738
+ info: info ?? EMPTY_ROW_INFO,
739
+ owners: 1
740
+ });
741
+ visibleRows.invalidate();
742
+ return;
743
+ }
744
+ current.owners += 1;
745
+ if (info !== void 0 && (current.info.title !== info.title || current.info.live !== info.live)) {
746
+ current.info = info;
747
+ visibleRows.invalidate();
748
+ }
719
749
  }
720
- function stopObserving() {
721
- observer?.disconnect();
722
- observer = void 0;
750
+ /**
751
+ * 标题/运行态变了就地更新:**不注销、不重登记**,否则这一行会被挪到登记序末尾(ADR-0034 决定 2)。
752
+ */
753
+ function updateHostRowInfo(sessionId, info) {
754
+ const current = rowInfo.get(sessionId);
755
+ if (current === void 0) return;
756
+ const title = info.title === "" ? current.info.title : info.title;
757
+ if (current.info.title === title && current.info.live === info.live) return;
758
+ current.info = {
759
+ title,
760
+ live: info.live
761
+ };
762
+ visibleRows.invalidate();
723
763
  }
724
- const visibleRows = createLazyStore(EMPTY_ROWS, readVisibleSelectableRows, {
725
- onFirstSubscribe: startObserving,
726
- onLastUnsubscribe: stopObserving
727
- });
728
764
  /**
729
- * 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
730
- * 只有标题/运行态**真的变了**才标脏:重渲染时的同值登记不该把快照翻新一遍。
765
+ * 槽位组件共用的登记生命周期:挂载登记、卸载注销;标题/运行态变化只走更新口。
766
+ * 两次 effect 分工是顺序契约:先占住首次登记序,再填值。
731
767
  */
732
- function registerHostRowInfo(sessionId, info) {
768
+ function useHostRowRegistration(sessionId, info) {
769
+ (0, react.useEffect)(() => {
770
+ registerHostRowInfo(sessionId);
771
+ return () => {
772
+ unregisterHostRowInfo(sessionId);
773
+ };
774
+ }, [sessionId]);
775
+ const { title, live } = info;
776
+ (0, react.useEffect)(() => {
777
+ updateHostRowInfo(sessionId, {
778
+ title,
779
+ live
780
+ });
781
+ }, [
782
+ sessionId,
783
+ title,
784
+ live
785
+ ]);
786
+ }
787
+ /** 行槽位开关卸载时调用:那一行已经离开渲染树,登记跟着走。
788
+ * 还有别的挂载实例在用同一行(重复行)就只减计数,不摘登记。 */
789
+ function unregisterHostRowInfo(sessionId) {
733
790
  const current = rowInfo.get(sessionId);
734
- if (current?.title === info.title && current.live === info.live) return;
735
- rowInfo.set(sessionId, info);
791
+ if (current === void 0) return;
792
+ if (current.owners > 1) {
793
+ current.owners -= 1;
794
+ return;
795
+ }
796
+ rowInfo.delete(sessionId);
736
797
  visibleRows.invalidate();
737
798
  }
799
+ /** `live` 行不可勾:与置灰的勾选框、守卫拦下的整行点击同一条判据。 */
800
+ function isPickable(info) {
801
+ return !info.live;
802
+ }
803
+ /**
804
+ * 守卫与全选共用的一行读数:这一行此刻可勾选时交回登记(含标题);未登记或在跑回 `undefined`。
805
+ * 登记表是可见行的事实源,所以"没登记"就是"这一行此刻不在可见集合里"。
806
+ */
807
+ function pickableRowOf(sessionId) {
808
+ const entry = rowInfo.get(sessionId);
809
+ return entry === void 0 || !isPickable(entry.info) ? void 0 : entry.info;
810
+ }
738
811
  /**
739
- * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
740
- * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
741
- * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
812
+ * 「当前可见」的可选行:**已登记**的会话行(槽位开关还挂在渲染树上)——折叠组、归档过滤外的行
813
+ * 没有挂载,因此没有登记;在跑的行跳过(与置灰勾选框同口径)。标题/运行态来自登记值;
814
+ * 顺序是登记顺序(宿主按列表顺序挂载行)。
742
815
  *
743
816
  * 这是**当场读**:事件路径(点「全选」那一刻的页面)与测试用它;渲染侧读下面的快照。
744
817
  */
745
818
  function readVisibleSelectableRows() {
746
819
  const rows = [];
747
- const seen = /* @__PURE__ */ new Set();
748
- for (const row of document.querySelectorAll("[data-row-key^=\"session:\"]")) {
749
- const key = row.getAttribute("data-row-key") ?? "";
750
- if (!key.startsWith("session:")) continue;
751
- const sessionId = key.slice(8);
752
- if (sessionId === "" || seen.has(sessionId)) continue;
753
- const info = rowInfo.get(sessionId);
754
- if (info === void 0 || info.live) continue;
755
- seen.add(sessionId);
820
+ for (const [sessionId, entry] of rowInfo) {
821
+ if (!isPickable(entry.info)) continue;
756
822
  rows.push({
757
823
  sessionId,
758
- title: info.title
824
+ title: entry.info.title
759
825
  });
760
826
  }
761
827
  return rows;
762
828
  }
763
829
  /**
764
- * `enabled === false` 时的稳定替身:同一个空表、不订阅、不读 DOM。
765
- * 浮条是 `shell.overlay` 的常驻槽位,模式外若照常订阅,body 上每次变更(以及每次行登记)
766
- * 都会白叫醒它重渲染一次。
830
+ * `enabled === false` 时的稳定替身:同一个空表、不订阅、不读登记。
831
+ * 浮条是 `shell.overlay` 的常驻槽位,模式外若照常订阅,每次行登记都会白叫醒它重渲染一次。
767
832
  */
768
833
  function getEmptyRowsSnapshot() {
769
834
  return EMPTY_ROWS;
@@ -772,126 +837,97 @@ window.__ModuleLoader__.load({
772
837
  return () => {};
773
838
  }
774
839
  /**
775
- * 渲染侧只读快照 + DOM 观察订阅;首个订阅者起开始观察,最后一个走了断开。
776
- * `enabled === false` 不订阅也不读 DOM——行列表只随多选态存在,模式外不必付观察的成本。
840
+ * 渲染侧只读快照 + 登记订阅;`enabled === false` 不订阅也不读登记——行列表只随多选态存在,
841
+ * 模式外不必付订阅的成本。
777
842
  */
778
843
  function useVisibleSelectableRows(enabled) {
779
844
  return (0, react.useSyncExternalStore)(enabled ? visibleRows.subscribe : noopSubscribe, enabled ? visibleRows.get : getEmptyRowsSnapshot, enabled ? visibleRows.get : getEmptyRowsSnapshot);
780
845
  }
781
- /** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
782
- function selectAllVisible() {
783
- addPicks(readVisibleSelectableRows());
784
- }
785
- /**
786
- * 同一个切换口该显示「全选」还是「取消全选」:
787
- * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
788
- * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
789
- * - 其余(含 0 条)⇒ 全选。
790
- */
791
- function areAllVisibleRowsSelected(rows, picks) {
792
- if (picks.length === 0) return false;
793
- if (rows.length === 0) return true;
794
- const chosen = new Set(picks.map((item) => item.sessionId));
795
- return rows.every((row) => chosen.has(row.sessionId));
796
- }
797
- /** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
798
- const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
799
- /**
800
- * 多选态内的整行点击守卫(document 捕获阶段)。
801
- *
802
- * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
803
- * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
804
- * 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
805
- * 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
806
- */
807
- function useSelectionRowClickGuard() {
808
- const selecting = useSelection().selecting;
809
- (0, react.useEffect)(() => {
810
- if (!selecting) return;
811
- const handle = (event) => {
812
- if (!(event.target instanceof Element)) return;
813
- const row = event.target.closest("[data-row-key^=\"session:\"]");
814
- if (row === null) return;
815
- if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
816
- event.preventDefault();
817
- event.stopPropagation();
818
- if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
819
- const key = row.getAttribute("data-row-key") ?? "";
820
- if (!key.startsWith("session:")) return;
821
- const sessionId = key.slice(8);
822
- if (sessionId === "") return;
823
- const info = rowInfo.get(sessionId);
824
- if (info?.live === true) return;
825
- togglePick({
826
- sessionId,
827
- title: info?.title ?? ""
828
- });
829
- };
830
- document.addEventListener("click", handle, true);
831
- document.addEventListener("dblclick", handle, true);
832
- return () => {
833
- document.removeEventListener("click", handle, true);
834
- document.removeEventListener("dblclick", handle, true);
835
- };
836
- }, [selecting]);
837
- }
838
- //#endregion
839
- //#region src/shared/wire.ts
840
846
  /**
841
- * 跨半 wire 契约:前缀、九个端点、请求/响应形状与失败码。
842
- * Node 半(src/api/dispatch.ts)与浏览器半(src/client/remote.ts)共用这一份,不各写字面量。
843
- *
844
- * 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
845
- * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
846
- */
847
- /**
848
- * 插件专属前缀。
849
- *
850
- * 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
851
- * 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
852
- * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
847
+ * 唯一的取数基础路径:宿主 `/api` + 插件前缀。九段 exact Fetch 路由、客户端探测与附件 URL
848
+ * 都从它起;`SESSION_DELETE_API_PREFIX` 只作为段名的相对前缀与错误原文。
853
849
  */
854
- const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
855
- /** 九个段名(相对前缀)。写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
850
+ const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api/dsh-session-delete-api`;
851
+ /** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
852
+ * (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
853
+ * 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
854
+ * 两半都不逐段手写一份。
855
+ * 写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
856
856
  * 单条就是长度为 1 的批量,仓里不留第二条执行路径。
857
- * - `GET /list`:对账并读清单(`session-trash/` 里的目录是准)。
858
- * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
859
- * - `POST /detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts`)。
860
- * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
861
- * - `POST /empty`:清空回收站(聚合回执,不按条回)。
862
- * - `GET /orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
857
+ * - `list`:对账并读清单(`session-trash/` 里的目录是准)。
858
+ * - `ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
859
+ * - `detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts` 的 detail 行)。
860
+ * - `apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
861
+ * - `empty`:清空回收站(聚合回执,不按条回)。
862
+ * - `orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
863
863
  * 这一段只负责"有多少、在哪、多大"。
864
- * - `GET /attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
865
- * - `GET /attachment-orphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
866
- * - `POST /attachment-clean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
864
+ * - `attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
865
+ * - `attachmentOrphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
866
+ * - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
867
867
  const ROUTES = {
868
- list: "/list",
868
+ list: {
869
+ path: "/list",
870
+ method: "GET"
871
+ },
869
872
  /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
870
- ping: "/ping",
873
+ ping: {
874
+ path: "/ping",
875
+ method: "GET"
876
+ },
871
877
  /** 单条详情(读回收站目录里那条会话自己的日志)。 */
872
- detail: "/detail",
878
+ detail: {
879
+ path: "/detail",
880
+ method: "POST"
881
+ },
873
882
  /** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
874
- apply: "/apply",
883
+ apply: {
884
+ path: "/apply",
885
+ method: "POST"
886
+ },
875
887
  /** 清空回收站。 */
876
- empty: "/empty",
888
+ empty: {
889
+ path: "/empty",
890
+ method: "POST"
891
+ },
877
892
  /** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
878
- orphans: "/orphans",
893
+ orphans: {
894
+ path: "/orphans",
895
+ method: "GET"
896
+ },
879
897
  /** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
880
- attachment: "/attachment",
898
+ attachment: {
899
+ path: "/attachment",
900
+ method: "GET"
901
+ },
881
902
  /** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
882
- attachmentOrphans: "/attachment-orphans",
903
+ attachmentOrphans: {
904
+ path: "/attachment-orphans",
905
+ method: "GET"
906
+ },
883
907
  /** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
884
- attachmentClean: "/attachment-clean"
908
+ attachmentClean: {
909
+ path: "/attachment-clean",
910
+ method: "POST"
911
+ }
885
912
  };
886
913
  /** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
887
914
  * 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
888
915
  const PARAMS = {
889
916
  entryId: "entryId",
917
+ /** `/detail` 的分页游标:只取这个块序号之前的块。缺省 = 从尾部取。 */
918
+ before: "before",
890
919
  action: "action",
891
920
  ids: "ids",
892
921
  attachmentId: "id",
893
922
  attachmentKind: "kind",
894
- attachmentName: "name"
923
+ attachmentName: "name",
924
+ /** 图片引用的媒体类型 / 宽高:只给宿主 `attachments.readImage` 做引用校验,不参与路径拼接。 */
925
+ attachmentMediaType: "mediaType",
926
+ /** 字节数:图片支与文件支都带——它是宿主读面的引用一部分,并先过 `maxAttachmentBytes` 闸
927
+ * (别为一张已知超限的图让宿主整份读出来)。 */
928
+ attachmentBytes: "bytes",
929
+ attachmentWidth: "width",
930
+ attachmentHeight: "height"
895
931
  };
896
932
  /**
897
933
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -906,6 +942,21 @@ window.__ModuleLoader__.load({
906
942
  const candidate = value;
907
943
  return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
908
944
  }
945
+ /**
946
+ * 运行期判形「这段 JSON 是不是信封」。中间层(反代 / SPA 兜底)会回 `{}`、`{"error":"…"}` 这类
947
+ * 不是信封的 JSON;判形不过就该走「回的不是信封」诊断,而不是把垃圾喂给展开层炸出
948
+ * TypeError / "undefined" 上屏(issue #8 第 1 条)。判据就是两臂的形状:`ok` 必须是布尔,
949
+ * `ok:true` 须带 `value` 键(值可以是 null),`ok:false` 的 `error` 须是带 string `code`/`message` 的对象。
950
+ */
951
+ function isRemoteResult(value) {
952
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
953
+ const body = value;
954
+ if (typeof body.ok !== "boolean") return false;
955
+ if (body.ok) return "value" in body;
956
+ if (typeof body.error !== "object" || body.error === null) return false;
957
+ const error = body.error;
958
+ return typeof error.code === "string" && typeof error.message === "string";
959
+ }
909
960
  const SESSION_DELETE_CODES = {
910
961
  live: "sessiondelete/live",
911
962
  "not-found": "sessiondelete/not-found",
@@ -1104,6 +1155,7 @@ window.__ModuleLoader__.load({
1104
1155
  };
1105
1156
  }
1106
1157
  const landedIds = receipt.action === "delete" ? landedEntryIds(receipt) : receipt.action === "restore" ? restoredSessionIds(receipt, sessionIdOfEntry) : [];
1158
+ const attachmentCleanup = summarizeAttachmentCleanup(receipt);
1107
1159
  return {
1108
1160
  summary: {
1109
1161
  successIds,
@@ -1112,7 +1164,7 @@ window.__ModuleLoader__.load({
1112
1164
  partial,
1113
1165
  landedIds,
1114
1166
  anySuccess: successIds.length > 0,
1115
- attachmentCleanup: summarizeAttachmentCleanup(receipt)
1167
+ ...attachmentCleanup !== void 0 ? { attachmentCleanup } : {}
1116
1168
  },
1117
1169
  failures
1118
1170
  };
@@ -1153,11 +1205,15 @@ window.__ModuleLoader__.load({
1153
1205
  //#endregion
1154
1206
  //#region src/client/remote.ts
1155
1207
  /**
1156
- * 浏览器半的取数通道:同源 fetch /dsh-session-delete-api,并处理信封与失败原文。
1208
+ * 浏览器半的取数通道:同源 fetch 宿主 `connection.fetch` 的 exact Fetch 路由(`/api` + 插件前缀)。
1209
+ * 信封与失败原文在这一层收口。
1157
1210
  *
1158
- * - 路由是宿主 web server 上的插件前缀;官方 remote.<namespace> 需要生成的 Typert 描述符,
1159
- * 第三方无法动态挂载,因此走这条薄线(ADR-0009)。
1160
- * - 九段:list / ping / orphans / detail / apply / empty / attachment / attachment-orphans / attachment-clean;
1211
+ * - 九段注册在宿主 `connection.fetch.register` 上,鉴权归宿主 `/api` bridge(ADR-0033);
1212
+ * 没有旧前缀 fallback——支持面是 web UI 与桌面端,二者的标准组合都装 `client-connection`。
1213
+ * 官方 remote.<namespace> 仍不可用(第三方拿不到 Typert 描述符的动态发现挂载点)。
1214
+ * - 九段的**段名与方法**住 `src/shared/wire.ts` 的结构化段名表(`ROUTES` 每项 `{ path, method }`),
1215
+ * 本文件只投影路径(`prefix + ROUTES.x.path`)与方法(GET 段直接取 `ROUTES.x.method`,写段把它
1216
+ * 交给 `post()`)——方法字面量不在本文件出现第二份;
1161
1217
  * 写动作统一走 apply;`attachment` 是唯一二进制段,由 `src/client/attachment-view.tsx` 直接拼地址,
1162
1218
  * 不经过本文件的信封通道;`attachment-orphans`(纯读)与 `attachment-clean`(聚合写)是孤儿附件的两段。
1163
1219
  * - 信封与官方 unary 面同形:{ok:true,value} / {ok:false,error}。HTTP 往返本身失败才 reject;
@@ -1225,63 +1281,74 @@ window.__ModuleLoader__.load({
1225
1281
  };
1226
1282
  }
1227
1283
  }
1284
+ /** 宿主 `/api` 的 401/403 是纯文本,不是本插件信封:固定中文原文只住这里(浏览器半唯一消费者)。 */
1285
+ const UNTRUSTED_MESSAGE = "非受信来源";
1286
+ const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
1228
1287
  /**
1229
1288
  * 一次 HTTP 往返 → 信封。**`ok:false` 也是落地**(返回,不抛);只有往返本身失败才抛:
1230
- * 打不通(宿主没起来 / 路由没挂)或回的不是信封(前缀路由缺席时请求会落到 SPA 兜底处理器上,
1231
- * 回的是 index.html)。两种抛都带原文,组件侧的 `failureOf` 会把它们显示成 `client/unknown` + 原因。
1289
+ * 打不通(宿主没起来 / 路由没挂)或回的不是信封(中间层回 `{}` / `{"error":"…"}` 这类非信封 JSON,
1290
+ * 或 SPA 兜底的 index.html——判形住 `isRemoteResult`,几种都落同一条诊断)。两种抛都带原文,
1291
+ * 组件侧的 `failureOf` 会把它们显示成 `client/unknown` + 原因。
1232
1292
  */
1233
1293
  async function requestEnvelope(path, init) {
1234
1294
  let response;
1235
1295
  try {
1236
1296
  response = await fetch(path, init);
1237
1297
  } catch (reason) {
1238
- throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}`);
1298
+ throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}`, { cause: reason });
1239
1299
  }
1300
+ let body;
1240
1301
  try {
1241
- return await response.json();
1302
+ body = await response.json();
1242
1303
  } catch {
1304
+ body = void 0;
1305
+ }
1306
+ if (!isRemoteResult(body)) {
1307
+ if (response.status === 401) throw new Error(UNAUTHENTICATED_MESSAGE);
1308
+ if (response.status === 403) throw new Error(UNTRUSTED_MESSAGE);
1243
1309
  throw new Error(`取数通道回的不是信封(HTTP ${String(response.status)})`);
1244
1310
  }
1311
+ return body;
1245
1312
  }
1246
1313
  /**
1247
- * 前缀路由的 fetch 实现。**相对路径**:不拼 base、不认 origin——页面本身就是从宿主 web server
1248
- * 加载的,同源是这条薄线的全部前提。本机(含转发落在回环的桌面壳)靠回环 + Origin/Referer 过栅栏;
1249
- * 远程(LAN / 反代)客户端另由宿主 `connection` 判「已认证的 Web 身份」,所以这条通道**只对**
1250
- * `dsh web` 打印的 URL 打开过的页面成立(凭据是那行 URL 换来的 cookie,不是插件自己的秘密)。
1314
+ * exact Fetch 路由的 fetch 实现。**相对路径**:不拼 host、不认 origin——页面本身就是从宿主页面
1315
+ * 加载的,同源是这条通道的全部前提;鉴权归宿主 `/api` bridge(ADR-0033)。所以这条通道**只对**
1316
+ * 宿主页面打开过、带宿主身份的客户端成立(凭据是那次访问换来的 cookie,不是插件自己的秘密)。
1251
1317
  */
1252
- function createHttpRemote(prefix = SESSION_DELETE_API_PREFIX) {
1253
- const post = (body) => ({
1254
- method: "POST",
1318
+ function createHttpRemote() {
1319
+ const post = (route, body) => ({
1320
+ method: route.method,
1255
1321
  headers: { "content-type": "application/json" },
1256
1322
  body: JSON.stringify(body)
1257
1323
  });
1258
1324
  return {
1259
- list: () => requestEnvelope(prefix + ROUTES.list, { method: "GET" }),
1260
- ping: () => requestEnvelope(prefix + ROUTES.ping, { method: "GET" }),
1261
- orphans: () => requestEnvelope(prefix + ROUTES.orphans, { method: "GET" }),
1262
- attachmentOrphans: () => requestEnvelope(prefix + ROUTES.attachmentOrphans, { method: "GET" }),
1263
- attachmentClean: () => requestEnvelope(prefix + ROUTES.attachmentClean, post({})),
1264
- detail: (entryId) => requestEnvelope(prefix + ROUTES.detail, post({ [PARAMS.entryId]: entryId })),
1265
- apply: (action, ids) => requestEnvelope(prefix + ROUTES.apply, post({
1325
+ list: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.list.path, { method: ROUTES.list.method }),
1326
+ ping: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.ping.path, { method: ROUTES.ping.method }),
1327
+ orphans: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.orphans.path, { method: ROUTES.orphans.method }),
1328
+ attachmentOrphans: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.attachmentOrphans.path, { method: ROUTES.attachmentOrphans.method }),
1329
+ attachmentClean: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.attachmentClean.path, post(ROUTES.attachmentClean, {})),
1330
+ detail: (entryId, before) => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.detail.path, post(ROUTES.detail, {
1331
+ [PARAMS.entryId]: entryId,
1332
+ ...before === void 0 ? {} : { [PARAMS.before]: before }
1333
+ })),
1334
+ apply: (action, ids) => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.apply.path, post(ROUTES.apply, {
1266
1335
  [PARAMS.action]: action,
1267
1336
  [PARAMS.ids]: [...ids]
1268
1337
  })),
1269
- empty: () => requestEnvelope(prefix + ROUTES.empty, post({}))
1338
+ empty: () => requestEnvelope(OFFICIAL_SESSION_DELETE_API_PREFIX + ROUTES.empty.path, post(ROUTES.empty, {}))
1270
1339
  };
1271
1340
  }
1272
1341
  /**
1273
- * 取数通道探测:一次 `GET /ping` 走通(或走到)就是"通道在"。
1342
+ * 探测唯一通道:一次 `GET /ping` 回成功信封(`pong`)才注册入口。
1274
1343
  *
1275
- * 判据是**「拿到信封」而不是「操作成功」**:`{ok:false, error}` 也是一次成功的往返(路由在、依赖接上了、
1276
- * 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
1277
- * 一个槽位贡献都不注册(docs/architecture.md §3「通道探测决定入口出现与否」)。
1278
- *
1279
- * 探测用一次零成本的 `GET /ping`,不读清单,避免每次页面加载都跑全表对账。
1344
+ * - 401/403 是宿主 `/api` 的纯文本身份/归属失败:入口不出现,不装禁用态。
1345
+ * - 失败信封 / 404 / 非信封同样按不可用处理:`/api` 网关对未知路径也可能回错误形状,
1346
+ * 不能据此把入口摆出来让每个动作都失败。
1280
1347
  */
1281
1348
  async function probeChannel(raw) {
1282
1349
  try {
1283
- await raw.ping();
1284
- return true;
1350
+ const result = await raw.ping();
1351
+ return result.ok && result.value === "pong";
1285
1352
  } catch {
1286
1353
  return false;
1287
1354
  }
@@ -1325,12 +1392,96 @@ window.__ModuleLoader__.load({
1325
1392
  scanOrphans: () => raw.orphans().then((result) => unwrapRemoteResult(result)),
1326
1393
  scanAttachmentOrphans: () => raw.attachmentOrphans().then((result) => unwrapRemoteResult(result)),
1327
1394
  cleanAttachmentOrphans: () => raw.attachmentClean().then((result) => unwrapRemoteResult(result)),
1328
- detail: (entryId) => raw.detail(entryId).then((result) => unwrapRemoteResult(result)),
1395
+ detail: (entryId, before) => raw.detail(entryId, before).then((result) => unwrapRemoteResult(result)),
1329
1396
  applyOutcomes: applyOnce,
1330
1397
  emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
1331
1398
  };
1332
1399
  }
1333
1400
  //#endregion
1401
+ //#region src/client/batch-selection-store.ts
1402
+ /**
1403
+ * 宿主列表批量删除的**选中集现场**:多选模式 + 选中名单,一份合并快照。
1404
+ *
1405
+ * 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
1406
+ * 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
1407
+ * 勾选框、行尾兜底、overlay 上的浮条与确认框。状态住这一处,`batch-select.tsx` 只剩控件,
1408
+ * `host-rows.ts` 只回答"页面上现在有哪些行、点行算不算选中"。订阅/通告那套仍只住在 `store.ts`
1409
+ * 的 `createStore` 里,本文件不另写一份。
1410
+ *
1411
+ * **合并快照**而不是两个 store:两半的变化总是成对出现(`exitSelection` 清两半),订阅者
1412
+ * (勾选框、浮条)两半都要读;分两份只会让"进模式的那一帧"出现两次通告、两个快照不同步的缝。
1413
+ *
1414
+ * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id):**逐条勾选序 = 名单序**;
1415
+ * 「全选」追加的条目按 `host-rows.ts` 的槽位登记序接在后面。`/apply` 那条「保持输入顺序」吃到的就是这份名单序。
1416
+ */
1417
+ const INITIAL = {
1418
+ selecting: false,
1419
+ picks: []
1420
+ };
1421
+ const selectionStore = createStore(INITIAL);
1422
+ function getSelection() {
1423
+ return selectionStore.get();
1424
+ }
1425
+ function getPicks() {
1426
+ return getSelection().picks;
1427
+ }
1428
+ /** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
1429
+ function useSelection() {
1430
+ return selectionStore.use();
1431
+ }
1432
+ /** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
1433
+ function enterSelection() {
1434
+ selectionStore.update((current) => current.selecting ? current : {
1435
+ ...current,
1436
+ selecting: true
1437
+ });
1438
+ }
1439
+ /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
1440
+ function exitSelection() {
1441
+ selectionStore.update((current) => current.selecting || current.picks.length > 0 ? INITIAL : current);
1442
+ }
1443
+ /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
1444
+ function togglePick(item) {
1445
+ selectionStore.update((current) => {
1446
+ const at = current.picks.findIndex((candidate) => candidate.sessionId === item.sessionId);
1447
+ return {
1448
+ ...current,
1449
+ picks: at === -1 ? [...current.picks, item] : current.picks.filter((candidate) => candidate.sessionId !== item.sessionId)
1450
+ };
1451
+ });
1452
+ }
1453
+ /** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
1454
+ function clearPicks() {
1455
+ selectionStore.update((current) => current.picks.length === 0 ? current : {
1456
+ ...current,
1457
+ picks: []
1458
+ });
1459
+ }
1460
+ /** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
1461
+ function addPicks(items) {
1462
+ selectionStore.update((current) => {
1463
+ const chosen = new Set(current.picks.map((item) => item.sessionId));
1464
+ const additions = [];
1465
+ for (const item of items) {
1466
+ if (chosen.has(item.sessionId)) continue;
1467
+ chosen.add(item.sessionId);
1468
+ additions.push(item);
1469
+ }
1470
+ return additions.length === 0 ? current : {
1471
+ ...current,
1472
+ picks: [...current.picks, ...additions]
1473
+ };
1474
+ });
1475
+ }
1476
+ /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
1477
+ function shrinkPicksToFailures(failedSessionIds) {
1478
+ const keep = new Set(failedSessionIds);
1479
+ selectionStore.update((current) => ({
1480
+ ...current,
1481
+ picks: current.picks.filter((item) => keep.has(item.sessionId))
1482
+ }));
1483
+ }
1484
+ //#endregion
1334
1485
  //#region src/client/ui/Checkbox.tsx
1335
1486
  /**
1336
1487
  * 勾选框,回收站多选态用。
@@ -1499,7 +1650,7 @@ window.__ModuleLoader__.load({
1499
1650
  }
1500
1651
  //#endregion
1501
1652
  //#region src/client/ui/ui.css?dsh-css-text
1502
- 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 * ⑥ `.sd-batch-pick-leading`(同日)——宿主行首那格只给 16×20,勾选框要一层 inline-flex 包装才压得回\r\n * 同样大小,官方没有这层包装。\r\n * 整张表里就这六条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的整套 UA reset。宿主 `DisclosureRow` 那份是\r\n * `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表会把背景、边框、内距、字体、居中与\r\n * flex 基线一起渗进来(真机上就是项目名整条居中,2026-10-15 截图)。它写在\r\n * `src/client/trash-panel.tsx` 组头那一行的 inline 样式上,**具体 reset 了哪几项由\r\n * `tests/skeleton.test.ts` 逐条钉住**(不在这里再抄一份名单)——所以「与官方逐条比对」时本表仍然只数\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 实测)——不自己描一条,键盘走\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 改版,**只在\r\n 多选模式内渲染**)。格本身由宿主居中(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";
1653
+ 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=\"row\"` 且要吃 ↑↓,而这一代 `--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 * ⑥ `.sd-batch-pick-leading`(同日)——宿主行首那格只给 16×20,勾选框要一层 inline-flex 包装才压得回\r\n * 同样大小,官方没有这层包装;⑦ `.sd-toast-action:disabled`(2026-11-01)——撤销横幅在飞时那颗动作要\r\n * 看着就按不动,官方 Toast 的 action 没有禁用档,而我们这份自带 `color`、浏览器默认置灰不生效,\r\n * 档位与③的禁用档同源(`opacity` + `not-allowed`)。\r\n * 整张表里就这七条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的整套 UA reset。宿主 `DisclosureRow` 那份是\r\n * `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表会把背景、边框、内距、字体、居中与\r\n * flex 基线一起渗进来(真机上就是项目名整条居中,2026-10-15 截图)。它写在\r\n * `src/client/trash-panel.tsx` 组头那一行的 inline 样式上,**具体 reset 了哪几项由\r\n * `tests/skeleton.test.ts` 逐条钉住**(不在这里再抄一份名单)——所以「与官方逐条比对」时本表仍然只数\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/* 本插件自造的第七条(不是抄本,见文件头点名):撤销横幅在飞时那颗动作要**看着就按不动**。\r\n 官方 Toast 的 action 只有常态与 hover,没有禁用档;而 `.sd-toast-action` 自带 `color`,\r\n 浏览器默认的置灰根本不生效(真机上只会看见一个光标照常是手、点下去没反应的「撤销」)。\r\n 禁用档与行尾勾选开关同源(`opacity` + `not-allowed`),不新造颜色。 */\r\n.sd-toast-action:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\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=\"row\"` 的可聚焦 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 改版,**只在\r\n 多选模式内渲染**)。格本身由宿主居中(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";
1503
1654
  //#endregion
1504
1655
  //#region src/client/ui/markdown.css?dsh-css-text
1505
1656
  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 * (图片渲成本地化占位,见 `copy.ts` 的 `detail.image` / `detail.image.alt`)/ `.katex-display` /\r\n * `:global(.md-code-block)` / `.shiki`(高亮由宿主的 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";
@@ -1689,21 +1840,6 @@ window.__ModuleLoader__.load({
1689
1840
  //#endregion
1690
1841
  //#region src/client/batch-select.tsx
1691
1842
  /**
1692
- * 宿主列表批量删除的**控件面**:行首勾选框、行尾兜底开关,
1693
- * 以及确认框/浮条读的那一份计数问句。
1694
- *
1695
- * 入口在每行的「⋯」菜单里(`batch-menu-item.tsx`):**按下才进入多选模式**,模式内才渲染勾选框、
1696
- * 才把整行点击变成"勾/取消";退出模式清空名单。选中集只住 `batch-selection-store.ts`(模式 + 名单一份
1697
- * 合并快照);"哪些行现在可见、点行算不算选中"只住 `host-rows.ts`。四处隔着槽位边界,只有模块级
1698
- * store 能让他们看见同一份名单;本文件不再持有任何现场。
1699
- *
1700
- * **为什么主勾选在行首**:宿主对行尾 `.rowActions` 是 `display:none`,只有 `row:hover` / 菜单打开
1701
- * 才出现(app.asar 0.2.0-rc.2 的 Rows 渲染);行首 16px 格在无状态点时是空的,模式内可见。
1702
- * 宿主在「有状态点(运行中/待确认/未读完成)」与归档行不渲染行首格 ⇒ 这些行走行尾悬停兜底,
1703
- * 但模式内**点整行**也能选(`host-rows.ts` 的 `useSelectionRowClickGuard`),所以不依赖那颗小框。
1704
- * 两条勾选不会同时出现(`ui.css` 的 `:has` 兜底隐藏规则,靠宿主行的 `data-row-key`)。
1705
- */
1706
- /**
1707
1843
  * 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
1708
1844
  *
1709
1845
  * **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
@@ -1724,8 +1860,12 @@ window.__ModuleLoader__.load({
1724
1860
  * (行首格在宿主行那条 `onClick` 里面)。宿主对「有状态点」和归档行不渲染这一格,
1725
1861
  * 那些行走 `BatchPickButton` 兜底;两条不会同时出现(见 `ui.css` 的兜底隐藏规则)。
1726
1862
  */
1727
- function BatchPickCheckbox({ sessionId, title, t = zhTranslate }) {
1863
+ function BatchPickCheckbox({ sessionId, title, live = false, t = zhTranslate }) {
1728
1864
  const { selecting, picks } = useSelection();
1865
+ useHostRowRegistration(sessionId, {
1866
+ title,
1867
+ live
1868
+ });
1729
1869
  const on = picks.some((candidate) => candidate.sessionId === sessionId);
1730
1870
  if (!selecting) return null;
1731
1871
  const name = title === "" ? t("common.untitled") : title;
@@ -1754,16 +1894,10 @@ window.__ModuleLoader__.load({
1754
1894
  function BatchPickButton({ sessionId, title, live = false, t = zhTranslate }) {
1755
1895
  const { selecting, picks } = useSelection();
1756
1896
  const on = picks.some((candidate) => candidate.sessionId === sessionId);
1757
- (0, react.useEffect)(() => {
1758
- registerHostRowInfo(sessionId, {
1759
- title,
1760
- live
1761
- });
1762
- }, [
1763
- sessionId,
1897
+ useHostRowRegistration(sessionId, {
1764
1898
  title,
1765
1899
  live
1766
- ]);
1900
+ });
1767
1901
  if (!selecting) return null;
1768
1902
  const name = title === "" ? t("common.untitled") : title;
1769
1903
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tooltip, {
@@ -1794,21 +1928,26 @@ window.__ModuleLoader__.load({
1794
1928
  //#endregion
1795
1929
  //#region src/client/busy-gate.ts
1796
1930
  /**
1797
- * 「这一趟还算忙」的一枚闸——面板与两个确认框共用的一份实现(各面各持一枚实例)。
1931
+ * 「这一趟还算忙」的一枚闸——面板、两个确认框与撤销横幅共用的一份实现(各面各持一枚实例)。
1798
1932
  *
1799
- * 为什么收回一处:三个面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有一条:
1800
- * 任务落地或抛错都把 busy 放回 false;不做引用计数——同一实例同一时刻只跑一趟写动作,多一层计数
1933
+ * 为什么收回一处:各面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有两条:
1934
+ * 任务落地或抛错都把 busy 放回 false;同一实例同一时刻只跑一趟——在飞时再包一趟会被**同步**
1935
+ * 挡回(`busy` 是 state,要等下一拍才上屏,同一 tick 的连点拦不住),不做引用计数,多一层计数
1801
1936
  * 只会把「这一趟什么时候算完」变成第二个判据。
1802
1937
  *
1803
- * 边界:闸只拥有 busy 的生命周期,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
1938
+ * 边界:闸只拥有 busy 的生命周期与同实例防重,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
1804
1939
  */
1805
1940
  function useBusyGate() {
1806
1941
  const [busy, setBusy] = (0, react.useState)(false);
1942
+ const flying = (0, react.useRef)(false);
1807
1943
  const around = (0, react.useCallback)(async (task) => {
1944
+ if (flying.current) return void 0;
1945
+ flying.current = true;
1808
1946
  setBusy(true);
1809
1947
  try {
1810
1948
  return await task();
1811
1949
  } finally {
1950
+ flying.current = false;
1812
1951
  setBusy(false);
1813
1952
  }
1814
1953
  }, []);
@@ -2018,7 +2157,7 @@ window.__ModuleLoader__.load({
2018
2157
  /**
2019
2158
  * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
2020
2159
  *
2021
- * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(refreshAll)`),而删除发生在
2160
+ * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
2022
2161
  * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
2023
2162
  * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-notices.ts`)
2024
2163
  * 同理。
@@ -2105,23 +2244,117 @@ window.__ModuleLoader__.load({
2105
2244
  }));
2106
2245
  }
2107
2246
  //#endregion
2247
+ //#region src/client/pending-open.ts
2248
+ /**
2249
+ * 重开等待的唯一生命周期:一张票、一条订阅、一个放弃 timer、一次 settle/cleanup。
2250
+ *
2251
+ * ADR-0036 的语义不变:**订阅优先、出现即开、最多等一个放弃边界、不轮询**。这里把这段状态机
2252
+ * 收成一个可取消句柄:调用方 `schedule` 一次、`cancel` 一次;票、订阅、timer 与 settle 标志
2253
+ * 都关在 implementation 里,不再由 main-view 在四个调用点成对手工清理。
2254
+ *
2255
+ * 依赖(store 的 subscribe、是否已出现、是否还能抢焦点、怎么打开)由调用方交进来——本模块不认宿主。
2256
+ */
2257
+ /** 默认放弃边界:给宿主 `api-session/added` 到客户端摘要落地留一拍(ADR-0036 的产品决定)。 */
2258
+ const DEFAULT_GIVE_UP_MS = 2e3;
2259
+ function createPendingOpen(giveUpMs = DEFAULT_GIVE_UP_MS) {
2260
+ const latest = createLatestWins();
2261
+ let cleanup;
2262
+ const cancel = () => {
2263
+ latest.invalidate();
2264
+ const stop = cleanup;
2265
+ cleanup = void 0;
2266
+ stop?.();
2267
+ };
2268
+ return {
2269
+ cancel,
2270
+ schedule(input) {
2271
+ cancel();
2272
+ const ticket = latest.issue();
2273
+ let settled = false;
2274
+ let unsubscribe;
2275
+ let timer;
2276
+ const unsubscribeNow = () => {
2277
+ if (unsubscribe === void 0) return;
2278
+ const stop = unsubscribe;
2279
+ unsubscribe = void 0;
2280
+ try {
2281
+ stop();
2282
+ } catch {}
2283
+ };
2284
+ const finish = () => {
2285
+ if (settled) return;
2286
+ settled = true;
2287
+ if (timer !== void 0) {
2288
+ clearTimeout(timer);
2289
+ timer = void 0;
2290
+ }
2291
+ unsubscribeNow();
2292
+ if (cleanup === finish) cleanup = void 0;
2293
+ };
2294
+ const open = () => {
2295
+ if (!ticket.current || !input.canOpen()) {
2296
+ finish();
2297
+ return;
2298
+ }
2299
+ try {
2300
+ input.open();
2301
+ } finally {
2302
+ finish();
2303
+ }
2304
+ };
2305
+ if (input.isReady()) {
2306
+ open();
2307
+ return;
2308
+ }
2309
+ const subscribe = input.subscribe;
2310
+ if (subscribe === void 0) return;
2311
+ cleanup = finish;
2312
+ let stop;
2313
+ try {
2314
+ stop = subscribe(() => {
2315
+ if (input.isReady()) open();
2316
+ });
2317
+ } catch {
2318
+ finish();
2319
+ return;
2320
+ }
2321
+ unsubscribe = stop;
2322
+ if (settled) {
2323
+ unsubscribeNow();
2324
+ return;
2325
+ }
2326
+ timer = setTimeout(finish, giveUpMs);
2327
+ if (input.isReady()) open();
2328
+ }
2329
+ };
2330
+ }
2331
+ //#endregion
2108
2332
  //#region src/client/main-view.ts
2109
2333
  let client;
2110
2334
  /** 我们自己关掉的主视图会话:只有这里记过的,撤销恢复后才会重开;面板恢复用 `forgetMainView` 清掉。 */
2111
2335
  let closedByUs = /* @__PURE__ */ new Set();
2112
- /** 重开排队的最新票:后一次删除/恢复(或重开)签发新票,前一次的等待随之作废(只开最新那条)。 */
2113
- const reopen = createLatestWins();
2336
+ /** 在等的重开:票、订阅、放弃 timer 与 cleanup 都归这个句柄(`src/client/pending-open.ts`)。 */
2337
+ const pendingOpen = createPendingOpen();
2114
2338
  /** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
2115
2339
  function bindMainView(ctx) {
2116
2340
  client = ctx;
2341
+ if (typeof ctx.effect === "function") ctx.effect(() => () => {
2342
+ pendingOpen.cancel();
2343
+ closedByUs = /* @__PURE__ */ new Set();
2344
+ }, "session-delete: main-view");
2345
+ }
2346
+ /** 宿主服务是否可用:把检查写成类型谓词,调用点拿到的就是 `ReadyUiWorkspace`。
2347
+ * `ctx.get` 的窄镜像声明成可能回 null(宿主可回 null),所以空值判据必须同时认 undefined / null。 */
2348
+ function isReadyUiWorkspace(candidate) {
2349
+ if (candidate === void 0 || candidate === null) return false;
2350
+ if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return false;
2351
+ if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return false;
2352
+ return true;
2117
2353
  }
2118
2354
  function uiWorkspaceOf() {
2119
2355
  try {
2120
2356
  const candidate = client?.get?.("uiWorkspace");
2121
- if (candidate === void 0 || candidate === null) return void 0;
2122
- if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return void 0;
2123
- if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return void 0;
2124
- return candidate;
2357
+ return isReadyUiWorkspace(candidate) ? candidate : void 0;
2125
2358
  } catch {
2126
2359
  return;
2127
2360
  }
@@ -2139,7 +2372,7 @@ window.__ModuleLoader__.load({
2139
2372
  */
2140
2373
  function afterDelete(sessionIds) {
2141
2374
  if (sessionIds.length === 0) return;
2142
- reopen.invalidate();
2375
+ pendingOpen.cancel();
2143
2376
  const ui = uiWorkspaceOf();
2144
2377
  if (ui === void 0) return;
2145
2378
  const current = currentSessionOf(ui);
@@ -2158,11 +2391,13 @@ window.__ModuleLoader__.load({
2158
2391
  */
2159
2392
  function afterRestore(sessionIds) {
2160
2393
  if (sessionIds.length === 0) return;
2161
- reopen.invalidate();
2394
+ pendingOpen.cancel();
2162
2395
  const targets = sessionIds.filter((id) => closedByUs.has(id));
2163
2396
  if (targets.length === 0) return;
2164
2397
  for (const id of targets) closedByUs.delete(id);
2165
- openWhenListed(targets[targets.length - 1]);
2398
+ const last = targets[targets.length - 1];
2399
+ if (last === void 0) return;
2400
+ openWhenListed(last);
2166
2401
  }
2167
2402
  /**
2168
2403
  * 面板恢复已经替这些会话完成了“回来”的结算:只忘掉我们关过的那几条,不重开、不抢焦点。
@@ -2173,19 +2408,23 @@ window.__ModuleLoader__.load({
2173
2408
  }
2174
2409
  /**
2175
2410
  * 把会话重新打开:`api-session/added` 从宿主发出到客户端摘要表落地有一拍(WS 与 HTTP 响应各走
2176
- * 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——所以先等它出现,
2177
- * 最多 2 秒;期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
2411
+ * 各的),`openSession` 的目标必须先出现在清单里才不会抛「unknown session」——先查一次快照,没有就
2412
+ * **订阅**宿主快照 store;出现且票仍是当前、焦点没被抢走时打开。最多等 2 秒,一个放弃 timer,不轮询。
2413
+ * 期间用户若已经选了别的会话就不再抢焦点。等不到就什么都不做,行还在那里。
2178
2414
  *
2179
- * **最新票守卫**(`reopen`):开始等待时签一张票,立即开与每次 tick 都核对;后一次删除/
2180
- * 恢复(或 `resetMainView`)签发新票或作废当前票,前一次的等待就不再打开、也不再续时。
2415
+ * 这段状态机住 `src/client/pending-open.ts` 的 `createPendingOpen`:这里只把「查什么、能不能开、
2416
+ * 怎么开」交进去;取消也只剩 `pendingOpen.cancel()` 一次。没有 `subscribe` 的旧 store 直接放弃
2417
+ * 等待,不退回轮询(ADR-0036)。
2181
2418
  */
2182
2419
  function openWhenListed(sessionId) {
2183
2420
  const ui = uiWorkspaceOf();
2184
2421
  if (ui === void 0) return;
2185
- const ticket = reopen.issue();
2422
+ const store = ui.sessions?.list;
2423
+ if (store === void 0) return;
2424
+ const subscribe = typeof store.subscribe === "function" ? store.subscribe.bind(store) : void 0;
2186
2425
  const listReady = () => {
2187
2426
  try {
2188
- return ui.sessions?.list?.getSnapshot()?.byId?.[sessionId] !== void 0;
2427
+ return store.getSnapshot()?.byId?.[sessionId] !== void 0;
2189
2428
  } catch {
2190
2429
  return false;
2191
2430
  }
@@ -2194,28 +2433,16 @@ window.__ModuleLoader__.load({
2194
2433
  const current = currentSessionOf(ui);
2195
2434
  return current === void 0 || current === sessionId;
2196
2435
  };
2197
- const open = () => {
2198
- if (!ticket.current) return;
2199
- if (!canTakeFocus()) return;
2200
- try {
2201
- ui.openSession?.(sessionId);
2202
- } catch {}
2203
- };
2204
- if (listReady()) {
2205
- open();
2206
- return;
2207
- }
2208
- let attempts = 0;
2209
- const tick = () => {
2210
- if (!ticket.current) return;
2211
- attempts += 1;
2212
- if (listReady()) {
2213
- open();
2214
- return;
2436
+ pendingOpen.schedule({
2437
+ subscribe,
2438
+ isReady: listReady,
2439
+ canOpen: canTakeFocus,
2440
+ open: () => {
2441
+ try {
2442
+ ui.openSession(sessionId);
2443
+ } catch {}
2215
2444
  }
2216
- if (attempts < 20) setTimeout(tick, 100);
2217
- };
2218
- setTimeout(tick, 100);
2445
+ });
2219
2446
  }
2220
2447
  //#endregion
2221
2448
  //#region src/client/write-settlement.ts
@@ -2307,9 +2534,12 @@ window.__ModuleLoader__.load({
2307
2534
  }
2308
2535
  //#endregion
2309
2536
  //#region src/client/write-seats.ts
2310
- /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。 */
2537
+ /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。
2538
+ * 三种形状各有意思,不许互相冒充:字符串 = 这一条失败了(原文);`null` = 跑了、这一条没有失败要说;
2539
+ * `undefined` = 闸把这一趟挡回来了、什么都没跑(框不收,等在飞那一趟的结论——先收就会把它的失败原文
2540
+ * 落在已经卸载的确认框上)。 */
2311
2541
  async function deleteSessionFromMenu(deps, input, gate) {
2312
- return (await runWrite({
2542
+ const outcome = await runWrite({
2313
2543
  deps,
2314
2544
  action: "delete",
2315
2545
  ids: [input.sessionId],
@@ -2322,10 +2552,13 @@ window.__ModuleLoader__.load({
2322
2552
  })
2323
2553
  },
2324
2554
  gate
2325
- })).failures.get(input.sessionId) ?? null;
2555
+ });
2556
+ if (outcome === void 0) return void 0;
2557
+ return outcome.failures.get(input.sessionId) ?? null;
2326
2558
  }
2327
2559
  /** 批量删除座位(宿主会话列表的多选 → 批量确认框):一次 `/apply`,撤销名单只收 ok 支的条目 id(跟着组走的幂等条不单列)。
2328
- * 只收 id:标题是浮条的呈现,不参与这一次写入。 */
2560
+ * 只收 id:标题是浮条的呈现,不参与这一次写入。**被闸挡回时交回 `undefined`**——这一趟没跑,
2561
+ * 浮条与确认框都不许照着它收缩名单或关框(那会吞掉在飞那一趟的结果)。 */
2329
2562
  async function deleteSessionsFromBatch(deps, ids, gate) {
2330
2563
  return (await runWrite({
2331
2564
  deps,
@@ -2339,19 +2572,42 @@ window.__ModuleLoader__.load({
2339
2572
  })
2340
2573
  },
2341
2574
  gate
2342
- })).failures;
2343
- }
2344
- /** 外部恢复座位(撤销横幅):没有 busy 面,不传闸;主视图重开归 settlement,这里只让清单失效。 */
2345
- function restoreFromUndo(deps, input) {
2346
- return runWrite({
2575
+ }))?.failures;
2576
+ }
2577
+ /** 外部恢复座位:拥有整趟——ids 组装、runWrite、三种回执形状各改写一次横幅、成功收自己。
2578
+ * 闸在调用方(横幅组件每条通知一枚);座位不传闸(双套会让外层 busy 提前落)。
2579
+ * 单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
2580
+ * null 是协议错);失败就把原因换到同一条横幅上,不静默。多条逐条失败时横幅要说清**条数与各条
2581
+ * 原因**(原因去重)——只显第一条会把条数与其余原因折没(issue #8 第 5 条);单条失败仍原文上屏。
2582
+ * settlement 负责重开删除时被关掉的会话(只认 main-view 自己记过的)并让清单重读;
2583
+ * 座位**不传 `dismissUndo`**——部分失败的回滚会让 settlement 先熄灭横幅,随后 `failNotice` 因
2584
+ * `ownsNotice` 落空而吞掉失败原文。 */
2585
+ async function restoreFromUndo(deps, notice, t) {
2586
+ const ids = notice.kind === "one" ? [notice.entryId] : notice.entryIds;
2587
+ if (notice.kind === "one" && notice.entryId === "") return;
2588
+ const outcome = await runWrite({
2347
2589
  deps,
2348
2590
  action: "restore",
2349
- ids: input.ids,
2350
- single: input.single,
2591
+ ids,
2592
+ single: notice.kind === "one",
2351
2593
  presentation: { invalidate: invalidateTrash }
2352
2594
  });
2595
+ if (!outcome.ok) {
2596
+ failNotice(notice, outcome.failure.message);
2597
+ return;
2598
+ }
2599
+ const failure = outcome.failures.values().next().value;
2600
+ if (failure !== void 0) {
2601
+ failNotice(notice, outcome.failures.size > 1 ? t("undo.failedBatch", {
2602
+ n: outcome.failures.size,
2603
+ reasons: [...new Set(outcome.failures.values())].join(" · ")
2604
+ }) : failure);
2605
+ return;
2606
+ }
2607
+ if (ownsNotice(notice)) clearUndoNotice();
2353
2608
  }
2354
- /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。 */
2609
+ /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。
2610
+ * 闸被面板那一趟占着时交回 `undefined`(连点不发第二条),面板照它什么都不改。 */
2355
2611
  function restoreFromPanel(input) {
2356
2612
  return runWrite({
2357
2613
  deps: input.deps,
@@ -2368,7 +2624,8 @@ window.__ModuleLoader__.load({
2368
2624
  refresh: input.refreshAfterWrite
2369
2625
  });
2370
2626
  }
2371
- /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。 */
2627
+ /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。
2628
+ * 与恢复座位同一条:闸被占着时交回 `undefined`,这一趟不改任何界面后果。 */
2372
2629
  function purgeFromPanel(input) {
2373
2630
  return runWrite({
2374
2631
  deps: input.deps,
@@ -2466,6 +2723,7 @@ window.__ModuleLoader__.load({
2466
2723
  sessionId: current.sessionId,
2467
2724
  title: current.title
2468
2725
  }, gate);
2726
+ if (failure === void 0) return;
2469
2727
  if (failure !== null) {
2470
2728
  setFailure(failure);
2471
2729
  return;
@@ -2603,7 +2861,7 @@ window.__ModuleLoader__.load({
2603
2861
  const [counts, setCounts] = (0, react.useState)(null);
2604
2862
  const ids = current.items.map((item) => item.sessionId);
2605
2863
  const failedIds = [...failures.keys()];
2606
- const totalKids = counts === null ? 0 : [...counts.values()].reduce((sum, n) => sum + n, 0);
2864
+ const totalKids = counts === null ? 0 : current.items.reduce((sum, item) => sum + (counts.get(item.sessionId) ?? 0), 0);
2607
2865
  (0, react.useEffect)(() => {
2608
2866
  let alive = true;
2609
2867
  fetchCounts(deps, current.items.map((item) => item.sessionId)).then((result) => {
@@ -2616,6 +2874,7 @@ window.__ModuleLoader__.load({
2616
2874
  const run = async () => {
2617
2875
  setFailures(/* @__PURE__ */ new Map());
2618
2876
  const failed = await deleteSessionsFromBatch(deps, current.items.map((item) => item.sessionId), gate);
2877
+ if (failed === void 0) return;
2619
2878
  shrinkPicksToFailures([...failed.keys()]);
2620
2879
  if (failed.size === 0) {
2621
2880
  clearDeleteBatchRequest();
@@ -2671,12 +2930,81 @@ window.__ModuleLoader__.load({
2671
2930
  });
2672
2931
  }
2673
2932
  //#endregion
2933
+ //#region src/client/batch-pick.ts
2934
+ /**
2935
+ * 批量选择的**动作面**:全选、全选判定与模式内整行点击守卫。
2936
+ *
2937
+ * 可见行的事实源仍住 `host-rows.ts`(槽位登记 + 快照);选中集住 `batch-selection-store.ts`。
2938
+ * 本文件只把两者接起来,于是依赖方向单向:`batch-pick` → `host-rows` / `batch-selection-store`;
2939
+ * 浮条与行开关 → `batch-pick` / `host-rows`。`host-rows` 不再 import 任何选择动作。
2940
+ *
2941
+ * DOM 只在这里读一次 `data-row-key`(ADR-0034 记下的受约束偏离):守卫把指针下的行映射成
2942
+ * sessionId,再查登记;它不枚举行,也不维护行列表。
2943
+ */
2944
+ /** 全选:把当前可见可选行按登记序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
2945
+ function selectAllVisible() {
2946
+ addPicks(readVisibleSelectableRows());
2947
+ }
2948
+ /**
2949
+ * 同一个切换口该显示「全选」还是「取消全选」:
2950
+ * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
2951
+ * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
2952
+ * - 其余(含 0 条)⇒ 全选。
2953
+ */
2954
+ function areAllVisibleRowsSelected(rows, picks) {
2955
+ if (picks.length === 0) return false;
2956
+ if (rows.length === 0) return true;
2957
+ const chosen = new Set(picks.map((item) => item.sessionId));
2958
+ return rows.every((row) => chosen.has(row.sessionId));
2959
+ }
2960
+ /** 行内这些元素有自己的动作:模式内也不许被"点行=勾选"吃掉。 */
2961
+ const INTERACTIVE_IN_ROW = "button, a, input, select, textarea, [role=\"menuitem\"], [role=\"menu\"], [data-dsh-session-delete=\"batch-pick\"]";
2962
+ /**
2963
+ * 多选态内的整行点击守卫(document 捕获阶段)。
2964
+ *
2965
+ * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
2966
+ * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
2967
+ * 认行靠 `data-row-key="session:…"`——**这是本文件唯一一处 DOM 读**(ADR-0034):宿主将来改掉这个
2968
+ * 属性,最差是模式内点行不生效,勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
2969
+ */
2970
+ function useSelectionRowClickGuard() {
2971
+ const selecting = useSelection().selecting;
2972
+ (0, react.useEffect)(() => {
2973
+ if (!selecting) return;
2974
+ const handle = (event) => {
2975
+ if (!(event.target instanceof Element)) return;
2976
+ const row = event.target.closest("[data-row-key^=\"session:\"]");
2977
+ if (row === null) return;
2978
+ if (event.target.closest(INTERACTIVE_IN_ROW) !== null) return;
2979
+ event.preventDefault();
2980
+ event.stopPropagation();
2981
+ if (event.type === "dblclick" || event.type === "click" && event.detail > 1) return;
2982
+ const key = row.getAttribute("data-row-key") ?? "";
2983
+ if (!key.startsWith("session:")) return;
2984
+ const sessionId = key.slice(8);
2985
+ if (sessionId === "") return;
2986
+ const info = pickableRowOf(sessionId);
2987
+ if (info === void 0) return;
2988
+ togglePick({
2989
+ sessionId,
2990
+ title: info.title
2991
+ });
2992
+ };
2993
+ document.addEventListener("click", handle, true);
2994
+ document.addEventListener("dblclick", handle, true);
2995
+ return () => {
2996
+ document.removeEventListener("click", handle, true);
2997
+ document.removeEventListener("dblclick", handle, true);
2998
+ };
2999
+ }, [selecting]);
3000
+ }
3001
+ //#endregion
2674
3002
  //#region src/client/batch-bar.tsx
2675
3003
  /**
2676
3004
  * 批量删除的浮条:**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2677
3005
  * 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
2678
3006
  * (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
2679
- * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `host-rows.ts` 的 `useSelectionRowClickGuard`)。
3007
+ * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `batch-pick.ts` 的 `useSelectionRowClickGuard`)。
2680
3008
  *
2681
3009
  * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"行开关控件",而浮条要点开确认框
2682
3010
  * (要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
@@ -2923,12 +3251,16 @@ window.__ModuleLoader__.load({
2923
3251
  if (list === void 0) byProject.set(entry.projectDir, [entry]);
2924
3252
  else list.push(entry);
2925
3253
  }
2926
- return [...byProject].map(([projectDir, list]) => ({
2927
- projectDir,
2928
- label: projectLabel(projectDir, t),
2929
- latest: list[0].deletedAt,
2930
- entries: list
2931
- })).sort((a, b) => b.latest - a.latest);
3254
+ return [...byProject].flatMap(([projectDir, list]) => {
3255
+ const first = list[0];
3256
+ if (first === void 0) return [];
3257
+ return [{
3258
+ projectDir,
3259
+ label: projectLabel(projectDir, t),
3260
+ latest: first.deletedAt,
3261
+ entries: list
3262
+ }];
3263
+ }).sort((a, b) => b.latest - a.latest);
2932
3264
  }
2933
3265
  //#endregion
2934
3266
  //#region src/client/attachment-view.tsx
@@ -2936,8 +3268,10 @@ window.__ModuleLoader__.load({
2936
3268
  * 回收站详情与设置页维护卡预览里的一条附件:图片给缩略图 + 放大框,普通文件给下载 chip。
2937
3269
  *
2938
3270
  * 三条口径:
2939
- * - **地址只由 `attachmentId` + `kind` 拼**:`/attachment` 段只认这两个参数寻址(`name` 只做下载名建议,
2940
- * 不参与路径推导,见 `src/api/attachment.ts`)。空串与缺席同义——空串不是一个名字,不写进查询串。
3271
+ * - **地址只由 `attachmentId` + `kind` 寻址**:`/attachment` 段以这两个参数找对象;媒体元数据
3272
+ * (`mediaType` / `bytes` / `width` / `height`)只给宿主 `attachments` 服务做引用校验,`name` 只做
3273
+ * 下载名建议,均不参与路径推导(见 `src/api/attachment.ts`)。空串与缺席同义——空串不是一个名字,
3274
+ * 不写进查询串。
2941
3275
  * - **图裂了就换占位**:缩略图与放大框共用同一份 `failed` 集合,`onError` 之后这一格改成「附件不可用」,
2942
3276
  * 不把破图留在屏上(放大框里裂了也换)。普通文件走 `<a download>`,没有页面内失败面:服务端失败回
2943
3277
  * 200 + JSON 信封,浏览器可能把这份错误响应存成文件。
@@ -2954,10 +3288,16 @@ window.__ModuleLoader__.load({
2954
3288
  function attachmentUrl(a) {
2955
3289
  const query = new URLSearchParams({
2956
3290
  [PARAMS.attachmentId]: a.attachmentId,
2957
- [PARAMS.attachmentKind]: a.kind
3291
+ [PARAMS.attachmentKind]: a.kind,
3292
+ [PARAMS.attachmentBytes]: String(a.bytes)
2958
3293
  });
3294
+ if (a.kind === "image") {
3295
+ if (a.mediaType !== void 0) query.set(PARAMS.attachmentMediaType, a.mediaType);
3296
+ if (a.width !== void 0) query.set(PARAMS.attachmentWidth, String(a.width));
3297
+ if (a.height !== void 0) query.set(PARAMS.attachmentHeight, String(a.height));
3298
+ }
2959
3299
  if (a.kind === "file" && a.name !== void 0 && a.name !== "") query.set(PARAMS.attachmentName, a.name);
2960
- return `${SESSION_DELETE_API_PREFIX}${ROUTES.attachment}?${query.toString()}`;
3300
+ return `${OFFICIAL_SESSION_DELETE_API_PREFIX}${ROUTES.attachment.path}?${query.toString()}`;
2961
3301
  }
2962
3302
  /** 空串与缺席同义:宿主侧的 `name` 是可选建议名,空串不是名字(兜底文案与"不带 name 查询"都看它)。 */
2963
3303
  function nameOf(a) {
@@ -3047,7 +3387,8 @@ window.__ModuleLoader__.load({
3047
3387
  setSaveError(null);
3048
3388
  try {
3049
3389
  const response = await fetch(attachmentUrl(attachment));
3050
- const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
3390
+ const mediaType = response.headers.get("content-type")?.split(";")[0];
3391
+ const contentType = mediaType === void 0 ? "" : mediaType.trim();
3051
3392
  if (!response.ok || !contentType.startsWith("image/")) {
3052
3393
  let message = `HTTP ${String(response.status)}`;
3053
3394
  if (contentType === "application/json") try {
@@ -3495,21 +3836,21 @@ window.__ModuleLoader__.load({
3495
3836
  }
3496
3837
  //#endregion
3497
3838
  //#region src/client/orphan-maintenance.tsx
3498
- function OrphanMaintenance({ query, busy, t }) {
3499
- const { orphanReport, orphanFailure, orphanNotice, orphanConfirm } = query;
3839
+ function OrphanMaintenance({ section, t }) {
3840
+ const { report, failure, notice, confirm, busy } = section;
3500
3841
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3501
- orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(MaintenanceBanner, {
3842
+ report !== null && report.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(MaintenanceBanner, {
3502
3843
  slot: "orphan-banner",
3503
3844
  busy,
3504
3845
  t,
3505
3846
  gapBelow: true,
3506
- onOpen: () => query.openOrphanPreview(),
3847
+ onOpen: () => section.openPreview(),
3507
3848
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3508
3849
  style: {
3509
3850
  fontSize: 13,
3510
3851
  lineHeight: "19px"
3511
3852
  },
3512
- children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
3853
+ children: t("trash.orphans.banner", { count: report.totalSessions })
3513
3854
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3514
3855
  style: {
3515
3856
  fontSize: 12,
@@ -3517,19 +3858,19 @@ window.__ModuleLoader__.load({
3517
3858
  color: "var(--dsw-alias-label-tertiary)"
3518
3859
  },
3519
3860
  children: t("trash.orphans.meta", {
3520
- size: fileSizeText(orphanReport.totalBytes),
3521
- parents: orphanReport.missingParents
3861
+ size: fileSizeText(report.totalBytes),
3862
+ parents: report.missingParents
3522
3863
  })
3523
3864
  })]
3524
3865
  }) : null,
3525
- orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3526
- message: orphanFailure,
3866
+ failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3867
+ message: failure,
3527
3868
  busy,
3528
3869
  t,
3529
3870
  gapBelow: true,
3530
- onRetry: () => void query.scanOrphans()
3871
+ onRetry: () => void section.scan()
3531
3872
  }) : null,
3532
- orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3873
+ notice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3533
3874
  "data-dsh-session-delete": "orphan-notice",
3534
3875
  role: "status",
3535
3876
  style: {
@@ -3557,29 +3898,127 @@ window.__ModuleLoader__.load({
3557
3898
  lineHeight: "17px",
3558
3899
  color: "var(--dsw-alias-label-secondary)"
3559
3900
  },
3560
- children: orphanNotice
3901
+ children: notice
3561
3902
  })]
3562
3903
  }) : null,
3563
- orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3564
- report: orphanReport,
3904
+ confirm && report !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3905
+ report,
3565
3906
  busy,
3566
3907
  t,
3567
- onCancel: () => query.setOrphanConfirm(false),
3568
- onConfirm: () => void query.cleanOrphans()
3908
+ onCancel: () => section.cancel(),
3909
+ onConfirm: () => void section.clean()
3569
3910
  }) : null
3570
3911
  ] });
3571
3912
  }
3572
3913
  //#endregion
3914
+ //#region src/client/use-maintenance-section.ts
3915
+ /**
3916
+ * 维护卡一段维护的**泛型状态机**:报告 / 失败 / 中性提示 / 确认框 / 在飞档位 / 最新票 / 清完必重查。
3917
+ * 孤儿会话与孤儿附件曾是这台机器按前缀各抄一份的两段——这里收成一台,两段各自只带自己的
3918
+ * 远程调用、判据与拼句(`use-maintenance.ts` 两次实例化)。
3919
+ *
3920
+ * 三条承重规矩(ADR-0021)在这一层保证,两段都不可绕过:
3921
+ * - **没有自动扫描**:本 module 不挂 effect、没有定时器;挂载只交初始态(报告 `null` = 尚未检查)。
3922
+ * - **读不到就说读不到**:检查失败回 `null` + 原文(`scanFailureText` 由各段拼),不折成"没有发现"。
3923
+ * - **清理后必重查**:清完立刻重读;回执句子由 `settle` 拼好后**等重查赢了才落地**——顺序反了,
3924
+ * 重查成功会清掉旧提示,把这一趟的后果吞掉。
3925
+ *
3926
+ * **不变量由构造保证**:开确认框只有 `openPreview()` 一条路(先清旧失败与旧提示再开),收框只有
3927
+ * `cancel()`(只收框,不碰提示 / 失败);界面上写不出"裸把 confirm 摆 true"这种开门,于是
3928
+ * "确认框开着、上一趟的失败句还挂着"的组合从类型上就不存在。`busy` 同理由段自己给
3929
+ * (在飞即忙),不再作为 prop 逐层传。
3930
+ *
3931
+ * 动作是每渲染重建的普通闭包、不包 `useCallback`:config 每渲染本来就要重建,包了也稳定不下来;
3932
+ * 这些动作只被点击触发,没有按身份依赖它们的 effect(`useIdleClose` 只在调用时读它们)。
3933
+ */
3934
+ function useMaintenanceSection(config) {
3935
+ const [report, setReport] = (0, react.useState)(null);
3936
+ const [failure, setFailure] = (0, react.useState)(null);
3937
+ const [notice, setNotice] = (0, react.useState)(null);
3938
+ const [confirm, setConfirm] = (0, react.useState)(false);
3939
+ const [phase, setPhase] = (0, react.useState)(null);
3940
+ const [latest] = (0, react.useState)(createLatestWins);
3941
+ /**
3942
+ * 一次检查(不碰 phase)。交回这张票是否仍是赢家:清理后的重查被后发的检查顶掉时,
3943
+ * 由赢家负责把读数与 busy 一起收尾,输家什么都不落地。**输家不许替赢家清 `phase`**——phase 归
3944
+ * 赢下最新票的那一趟收尾,输家提前清会在赢家还在飞时把界面摆成空闲(忙碌收尾与
3945
+ * `use-trash-query.ts` 的空闲判定是两套写法,判据不同源,别互相抄)。
3946
+ */
3947
+ async function rescan() {
3948
+ const ticket = latest.issue();
3949
+ const outcome = await config.scan();
3950
+ if (!ticket.current) return false;
3951
+ config.onScanWon?.();
3952
+ if (outcome.ok) {
3953
+ setReport(outcome.value);
3954
+ setFailure(null);
3955
+ setNotice(null);
3956
+ return true;
3957
+ }
3958
+ setReport(null);
3959
+ setNotice(null);
3960
+ setConfirm(false);
3961
+ setFailure(config.scanFailureText(outcome.failure.message));
3962
+ return true;
3963
+ }
3964
+ async function scan() {
3965
+ setPhase("scanning");
3966
+ if (await rescan()) setPhase(null);
3967
+ }
3968
+ async function clean() {
3969
+ const before = report;
3970
+ if (before === null) {
3971
+ setConfirm(false);
3972
+ return;
3973
+ }
3974
+ const pending = config.clean(before);
3975
+ if (pending === null) {
3976
+ setConfirm(false);
3977
+ return;
3978
+ }
3979
+ setPhase("cleaning");
3980
+ setFailure(null);
3981
+ setNotice(null);
3982
+ const outcome = await pending;
3983
+ setConfirm(false);
3984
+ const preset = config.beforeRescan?.(outcome);
3985
+ if (preset !== null && preset !== void 0) setReport(preset);
3986
+ if (!await rescan()) return;
3987
+ const plan = config.settle(outcome, before);
3988
+ if (plan.notice !== void 0) setNotice(plan.notice);
3989
+ if (plan.failure !== void 0) setFailure(plan.failure);
3990
+ setPhase(null);
3991
+ }
3992
+ function openPreview() {
3993
+ setFailure(null);
3994
+ setNotice(null);
3995
+ setConfirm(true);
3996
+ }
3997
+ function cancel() {
3998
+ setConfirm(false);
3999
+ }
4000
+ return {
4001
+ report,
4002
+ failure,
4003
+ notice,
4004
+ confirm,
4005
+ phase,
4006
+ busy: phase !== null,
4007
+ scan,
4008
+ openPreview,
4009
+ clean,
4010
+ cancel
4011
+ };
4012
+ }
4013
+ //#endregion
3573
4014
  //#region src/client/use-maintenance.ts
3574
4015
  /**
3575
- * 插件设置页「维护」卡的手动编排:孤儿会话 + 孤儿附件,各一组读数、提示与动作。
4016
+ * 插件设置页「维护」卡的手动编排:孤儿会话 + 孤儿附件两段。
3576
4017
  *
3577
- * 三条承重规矩:
3578
- * - **没有自动扫描**:本 module 不挂 `useEffect`、没有定时器;只有用户点「检查 / 查看并清理 / 清理」
3579
- * 才发请求。挂载只交初始态(报告 `null` = 尚未检查)。
3580
- * - **读不到就说读不到**:两份报告失败都回 `null` + 原文,不折成「没有发现」。
3581
- * - **清理后必重查**:报告说的是"报告那一刻"的盘;清完立刻重读。回执里的中性 / 失败 / 部分计数
3582
- * 先读出来、重查之后再上屏——重查成功会清掉旧提示,顺序反了会把这一趟的后果吞掉。
4018
+ * 状态机本身(报告 / 失败 / 中性提示 / 确认框 / 在飞档位 / 最新票 / 清完必重查)收在
4019
+ * `src/client/use-maintenance-section.ts` 的泛型段 module 里,两段各实例化一次;这一层只剩薄 glue:
4020
+ * 各段自己的远程调用、"没活可干"的判据与回执拼句。三条承重规矩(没有自动扫描 / 挂载只交初始态 /
4021
+ * 没有定时器)由段 module 保证,这一层同样不挂 effect。
3583
4022
  *
3584
4023
  * 写动作走既有的两段:孤儿会话是 `/apply` 的 `orphan-clean`(回执判读复用 `receipt.ts` 的读法),
3585
4024
  * 孤儿附件是聚合的 `POST /attachment-clean`(回执就是 `AttachmentCleanupWire` 本身)。
@@ -3603,178 +4042,63 @@ window.__ModuleLoader__.load({
3603
4042
  size: fileSizeText(a.freedBytes)
3604
4043
  }) : null;
3605
4044
  }
4045
+ /** orphan-clean 回执里的"没清成"句:服务端原文 + 部分计数("删了一半才炸"的那部分释放量)。
4046
+ * 首条失败由调用方在判过非空之后作为参数交进来——签名因此写明前提,函数体不必再赌 `failed[0]` 在。 */
4047
+ function orphanCleanFailureSentence(summary, first, t) {
4048
+ const message = first.error.message;
4049
+ return `${t("trash.orphans.cleanFailed", {
4050
+ count: summary.failed.length,
4051
+ message
4052
+ })}${summary.partial.removed > 0 || summary.partial.freedBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
4053
+ removed: summary.partial.removed,
4054
+ size: fileSizeText(summary.partial.freedBytes)
4055
+ })}` : ""}`;
4056
+ }
3606
4057
  function useMaintenance({ deps, t }) {
3607
- const [orphanReport, setOrphanReport] = (0, react.useState)(null);
3608
- const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
3609
- const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
3610
4058
  const [orphanAttachmentNotice, setOrphanAttachmentNotice] = (0, react.useState)(null);
3611
- const [orphanConfirm, setOrphanConfirm] = (0, react.useState)(false);
3612
- const [orphanPhase, setOrphanPhase] = (0, react.useState)(null);
3613
- const orphanBusy = orphanPhase !== null;
3614
- const [orphanLatest] = (0, react.useState)(createLatestWins);
3615
- /**
3616
- * 一次孤儿检查(不碰 busy / phase)。交回这张票是否仍是赢家:清理后的重查被后发的检查顶掉时,
3617
- * 由赢家负责把读数与 busy 一起收尾,输家什么都不落地。
3618
- */
3619
- const rescanOrphans = (0, react.useCallback)(async () => {
3620
- const ticket = orphanLatest.issue();
3621
- const outcome = await callRemote(() => deps.scanOrphans());
3622
- if (!ticket.current) return false;
3623
- setOrphanAttachmentNotice(null);
3624
- if (!outcome.ok) {
3625
- setOrphanReport(null);
3626
- setOrphanNotice(null);
3627
- setOrphanConfirm(false);
3628
- setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
3629
- return true;
3630
- }
3631
- setOrphanReport(outcome.value);
3632
- setOrphanFailure(null);
3633
- setOrphanNotice(null);
3634
- return true;
3635
- }, [
3636
- deps,
3637
- orphanLatest,
3638
- t
3639
- ]);
3640
- const scanOrphans = (0, react.useCallback)(async () => {
3641
- setOrphanPhase("scanning");
3642
- if (await rescanOrphans()) setOrphanPhase(null);
3643
- }, [rescanOrphans]);
3644
- const cleanOrphans = (0, react.useCallback)(async () => {
3645
- const report = orphanReport;
3646
- if (report === null || report.roots.length === 0) {
3647
- setOrphanConfirm(false);
3648
- return;
3649
- }
3650
- const roots = report.roots;
3651
- setOrphanPhase("cleaning");
3652
- setOrphanFailure(null);
3653
- setOrphanNotice(null);
3654
- setOrphanAttachmentNotice(null);
3655
- const outcome = await callRemote(() => deps.applyOutcomes("orphan-clean", roots));
3656
- setOrphanConfirm(false);
3657
- if (!outcome.ok) {
3658
- if (!await rescanOrphans()) return;
3659
- setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
3660
- setOrphanPhase(null);
3661
- return;
3662
- }
3663
- const { summary } = summarizeReceipt(readReceipt("orphan-clean", roots, outcome.value));
3664
- if (!await rescanOrphans()) return;
3665
- if (summary.neutralIds.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: summary.neutralIds.length }));
3666
- if (summary.failed.length > 0) {
3667
- const message = summary.failed[0].error.message;
3668
- const base = t("trash.orphans.cleanFailed", {
3669
- count: summary.failed.length,
3670
- message
3671
- });
3672
- const partial = summary.partial.removed > 0 || summary.partial.freedBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
3673
- removed: summary.partial.removed,
3674
- size: fileSizeText(summary.partial.freedBytes)
3675
- })}` : "";
3676
- setOrphanFailure(`${base}${partial}`);
3677
- }
3678
- if (summary.attachmentCleanup !== void 0) setOrphanAttachmentNotice(attachmentCleanupNotice(summary.attachmentCleanup, t));
3679
- setOrphanPhase(null);
3680
- }, [
3681
- deps,
3682
- orphanReport,
3683
- rescanOrphans,
3684
- t
3685
- ]);
3686
- const openOrphanPreview = (0, react.useCallback)(() => {
3687
- setOrphanFailure(null);
3688
- setOrphanNotice(null);
3689
- setOrphanConfirm(true);
3690
- }, []);
3691
- const [attachmentReport, setAttachmentReport] = (0, react.useState)(null);
3692
- const [attachmentFailure, setAttachmentFailure] = (0, react.useState)(null);
3693
- const [attachmentNotice, setAttachmentNotice] = (0, react.useState)(null);
3694
- const [attachmentConfirm, setAttachmentConfirm] = (0, react.useState)(false);
3695
- const [attachmentPhase, setAttachmentPhase] = (0, react.useState)(null);
3696
- const attachmentBusy = attachmentPhase !== null;
3697
- const [attachmentLatest] = (0, react.useState)(createLatestWins);
3698
- const rescanAttachments = (0, react.useCallback)(async () => {
3699
- const ticket = attachmentLatest.issue();
3700
- const outcome = await callRemote(() => deps.scanAttachmentOrphans());
3701
- if (!ticket.current) return false;
3702
- if (!outcome.ok) {
3703
- setAttachmentReport(null);
3704
- setAttachmentNotice(null);
3705
- setAttachmentConfirm(false);
3706
- setAttachmentFailure(t("maintenance.attachments.scanFailed", { message: outcome.failure.message }));
3707
- return true;
3708
- }
3709
- setAttachmentReport(outcome.value);
3710
- setAttachmentFailure(null);
3711
- setAttachmentNotice(null);
3712
- return true;
3713
- }, [
3714
- attachmentLatest,
3715
- deps,
3716
- t
3717
- ]);
3718
- const scanAttachmentOrphans = (0, react.useCallback)(async () => {
3719
- setAttachmentPhase("scanning");
3720
- if (await rescanAttachments()) setAttachmentPhase(null);
3721
- }, [rescanAttachments]);
3722
- const cleanAttachmentOrphans = (0, react.useCallback)(async () => {
3723
- const report = attachmentReport;
3724
- if (report === null || report.count === 0) {
3725
- setAttachmentConfirm(false);
3726
- return;
3727
- }
3728
- setAttachmentPhase("cleaning");
3729
- setAttachmentFailure(null);
3730
- setAttachmentNotice(null);
3731
- const outcome = await callRemote(() => deps.cleanAttachmentOrphans());
3732
- setAttachmentConfirm(false);
3733
- if (outcome.ok && outcome.value.failed === 0 && outcome.value.skipped === void 0) setAttachmentReport({
3734
- count: 0,
3735
- totalBytes: 0,
3736
- items: []
3737
- });
3738
- if (!await rescanAttachments()) return;
3739
- if (!outcome.ok) {
3740
- setAttachmentFailure(t("maintenance.attachments.cleanError", { message: outcome.failure.message }));
3741
- setAttachmentPhase(null);
3742
- return;
4059
+ const orphans = useMaintenanceSection({
4060
+ scan: () => callRemote(() => deps.scanOrphans()),
4061
+ onScanWon: () => {
4062
+ setOrphanAttachmentNotice(null);
4063
+ },
4064
+ clean: (report) => {
4065
+ if (report.roots.length === 0) return null;
4066
+ setOrphanAttachmentNotice(null);
4067
+ return callRemote(() => deps.applyOutcomes("orphan-clean", report.roots));
4068
+ },
4069
+ scanFailureText: (message) => t("trash.orphans.failure", { message }),
4070
+ settle: (outcome, report) => {
4071
+ if (!outcome.ok) return { failure: t("trash.orphans.failure", { message: outcome.failure.message }) };
4072
+ const { summary } = summarizeReceipt(readReceipt("orphan-clean", report.roots, outcome.value));
4073
+ if (summary.attachmentCleanup !== void 0) setOrphanAttachmentNotice(attachmentCleanupNotice(summary.attachmentCleanup, t));
4074
+ const plan = {};
4075
+ if (summary.neutralIds.length > 0) plan.notice = t("trash.orphans.cleanGone", { count: summary.neutralIds.length });
4076
+ const firstFailed = summary.failed[0];
4077
+ if (firstFailed !== void 0) plan.failure = orphanCleanFailureSentence(summary, firstFailed, t);
4078
+ return plan;
3743
4079
  }
3744
- setAttachmentNotice(attachmentCleanupNotice(outcome.value, t));
3745
- setAttachmentPhase(null);
3746
- }, [
3747
- attachmentReport,
3748
- deps,
3749
- rescanAttachments,
3750
- t
3751
- ]);
4080
+ });
4081
+ const attachments = useMaintenanceSection({
4082
+ scan: () => callRemote(() => deps.scanAttachmentOrphans()),
4083
+ clean: (report) => report.count === 0 ? null : callRemote(() => deps.cleanAttachmentOrphans()),
4084
+ beforeRescan: (outcome) => {
4085
+ if (!outcome.ok) return null;
4086
+ const receipt = outcome.value;
4087
+ return receipt.failed === 0 && receipt.skipped === void 0 ? {
4088
+ count: 0,
4089
+ totalBytes: 0,
4090
+ items: []
4091
+ } : null;
4092
+ },
4093
+ scanFailureText: (message) => t("maintenance.attachments.scanFailed", { message }),
4094
+ settle: (outcome) => outcome.ok ? { notice: attachmentCleanupNotice(outcome.value, t) } : { failure: t("maintenance.attachments.cleanError", { message: outcome.failure.message }) }
4095
+ });
3752
4096
  return {
3753
- orphanReport,
3754
- orphanFailure,
3755
- orphanNotice,
3756
- orphanAttachmentNotice,
3757
- orphanConfirm,
3758
- orphanBusy,
3759
- orphanPhase,
3760
- scanOrphans,
3761
- openOrphanPreview,
3762
- cleanOrphans,
3763
- setOrphanConfirm,
3764
- attachmentReport,
3765
- attachmentFailure,
3766
- attachmentNotice,
3767
- attachmentConfirm,
3768
- attachmentBusy,
3769
- attachmentPhase,
3770
- scanAttachmentOrphans,
3771
- openAttachmentPreview: (0, react.useCallback)(() => {
3772
- setAttachmentFailure(null);
3773
- setAttachmentNotice(null);
3774
- setAttachmentConfirm(true);
3775
- }, []),
3776
- cleanAttachmentOrphans,
3777
- setAttachmentConfirm
4097
+ orphans: {
4098
+ ...orphans,
4099
+ attachmentNotice: orphanAttachmentNotice
4100
+ },
4101
+ attachments
3778
4102
  };
3779
4103
  }
3780
4104
  //#endregion
@@ -3790,7 +4114,14 @@ window.__ModuleLoader__.load({
3790
4114
  padding: "14px 16px"
3791
4115
  };
3792
4116
  /** 一段的标题行:标题 + 说明 + 状态 + 检查按钮。状态按优先级:清理中 / 检查中 / 检查失败 / 尚未检查 / 没有发现 / N。 */
3793
- function MaintenanceSection({ slot, title, hint, status, busy, onScan, t, children }) {
4117
+ function MaintenanceSection({ slot, title, hint, count, countKey, section, onScan, t, children }) {
4118
+ const status = statusText({
4119
+ phase: section.phase,
4120
+ failure: section.failure,
4121
+ count,
4122
+ countKey,
4123
+ t
4124
+ });
3794
4125
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3795
4126
  "data-dsh-session-delete": slot,
3796
4127
  style: {
@@ -3847,7 +4178,7 @@ window.__ModuleLoader__.load({
3847
4178
  variant: "outline",
3848
4179
  size: "sm",
3849
4180
  style: { flex: "none" },
3850
- disabled: busy,
4181
+ disabled: section.busy,
3851
4182
  onClick: onScan,
3852
4183
  children: t("maintenance.scan")
3853
4184
  })
@@ -3871,46 +4202,46 @@ window.__ModuleLoader__.load({
3871
4202
  * 孤儿附件段:报告 >0 时出横幅(数量 + 体积 + 查看并清理);检查/清理失败在原位摆原文 + 重试;
3872
4203
  * 上一趟清理的回执单独一行(已清 / 跳过 / 逐文件失败)。确认框把三条边界说完再动手。
3873
4204
  */
3874
- function AttachmentMaintenance({ query, busy, t }) {
3875
- const { attachmentReport, attachmentFailure, attachmentNotice, attachmentConfirm } = query;
4205
+ function AttachmentMaintenance({ section, t }) {
4206
+ const { report, failure, notice, confirm, busy } = section;
3876
4207
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3877
- !busy && attachmentReport !== null && attachmentReport.count > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceBanner, {
4208
+ !busy && report !== null && report.count > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceBanner, {
3878
4209
  slot: "attachment-banner",
3879
4210
  busy,
3880
4211
  t,
3881
- onOpen: () => query.openAttachmentPreview(),
4212
+ onOpen: () => section.openPreview(),
3882
4213
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3883
4214
  style: {
3884
4215
  fontSize: 13,
3885
4216
  lineHeight: "19px"
3886
4217
  },
3887
4218
  children: [
3888
- t("maintenance.attachments.count", { count: attachmentReport.count }),
4219
+ t("maintenance.attachments.count", { count: report.count }),
3889
4220
  " · ",
3890
- fileSizeText(attachmentReport.totalBytes)
4221
+ fileSizeText(report.totalBytes)
3891
4222
  ]
3892
4223
  })
3893
4224
  }) : null,
3894
- attachmentFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
4225
+ failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3895
4226
  slot: "attachment-failure",
3896
- message: attachmentFailure,
4227
+ message: failure,
3897
4228
  busy,
3898
4229
  t,
3899
- onRetry: () => void query.scanAttachmentOrphans()
4230
+ onRetry: () => void section.scan()
3900
4231
  }) : null,
3901
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: attachmentNotice }),
3902
- attachmentConfirm && attachmentReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentConfirm, {
3903
- count: attachmentReport.count,
3904
- items: attachmentReport.items,
4232
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: notice }),
4233
+ confirm && report !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentConfirm, {
4234
+ count: report.count,
4235
+ items: report.items,
3905
4236
  busy,
3906
4237
  t,
3907
- onCancel: () => query.setAttachmentConfirm(false),
3908
- onConfirm: () => void query.cleanAttachmentOrphans()
4238
+ onCancel: () => section.cancel(),
4239
+ onConfirm: () => void section.clean()
3909
4240
  }) : null
3910
4241
  ] });
3911
4242
  }
3912
4243
  /** 预览 + 确认:先摆出这些图片(点开可放大),再决定清不清;只清 `.dsh` 内无引用的图片副本。
3913
- * `items` 只是预览清单(服务端最多给 200 条),`count` 才是这次要清理的总数。 */
4244
+ * `items` 只是预览清单(服务端按配置给条数,默认最多 200 条),`count` 才是这次要清理的总数。 */
3914
4245
  function AttachmentConfirm({ count, items, busy, t, onCancel, onConfirm }) {
3915
4246
  const { close, closeDisabled } = useIdleClose(busy, onCancel);
3916
4247
  const attachments = items.map((item) => ({
@@ -3993,39 +4324,27 @@ window.__ModuleLoader__.load({
3993
4324
  slot: "maintenance-orphans",
3994
4325
  title: t("maintenance.orphans.title"),
3995
4326
  hint: t("maintenance.orphans.hint"),
3996
- status: statusText({
3997
- phase: maintenance.orphanPhase,
3998
- failure: maintenance.orphanFailure,
3999
- count: maintenance.orphanReport?.totalSessions ?? null,
4000
- countKey: "maintenance.orphans.count",
4001
- t
4002
- }),
4003
- busy: maintenance.orphanBusy,
4004
- onScan: () => void maintenance.scanOrphans(),
4327
+ count: maintenance.orphans.report?.totalSessions ?? null,
4328
+ countKey: "maintenance.orphans.count",
4329
+ section: maintenance.orphans,
4330
+ onScan: () => void maintenance.orphans.scan(),
4005
4331
  t,
4006
4332
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
4007
- query: maintenance,
4008
- busy: maintenance.orphanBusy,
4333
+ section: maintenance.orphans,
4009
4334
  t
4010
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.orphanAttachmentNotice })]
4335
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.orphans.attachmentNotice })]
4011
4336
  }),
4012
4337
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceSection, {
4013
4338
  slot: "maintenance-attachments",
4014
4339
  title: t("maintenance.attachments.title"),
4015
4340
  hint: t("maintenance.attachments.hint"),
4016
- status: statusText({
4017
- phase: maintenance.attachmentPhase,
4018
- failure: maintenance.attachmentFailure,
4019
- count: maintenance.attachmentReport?.count ?? null,
4020
- countKey: "maintenance.attachments.count",
4021
- t
4022
- }),
4023
- busy: maintenance.attachmentBusy,
4024
- onScan: () => void maintenance.scanAttachmentOrphans(),
4341
+ count: maintenance.attachments.report?.count ?? null,
4342
+ countKey: "maintenance.attachments.count",
4343
+ section: maintenance.attachments,
4344
+ onScan: () => void maintenance.attachments.scan(),
4025
4345
  t,
4026
4346
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentMaintenance, {
4027
- query: maintenance,
4028
- busy: maintenance.attachmentBusy,
4347
+ section: maintenance.attachments,
4029
4348
  t
4030
4349
  })
4031
4350
  })
@@ -25997,14 +26316,13 @@ window.__ModuleLoader__.load({
25997
26316
  }
25998
26317
  /**
25999
26318
  * `memo` 是必需的而不是优化:`react-markdown@10.1.0` 里**一处 memoization 都没有**(读实
26000
- * `node_modules/react-markdown/lib/index.js:175-178`——`Markdown()` 每次调用都 `createProcessor(options)`
26319
+ * `node_modules/react-markdown/lib/index.js`——`Markdown()` 每次调用都 `createProcessor(options)`
26001
26320
  * 再 `runSync(parse(file))`,`MarkdownHooks` 那一支也一样),而面板任何一次重渲(悬停、↑↓、
26002
26321
  * 「刚刚删除」那句要读 `Date.now()`)都会走到这里 ⇒ 不按住就是每条助手回复每拍重解析一遍。
26003
- * 按住的条件是 `text` 与**探针 `localeKey`**(调用方在渲染时算好的一句固定键译文 `t('common.close')`)。
26004
- * `t` 本身是宿主 `bind` 出来的**稳定身份**(`copy.ts` 头注),一次客户端激活只造一个——拿身份当判据,
26005
- * 换语言就漏了:同一个 `t` 换 locale 后吐另一套词,烘进正文树的那句图片占位会滞留旧语言,而周围的
26006
- * 标签那一拍已经改口。探针也不能在 comparator 里现调 `t` 取——宿主 `t` 读的是**当前** locale,
26007
- * prev/next 那一拍都取到新值、恒等;只有渲染时算好存进 props 的那份才是换语言那一刻的读数。
26322
+ * 按住的条件是 `text` 与**探针 `probe`**:一句按当前 locale 现算的译文,专给 comparator 当
26323
+ * 「换语言了没」的读数。为什么必须渲染时现算存进 props、为什么宿主 `t` 只能当稳定身份而不是
26324
+ * 语言读数、「借哪句」,这些都住 `copy.ts` 的 `localeProbe`——调用方与测试一律
26325
+ * `probe={localeProbe(t)}`,「借哪句」的知识不散到各调用方。
26008
26326
  * 探针没变 = 同语言同 `t` ⇒ 跳过重解析;变了(换语言或换 `t`)⇒ 重解析。
26009
26327
  */
26010
26328
  const MarkdownBody = (0, react.memo)(function MarkdownBody({ text, t }) {
@@ -26017,7 +26335,7 @@ window.__ModuleLoader__.load({
26017
26335
  children: text
26018
26336
  })
26019
26337
  });
26020
- }, (prev, next) => prev.text === next.text && prev.localeKey === next.localeKey);
26338
+ }, (prev, next) => prev.text === next.text && prev.probe === next.probe);
26021
26339
  //#endregion
26022
26340
  //#region src/client/trash-detail.tsx
26023
26341
  /**
@@ -26026,10 +26344,12 @@ window.__ModuleLoader__.load({
26026
26344
  * 三条界面约束:
26027
26345
  * - 动作(恢复/彻底删除)留在清单行右侧;详情只负责看。
26028
26346
  * - 标题优先用清册标题,空串才回落到日志的 session/title。
26029
- * - 注入条数与截断条数必须上屏,避免正文变少被误读成“会话只有这些内容”。
26347
+ * - 正文是**块**:用户/助手文字常显;思考、工具、注入、系统消息、压缩摘要默认折叠,折叠头给
26348
+ * 标签 + 首行摘要(工具另给状态)。注入也铺进正文——这是对宿主 Chat 的有意偏离(它隐藏普通注入,
26349
+ * 而这里的目标是「删之前看全」);条数另进元信息表。
26030
26350
  *
26031
- * 助手正文走 Markdown,用户提问保持原话;样式 token 与差异见 ui/markdown.css 文件头。
26032
- * 附件随消息位置渲染:用户气泡之上、助手正文之后;附件是那条消息的内容,不单开区块。
26351
+ * 助手正文与思考走 Markdown,用户提问保持原话;工具参数/结果走等宽纯文本(限高可滚)。
26352
+ * 附件随块位置渲染:用户气泡之上、助手正文之后、工具展开体末尾。
26033
26353
  */
26034
26354
  const SHELL = {
26035
26355
  display: "flex",
@@ -26039,11 +26359,24 @@ window.__ModuleLoader__.load({
26039
26359
  height: "100%",
26040
26360
  minWidth: 0
26041
26361
  };
26362
+ /** 返回键排在标题之后:排在之前会把整条标题的起点往右挤(docs/ui-spec.md 状态⑥)。
26363
+ * 标题排按紧凑按钮(`ui.css` 的 `.sd-button-sm`)预留高度:宽档父详情没有返回键、孩子视图有,
26364
+ * 不预留时下方元信息会在父子切换时跳 6px。 */
26042
26365
  const HEAD = {
26043
26366
  display: "flex",
26044
26367
  alignItems: "flex-start",
26045
- gap: 10
26368
+ gap: 10,
26369
+ minHeight: 28
26046
26370
  };
26371
+ const TITLE_LINE = {
26372
+ ...ELLIPSIS,
26373
+ flex: "1 1 auto",
26374
+ minWidth: 0,
26375
+ fontSize: 15,
26376
+ fontWeight: 600,
26377
+ lineHeight: "22px"
26378
+ };
26379
+ const BACK = { flex: "none" };
26047
26380
  /** 元信息那张表:label 一列、值一列,窄档也不横向溢出。 */
26048
26381
  const META = {
26049
26382
  display: "grid",
@@ -26123,6 +26456,107 @@ window.__ModuleLoader__.load({
26123
26456
  border: "0.5px solid var(--dsw-alias-border-l4)",
26124
26457
  borderRadius: "var(--dsw-radius-lg)"
26125
26458
  };
26459
+ const BLOCK = {
26460
+ display: "flex",
26461
+ flexDirection: "column",
26462
+ minWidth: 0
26463
+ };
26464
+ /** 折叠头:与清单组头共用 `sd-disclosure-row` 的布局/悬停/无障碍;这里把 button 的 UA 样式压掉。 */
26465
+ const BLOCK_ROW = {
26466
+ background: "none",
26467
+ border: "none",
26468
+ padding: 0,
26469
+ font: "inherit",
26470
+ textAlign: "start",
26471
+ width: "100%",
26472
+ minWidth: 0,
26473
+ gap: 6
26474
+ };
26475
+ /** 标签不参与收缩:摘要是 `1 1 auto`,标签若也可缩,长结果会把「工具调用 / 来源」挤成几个字。 */
26476
+ const BLOCK_TITLE = {
26477
+ ...ELLIPSIS,
26478
+ flex: "none",
26479
+ maxWidth: "50%",
26480
+ fontWeight: 500,
26481
+ fontSize: "var(--dsh-content-font-size-secondary, 13px)"
26482
+ };
26483
+ const BLOCK_SUMMARY = {
26484
+ ...ELLIPSIS,
26485
+ flex: "1 1 auto",
26486
+ minWidth: 0,
26487
+ fontSize: 12,
26488
+ lineHeight: "18px",
26489
+ color: "var(--dsw-alias-label-tertiary)"
26490
+ };
26491
+ const BLOCK_META = {
26492
+ flex: "none",
26493
+ fontSize: 11.5,
26494
+ lineHeight: "18px",
26495
+ color: "var(--dsw-alias-label-tertiary)",
26496
+ whiteSpace: "nowrap"
26497
+ };
26498
+ const BLOCK_BODY = {
26499
+ display: "flex",
26500
+ flexDirection: "column",
26501
+ gap: 8,
26502
+ padding: "8px 0 4px calc(22px + var(--dsh-content-font-delta, 0px))",
26503
+ minWidth: 0
26504
+ };
26505
+ const PLAIN_BODY = {
26506
+ whiteSpace: "pre-wrap",
26507
+ wordBreak: "break-word",
26508
+ fontSize: "var(--dsh-content-font-size-secondary, 13px)",
26509
+ lineHeight: "20px",
26510
+ color: "var(--dsw-alias-label-secondary)"
26511
+ };
26512
+ /** 工具参数/结果:等宽、保留换行、限高自滚——一段工具输出可能比整页还长。 */
26513
+ const CODE_BLOCK = {
26514
+ margin: 0,
26515
+ padding: "8px 10px",
26516
+ background: "var(--dsw-alias-bg-layer-2)",
26517
+ border: "0.5px solid var(--dsw-alias-border-l4)",
26518
+ borderRadius: "var(--dsw-radius-md)",
26519
+ fontFamily: "var(--ds-font-family-code)",
26520
+ fontSize: 12,
26521
+ lineHeight: "18px",
26522
+ whiteSpace: "pre-wrap",
26523
+ wordBreak: "break-word",
26524
+ maxHeight: 320,
26525
+ overflow: "auto"
26526
+ };
26527
+ const SECTION_LABEL = {
26528
+ fontSize: 11.5,
26529
+ lineHeight: "16px",
26530
+ color: "var(--dsw-alias-label-tertiary)"
26531
+ };
26532
+ /**
26533
+ * 子会话行:折叠头同族的可点行,卡片底 + 小圆角;悬停走 sd-disclosure-row 的宿主配方。
26534
+ * 两处内联覆盖各有理由:宿主类给折叠头钉死行高并随之裁溢出,导航行带内边距,须放开
26535
+ * 高度按内容撑开,否则裁字;指针光标宿主只发给带展开语义的行,导航行不借那套语义,
26536
+ * 光标就内联自给。
26537
+ */
26538
+ const SUBAGENT_ROW = {
26539
+ ...BLOCK_ROW,
26540
+ padding: "6px 10px",
26541
+ height: "auto",
26542
+ cursor: "pointer",
26543
+ background: "var(--dsw-alias-bg-layer-2)",
26544
+ border: "0.5px solid var(--dsw-alias-border-l4)",
26545
+ borderRadius: "var(--dsw-radius-md)"
26546
+ };
26547
+ const TOOLBAR$1 = {
26548
+ display: "flex",
26549
+ alignItems: "center",
26550
+ gap: 8,
26551
+ flexWrap: "wrap",
26552
+ minWidth: 0
26553
+ };
26554
+ const TRUNCATED = {
26555
+ fontSize: 11.5,
26556
+ lineHeight: "16px",
26557
+ color: "var(--dsw-alias-label-tertiary)"
26558
+ };
26559
+ const ERROR_TEXT = { color: "var(--dsw-alias-state-error-primary)" };
26126
26560
  const HOUR = 36e5;
26127
26561
  const DAY = 864e5;
26128
26562
  /** 日志里那两个时间戳缺省时的降级(`src/trash/detail.ts` 把初值写成 0,读不到就是 0)。 */
@@ -26130,11 +26564,13 @@ window.__ModuleLoader__.load({
26130
26564
  return ms === 0 ? "—" : absoluteText(ms);
26131
26565
  }
26132
26566
  /** 活跃跨度:不足一天给小时,超过给天——都是"这条会话活了多久"这一个问题的答案。
26133
- * 两端任一缺省就**不答**:`Math.max(1, …)` 会把"不知道"报成"1 小时",那是一条编出来的账。 */
26567
+ * 两端任一缺省就**不答**(不把"不知道"报成任何量级);不足一小时说"不足 1 小时",
26568
+ * 不把 10 分钟舍入成 1 小时冒充整点。 */
26134
26569
  function spanText(from, to, t) {
26135
26570
  if (from === 0 || to === 0) return void 0;
26136
26571
  const ms = Math.max(0, to - from);
26137
- return ms < DAY ? t("detail.value.spanHours", { n: Math.max(1, Math.round(ms / HOUR)) }) : t("detail.value.spanDays", { n: Math.round(ms / DAY) });
26572
+ if (ms < HOUR) return t("detail.value.spanLessThanHour");
26573
+ return ms < DAY ? t("detail.value.spanHours", { n: Math.round(ms / HOUR) }) : t("detail.value.spanDays", { n: Math.round(ms / DAY) });
26138
26574
  }
26139
26575
  /** 详情头部显示的那句标题:清册优先,空串才回落到日志里的 `session/title`。 */
26140
26576
  function titleOf(entry, detail, t) {
@@ -26142,7 +26578,246 @@ window.__ModuleLoader__.load({
26142
26578
  if (detail !== void 0 && detail.logTitle !== "") return detail.logTitle;
26143
26579
  return t("common.untitled");
26144
26580
  }
26145
- function TrashDetail({ state, busy, wide, onRetry, onBack, t }) {
26581
+ /** 折叠头的摘要:第一行非空文字。 */
26582
+ function firstLine(text) {
26583
+ return (text.split("\n").find((candidate) => candidate.trim() !== "") ?? "").trim();
26584
+ }
26585
+ /** Markdown 块的摘要去掉粗体星号(与宿主 ReasoningRow 同一手法);纯文本块照原样。 */
26586
+ function markdownSummary(text) {
26587
+ return firstLine(text).replaceAll("**", "");
26588
+ }
26589
+ /** 宿主当前发过的 `source.kind` 全在这里;日志词表随宿主代次增长,新值先落到「其他上下文」。 */
26590
+ const INJECTION_LABEL_KEYS = {
26591
+ "runtime-context": "detail.injection.runtimeContext",
26592
+ "time-context": "detail.injection.timeContext",
26593
+ "agent-instructions": "detail.injection.agentInstructions",
26594
+ "skill-catalog": "detail.injection.skillCatalog",
26595
+ "agent-message": "detail.injection.agentMessage",
26596
+ "subagent-settled": "detail.injection.subagentSettled",
26597
+ "tool-jobs": "detail.injection.toolJobs",
26598
+ "skill-invocation": "detail.injection.skillInvocation",
26599
+ "user-approval": "detail.injection.userApproval",
26600
+ "goal": "detail.injection.goal"
26601
+ };
26602
+ /** 来源标签 + 悬停原串:认不出的 kind 收进「其他上下文」,原串留在 title。 */
26603
+ function injectionLabel(kind, t) {
26604
+ const key = INJECTION_LABEL_KEYS[kind];
26605
+ if (key !== void 0) {
26606
+ const text = t(key);
26607
+ return {
26608
+ text,
26609
+ title: text
26610
+ };
26611
+ }
26612
+ return {
26613
+ text: t("detail.injection.other"),
26614
+ title: kind === "" ? t("detail.injection.other") : kind
26615
+ };
26616
+ }
26617
+ function toolStatus(status, t) {
26618
+ if (status === "ok") return t("detail.block.status.ok");
26619
+ if (status === "error") return t("detail.block.status.error");
26620
+ return t("detail.block.status.unfinished");
26621
+ }
26622
+ function isDisclosureBlock(block) {
26623
+ return block.kind !== "user" && block.kind !== "assistant";
26624
+ }
26625
+ /** 一个可折叠块:折叠头一行(chevron + 标签 + 摘要 + 可选状态 + 时间),展开体由调用方给。 */
26626
+ function Disclosure({ block, label, title, summary, status, time, open, onToggle, children }) {
26627
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26628
+ "data-sd-block": block.kind,
26629
+ style: BLOCK,
26630
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
26631
+ type: "button",
26632
+ className: "sd-disclosure-row",
26633
+ "data-expandable": "",
26634
+ "aria-expanded": open,
26635
+ onClick: () => onToggle(block.id),
26636
+ style: BLOCK_ROW,
26637
+ children: [
26638
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26639
+ className: "sd-disclosure-leading",
26640
+ style: { marginRight: 0 },
26641
+ "aria-hidden": "true",
26642
+ children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 })
26643
+ }),
26644
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26645
+ style: BLOCK_TITLE,
26646
+ title: title ?? label,
26647
+ children: label
26648
+ }),
26649
+ summary === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26650
+ style: BLOCK_SUMMARY,
26651
+ title: summary,
26652
+ children: summary
26653
+ }),
26654
+ status === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26655
+ style: status.error ? {
26656
+ ...BLOCK_META,
26657
+ ...ERROR_TEXT
26658
+ } : BLOCK_META,
26659
+ children: status.text
26660
+ }),
26661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26662
+ style: BLOCK_META,
26663
+ children: time
26664
+ })
26665
+ ]
26666
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26667
+ "data-sd-block-body": true,
26668
+ style: BLOCK_BODY,
26669
+ children
26670
+ }) : null]
26671
+ });
26672
+ }
26673
+ /** 单块载荷闸截断后的提示:各块共用同一句,别有的画省略号、有的只画字。 */
26674
+ function TruncatedNote({ t }) {
26675
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26676
+ style: TRUNCATED,
26677
+ children: t("detail.block.truncated")
26678
+ });
26679
+ }
26680
+ /** 展开体走 Markdown 的折叠块(思考、压缩摘要)。 */
26681
+ function markdownFold(block, label, context) {
26682
+ const { open, toggle, probe, t } = context;
26683
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
26684
+ block,
26685
+ label,
26686
+ summary: markdownSummary(block.text),
26687
+ time: timeText(block.time),
26688
+ open: open.has(block.id),
26689
+ onToggle: toggle,
26690
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
26691
+ text: block.text,
26692
+ t,
26693
+ probe
26694
+ }), block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null]
26695
+ }, block.id);
26696
+ }
26697
+ /** 展开体走等宽原文的折叠块(注入、系统消息):这两类带附件。 */
26698
+ function plainFold(block, label, context, title) {
26699
+ const { open, toggle, t } = context;
26700
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
26701
+ block,
26702
+ label,
26703
+ title,
26704
+ summary: firstLine(block.text),
26705
+ time: timeText(block.time),
26706
+ open: open.has(block.id),
26707
+ onToggle: toggle,
26708
+ children: [
26709
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26710
+ style: PLAIN_BODY,
26711
+ children: block.text
26712
+ }),
26713
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26714
+ attachments: block.attachments,
26715
+ t,
26716
+ align: "start"
26717
+ }),
26718
+ block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
26719
+ ]
26720
+ }, block.id);
26721
+ }
26722
+ /** 块联合新增成员而 `renderBlock` 没跟上时这里编译失败;运行时只可能是前后半版本错配,画空不炸整栏。 */
26723
+ function unhandledBlock(block) {
26724
+ return null;
26725
+ }
26726
+ function renderBlock(block, context) {
26727
+ const { open, toggle, probe, t } = context;
26728
+ switch (block.kind) {
26729
+ case "user": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26730
+ "data-sd-msg": "user",
26731
+ style: USER_ROW,
26732
+ children: [
26733
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26734
+ style: META_LINE,
26735
+ children: `${t("detail.role.user")} · ${timeText(block.time)}`
26736
+ }),
26737
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26738
+ attachments: block.attachments,
26739
+ t,
26740
+ align: "end"
26741
+ }),
26742
+ block.text === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26743
+ "data-sd-bubble": true,
26744
+ style: BUBBLE,
26745
+ children: block.text
26746
+ }),
26747
+ block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
26748
+ ]
26749
+ }, block.id);
26750
+ case "assistant": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26751
+ "data-sd-msg": "assistant",
26752
+ style: ASSISTANT_ROW,
26753
+ children: [
26754
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26755
+ style: META_LINE,
26756
+ children: `${t("detail.role.assistant")} · ${timeText(block.time)}`
26757
+ }),
26758
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
26759
+ text: block.text,
26760
+ t,
26761
+ probe
26762
+ }),
26763
+ block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null,
26764
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26765
+ attachments: block.attachments,
26766
+ t,
26767
+ align: "start"
26768
+ })
26769
+ ]
26770
+ }, block.id);
26771
+ case "reasoning": return markdownFold(block, t("detail.block.reasoning"), context);
26772
+ case "tool": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
26773
+ block,
26774
+ label: block.name === "" ? t("detail.block.tool") : block.name,
26775
+ summary: firstLine(block.result) || firstLine(block.args),
26776
+ status: {
26777
+ text: toolStatus(block.status, t),
26778
+ error: block.status === "error"
26779
+ },
26780
+ time: timeText(block.time),
26781
+ open: open.has(block.id),
26782
+ onToggle: toggle,
26783
+ children: [
26784
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26785
+ style: SECTION_LABEL,
26786
+ children: t("detail.block.args")
26787
+ }),
26788
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
26789
+ style: CODE_BLOCK,
26790
+ children: block.args === "" ? "—" : block.args
26791
+ }),
26792
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26793
+ style: SECTION_LABEL,
26794
+ children: t("detail.block.result")
26795
+ }),
26796
+ block.status === "unfinished" && block.result === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26797
+ style: META_LINE,
26798
+ children: t("detail.block.noResult")
26799
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
26800
+ style: CODE_BLOCK,
26801
+ children: block.result === "" ? "—" : block.result
26802
+ }),
26803
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26804
+ attachments: block.attachments,
26805
+ t,
26806
+ align: "start"
26807
+ }),
26808
+ block.argsTruncated || block.resultTruncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
26809
+ ]
26810
+ }, block.id);
26811
+ case "injection": {
26812
+ const label = injectionLabel(block.sourceKind, t);
26813
+ return plainFold(block, `${t("detail.block.injection")} · ${label.text}`, context, `${t("detail.block.injection")} · ${label.title}`);
26814
+ }
26815
+ case "system": return plainFold(block, t("detail.block.system"), context);
26816
+ case "compaction": return markdownFold(block, t("detail.block.compaction"), context);
26817
+ default: return unhandledBlock(block);
26818
+ }
26819
+ }
26820
+ function TrashDetail({ state, busy, wide, onRetry, onBack, detailNav, pager, t }) {
26146
26821
  if (state.kind === "idle") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26147
26822
  style: {
26148
26823
  display: "flex",
@@ -26159,29 +26834,32 @@ window.__ModuleLoader__.load({
26159
26834
  });
26160
26835
  const entry = state.entry;
26161
26836
  const detail = state.kind === "ready" ? state.detail : void 0;
26837
+ const title = titleOf(entry, detail, t);
26162
26838
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26163
26839
  style: SHELL,
26164
26840
  children: [
26165
26841
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26842
+ "data-dsh-session-delete": "trash-detail-head",
26166
26843
  style: HEAD,
26167
- children: [wide ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26844
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26845
+ style: TITLE_LINE,
26846
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26847
+ title,
26848
+ children: title
26849
+ })
26850
+ }), detailNav.parentContext !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26168
26851
  variant: "ghost",
26169
26852
  size: "sm",
26170
- style: { flex: "none" },
26853
+ style: BACK,
26854
+ onClick: detailNav.parentContext.onBack,
26855
+ title: detailNav.parentContext.title === "" ? t("common.untitled") : detailNav.parentContext.title,
26856
+ children: t("detail.backToParent")
26857
+ }) : wide ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26858
+ variant: "ghost",
26859
+ size: "sm",
26860
+ style: BACK,
26171
26861
  onClick: onBack,
26172
26862
  children: t("detail.back")
26173
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26174
- style: { minWidth: 0 },
26175
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26176
- style: {
26177
- ...ELLIPSIS,
26178
- fontSize: 15,
26179
- fontWeight: 600,
26180
- lineHeight: "22px"
26181
- },
26182
- title: titleOf(entry, detail, t),
26183
- children: titleOf(entry, detail, t)
26184
- })
26185
26863
  })]
26186
26864
  }),
26187
26865
  state.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoadingBody, { t }) : null,
@@ -26236,8 +26914,10 @@ window.__ModuleLoader__.load({
26236
26914
  detail === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReadyBody, {
26237
26915
  entry,
26238
26916
  detail,
26917
+ detailNav,
26918
+ pager,
26239
26919
  t
26240
- })
26920
+ }, entry.id)
26241
26921
  ]
26242
26922
  });
26243
26923
  }
@@ -26281,17 +26961,60 @@ window.__ModuleLoader__.load({
26281
26961
  })] });
26282
26962
  }
26283
26963
  /**
26284
- * 有内容:元信息(时间 → 体量 → 位置三组的顺序)+ 正文。
26964
+ * 有内容:元信息(时间 → 体量 → 位置三组的顺序)+ 子会话段 + 折叠块。
26285
26965
  *
26286
- * 页面上**两本账分开读**:`提问`/`回复` 是过滤后的条数(注入那些由 `injectedCount` 单独说清,不并进
26287
- * 这两个数),**不受载荷闸影响**;正文是闸过的那一份(单条超限截断、累计到顶后整条不显示)。
26288
- * 于是「计数 421、正文 200」可以同时成立——怎么过滤、两个闸各是多少归 `src/trash/detail.ts`,这里只摆数。
26966
+ * 页面上**两本账分开读**:元信息里的计数是整条日志的(注入条数也在表里);
26967
+ * 正文是分页取回来的块。`olderCursor` 非空时顶部出「加载更早」。
26968
+ * 折叠状态只活在这个组件里;切条目用 `key={entry.id}` 重挂载,不会把 A 的展开状态带到 B。
26289
26969
  */
26290
- function ReadyBody({ entry, detail, t }) {
26291
- const folder = entry.id;
26970
+ function ReadyBody({ entry, detail, detailNav, pager, t }) {
26971
+ const { loadingOlder, olderFailure, onLoadOlder, scrollRef } = pager;
26972
+ const [open, setOpen] = (0, react.useState)(() => /* @__PURE__ */ new Set());
26973
+ /** 更早一页要 prepend 时的块数;换页回来(组件重挂载)这份意图会丢,那是可接受的边界。 */
26974
+ const pendingScroll = (0, react.useRef)(null);
26975
+ /** 上一次布局后的滚动高度:合并更早一页时用它算**真正插进来的高度**,不把请求在飞期间的
26976
+ * 滚动或折叠变化也算进去。 */
26977
+ const lastHeight = (0, react.useRef)(null);
26978
+ const probe = localeProbe(t);
26292
26979
  const project = detail.cwd === "" ? t("trash.project.unknown") : detail.cwd;
26293
- const localeKey = t("common.close");
26294
- const messages = detail.messages.filter((message) => message.text !== "" || message.attachments.length > 0);
26980
+ const disclosureBlocks = detail.blocks.filter(isDisclosureBlock);
26981
+ const allOpen = disclosureBlocks.length > 0 && disclosureBlocks.every((block) => open.has(block.id));
26982
+ const openChild = detailNav.onOpenChild;
26983
+ const toggle = (id) => {
26984
+ setOpen((current) => {
26985
+ const next = new Set(current);
26986
+ if (next.has(id)) next.delete(id);
26987
+ else next.add(id);
26988
+ return next;
26989
+ });
26990
+ };
26991
+ const toggleAll = () => {
26992
+ setOpen(allOpen ? /* @__PURE__ */ new Set() : new Set(disclosureBlocks.map((block) => block.id)));
26993
+ };
26994
+ const loadOlder = () => {
26995
+ const element = scrollRef?.current;
26996
+ if (element !== null && element !== void 0) pendingScroll.current = { blocks: detail.blocks.length };
26997
+ onLoadOlder();
26998
+ };
26999
+ (0, react.useLayoutEffect)(() => {
27000
+ const element = scrollRef?.current;
27001
+ const pending = pendingScroll.current;
27002
+ if (element === null || element === void 0 || pending === null) return;
27003
+ if (detail.blocks.length !== pending.blocks) {
27004
+ pendingScroll.current = null;
27005
+ if (lastHeight.current !== null) element.scrollTop += element.scrollHeight - lastHeight.current;
27006
+ return;
27007
+ }
27008
+ if (!loadingOlder) pendingScroll.current = null;
27009
+ }, [
27010
+ detail.blocks.length,
27011
+ loadingOlder,
27012
+ scrollRef
27013
+ ]);
27014
+ (0, react.useLayoutEffect)(() => {
27015
+ const element = scrollRef?.current;
27016
+ if (element !== null && element !== void 0) lastHeight.current = element.scrollHeight;
27017
+ });
26295
27018
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
26296
27019
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", {
26297
27020
  style: META,
@@ -26335,6 +27058,10 @@ window.__ModuleLoader__.load({
26335
27058
  label: t("detail.field.toolCalls"),
26336
27059
  children: String(detail.toolCallCount)
26337
27060
  }),
27061
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
27062
+ label: t("detail.field.injections"),
27063
+ children: String(detail.injectedCount)
27064
+ }),
26338
27065
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26339
27066
  label: t("detail.field.models"),
26340
27067
  children: detail.models.join(", ") || "—"
@@ -26351,8 +27078,8 @@ window.__ModuleLoader__.load({
26351
27078
  }),
26352
27079
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26353
27080
  label: t("detail.field.trashDir"),
26354
- title: folder,
26355
- children: folder
27081
+ title: entry.id,
27082
+ children: entry.id
26356
27083
  }),
26357
27084
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26358
27085
  label: t("detail.field.wasArchived"),
@@ -26360,46 +27087,98 @@ window.__ModuleLoader__.load({
26360
27087
  })
26361
27088
  ]
26362
27089
  }),
27090
+ detail.subagents === void 0 || detail.subagents.length === 0 || openChild === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27091
+ "data-dsh-session-delete": "trash-subagents",
27092
+ style: {
27093
+ display: "flex",
27094
+ flexDirection: "column",
27095
+ gap: 6
27096
+ },
27097
+ children: [
27098
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27099
+ style: SECTION_LABEL,
27100
+ children: t("detail.subagents.header", {
27101
+ n: detail.subagents.length,
27102
+ size: fileSizeText(detail.subagents.reduce((sum, child) => sum + child.sizeBytes, 0))
27103
+ })
27104
+ }),
27105
+ detail.subagents.map((child) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
27106
+ type: "button",
27107
+ className: "sd-disclosure-row",
27108
+ "data-dsh-session-delete": "trash-subagent-row",
27109
+ "data-entry-id": child.id,
27110
+ style: SUBAGENT_ROW,
27111
+ onClick: () => openChild(child),
27112
+ children: [
27113
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27114
+ style: {
27115
+ ...ELLIPSIS,
27116
+ flex: "1 1 auto",
27117
+ minWidth: 0
27118
+ },
27119
+ children: child.title === "" ? t("common.untitled") : child.title
27120
+ }),
27121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27122
+ style: BLOCK_META,
27123
+ children: fileSizeText(child.sizeBytes)
27124
+ }),
27125
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27126
+ style: BLOCK_META,
27127
+ children: agoText(child.deletedAt, Date.now(), t)
27128
+ })
27129
+ ]
27130
+ }, child.id)),
27131
+ detail.subagentsTotal === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27132
+ style: SECTION_LABEL,
27133
+ "data-dsh-session-delete": "trash-subagents-truncated",
27134
+ children: t("detail.subagents.truncated", {
27135
+ shown: detail.subagents.length,
27136
+ total: detail.subagentsTotal
27137
+ })
27138
+ })
27139
+ ]
27140
+ }),
26363
27141
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26364
- style: META_LINE,
26365
- children: [detail.injectedCount > 0 ? t("detail.messages.hidden", { count: detail.injectedCount }) : "", detail.omittedByPayloadCap > 0 ? `${detail.injectedCount > 0 ? " · " : ""}${t("detail.messages.omitted", { count: detail.omittedByPayloadCap })}` : ""]
27142
+ style: TOOLBAR$1,
27143
+ children: [
27144
+ detail.olderCursor === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
27145
+ variant: "outline",
27146
+ size: "sm",
27147
+ disabled: loadingOlder,
27148
+ onClick: loadOlder,
27149
+ children: loadingOlder ? t("detail.loadOlder.loading") : t("detail.loadOlder")
27150
+ }),
27151
+ olderFailure === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27152
+ role: "status",
27153
+ style: {
27154
+ ...META_LINE,
27155
+ minWidth: 0,
27156
+ wordBreak: "break-word"
27157
+ },
27158
+ children: olderFailure
27159
+ }),
27160
+ disclosureBlocks.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27161
+ style: { marginLeft: "auto" },
27162
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
27163
+ variant: "ghost",
27164
+ size: "sm",
27165
+ onClick: toggleAll,
27166
+ children: allOpen ? t("detail.collapseAll") : t("detail.expandAll")
27167
+ })
27168
+ })
27169
+ ]
26366
27170
  }),
26367
- messages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27171
+ detail.blocks.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26368
27172
  style: META_LINE,
26369
27173
  children: t("detail.noBody")
26370
27174
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26371
27175
  style: THREAD,
26372
- children: messages.map((message, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26373
- "data-sd-msg": message.role,
26374
- style: message.role === "user" ? USER_ROW : ASSISTANT_ROW,
26375
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26376
- style: META_LINE,
26377
- children: `${message.role === "user" ? t("detail.role.user") : t("detail.role.assistant")} · ${timeText(message.time)}`
26378
- }), message.role === "user" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26379
- attachments: message.attachments,
26380
- t,
26381
- align: "end"
26382
- }), message.text === "" && !message.truncated ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26383
- "data-sd-bubble": true,
26384
- style: BUBBLE,
26385
- children: [message.text, message.truncated ? "…" : ""]
26386
- })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
26387
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
26388
- text: message.text,
26389
- t,
26390
- localeKey
26391
- }),
26392
- message.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26393
- style: META_LINE,
26394
- children: "…"
26395
- }) : null,
26396
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26397
- attachments: message.attachments,
26398
- t,
26399
- align: "start"
26400
- })
26401
- ] })]
26402
- }, `${message.time}-${index}`))
27176
+ children: detail.blocks.map((block) => renderBlock(block, {
27177
+ open,
27178
+ toggle,
27179
+ probe,
27180
+ t
27181
+ }))
26403
27182
  })
26404
27183
  ] });
26405
27184
  }
@@ -26496,7 +27275,7 @@ window.__ModuleLoader__.load({
26496
27275
  */
26497
27276
  function usePanelWidth(ref) {
26498
27277
  const [width, setWidth] = (0, react.useState)(0);
26499
- (0, react.useEffect)(() => {
27278
+ (0, react.useLayoutEffect)(() => {
26500
27279
  const el = ref.current;
26501
27280
  if (el === null) return;
26502
27281
  setWidth(el.getBoundingClientRect().width);
@@ -26515,7 +27294,7 @@ window.__ModuleLoader__.load({
26515
27294
  //#region src/client/trash-nav.ts
26516
27295
  /**
26517
27296
  * 清单的键盘导航面:方向键走环、Enter/Space 的双重语义、roving tabindex 的落点,
26518
- * 以及窄档下钻里按 Esc 退回清单并把焦点交还给那一行。
27297
+ * 以及窄档下钻里按 Esc 回退——孩子视图开着时先回父详情,再按一次才退出下钻并把焦点交还给那一行。
26519
27298
  *
26520
27299
  * 它不查自己的行、不认行选择器:焦点动作由调用方注入 focusRow,行的 DOM 查询留在面板;
26521
27300
  * 唯一的文档查询是 Esc 让路判据(问 ui/top-layer.ts 当前有没有打开的 dialog/menu)。
@@ -26524,17 +27303,18 @@ window.__ModuleLoader__.load({
26524
27303
  * 判据(界面现状见 docs/ui-spec.md「键盘」):
26525
27304
  * - 方向键走环:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷。
26526
27305
  * - aria-activedescendant 只指向在 DOM 里的行;tab 落点永远有一行拿着。
26527
- * - Esc 只在下钻期间生效;退回后把焦点交还选中行一次;最上层是确认框/菜单时让路。
27306
+ * - Esc 只在下钻期间生效,回退分两程(先收孩子视图、再退下钻);退出下钻后把焦点交还选中行一次;
27307
+ * 最上层是确认框/菜单时让路。
26528
27308
  */
26529
- /** 事件是不是清单自己的:从行(`role="option"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
27309
+ /** 事件是不是清单自己的:从行(`role="row"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
26530
27310
  * 归它们自己。测试里的窄事件没有 `target`,按清单自己的处理。 */
26531
27311
  function isListOwnedEvent(event) {
26532
27312
  const target = event.target;
26533
27313
  if (target == null || target === event.currentTarget) return true;
26534
- return typeof target.getAttribute === "function" && target.getAttribute("role") === "option";
27314
+ return typeof target.getAttribute === "function" && target.getAttribute("role") === "row";
26535
27315
  }
26536
27316
  function useTrashNav(input) {
26537
- const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, exitDrill, focusRow } = input;
27317
+ const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, back, focusRow } = input;
26538
27318
  /** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
26539
27319
  const refocusRow = (0, react.useRef)(null);
26540
27320
  /** 折叠起来的组只是收掉那一列,选中的 id 还留着——所以这条可见性得逐条对账,不能只看非空。 */
@@ -26548,6 +27328,7 @@ window.__ModuleLoader__.load({
26548
27328
  const step = event.key === "ArrowDown" ? 1 : -1;
26549
27329
  const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
26550
27330
  const target = ordered[next];
27331
+ if (target === void 0) return;
26551
27332
  select(target);
26552
27333
  focusRow(target.id);
26553
27334
  return;
@@ -26578,7 +27359,7 @@ window.__ModuleLoader__.load({
26578
27359
  if (event.key !== "Escape" || event.defaultPrevented) return;
26579
27360
  if (hasTopLayer(document)) return;
26580
27361
  refocusRow.current = selectedId;
26581
- exitDrill();
27362
+ back();
26582
27363
  };
26583
27364
  document.addEventListener("keydown", onKey);
26584
27365
  return () => {
@@ -26587,7 +27368,7 @@ window.__ModuleLoader__.load({
26587
27368
  }, [
26588
27369
  drillEnabled,
26589
27370
  drilled,
26590
- exitDrill,
27371
+ back,
26591
27372
  selectedId
26592
27373
  ]);
26593
27374
  (0, react.useEffect)(() => {
@@ -26611,6 +27392,9 @@ window.__ModuleLoader__.load({
26611
27392
  * - 读不到就说读不到:失败进 `unknown`(原文上屏),不折成"空的"。
26612
27393
  * - 附件读数只住在写动作的回执里:清单重读读不出来,所以它单独一条提示、**最新的写赢**。
26613
27394
  *
27395
+ * 输入的引用**不需稳定**:`deps` / `t` / `gate` 经 `useLatest` 归一(判据住 `store.ts`),
27396
+ * 返回的三个回调身份恒稳,可安全进调用方 effect 的依赖。
27397
+ *
26614
27398
  * 无主子会话的检查/清理**不在这一面**:那是插件设置页维护卡的手动动作(`use-maintenance.ts`)。
26615
27399
  * 面板挂载、写后重读与清空都只碰清单,一次都不自动扫(ADR-0021)。
26616
27400
  */
@@ -26639,11 +27423,17 @@ window.__ModuleLoader__.load({
26639
27423
  function useTrashQuery({ deps, t, gate }) {
26640
27424
  const [list, setList] = (0, react.useState)({ status: "loading" });
26641
27425
  const [attachmentNotice, setAttachmentNotice] = (0, react.useState)(null);
27426
+ /** 输入引用归一(判据住 `store.ts` 的 `useLatest`):回调经它读最新输入,身份恒稳。 */
27427
+ const latest = useLatest({
27428
+ deps,
27429
+ t,
27430
+ gate
27431
+ });
26642
27432
  /** 清单的唯一读法:签到一张票再发请求;输给后发的读时这一次什么都不落地(不让旧响应盖掉新状态)。 */
26643
27433
  const [reloadLatest] = (0, react.useState)(createLatestWins);
26644
27434
  const reload = (0, react.useCallback)(async () => {
26645
27435
  const ticket = reloadLatest.issue();
26646
- const outcome = await callRemote(() => deps.list());
27436
+ const outcome = await callRemote(() => latest.current.deps.list());
26647
27437
  if (!ticket.current) return;
26648
27438
  if (!outcome.ok) {
26649
27439
  setList({
@@ -26656,7 +27446,7 @@ window.__ModuleLoader__.load({
26656
27446
  status: "ready",
26657
27447
  rows: outcome.value
26658
27448
  });
26659
- }, [deps, reloadLatest]);
27449
+ }, [reloadLatest]);
26660
27450
  /**
26661
27451
  * 附件读数上屏:`skipped`(这一轮部分或整体没敢清)优先于 `failed`(逐文件真出错)——前者是原因,
26662
27452
  * 后者只是件数。**`removed > 0` 时不许说成"整轮没清"**:那一轮清掉了 `removed` 件,只是没清完,
@@ -26668,21 +27458,20 @@ window.__ModuleLoader__.load({
26668
27458
  setAttachmentNotice(null);
26669
27459
  return;
26670
27460
  }
26671
- const skipped = attachmentSkippedSentence(a, t);
27461
+ const skipped = attachmentSkippedSentence(a, latest.current.t);
26672
27462
  if (skipped !== null) {
26673
27463
  setAttachmentNotice(skipped);
26674
27464
  return;
26675
27465
  }
26676
- setAttachmentNotice(a.failed > 0 ? t("trash.attachments.failed", { count: a.failed }) : null);
26677
- }, [t]);
27466
+ setAttachmentNotice(a.failed > 0 ? latest.current.t("trash.attachments.failed", { count: a.failed }) : null);
27467
+ }, []);
26678
27468
  return {
26679
27469
  list,
26680
27470
  attachmentNotice,
26681
27471
  noteAttachmentCleanup,
26682
- refreshAll: reload,
26683
- refreshAfterWrite: reload,
26684
- emptyTrash: (0, react.useCallback)(() => gate.around(async () => {
26685
- const outcome = await callRemote(() => deps.emptyTrash());
27472
+ reload,
27473
+ emptyTrash: (0, react.useCallback)(() => latest.current.gate.around(async () => {
27474
+ const outcome = await callRemote(() => latest.current.deps.emptyTrash());
26686
27475
  if (!outcome.ok) {
26687
27476
  reloadLatest.invalidate();
26688
27477
  setList({
@@ -26697,8 +27486,6 @@ window.__ModuleLoader__.load({
26697
27486
  noteAttachmentCleanup(outcome.value.attachments);
26698
27487
  return true;
26699
27488
  }), [
26700
- gate,
26701
- deps,
26702
27489
  reload,
26703
27490
  reloadLatest,
26704
27491
  noteAttachmentCleanup
@@ -26708,7 +27495,7 @@ window.__ModuleLoader__.load({
26708
27495
  //#endregion
26709
27496
  //#region src/client/use-trash-panel-controller.ts
26710
27497
  /**
26711
- * 回收站面板状态机:选中、详情缓存、窄档下钻、清单换版清场、宽档自动选中、
27498
+ * 回收站面板状态机:选中、孩子视图、详情缓存、窄档下钻、清单换版清场、宽档自动选中、
26712
27499
  * 失效重读,以及确认后的恢复/批量/清空与失败落点。
26713
27500
  *
26714
27501
  * 关键判据:
@@ -26716,36 +27503,87 @@ window.__ModuleLoader__.load({
26716
27503
  * - 详情成功读数按条目 id 缓存;失败不缓存,重试会重新读。
26717
27504
  * - 选中 id 不在新清单里 ⇒ 丢缓存、清选中、退出下钻;清单读不到(null)不算“没了”。
26718
27505
  * - 自动选中只在 selectedId 为 null 时接管。
26719
- * - 失效通告只触发 refreshAfterWrite();动作成功必须在同一趟里丢详情缓存。
26720
- * - back() 只收下钻,不碰焦点;失败原文不翻译。
27506
+ * - 失效通告与挂载都只触发 reload();动作成功必须在同一趟里丢详情缓存。
27507
+ * - back() 先收孩子视图、再收下钻,不碰焦点;失败原文不翻译。
27508
+ */
27509
+ /**
27510
+ * 「渲染态 + 同步镜像」成对出现时的唯一写口:setter 同步更新 ref。`setState` 要等下一拍,
27511
+ * 而落地判「用户还在不在这一条」(`loadOlder` 的晚到结果)是同一 tick 里的事。写只走 setter——
27512
+ * 再手写一处 `ref.current = x` 就是第二份同步,两处迟早漂移。
27513
+ */
27514
+ function useSyncedRef(initial) {
27515
+ const ref = (0, react.useRef)(initial);
27516
+ const [value, setValue] = (0, react.useState)(initial);
27517
+ return [
27518
+ value,
27519
+ (0, react.useCallback)((next) => {
27520
+ ref.current = next;
27521
+ setValue(next);
27522
+ }, []),
27523
+ ref
27524
+ ];
27525
+ }
27526
+ /**
27527
+ * 在飞句柄:`/detail` 与「更早一页」各持一份。两条时间线各有自己的载体,别混——判重在
27528
+ * **同步**这一侧(`begin`:同一 tick 连点不发第二条;登记按条目,A 在飞不挡 B,也绝不放
27529
+ * 同一条进两条),上屏在 **state** 那一侧(渲染位单槽要等下一拍,只回答「当前这条转不转圈」)。
27530
+ * 置位/清位只走这四个方法,不再一对 state+ref 镜像各写一份同步。
26721
27531
  */
27532
+ function useInFlight() {
27533
+ const flying = (0, react.useRef)(/* @__PURE__ */ new Set());
27534
+ const [shownId, setShownId] = (0, react.useState)(null);
27535
+ const show = (0, react.useCallback)((id) => setShownId(id), []);
27536
+ const begin = (0, react.useCallback)((id) => {
27537
+ if (flying.current.has(id)) return false;
27538
+ flying.current.add(id);
27539
+ return true;
27540
+ }, []);
27541
+ const end = (0, react.useCallback)((id) => {
27542
+ flying.current.delete(id);
27543
+ setShownId((current) => current === id ? null : current);
27544
+ }, []);
27545
+ const clear = (0, react.useCallback)(() => {
27546
+ flying.current.clear();
27547
+ setShownId(null);
27548
+ }, []);
27549
+ return [shownId, (0, react.useMemo)(() => ({
27550
+ show,
27551
+ begin,
27552
+ end,
27553
+ clear
27554
+ }), [
27555
+ show,
27556
+ begin,
27557
+ end,
27558
+ clear
27559
+ ])];
27560
+ }
26722
27561
  function useTrashPanelController(input) {
26723
27562
  const { deps, gate, query, ordered, canAutoSelect } = input;
26724
- const { list, refreshAll, refreshAfterWrite, emptyTrash, noteAttachmentCleanup } = query;
27563
+ const { list, reload, emptyTrash, noteAttachmentCleanup } = query;
26725
27564
  const entries = listView(list).rows;
26726
- const [selectedId, setSelectedId] = (0, react.useState)(null);
27565
+ const [selectedId, setSelectedId, selectedIdRef] = useSyncedRef(null);
27566
+ /** 详情主体可以离清单:孩子视图开着时画的是孩子,`selectedId` 仍指着父行。 */
27567
+ const [childView, setChildView, childViewRef] = useSyncedRef(null);
26727
27568
  const [drilled, setDrilled] = (0, react.useState)(false);
26728
- const [loadingId, setLoadingId] = (0, react.useState)(null);
27569
+ const [detailShown, detailFlight] = useInFlight();
26729
27570
  const [detailFailure, setDetailFailure] = (0, react.useState)(null);
27571
+ const [olderShown, olderFlight] = useInFlight();
27572
+ const [olderFailureState, setOlderFailure] = (0, react.useState)(null);
27573
+ /** 缓存是 ref:合并更早一页后要有一个**必然变化**的 state 把新对象推上屏(极快的响应会把
27574
+ * 分页渲染位的置位/清位合批成净零变化,React 会直接 bail out)。 */
27575
+ const [detailVersion, setDetailVersion] = (0, react.useState)(0);
26730
27576
  const [pending, setPending] = (0, react.useState)(null);
26731
27577
  const [actionFailure, setActionFailure] = (0, react.useState)({});
26732
27578
  /** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
26733
27579
  const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
26734
- /** 挂载那一拍读一次清单,之后失效通告只走写后刷新(两条名字不同,读法是同一条)。 */
26735
- const firstRefresh = (0, react.useRef)(true);
27580
+ /** 失效重读的载体:`reload` 回调经 `useLatest` 读最新那一份(判据住 `store.ts`),
27581
+ * 失效 effect 的判据只有 `revision`——挂载首拍与后续通告现在是同一个动作,不再分两支。 */
27582
+ const refreshers = useLatest({ reload });
26736
27583
  const revision = useTrashRevision();
26737
27584
  (0, react.useEffect)(() => {
26738
- if (firstRefresh.current) {
26739
- firstRefresh.current = false;
26740
- refreshAll();
26741
- return;
26742
- }
26743
- refreshAfterWrite();
26744
- }, [
26745
- refreshAll,
26746
- refreshAfterWrite,
26747
- revision
26748
- ]);
27585
+ refreshers.current.reload();
27586
+ }, [revision]);
26749
27587
  (0, react.useEffect)(() => {
26750
27588
  if (pending !== null && !gate.busy && list.status === "unknown") setPending(null);
26751
27589
  }, [
@@ -26753,40 +27591,108 @@ window.__ModuleLoader__.load({
26753
27591
  list.status,
26754
27592
  pending
26755
27593
  ]);
27594
+ /** 按 entryId 确保详情在缓存里:命中直接摆,没命中读一次(在飞闸挡重复)。
27595
+ * 选中(select)与孩子视图(openChild)共用这一条读路——缓存/在飞/失败都按 entryId 键控,
27596
+ * 谁在屏幕上是 subject 的事,不是读路的事。 */
27597
+ const ensureDetail = (0, react.useCallback)(async (entryId) => {
27598
+ if (detailCache.current.has(entryId)) {
27599
+ detailFlight.show(null);
27600
+ setDetailFailure(null);
27601
+ return;
27602
+ }
27603
+ detailFlight.show(entryId);
27604
+ setDetailFailure(null);
27605
+ if (!detailFlight.begin(entryId)) return;
27606
+ const outcome = await callRemote(() => deps.detail(entryId));
27607
+ detailFlight.end(entryId);
27608
+ if (outcome.ok) detailCache.current.set(entryId, outcome.value);
27609
+ else setDetailFailure({
27610
+ entryId,
27611
+ message: outcome.failure.message
27612
+ });
27613
+ }, [deps, detailFlight]);
26756
27614
  /**
26757
27615
  * 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
26758
27616
  * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
27617
+ * 选清单行就是回清单:孩子视图跟着收掉(详情主体交还给选中那条)。
26759
27618
  */
26760
27619
  const select = (0, react.useCallback)(async (entry, options = {}) => {
27620
+ setChildView(null);
26761
27621
  setSelectedId(entry.id);
27622
+ setOlderFailure(null);
26762
27623
  if (options.drill === true) setDrilled(true);
26763
- if (detailCache.current.has(entry.id)) {
26764
- setLoadingId(null);
26765
- setDetailFailure(null);
27624
+ await ensureDetail(entry.id);
27625
+ }, [ensureDetail]);
27626
+ const openChild = (0, react.useCallback)(async (child) => {
27627
+ setChildView(child);
27628
+ setOlderFailure(null);
27629
+ await ensureDetail(child.id);
27630
+ }, [ensureDetail]);
27631
+ const closeChild = (0, react.useCallback)(() => {
27632
+ setChildView(null);
27633
+ setOlderFailure(null);
27634
+ }, []);
27635
+ /**
27636
+ * 按当前详情的 `olderCursor` 取更早一页,拼到现有块前面。
27637
+ *
27638
+ * 三条判据:没有游标 / 这一条已有更早页在飞 ⇒ 不发请求(同一 tick 连点、或切走再切回都撞
27639
+ * 在飞登记——晚到的第二条注定落不了屏,白打一次往返);用户已经切走或缓存已被换掉 ⇒ 晚到的
27640
+ * 页**不落屏**(A 的更早页不许盖掉 B);失败只走 `olderFailure`,已读到的正文照旧是 ready。
27641
+ */
27642
+ const loadOlder = (0, react.useCallback)(async () => {
27643
+ const entryId = childView?.id ?? selectedId;
27644
+ if (entryId === null) return;
27645
+ const cached = detailCache.current.get(entryId);
27646
+ if (cached === void 0 || cached.olderCursor === null) return;
27647
+ olderFlight.show(entryId);
27648
+ setOlderFailure(null);
27649
+ if (!olderFlight.begin(entryId)) return;
27650
+ const cursor = cached.olderCursor;
27651
+ const outcome = await callRemote(() => deps.detail(entryId, cursor));
27652
+ olderFlight.end(entryId);
27653
+ if ((childViewRef.current?.id ?? selectedIdRef.current) !== entryId) return;
27654
+ const current = detailCache.current.get(entryId);
27655
+ if (current !== cached) return;
27656
+ if (outcome.ok) {
27657
+ detailCache.current.set(entryId, {
27658
+ ...current,
27659
+ blocks: [...outcome.value.blocks, ...current.blocks],
27660
+ olderCursor: outcome.value.olderCursor
27661
+ });
27662
+ setDetailVersion((version) => version + 1);
26766
27663
  return;
26767
27664
  }
26768
- setLoadingId(entry.id);
26769
- setDetailFailure(null);
26770
- const outcome = await callRemote(() => deps.detail(entry.id));
26771
- setLoadingId((current) => current === entry.id ? null : current);
26772
- if (outcome.ok) detailCache.current.set(entry.id, outcome.value);
26773
- else setDetailFailure({
26774
- entryId: entry.id,
27665
+ setOlderFailure({
27666
+ entryId,
26775
27667
  message: outcome.failure.message
26776
27668
  });
26777
- }, [deps]);
27669
+ }, [
27670
+ deps,
27671
+ childView,
27672
+ selectedId,
27673
+ olderFlight
27674
+ ]);
26778
27675
  (0, react.useEffect)(() => {
26779
27676
  if (entries === null || selectedId === null) return;
26780
27677
  if (entries.some((entry) => entry.id === selectedId)) return;
26781
27678
  detailCache.current.delete(selectedId);
26782
27679
  setSelectedId(null);
27680
+ setChildView(null);
27681
+ olderFlight.clear();
26783
27682
  setDrilled(false);
26784
- }, [entries, selectedId]);
27683
+ setOlderFailure(null);
27684
+ }, [
27685
+ entries,
27686
+ selectedId,
27687
+ setSelectedId,
27688
+ olderFlight
27689
+ ]);
26785
27690
  (0, react.useEffect)(() => {
26786
27691
  if (canAutoSelect && drilled) setDrilled(false);
26787
27692
  }, [canAutoSelect, drilled]);
26788
27693
  (0, react.useEffect)(() => {
26789
- if (canAutoSelect && selectedId === null && ordered.length > 0) select(ordered[0]);
27694
+ const first = ordered[0];
27695
+ if (canAutoSelect && selectedId === null && first !== void 0) select(first);
26790
27696
  }, [
26791
27697
  canAutoSelect,
26792
27698
  selectedId,
@@ -26798,6 +27704,34 @@ window.__ModuleLoader__.load({
26798
27704
  * 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
26799
27705
  */
26800
27706
  const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
27707
+ /** 详情现在画谁:孩子视图开着就是孩子,否则是清单选中那条。孩子不在清单里,
27708
+ * 所以它是离清单的导航态——selectedId 不动,清场/自动选中/组动作全部照旧。 */
27709
+ const subject = childView ?? selected;
27710
+ /**
27711
+ * 详情的导航面(ADR-0029 单级判据的唯一实现,住控制器):
27712
+ * - 孩子视图没开(主体是清单选中那条)⇒ `onOpenChild` 在场、`parentContext` 缺席。
27713
+ * 在孩子视图里再开一层会造出第二套导航路径,还会让那颗返回指着孙辈说话(更深一代走清单可达,
27714
+ * 判据住 `src/trash/groups.ts`),所以入口只在这半边交出。
27715
+ * - 孩子视图开着 ⇒ `onOpenChild` 缺席、`parentContext` 交回父:title 是**选中行的原文**
27716
+ * (未命名兜底由视图按 t 处理,控制器不碰翻译),onBack 收孩子视图。
27717
+ * - `selected` 为 null(清单换版把父行收走的那一帧,清场 effect 随后也把孩子视图收掉)
27718
+ * ⇒ `parentContext` 缺席:返回键不能指着一个不存在的父。
27719
+ */
27720
+ const detailNav = (0, react.useMemo)(() => {
27721
+ if (childView === null) return { onOpenChild: (child) => {
27722
+ openChild(child);
27723
+ } };
27724
+ if (selected === null) return {};
27725
+ return { parentContext: {
27726
+ title: selected.title,
27727
+ onBack: closeChild
27728
+ } };
27729
+ }, [
27730
+ childView,
27731
+ selected,
27732
+ openChild,
27733
+ closeChild
27734
+ ]);
26801
27735
  /** 恢复确认框要摆的这一批:按勾选顺序,清单里已经找不到的 id 滤掉
26802
27736
  * (别的窗口刚动过;重读会把勾选也剔掉)。单条与整站确认框不摆条目,各自念自己的那一句。 */
26803
27737
  const pendingEntries = (0, react.useMemo)(() => {
@@ -26806,39 +27740,58 @@ window.__ModuleLoader__.load({
26806
27740
  return pending.ids.map((id) => byId.get(id)).filter((entry) => entry !== void 0);
26807
27741
  }, [entries, pending]);
26808
27742
  const detail = (0, react.useMemo)(() => {
26809
- if (selected === null) return { kind: "idle" };
26810
- if (loadingId === selected.id) return {
27743
+ if (subject === null) return { kind: "idle" };
27744
+ if (detailShown === subject.id) return {
26811
27745
  kind: "loading",
26812
- entry: selected
27746
+ entry: subject
26813
27747
  };
26814
- const cached = detailCache.current.get(selected.id);
27748
+ const cached = detailCache.current.get(subject.id);
26815
27749
  if (cached !== void 0) return {
26816
27750
  kind: "ready",
26817
- entry: selected,
27751
+ entry: subject,
26818
27752
  detail: cached
26819
27753
  };
26820
- if (detailFailure?.entryId === selected.id) return {
27754
+ if (detailFailure?.entryId === subject.id) return {
26821
27755
  kind: "failed",
26822
- entry: selected,
27756
+ entry: subject,
26823
27757
  message: detailFailure.message
26824
27758
  };
26825
27759
  return {
26826
27760
  kind: "loading",
26827
- entry: selected
27761
+ entry: subject
26828
27762
  };
26829
27763
  }, [
26830
- selected,
26831
- loadingId,
26832
- detailFailure
27764
+ subject,
27765
+ detailShown,
27766
+ detailFailure,
27767
+ detailVersion
26833
27768
  ]);
27769
+ /** 分页/失败都只对当前详情主体上屏(孩子视图开着就是孩子);切到别的条目时它们是别人家的旧账。 */
27770
+ const subjectId = subject?.id ?? null;
27771
+ const loadingOlder = subjectId !== null && olderShown === subjectId;
27772
+ const olderFailure = olderFailureState !== null && olderFailureState.entryId === subjectId ? olderFailureState.message : null;
26834
27773
  /** 「重试」只重读当前这一条,不牵清单。 */
26835
27774
  const retry = (0, react.useCallback)(() => {
27775
+ if (childView !== null) {
27776
+ ensureDetail(childView.id);
27777
+ return;
27778
+ }
26836
27779
  if (selected !== null) select(selected);
26837
- }, [selected, select]);
26838
- /** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
27780
+ }, [
27781
+ childView,
27782
+ ensureDetail,
27783
+ selected,
27784
+ select
27785
+ ]);
27786
+ /** 窄档返回/Esc 的两程:孩子视图开着先回父详情(仍下钻),再按一次才退出下钻。
27787
+ * 选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
26839
27788
  const back = (0, react.useCallback)(() => {
27789
+ if (childView !== null) {
27790
+ closeChild();
27791
+ return;
27792
+ }
26840
27793
  setDrilled(false);
26841
- }, []);
27794
+ }, [childView, closeChild]);
26842
27795
  const request = (0, react.useCallback)((action) => {
26843
27796
  setPending(action);
26844
27797
  }, []);
@@ -26856,38 +27809,51 @@ window.__ModuleLoader__.load({
26856
27809
  const clearDetailCache = (0, react.useCallback)((successIds) => {
26857
27810
  for (const id of successIds) detailCache.current.delete(id);
26858
27811
  }, []);
27812
+ /** 清掉这些行的旧失败;没有任何一行挂着失败时原样返回,不触发多余的一拍。 */
27813
+ const clearRowFailures = (0, react.useCallback)((ids) => {
27814
+ setActionFailure((current) => {
27815
+ let changed = false;
27816
+ const next = { ...current };
27817
+ for (const id of ids) if (Object.hasOwn(next, id)) {
27818
+ delete next[id];
27819
+ changed = true;
27820
+ }
27821
+ return changed ? next : current;
27822
+ });
27823
+ }, []);
26859
27824
  /**
26860
27825
  * 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把整趟交给面板恢复座位。
26861
27826
  * 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
26862
27827
  * 失败也重读:组回滚可能自己失败(`details.rollbackFailures`),清单不能继续摆旧行。
26863
- * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次。
27828
+ * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次——再点那一次
27829
+ * 被同步挡回、整趟不跑(座位交回 `undefined`),这一行保持原样,等在飞那一趟的结论。
26864
27830
  * 单条恢复走 `single` 读法,`already-gone` 不可能出现,座位里那条查找兜底在此不会命中。
26865
27831
  * 座位的 refresh 仍按 `WriteOutcome` 给(那只信封里没有附件读数要转交):这里只把它换成"重读"这一个动作,
26866
- * outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021)。
27832
+ * outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021)。座位字段仍叫 `refreshAfterWrite`
27833
+ * (`write-seats.ts` 的信封接口,写座位自己的名字),与清单重读已合一的旧双名无关。
26867
27834
  */
26868
27835
  const restore = (0, react.useCallback)(async (entry) => {
26869
- setActionFailure((current) => {
26870
- const next = { ...current };
26871
- delete next[entry.id];
26872
- return next;
26873
- });
26874
- const failure = (await restoreFromPanel({
27836
+ clearRowFailures([entry.id]);
27837
+ const outcome = await restoreFromPanel({
26875
27838
  deps,
26876
27839
  ids: [entry.id],
26877
27840
  single: true,
26878
27841
  entries,
26879
27842
  gate,
26880
- refreshAfterWrite: () => refreshAfterWrite(),
27843
+ refreshAfterWrite: () => reload(),
26881
27844
  clearDetailCache
26882
- })).failures.get(entry.id);
27845
+ });
27846
+ if (outcome === void 0) return;
27847
+ const failure = outcome.failures.get(entry.id);
26883
27848
  if (failure !== void 0) failRow(entry.id, failure);
26884
27849
  }, [
26885
27850
  clearDetailCache,
27851
+ clearRowFailures,
26886
27852
  deps,
26887
27853
  entries,
26888
27854
  failRow,
26889
27855
  gate,
26890
- refreshAfterWrite
27856
+ reload
26891
27857
  ]);
26892
27858
  /**
26893
27859
  * 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
@@ -26895,10 +27861,11 @@ window.__ModuleLoader__.load({
26895
27861
  * 不能断言哪几条落地:原文保守地铺到每一个 id(别让清单看起来"删成功了"),清单照样重读。
26896
27862
  */
26897
27863
  const confirmApply = (0, react.useCallback)(async (action, ids) => {
27864
+ clearRowFailures(ids);
26898
27865
  const settle = (outcome) => {
26899
27866
  setPending(null);
26900
27867
  if (action === "purge") noteAttachmentCleanup(outcome.ok ? outcome.summary.attachmentCleanup : void 0);
26901
- return refreshAfterWrite();
27868
+ return reload();
26902
27869
  };
26903
27870
  const seat = {
26904
27871
  deps,
@@ -26911,6 +27878,7 @@ window.__ModuleLoader__.load({
26911
27878
  ...seat,
26912
27879
  entries
26913
27880
  });
27881
+ if (outcome === void 0) return;
26914
27882
  if (outcome.failures.size === 0) return;
26915
27883
  setActionFailure((current) => {
26916
27884
  const next = { ...current };
@@ -26919,10 +27887,11 @@ window.__ModuleLoader__.load({
26919
27887
  });
26920
27888
  }, [
26921
27889
  clearDetailCache,
27890
+ clearRowFailures,
26922
27891
  deps,
26923
27892
  entries,
26924
27893
  gate,
26925
- refreshAfterWrite,
27894
+ reload,
26926
27895
  noteAttachmentCleanup
26927
27896
  ]);
26928
27897
  /**
@@ -26937,6 +27906,7 @@ window.__ModuleLoader__.load({
26937
27906
  if (target.kind === "entries") return confirmApply("purge", target.ids);
26938
27907
  if (target.kind === "restore") return confirmApply("restore", target.ids);
26939
27908
  const emptied = await emptyTrash();
27909
+ if (emptied === void 0) return;
26940
27910
  setPending(null);
26941
27911
  if (emptied) detailCache.current.clear();
26942
27912
  }, [
@@ -26948,12 +27918,19 @@ window.__ModuleLoader__.load({
26948
27918
  busy: gate.busy,
26949
27919
  selected,
26950
27920
  selectedId,
27921
+ childView,
27922
+ detailNav,
27923
+ openChild,
27924
+ closeChild,
26951
27925
  drilled,
26952
27926
  detail,
27927
+ loadingOlder,
27928
+ olderFailure,
26953
27929
  pending,
26954
27930
  pendingEntries,
26955
27931
  actionFailure,
26956
27932
  select,
27933
+ loadOlder,
26957
27934
  retry,
26958
27935
  back,
26959
27936
  request,
@@ -27091,6 +28068,9 @@ window.__ModuleLoader__.load({
27091
28068
  const HANDLE_STEP = 20;
27092
28069
  /** 详情正文的收口宽度:中央列再宽也不把文字拉成长线。 */
27093
28070
  const DETAIL_TEXT_MAX_WIDTH = 860;
28071
+ /** 清单 grid 的列数:条目行恒为两格(常态:内容 + 动作;多选态:勾选框占内容前的一格、动作列退场);
28072
+ * 组头只有一格,用 colspan 横跨整行——各行列数一致是 grid 的播报契约(ADR-0028)。 */
28073
+ const LIST_COLUMN_COUNT = 2;
27094
28074
  /**
27095
28075
  * 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
27096
28076
  *
@@ -27177,6 +28157,15 @@ window.__ModuleLoader__.load({
27177
28157
  unit: unitWord(unit, t)
27178
28158
  });
27179
28159
  }
28160
+ /**
28161
+ * 行的 DOM id。entry id 里可能带空白/引号(目录名只挡路径分隔符),直接拼进 HTML id 是非法值、
28162
+ * `aria-activedescendant` 也会失配——所以**在 DOM 这一侧编码**(issue #8 第 7 条):`data-entry-id`
28163
+ * 仍留原始 id,焦点交还按属性值比对不受影响。entry id 本身不许改形:它是回收站目录名,
28164
+ * 恢复要按它反推原目录名放回(`trash/entry.ts` 的往返契约)。
28165
+ */
28166
+ function domRowId(entryId) {
28167
+ return `sd-trash-${encodeURIComponent(entryId)}`;
28168
+ }
27180
28169
  function TrashPanel({ deps, t = zhTranslate }) {
27181
28170
  /** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住 `busy-gate.ts`,查询侧的写动作也吃它。 */
27182
28171
  const gate = useBusyGate();
@@ -27185,11 +28174,13 @@ window.__ModuleLoader__.load({
27185
28174
  t,
27186
28175
  gate
27187
28176
  });
27188
- const { list: listState, refreshAll } = query;
28177
+ const { list: listState, reload } = query;
27189
28178
  const view = listView(listState);
27190
28179
  const entries = view.rows;
27191
28180
  const listRef = (0, react.useRef)(null);
27192
28181
  const rootRef = (0, react.useRef)(null);
28182
+ /** 详情栏的滚动容器:分页 prepend 后按高度差补偿滚动位置(归 `trash-detail.tsx` 消费)。 */
28183
+ const detailScrollRef = (0, react.useRef)(null);
27193
28184
  const width = usePanelWidth(rootRef);
27194
28185
  /**
27195
28186
  * 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
@@ -27211,9 +28202,16 @@ window.__ModuleLoader__.load({
27211
28202
  entries,
27212
28203
  groups
27213
28204
  });
27214
- /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。 */
28205
+ /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。
28206
+ * 按属性值自己比,不把 `entryId` 拼进选择器:id 只挡路径分隔符(`trash/entry.ts`),另一半是
28207
+ * 宿主给的目录名,带引号时选择器直接抛。 */
27215
28208
  const focusRow = (0, react.useCallback)((entryId) => {
27216
- (listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
28209
+ const rows = listRef.current?.querySelectorAll("[data-entry-id]");
28210
+ if (rows === void 0) return;
28211
+ for (const row of rows) if (row.getAttribute("data-entry-id") === entryId) {
28212
+ row.focus();
28213
+ return;
28214
+ }
27217
28215
  }, []);
27218
28216
  /**
27219
28217
  * 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / 写动作与失败落点,整块住在
@@ -27240,7 +28238,7 @@ window.__ModuleLoader__.load({
27240
28238
  drilled: controller.drilled,
27241
28239
  select: controller.select,
27242
28240
  toggleCheck,
27243
- exitDrill: controller.back,
28241
+ back: controller.back,
27244
28242
  focusRow
27245
28243
  });
27246
28244
  const pending = controller.pending;
@@ -27256,14 +28254,19 @@ window.__ModuleLoader__.load({
27256
28254
  /**
27257
28255
  * 清单容器(roving tabindex 的唯一 tab 巢)。`flex:'1 1 auto'` 撑起空态居中的父链;
27258
28256
  * `min-height:auto` 让长清单保持内容高,滚动交给外层。
28257
+ *
28258
+ * ARIA 模式是 **grid** 而不是 listbox(为什么:裁决见 ADR-0028)。只有真有行时才挂 grid
28259
+ * 与它的 aria:失败/加载/空态是一块普通内容,不是「空的 grid」,generic div 不背 aria 名字。
27259
28260
  */
28261
+ const gridActive = view.failure === null && hasEntries;
27260
28262
  const list = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27261
28263
  ref: listRef,
27262
28264
  "data-dsh-session-delete": "trash-list",
27263
- role: "listbox",
27264
- "aria-label": t("detail.list.label"),
27265
- "aria-activedescendant": nav.activeDescendantId === null ? void 0 : `sd-trash-${nav.activeDescendantId}`,
27266
- "aria-multiselectable": selectMode ? true : void 0,
28265
+ role: gridActive ? "grid" : void 0,
28266
+ "aria-label": gridActive ? t("detail.list.label") : void 0,
28267
+ "aria-colcount": gridActive ? LIST_COLUMN_COUNT : void 0,
28268
+ "aria-activedescendant": gridActive && nav.activeDescendantId !== null ? domRowId(nav.activeDescendantId) : void 0,
28269
+ "aria-multiselectable": gridActive && selectMode ? true : void 0,
27267
28270
  tabIndex: -1,
27268
28271
  onKeyDown: nav.onListKeyDown,
27269
28272
  style: {
@@ -27320,7 +28323,7 @@ window.__ModuleLoader__.load({
27320
28323
  variant: "outline",
27321
28324
  size: "sm",
27322
28325
  style: { flex: "none" },
27323
- onClick: () => void refreshAll(),
28326
+ onClick: () => void reload(),
27324
28327
  children: t("common.retry")
27325
28328
  })
27326
28329
  ]
@@ -27376,6 +28379,7 @@ window.__ModuleLoader__.load({
27376
28379
  })
27377
28380
  ]
27378
28381
  }) : groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
28382
+ role: "rowgroup",
27379
28383
  "data-dsh-session-delete": "trash-group",
27380
28384
  "aria-label": group.label,
27381
28385
  style: {
@@ -27384,7 +28388,8 @@ window.__ModuleLoader__.load({
27384
28388
  gap: 8,
27385
28389
  minWidth: 0
27386
28390
  },
27387
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28391
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
28392
+ role: "row",
27388
28393
  "data-dsh-session-delete": "trash-group-row",
27389
28394
  style: {
27390
28395
  display: "flex",
@@ -27392,71 +28397,82 @@ window.__ModuleLoader__.load({
27392
28397
  gap: 6,
27393
28398
  minWidth: 0
27394
28399
  },
27395
- children: [selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
27396
- checked: groupFullyChecked(group),
27397
- indeterminate: groupPartiallyChecked(group),
27398
- label: t("trash.select.group", {
27399
- project: group.label,
27400
- count: group.entries.length
27401
- }),
27402
- onChange: () => {
27403
- toggleGroupCheck(group);
27404
- }
27405
- }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
27406
- type: "button",
27407
- className: "sd-disclosure-row",
27408
- "data-dsh-session-delete": "trash-group-head",
27409
- "data-expandable": "",
27410
- "aria-expanded": !isCollapsed(group.projectDir),
27411
- onClick: () => {
27412
- toggleGroup(group.projectDir);
27413
- },
28400
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28401
+ role: "gridcell",
28402
+ "aria-colspan": LIST_COLUMN_COUNT,
27414
28403
  style: {
27415
- background: "none",
27416
- border: "none",
27417
- padding: 0,
27418
- font: "inherit",
27419
- textAlign: "start",
28404
+ display: "flex",
28405
+ alignItems: "center",
28406
+ gap: 6,
27420
28407
  flex: "1 1 auto",
27421
- gap: 6
28408
+ minWidth: 0
27422
28409
  },
27423
- children: [
27424
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27425
- className: "sd-disclosure-leading",
27426
- "aria-hidden": "true",
27427
- children: isCollapsed(group.projectDir) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 })
27428
- }),
27429
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27430
- style: {
27431
- display: "inline-flex",
27432
- flex: "none",
27433
- color: "var(--dsw-alias-label-tertiary)"
27434
- },
27435
- "aria-hidden": "true",
27436
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFolderOpenOutlineRegular, { size: 13 })
28410
+ children: [selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
28411
+ checked: groupFullyChecked(group),
28412
+ indeterminate: groupPartiallyChecked(group),
28413
+ label: t("trash.select.group", {
28414
+ project: group.label,
28415
+ count: group.entries.length
27437
28416
  }),
27438
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27439
- className: "sd-disclosure-title",
27440
- style: {
27441
- ...ELLIPSIS,
27442
- minWidth: 0,
27443
- flex: "1 1 auto",
27444
- fontWeight: 500
27445
- },
27446
- title: group.projectDir,
27447
- children: group.label
27448
- }),
27449
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27450
- style: {
27451
- marginLeft: "auto",
27452
- flex: "none",
27453
- fontSize: 11.5,
27454
- color: "var(--dsw-alias-label-tertiary)"
27455
- },
27456
- children: t("trash.group.count", { count: group.entries.length })
27457
- })
27458
- ]
27459
- })]
28417
+ onChange: () => {
28418
+ toggleGroupCheck(group);
28419
+ }
28420
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
28421
+ type: "button",
28422
+ className: "sd-disclosure-row",
28423
+ "data-dsh-session-delete": "trash-group-head",
28424
+ "data-expandable": "",
28425
+ "aria-expanded": !isCollapsed(group.projectDir),
28426
+ onClick: () => {
28427
+ toggleGroup(group.projectDir);
28428
+ },
28429
+ style: {
28430
+ background: "none",
28431
+ border: "none",
28432
+ padding: 0,
28433
+ font: "inherit",
28434
+ textAlign: "start",
28435
+ flex: "1 1 auto",
28436
+ gap: 6
28437
+ },
28438
+ children: [
28439
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28440
+ className: "sd-disclosure-leading",
28441
+ "aria-hidden": "true",
28442
+ children: isCollapsed(group.projectDir) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 })
28443
+ }),
28444
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28445
+ style: {
28446
+ display: "inline-flex",
28447
+ flex: "none",
28448
+ color: "var(--dsw-alias-label-tertiary)"
28449
+ },
28450
+ "aria-hidden": "true",
28451
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFolderOpenOutlineRegular, { size: 13 })
28452
+ }),
28453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28454
+ className: "sd-disclosure-title",
28455
+ style: {
28456
+ ...ELLIPSIS,
28457
+ minWidth: 0,
28458
+ flex: "1 1 auto",
28459
+ fontWeight: 500
28460
+ },
28461
+ title: group.projectDir,
28462
+ children: group.label
28463
+ }),
28464
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28465
+ style: {
28466
+ marginLeft: "auto",
28467
+ flex: "none",
28468
+ fontSize: 11.5,
28469
+ color: "var(--dsw-alias-label-tertiary)"
28470
+ },
28471
+ children: t("trash.group.count", { count: group.entries.length })
28472
+ })
28473
+ ]
28474
+ })]
28475
+ })
27460
28476
  }), isCollapsed(group.projectDir) ? null : group.entries.map((entry) => {
27461
28477
  const isSelected = entry.id === controller.selectedId;
27462
28478
  const isChecked = checked.has(entry.id);
@@ -27464,8 +28480,8 @@ window.__ModuleLoader__.load({
27464
28480
  const kids = entry.subagentCount ?? 0;
27465
28481
  const stranded = entry.strandedSubagents ?? 0;
27466
28482
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27467
- id: `sd-trash-${entry.id}`,
27468
- role: "option",
28483
+ id: domRowId(entry.id),
28484
+ role: "row",
27469
28485
  "aria-selected": selectMode ? isChecked : isSelected,
27470
28486
  tabIndex: entry.id === nav.tabStopId ? 0 : -1,
27471
28487
  "data-dsh-session-delete": "trash-row",
@@ -27479,14 +28495,22 @@ window.__ModuleLoader__.load({
27479
28495
  controller.select(entry, { drill: !split });
27480
28496
  },
27481
28497
  children: [
27482
- selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
27483
- checked: isChecked,
27484
- label: t("trash.select.row", { title }),
27485
- onChange: () => {
27486
- toggleCheck(entry.id);
27487
- }
28498
+ selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
28499
+ role: "gridcell",
28500
+ style: {
28501
+ display: "flex",
28502
+ alignItems: "center"
28503
+ },
28504
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
28505
+ checked: isChecked,
28506
+ label: t("trash.select.row", { title }),
28507
+ onChange: () => {
28508
+ toggleCheck(entry.id);
28509
+ }
28510
+ })
27488
28511
  }) : null,
27489
28512
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28513
+ role: "gridcell",
27490
28514
  style: {
27491
28515
  display: "flex",
27492
28516
  flexDirection: "column",
@@ -27551,6 +28575,7 @@ window.__ModuleLoader__.load({
27551
28575
  ]
27552
28576
  }),
27553
28577
  selectMode ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28578
+ role: "gridcell",
27554
28579
  style: {
27555
28580
  display: "flex",
27556
28581
  gap: 6,
@@ -27586,7 +28611,12 @@ window.__ModuleLoader__.load({
27586
28611
  })]
27587
28612
  }, group.projectDir))
27588
28613
  });
28614
+ /**
28615
+ * 孩子视图的开与回(ADR-0029 单级判据)住控制器的 `detailNav`:这里只转交,不再自己拼
28616
+ * openChild / parentContext 两个派生块——判据的第二个家会让视图与测试各自复述一遍语义。
28617
+ */
27589
28618
  const detailPane = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
28619
+ ref: detailScrollRef,
27590
28620
  "data-dsh-session-delete": "trash-detail",
27591
28621
  style: DETAIL_COLUMN,
27592
28622
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -27597,6 +28627,15 @@ window.__ModuleLoader__.load({
27597
28627
  wide: split,
27598
28628
  onRetry: () => controller.retry(),
27599
28629
  onBack: () => controller.back(),
28630
+ detailNav: controller.detailNav,
28631
+ pager: {
28632
+ loadingOlder: controller.loadingOlder,
28633
+ olderFailure: controller.olderFailure,
28634
+ onLoadOlder: () => {
28635
+ controller.loadOlder();
28636
+ },
28637
+ scrollRef: detailScrollRef
28638
+ },
27600
28639
  t
27601
28640
  })
27602
28641
  })
@@ -27840,6 +28879,7 @@ window.__ModuleLoader__.load({
27840
28879
  children: [text, actions?.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
27841
28880
  type: "button",
27842
28881
  className: "sd-toast-action",
28882
+ disabled: action.disabled,
27843
28883
  onClick: action.onClick,
27844
28884
  children: action.label
27845
28885
  }, action.label))]
@@ -27851,31 +28891,12 @@ window.__ModuleLoader__.load({
27851
28891
  /**
27852
28892
  * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
27853
28893
  *
27854
- * 现场与发布住在 `undo-notices.ts`;本文件只做组件,并把通知台与外部恢复座位组合起来。
28894
+ * 现场与发布住在 `undo-notices.ts`;本文件只做组件:画通知台已有的事实,「撤销」整趟按下交给
28895
+ * `write-seats.ts` 的 `restoreFromUndo`(编排归恢复座位,见 ADR-0018 的边界)。
27855
28896
  * 为什么组件要独立:宿主的菜单行随菜单**一起卸载**(真产物 Menu 的注释原文
27856
28897
  * "the rows unmount with the list"),而提示要在菜单关掉之后还活着。宿主的官方配方同形——
27857
28898
  * 会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的 `workspace.row-toast` 读它。
27858
28899
  */
27859
- /** 撤销:单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
27860
- * null 是协议错);失败就把原因换到同一条横幅上,不静默。 */
27861
- async function undo(deps, current) {
27862
- const ids = current.kind === "one" ? [current.entryId] : current.entryIds;
27863
- if (current.kind === "one" && current.entryId === "") return;
27864
- const outcome = await restoreFromUndo(deps, {
27865
- ids,
27866
- single: current.kind === "one"
27867
- });
27868
- if (!outcome.ok) {
27869
- failNotice(current, outcome.failure.message);
27870
- return;
27871
- }
27872
- const failure = outcome.failures.values().next().value;
27873
- if (failure !== void 0) {
27874
- failNotice(current, failure);
27875
- return;
27876
- }
27877
- if (ownsNotice(current)) clearUndoNotice();
27878
- }
27879
28900
  /**
27880
28901
  * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
27881
28902
  * pill all belong here"):没有待撤销的事时什么都不渲染。
@@ -27883,6 +28904,15 @@ window.__ModuleLoader__.load({
27883
28904
  function UndoToast({ deps, t = zhTranslate }) {
27884
28905
  const current = useUndoNotice();
27885
28906
  if (current === null) return null;
28907
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeToast, {
28908
+ deps,
28909
+ current,
28910
+ t
28911
+ }, current.seq);
28912
+ }
28913
+ /** 一条通知的躯体:busy 生命周期归 `useBusyGate`(连点的同步判重也在闸里),这里只组合呈现。 */
28914
+ function NoticeToast({ deps, current, t }) {
28915
+ const gate = useBusyGate();
27886
28916
  const { seq, failure } = current;
27887
28917
  if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
27888
28918
  text: failure,
@@ -27897,7 +28927,10 @@ window.__ModuleLoader__.load({
27897
28927
  holdMs: UNDO_HOLD_MS,
27898
28928
  actions: canUndo ? [{
27899
28929
  label: t("undo.undo"),
27900
- onClick: () => void undo(deps, current)
28930
+ disabled: gate.busy,
28931
+ onClick: () => {
28932
+ gate.around(() => restoreFromUndo(deps, current, t));
28933
+ }
27901
28934
  }] : void 0,
27902
28935
  onDone: clearUndoNotice
27903
28936
  }, seq);
@@ -27977,10 +29010,13 @@ window.__ModuleLoader__.load({
27977
29010
  }
27978
29011
  /** 多选模式行首勾选框的宿主:槽位只投 `sessionId`,标题从 root hook 的 `byId[id].title` 读。 */
27979
29012
  function PickLeadingRow({ sessionId, useSessions, t }) {
27980
- const title = (typeof useSessions === "function" ? useSessions : NEVER_SESSIONS)((state) => state?.byId?.[sessionId]?.title?.trim() ?? "");
29013
+ const select = typeof useSessions === "function" ? useSessions : NEVER_SESSIONS;
29014
+ const title = select((state) => state?.byId?.[sessionId]?.title?.trim() ?? "");
29015
+ const live = select((state) => state?.byId?.[sessionId]?.running === true);
27981
29016
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BatchPickCheckbox, {
27982
29017
  sessionId,
27983
29018
  title,
29019
+ live,
27984
29020
  t
27985
29021
  });
27986
29022
  }
@@ -28029,10 +29065,12 @@ window.__ModuleLoader__.load({
28029
29065
  /**
28030
29066
  * **探测 → 注册**:取数通道在不在,只有真发一次请求才知道。
28031
29067
  *
28032
- * - 探测不过(打不通 / 回的不是信封)⇒ **早返回**:一个槽位贡献都不注册。页面上的表现是"本插件
28033
- * 不存在",而不是点下去必然失败的入口——半吊子 UI 比没有 UI 更糟(docs/architecture.md §3「通道探测决定入口出现与否」)。
28034
- * - 探测过(哪怕 `ping` 回的是 `{ok:false,error}`)⇒ 注册:那次往返证明路由在、依赖接上了、
28035
- * 错误映射也生效了,失败该做的是把原文交给用户看。
29068
+ * - 探测不过(打不通 / 回的不是信封 / 401·403 表示没有宿主身份 / 失败信封)⇒ **早返回**:
29069
+ * 一个槽位贡献都不注册。页面上的表现是"本插件不存在",而不是点下去必然失败的入口——半吊子 UI
29070
+ * 比没有 UI 更糟(docs/architecture.md §3「通道探测决定入口出现与否」)。
29071
+ * - 探测过(`ping` 回成功信封的 `pong`)⇒ 注册:那次往返证明路由在、依赖接上了、鉴权也过了。
29072
+ *
29073
+ * 支持面是 web UI 与桌面端,二者都从宿主 `/api` 取数(ADR-0033);没有旧前缀可退回。
28036
29074
  *
28037
29075
  * 面板那两条是**一对座位**:行的 `selectPanel(id)` 要求 `main` 里有同字 key,缺了面板那一头,
28038
29076
  * 用户点一下就是宿主自己的 `layout.selectPanel: main panel "…" is not registered`。