@xrn1997/dsh-session-delete 0.4.0 → 0.5.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 (54) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +141 -135
  3. package/cordis.patch.yml +3 -3
  4. package/lib/client.js +1477 -563
  5. package/lib/index.js +1230 -755
  6. package/lib/types/api/dispatch.d.ts +6 -2
  7. package/lib/types/api/route-table.d.ts +29 -9
  8. package/lib/types/api/trash-rows.d.ts +3 -3
  9. package/lib/types/client/busy-gate.d.ts +6 -2
  10. package/lib/types/client/confirm-close.d.ts +4 -1
  11. package/lib/types/client/context.d.ts +3 -2
  12. package/lib/types/client/copy.d.ts +54 -4
  13. package/lib/types/client/delete-confirm.d.ts +1 -1
  14. package/lib/types/client/delete-menu-item.d.ts +1 -1
  15. package/lib/types/client/host-rows.d.ts +5 -1
  16. package/lib/types/client/markdown.d.ts +6 -7
  17. package/lib/types/client/orphan-maintenance.d.ts +10 -25
  18. package/lib/types/client/receipt.d.ts +3 -1
  19. package/lib/types/client/remote.d.ts +7 -3
  20. package/lib/types/client/store.d.ts +14 -3
  21. package/lib/types/client/trash-detail.d.ts +48 -6
  22. package/lib/types/client/trash-entry.d.ts +1 -1
  23. package/lib/types/client/trash-nav.d.ts +12 -8
  24. package/lib/types/client/ui/Toast.d.ts +3 -1
  25. package/lib/types/client/ui/icons.d.ts +1 -1
  26. package/lib/types/client/undo-toast.d.ts +2 -1
  27. package/lib/types/client/use-maintenance-section.d.ts +52 -0
  28. package/lib/types/client/use-maintenance.d.ts +7 -35
  29. package/lib/types/client/use-trash-panel-controller.d.ts +35 -14
  30. package/lib/types/client/use-trash-query.d.ts +10 -8
  31. package/lib/types/client/use-trash-selection.d.ts +15 -12
  32. package/lib/types/client/write-seats.d.ts +32 -16
  33. package/lib/types/client/write-settlement.d.ts +1 -1
  34. package/lib/types/client/write.d.ts +13 -3
  35. package/lib/types/domain/closure.d.ts +44 -0
  36. package/lib/types/log/zstd-frames.d.ts +2 -3
  37. package/lib/types/ports/fs-port.d.ts +15 -12
  38. package/lib/types/ports/host-port.d.ts +4 -5
  39. package/lib/types/ports/presence-port.d.ts +9 -0
  40. package/lib/types/shared/wire.d.ts +119 -26
  41. package/lib/types/trash/attachments.d.ts +8 -1
  42. package/lib/types/trash/detail.d.ts +32 -22
  43. package/lib/types/trash/entry.d.ts +7 -1
  44. package/lib/types/trash/groups.d.ts +6 -1
  45. package/lib/types/trash/manifest.d.ts +4 -4
  46. package/lib/types/verbs/batch-carried.d.ts +1 -3
  47. package/lib/types/verbs/live-admission.d.ts +1 -1
  48. package/lib/types/verbs/member-presence.d.ts +3 -1
  49. package/lib/types/verbs/move-group.d.ts +2 -1
  50. package/lib/types/verbs/orphan-subagents.d.ts +11 -5
  51. package/lib/types/verbs/permanent-removal.d.ts +39 -10
  52. package/lib/types/verbs/trash-scope.d.ts +36 -0
  53. package/package.json +117 -107
  54. 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,7 +572,7 @@ 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
578
  * 另有一个**懒读快照**变体(`createLazyStore`):值由组件树之外的事实源(宿主 DOM)现读,
@@ -499,6 +582,9 @@ window.__ModuleLoader__.load({
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
@@ -600,6 +686,19 @@ window.__ModuleLoader__.load({
600
686
  }
601
687
  };
602
688
  }
689
+ /**
690
+ * 引用归一:输入允许调用方逐渲染换新对象,回调只经这份 ref 读**最新**那一份,于是依赖它的回调
691
+ * 身份恒稳、调用方的 effect 可以放心把它们当依赖——「输入必须是稳定引用」的隐式契约就此不存在
692
+ * (issue #8 第 3 条)。写回在 effect 里(提交后),与 React 的渲染期只读约定一致。
693
+ * 这一形状的唯一实现:别在调用方再手写一份「useRef + 每渲染写回」。
694
+ */
695
+ function useLatest(value) {
696
+ const ref = (0, react.useRef)(value);
697
+ (0, react.useEffect)(() => {
698
+ ref.current = value;
699
+ });
700
+ return ref;
701
+ }
603
702
  //#endregion
604
703
  //#region src/client/batch-selection-store.ts
605
704
  /**
@@ -697,7 +796,11 @@ window.__ModuleLoader__.load({
697
796
  /**
698
797
  * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
699
798
  * (它对每个非空行都渲染,哪怕 CSS 把整排藏起来)在 effect 里把这两件登记进这张表。
700
- * 只增不删:会话 id 不复用,行被过滤掉也不会拿旧标题冒充新行。
799
+ * 生命周期跟着**行尾开关的挂载**走:卸载即注销({@link unregisterHostRowInfo}),
800
+ * 表的大小被"当前渲染的行"钉住,而不是随用过的会话只增不减;行重挂载时 effect 会重新登记。
801
+ *
802
+ * **按挂载实例计数**:同一个会话在 DOM 里可能有两行(重复行),两行各挂一颗行尾开关;
803
+ * 其中一颗卸载时不能把另一颗还在用的登记一起摘掉——那会让这一行从"全选"里消失、标题也变空。
701
804
  */
702
805
  const rowInfo = /* @__PURE__ */ new Map();
703
806
  /**
@@ -727,12 +830,29 @@ window.__ModuleLoader__.load({
727
830
  });
728
831
  /**
729
832
  * 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
730
- * 只有标题/运行态**真的变了**才标脏:重渲染时的同值登记不该把快照翻新一遍。
833
+ * 每次登记算**多一个挂载实例**;只有标题/运行态**真的变了**才标脏:重渲染时的同值登记
834
+ * 不该把快照翻新一遍。
731
835
  */
732
836
  function registerHostRowInfo(sessionId, info) {
733
837
  const current = rowInfo.get(sessionId);
734
- if (current?.title === info.title && current.live === info.live) return;
735
- rowInfo.set(sessionId, info);
838
+ const owners = (current?.owners ?? 0) + 1;
839
+ const changed = current === void 0 || current.info.title !== info.title || current.info.live !== info.live;
840
+ rowInfo.set(sessionId, {
841
+ info,
842
+ owners
843
+ });
844
+ if (changed) visibleRows.invalidate();
845
+ }
846
+ /** 行尾开关卸载时调用:那一行已经离开渲染树,登记跟着走。
847
+ * 还有别的挂载实例在用同一行(重复行)就只减计数,不摘登记。 */
848
+ function unregisterHostRowInfo(sessionId) {
849
+ const current = rowInfo.get(sessionId);
850
+ if (current === void 0) return;
851
+ if (current.owners > 1) {
852
+ current.owners -= 1;
853
+ return;
854
+ }
855
+ rowInfo.delete(sessionId);
736
856
  visibleRows.invalidate();
737
857
  }
738
858
  /**
@@ -750,12 +870,12 @@ window.__ModuleLoader__.load({
750
870
  if (!key.startsWith("session:")) continue;
751
871
  const sessionId = key.slice(8);
752
872
  if (sessionId === "" || seen.has(sessionId)) continue;
753
- const info = rowInfo.get(sessionId);
754
- if (info === void 0 || info.live) continue;
873
+ const entry = rowInfo.get(sessionId);
874
+ if (entry === void 0 || entry.info.live) continue;
755
875
  seen.add(sessionId);
756
876
  rows.push({
757
877
  sessionId,
758
- title: info.title
878
+ title: entry.info.title
759
879
  });
760
880
  }
761
881
  return rows;
@@ -820,11 +940,11 @@ window.__ModuleLoader__.load({
820
940
  if (!key.startsWith("session:")) return;
821
941
  const sessionId = key.slice(8);
822
942
  if (sessionId === "") return;
823
- const info = rowInfo.get(sessionId);
824
- if (info?.live === true) return;
943
+ const entry = rowInfo.get(sessionId);
944
+ if (entry?.info.live === true) return;
825
945
  togglePick({
826
946
  sessionId,
827
- title: info?.title ?? ""
947
+ title: entry?.info.title ?? ""
828
948
  });
829
949
  };
830
950
  document.addEventListener("click", handle, true);
@@ -852,41 +972,74 @@ window.__ModuleLoader__.load({
852
972
  * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
853
973
  */
854
974
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
855
- /** 九个段名(相对前缀)。写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
975
+ /** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
976
+ * (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
977
+ * 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
978
+ * 两半都不逐段手写一份。
979
+ * 写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
856
980
  * 单条就是长度为 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` 的第五个动作,
981
+ * - `list`:对账并读清单(`session-trash/` 里的目录是准)。
982
+ * - `ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
983
+ * - `detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts` 的 detail 行)。
984
+ * - `apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
985
+ * - `empty`:清空回收站(聚合回执,不按条回)。
986
+ * - `orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
863
987
  * 这一段只负责"有多少、在哪、多大"。
864
- * - `GET /attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
865
- * - `GET /attachment-orphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
866
- * - `POST /attachment-clean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
988
+ * - `attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
989
+ * - `attachmentOrphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
990
+ * - `attachmentClean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
867
991
  const ROUTES = {
868
- list: "/list",
992
+ list: {
993
+ path: "/list",
994
+ method: "GET"
995
+ },
869
996
  /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
870
- ping: "/ping",
997
+ ping: {
998
+ path: "/ping",
999
+ method: "GET"
1000
+ },
871
1001
  /** 单条详情(读回收站目录里那条会话自己的日志)。 */
872
- detail: "/detail",
1002
+ detail: {
1003
+ path: "/detail",
1004
+ method: "POST"
1005
+ },
873
1006
  /** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
874
- apply: "/apply",
1007
+ apply: {
1008
+ path: "/apply",
1009
+ method: "POST"
1010
+ },
875
1011
  /** 清空回收站。 */
876
- empty: "/empty",
1012
+ empty: {
1013
+ path: "/empty",
1014
+ method: "POST"
1015
+ },
877
1016
  /** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
878
- orphans: "/orphans",
1017
+ orphans: {
1018
+ path: "/orphans",
1019
+ method: "GET"
1020
+ },
879
1021
  /** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
880
- attachment: "/attachment",
1022
+ attachment: {
1023
+ path: "/attachment",
1024
+ method: "GET"
1025
+ },
881
1026
  /** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
882
- attachmentOrphans: "/attachment-orphans",
1027
+ attachmentOrphans: {
1028
+ path: "/attachment-orphans",
1029
+ method: "GET"
1030
+ },
883
1031
  /** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
884
- attachmentClean: "/attachment-clean"
1032
+ attachmentClean: {
1033
+ path: "/attachment-clean",
1034
+ method: "POST"
1035
+ }
885
1036
  };
886
1037
  /** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
887
1038
  * 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
888
1039
  const PARAMS = {
889
1040
  entryId: "entryId",
1041
+ /** `/detail` 的分页游标:只取这个块序号之前的块。缺省 = 从尾部取。 */
1042
+ before: "before",
890
1043
  action: "action",
891
1044
  ids: "ids",
892
1045
  attachmentId: "id",
@@ -906,6 +1059,21 @@ window.__ModuleLoader__.load({
906
1059
  const candidate = value;
907
1060
  return candidate.isDSHRemoteError === true && typeof candidate.code === "string";
908
1061
  }
1062
+ /**
1063
+ * 运行期判形「这段 JSON 是不是信封」。中间层(反代 / SPA 兜底)会回 `{}`、`{"error":"…"}` 这类
1064
+ * 不是信封的 JSON;判形不过就该走「回的不是信封」诊断,而不是把垃圾喂给展开层炸出
1065
+ * TypeError / "undefined" 上屏(issue #8 第 1 条)。判据就是两臂的形状:`ok` 必须是布尔,
1066
+ * `ok:true` 须带 `value` 键(值可以是 null),`ok:false` 的 `error` 须是带 string `code`/`message` 的对象。
1067
+ */
1068
+ function isRemoteResult(value) {
1069
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1070
+ const body = value;
1071
+ if (typeof body.ok !== "boolean") return false;
1072
+ if (body.ok) return "value" in body;
1073
+ if (typeof body.error !== "object" || body.error === null) return false;
1074
+ const error = body.error;
1075
+ return typeof error.code === "string" && typeof error.message === "string";
1076
+ }
909
1077
  const SESSION_DELETE_CODES = {
910
1078
  live: "sessiondelete/live",
911
1079
  "not-found": "sessiondelete/not-found",
@@ -1104,6 +1272,7 @@ window.__ModuleLoader__.load({
1104
1272
  };
1105
1273
  }
1106
1274
  const landedIds = receipt.action === "delete" ? landedEntryIds(receipt) : receipt.action === "restore" ? restoredSessionIds(receipt, sessionIdOfEntry) : [];
1275
+ const attachmentCleanup = summarizeAttachmentCleanup(receipt);
1107
1276
  return {
1108
1277
  summary: {
1109
1278
  successIds,
@@ -1112,7 +1281,7 @@ window.__ModuleLoader__.load({
1112
1281
  partial,
1113
1282
  landedIds,
1114
1283
  anySuccess: successIds.length > 0,
1115
- attachmentCleanup: summarizeAttachmentCleanup(receipt)
1284
+ ...attachmentCleanup !== void 0 ? { attachmentCleanup } : {}
1116
1285
  },
1117
1286
  failures
1118
1287
  };
@@ -1157,7 +1326,9 @@ window.__ModuleLoader__.load({
1157
1326
  *
1158
1327
  * - 路由是宿主 web server 上的插件前缀;官方 remote.<namespace> 需要生成的 Typert 描述符,
1159
1328
  * 第三方无法动态挂载,因此走这条薄线(ADR-0009)。
1160
- * - 九段:list / ping / orphans / detail / apply / empty / attachment / attachment-orphans / attachment-clean;
1329
+ * - 九段的**段名与方法**住 `src/shared/wire.ts` 的结构化段名表(`ROUTES` 每项 `{ path, method }`),
1330
+ * 本文件只投影路径(`prefix + ROUTES.x.path`)与方法(GET 段直接取 `ROUTES.x.method`,写段把它
1331
+ * 交给 `post()`)——方法字面量不在本文件出现第二份;
1161
1332
  * 写动作统一走 apply;`attachment` 是唯一二进制段,由 `src/client/attachment-view.tsx` 直接拼地址,
1162
1333
  * 不经过本文件的信封通道;`attachment-orphans`(纯读)与 `attachment-clean`(聚合写)是孤儿附件的两段。
1163
1334
  * - 信封与官方 unary 面同形:{ok:true,value} / {ok:false,error}。HTTP 往返本身失败才 reject;
@@ -1228,20 +1399,24 @@ window.__ModuleLoader__.load({
1228
1399
  /**
1229
1400
  * 一次 HTTP 往返 → 信封。**`ok:false` 也是落地**(返回,不抛);只有往返本身失败才抛:
1230
1401
  * 打不通(宿主没起来 / 路由没挂)或回的不是信封(前缀路由缺席时请求会落到 SPA 兜底处理器上,
1231
- * 回的是 index.html)。两种抛都带原文,组件侧的 `failureOf` 会把它们显示成 `client/unknown` + 原因。
1402
+ * 回的是 index.html;中间层也可能回 `{}` / `{"error":"…"}` 这类非信封 JSON——判形住
1403
+ * `isRemoteResult`,两种都落同一条诊断)。两种抛都带原文,组件侧的 `failureOf` 会把它们显示成 `client/unknown` + 原因。
1232
1404
  */
1233
1405
  async function requestEnvelope(path, init) {
1234
1406
  let response;
1235
1407
  try {
1236
1408
  response = await fetch(path, init);
1237
1409
  } catch (reason) {
1238
- throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}`);
1410
+ throw new Error(`取数通道打不通:${reason instanceof Error ? reason.message : String(reason)}`, { cause: reason });
1239
1411
  }
1412
+ let body;
1240
1413
  try {
1241
- return await response.json();
1414
+ body = await response.json();
1242
1415
  } catch {
1243
- throw new Error(`取数通道回的不是信封(HTTP ${String(response.status)})`);
1416
+ body = void 0;
1244
1417
  }
1418
+ if (!isRemoteResult(body)) throw new Error(`取数通道回的不是信封(HTTP ${String(response.status)})`);
1419
+ return body;
1245
1420
  }
1246
1421
  /**
1247
1422
  * 前缀路由的 fetch 实现。**相对路径**:不拼 base、不认 origin——页面本身就是从宿主 web server
@@ -1250,23 +1425,26 @@ window.__ModuleLoader__.load({
1250
1425
  * `dsh web` 打印的 URL 打开过的页面成立(凭据是那行 URL 换来的 cookie,不是插件自己的秘密)。
1251
1426
  */
1252
1427
  function createHttpRemote(prefix = SESSION_DELETE_API_PREFIX) {
1253
- const post = (body) => ({
1254
- method: "POST",
1428
+ const post = (route, body) => ({
1429
+ method: route.method,
1255
1430
  headers: { "content-type": "application/json" },
1256
1431
  body: JSON.stringify(body)
1257
1432
  });
1258
1433
  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({
1434
+ list: () => requestEnvelope(prefix + ROUTES.list.path, { method: ROUTES.list.method }),
1435
+ ping: () => requestEnvelope(prefix + ROUTES.ping.path, { method: ROUTES.ping.method }),
1436
+ orphans: () => requestEnvelope(prefix + ROUTES.orphans.path, { method: ROUTES.orphans.method }),
1437
+ attachmentOrphans: () => requestEnvelope(prefix + ROUTES.attachmentOrphans.path, { method: ROUTES.attachmentOrphans.method }),
1438
+ attachmentClean: () => requestEnvelope(prefix + ROUTES.attachmentClean.path, post(ROUTES.attachmentClean, {})),
1439
+ detail: (entryId, before) => requestEnvelope(prefix + ROUTES.detail.path, post(ROUTES.detail, {
1440
+ [PARAMS.entryId]: entryId,
1441
+ ...before === void 0 ? {} : { [PARAMS.before]: before }
1442
+ })),
1443
+ apply: (action, ids) => requestEnvelope(prefix + ROUTES.apply.path, post(ROUTES.apply, {
1266
1444
  [PARAMS.action]: action,
1267
1445
  [PARAMS.ids]: [...ids]
1268
1446
  })),
1269
- empty: () => requestEnvelope(prefix + ROUTES.empty, post({}))
1447
+ empty: () => requestEnvelope(prefix + ROUTES.empty.path, post(ROUTES.empty, {}))
1270
1448
  };
1271
1449
  }
1272
1450
  /**
@@ -1325,7 +1503,7 @@ window.__ModuleLoader__.load({
1325
1503
  scanOrphans: () => raw.orphans().then((result) => unwrapRemoteResult(result)),
1326
1504
  scanAttachmentOrphans: () => raw.attachmentOrphans().then((result) => unwrapRemoteResult(result)),
1327
1505
  cleanAttachmentOrphans: () => raw.attachmentClean().then((result) => unwrapRemoteResult(result)),
1328
- detail: (entryId) => raw.detail(entryId).then((result) => unwrapRemoteResult(result)),
1506
+ detail: (entryId, before) => raw.detail(entryId, before).then((result) => unwrapRemoteResult(result)),
1329
1507
  applyOutcomes: applyOnce,
1330
1508
  emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
1331
1509
  };
@@ -1499,10 +1677,10 @@ window.__ModuleLoader__.load({
1499
1677
  }
1500
1678
  //#endregion
1501
1679
  //#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";
1680
+ var ui_default = "/*\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\n *\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\n * `Toast.module.css`、`Menu.module.css`;2026-10-15 又加两张:`lib/Checkbox.module.css`、\n * `lib/DisclosureRow.module.css`,以及 `dsh-client-ui-conversation` 里的\n * `ConversationRoot.module.css` 的 `.widthHandle` 那一族——宿主自己那根可拖的宽度把手;\n * 2026-10-17 又加一张,也是首张来自 ui-primitives 之外的包:`dsh-client-ui-workspace` 的\n * `Rows.module.css` 的 `.iconButton` 那一族——会话行尾悬停排那颗按钮的基底(行尾兜底勾选开关\n * 要跟宿主自己的归档/置顶同形)。\n * 这是官方给第三方 UI 插件的处方:\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\n *\n * 三条改写规则:\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\n *\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)、Checkbox 的全部、DisclosureRow 的\n * 折叠头那一半(没有 `data-state` 那档状态点)、宽度把手的视觉与光标。**没抄进来的就是本插件不用的**,\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\n *\n * **七处不是抄本**:① `.sd-resizing`(拖动期间挂在面板根上、关掉文字选中)——宿主那份没有对应的类,\n * 它的把手由 JS 侧的 pointer capture 承担;② `[data-dsh-session-delete=\"trash-row\"]:focus-visible`\n * (清单那一行的键盘焦点描边,复用选中态那枚 `ghost-active-border`)——宿主那套表里行不是可聚焦\n * div,我们这一行是 `role=\"row\"` 且要吃 ↑↓,而这一代 `--dsw-focus-ring-color` 是 `transparent`;\n * ③ `.sd-rowaction-button` 的 `[aria-pressed='true']` 与 `:disabled` 两档(2026-10-17)——\n * 官方那颗 `iconButton` 只有常态与 hover,勾选开关要有\"按下\"和\"这条删不动\"两档,token 与回收站\n * 选中行同源(`--dsw-alias-button-ghost-active-*`);④ `.sd-batch-bar`(同日)——宿主没有\"浮条\"\n * 这件控件,四件 token 自拼;⑤ 宿主行的批量选中底色与兜底隐藏(同日改版)——宿主的行选中态不归\n * 第三方,整行可见性靠 `data-row-key` + `:has`,token 与回收站选中行同源;行首格在场时行尾兜底退场;\n * ⑥ `.sd-batch-pick-leading`(同日)——宿主行首那格只给 16×20,勾选框要一层 inline-flex 包装才压得回\n * 同样大小,官方没有这层包装;⑦ `.sd-toast-action:disabled`(2026-11-01)——撤销横幅在飞时那颗动作要\n * 看着就按不动,官方 Toast 的 action 没有禁用档,而我们这份自带 `color`、浏览器默认置灰不生效,\n * 档位与③的禁用档同源(`opacity` + `not-allowed`)。\n * 整张表里就这七条是我们自造的,标在这里,\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\n *\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的整套 UA reset。宿主 `DisclosureRow` 那份是\n * `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表会把背景、边框、内距、字体、居中与\n * flex 基线一起渗进来(真机上就是项目名整条居中,2026-10-15 截图)。它写在\n * `src/client/trash-panel.tsx` 组头那一行的 inline 样式上,**具体 reset 了哪几项由\n * `tests/skeleton.test.ts` 逐条钉住**(不在这里再抄一份名单)——所以「与官方逐条比对」时本表仍然只数\n * 上面那些,这条不算进抄本差异。\n */\n\n/* ────────────────────────── Button ────────────────────────── */\n\n.sd-button {\n box-sizing: border-box;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 4px;\n border: none;\n border-radius: var(--dsw-radius-md);\n cursor: pointer;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-label-primary);\n background: transparent;\n padding: 0 14px;\n}\n\n.sd-button:disabled {\n cursor: not-allowed;\n opacity: 0.4;\n}\n\n.sd-button-md {\n height: 36px;\n}\n\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\n.sd-button-sm {\n height: 28px;\n font-size: 12px;\n line-height: 18px;\n padding: 0 10px;\n border-radius: var(--dsw-radius-sm);\n}\n\n.sd-button-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n.sd-button-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n\n.sd-button-ghost:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.sd-button-ghost:active:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n.sd-button-outline {\n border: 0.5px solid var(--dsw-alias-border-l3);\n background: transparent;\n}\n\n.sd-button-outline:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.sd-button-icon {\n display: inline-flex;\n width: 16px;\n height: 16px;\n align-items: center;\n justify-content: center;\n}\n\n/* ────────────────────────── Tag ────────────────────────── */\n\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\n.sd-tag {\n display: inline-flex;\n align-items: center;\n border-radius: 999px;\n corner-shape: round;\n padding: 1px 8px;\n font-size: 11px;\n line-height: 17px;\n font-weight: 500;\n white-space: nowrap;\n}\n\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\n.sd-tag-danger {\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\n color: var(--dsw-alias-state-error-primary);\n}\n\n/* ────────────────────────── Modal ────────────────────────── */\n\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\n.sd-modal-root {\n pointer-events: auto;\n position: fixed;\n inset: 0;\n z-index: 1000;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\n}\n\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\n.sd-modal-mask {\n position: absolute;\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\n backdrop-filter: var(--dsw-mask-blur);\n}\n\n.sd-modal-mask::after {\n content: '';\n position: absolute;\n inset: 0;\n background: var(--dsw-alias-bg-mask-1);\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\n}\n\n.sd-modal-dialog {\n box-sizing: border-box;\n position: relative;\n z-index: 1;\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: min(380px, 100%);\n padding: 0 0 24px;\n overflow: hidden;\n border: 0;\n border-radius: var(--dsw-radius-panel);\n background: var(--dsw-alias-bg-layer-2);\n box-shadow: var(--dsw-elevation-prominent);\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\n}\n\n@keyframes sd-modal-enter {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sd-modal-mask::after,\n .sd-modal-dialog { animation: none; }\n}\n\n.sd-modal-dialog:focus {\n outline: none;\n}\n\n.sd-modal-content {\n display: flex;\n flex-direction: column;\n width: 100%;\n}\n\n/* 头行:标题左、关闭钮右。 */\n.sd-modal-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 22px 14px 12px 24px;\n}\n\n.sd-modal-title {\n margin: 0;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n color: var(--dsw-alias-label-primary);\n}\n\n.sd-modal-close {\n flex: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n border-radius: var(--dsw-radius-sm);\n background: transparent;\n cursor: pointer;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sd-modal-close:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.sd-modal-body {\n display: flex;\n flex-direction: column;\n min-width: 0;\n margin-top: 20px;\n padding: 0 24px;\n}\n\n.sd-modal-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 0 24px;\n}\n\n/* ────────────────────────── Tooltip ────────────────────────── */\n\n.sd-tooltip-bubble {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n position: fixed;\n z-index: 100;\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\n `max-content` 只按文案定宽,再用半个视口收口。 */\n width: max-content;\n max-width: 50vw;\n padding: 3px 7px;\n border-radius: var(--dsw-radius-sm);\n background: var(--dsw-alias-tooltip-bg);\n color: var(--dsw-static-neutral-bluish-00);\n font-size: 13px;\n line-height: 20px;\n white-space: pre-line;\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\n overflow-wrap: break-word;\n pointer-events: none;\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\n}\n\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\n.sd-tooltip-label { min-width: 0; }\n\n.sd-tooltip-bubble[data-side='right'] {\n transform: translateY(-50%);\n}\n\n.sd-tooltip-bubble[data-side='bottom'] {\n transform: translateX(-50%);\n}\n\n.sd-tooltip-bubble[data-side='top'] {\n transform: translate(-50%, -100%);\n}\n\n@keyframes sd-tooltip-in {\n from { opacity: 0; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sd-tooltip-bubble {\n animation: none;\n }\n}\n\n/* ────────────────────────── Toast ────────────────────────── */\n\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\n.sd-toast {\n position: fixed;\n top: 40px;\n left: 50%;\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\n z-index: 1100;\n /* 通告不截点击。 */\n pointer-events: none;\n display: flex;\n align-items: center;\n gap: 10px;\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\n width: max-content;\n max-width: min(640px, calc(100vw - 48px));\n padding: 12px 16px;\n border-radius: var(--dsw-radius-lg);\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\n background: var(--dsw-alias-toast-bg);\n color: var(--dsw-alias-toast-label);\n font-size: 14px;\n line-height: 22px;\n box-shadow: var(--dsw-shadow-lv3);\n transform: translateX(-50%);\n animation:\n sd-toast-in 160ms ease-out,\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\n}\n\n.sd-toast-icon {\n display: grid;\n place-items: center;\n flex: none;\n color: var(--dsw-alias-state-warn-label);\n}\n\n.sd-toast-icon-success {\n color: var(--dsw-alias-state-success-primary);\n}\n\n.sd-toast-text {\n min-width: 0;\n}\n\n.sd-toast-action {\n display: inline;\n padding: 0;\n border: 0;\n background: none;\n color: var(--dsw-static-deepseek-400);\n font: inherit;\n cursor: pointer;\n /* 动作文字与周围文案之间的呼吸位。 */\n margin-inline: 3px;\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\n pointer-events: auto;\n}\n\n.sd-toast-action:hover {\n opacity: 0.8;\n}\n\n/* 本插件自造的第七条(不是抄本,见文件头点名):撤销横幅在飞时那颗动作要**看着就按不动**。\n 官方 Toast 的 action 只有常态与 hover,没有禁用档;而 `.sd-toast-action` 自带 `color`,\n 浏览器默认的置灰根本不生效(真机上只会看见一个光标照常是手、点下去没反应的「撤销」)。\n 禁用档与行尾勾选开关同源(`opacity` + `not-allowed`),不新造颜色。 */\n.sd-toast-action:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n@keyframes sd-toast-in {\n from {\n opacity: 0;\n transform: translate(-50%, -6px);\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, 0);\n }\n}\n\n@keyframes sd-toast-fade {\n to {\n opacity: 0;\n visibility: hidden;\n }\n}\n\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\n@media (prefers-reduced-motion: reduce) {\n .sd-toast {\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\n }\n}\n\n/* ────────────────────────── 菜单行 ────────────────────────── */\n\n.sd-menu-item-wrap {\n position: relative;\n}\n\n/* 菜单格:紧凑文字、图标与选中间距。 */\n.sd-menu-item {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 34px;\n padding: 6px 8px;\n border: none;\n border-radius: var(--dsw-radius-md);\n background: transparent;\n cursor: pointer;\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n text-align: left;\n}\n\n.sd-menu-item:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\n 浏览器默认的 ring 再叠一层就重了。 */\n.sd-menu-item:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n\n.sd-menu-item:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n.sd-menu-item-icon {\n display: inline-flex;\n flex: none;\n width: 14px;\n height: 14px;\n align-items: center;\n justify-content: center;\n color: var(--dsw-alias-menu-icon);\n}\n\n.sd-menu-item-icon svg {\n width: 14px;\n height: 14px;\n}\n\n.sd-menu-item-label {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\n.sd-menu-item-danger {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.sd-menu-item-danger .sd-menu-item-icon {\n color: var(--dsw-alias-state-error-primary);\n}\n\n.sd-menu-item-danger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n}\n\n.sd-menu-item-danger:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n outline: none;\n}\n\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\n.sd-menu-separator {\n height: 0.5px;\n margin: 3px 2px;\n background: var(--dsw-alias-border-l2);\n}\n\n/* ────────────────────────── Checkbox ────────────────────────── */\n/* 抄 `lib/Checkbox.module.css` 全部四条声明,逐字。勾选框本体是**原生 input** + `accent-color`,\n 不是自绘的圆框——回收站多选态用它(清单行左侧那一格)。 */\n\n.sd-checkbox {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n}\n\n.sd-checkbox input {\n flex: 0 0 auto;\n width: 16px;\n height: 16px;\n margin: 0;\n accent-color: var(--dsw-alias-brand-primary);\n cursor: inherit;\n}\n\n.sd-checkbox input:focus-visible {\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\n outline-offset: 2px;\n}\n\n.sd-checkbox:has(input:disabled) {\n cursor: default;\n opacity: 0.5;\n}\n\n/* ────────────────────────── DisclosureRow(分组折叠头) ────────────────────────── */\n/* 抄 `lib/DisclosureRow.module.css` 的 `.root`/`.row`/`.leading`/`.title` 与那两枚 glyph 的\n 显隐。没抄进来的是 `StateDot`(`svg[data-state]`)那一档——它说\"这是状态标记不是文字装饰\",\n 我们的组头没有状态点。行高与 leading 盒走 `--dsh-content-font-delta` 那根轴,\n 标题字号走 secondary 档:宿主的\"设置里调正文字号\"会一起推着它走。 */\n\n.sd-disclosure {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n}\n\n.sd-disclosure-row {\n position: relative;\n overflow: hidden;\n display: flex;\n align-items: center;\n height: calc(24px + var(--dsh-content-font-delta, 0px));\n min-width: 0;\n color: var(--dsw-alias-label-tertiary);\n transition: color 100ms ease;\n}\n\n.sd-disclosure-row[data-expandable] {\n cursor: pointer;\n}\n\n.sd-disclosure-row:hover {\n color: var(--dsw-alias-label-secondary);\n}\n\n.sd-disclosure-leading {\n position: relative;\n flex: none;\n width: calc(16px + var(--dsh-content-font-delta, 0px));\n height: calc(16px + var(--dsh-content-font-delta, 0px));\n display: inline-flex;\n align-items: center;\n justify-content: center;\n margin-right: 6px;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n}\n\n.sd-disclosure-leading svg:not([data-state]) {\n width: calc(14px + var(--dsh-content-font-delta, 0px));\n height: calc(14px + var(--dsh-content-font-delta, 0px));\n}\n\nbutton.sd-disclosure-leading {\n cursor: pointer;\n}\n\n.sd-disclosure-title {\n flex: none;\n font-size: var(--dsh-content-font-size-secondary, 13px);\n line-height: calc(24px + var(--dsh-content-font-delta, 0px));\n color: inherit;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sd-disclosure-row {\n transition: none;\n }\n}\n\n/* ────────────────────────── 宽度把手(可拖的分栏线) ────────────────────────── */\n/* 抄 `ConversationRoot.module.css` 的 `.widthHandle` 一族:10px 的命中区、`col-resize` 光标、\n 平时不画、悬停或拖动时才浮出一条 2px 的 `--dsw-alias-scrollbar-bg-l1` 短标(那条渐变里\n `--dsh-width-handle-pointer-y` 的兜底是 50%,宿主靠 JS 跟着指针走;我们不跟,\n 停在中间——少一条 pointermove 上的写字,视觉上是同一族东西)。\n 与宿主的差别只有位置:它绝对定位在会话栏两侧,我们这根住在两栏之间的那一格 flex 位里,\n 所以 `top/bottom/position` 那三样交给内联布局,这里只留视觉与光标。 */\n\n.sd-width-handle {\n position: relative;\n z-index: 0;\n cursor: col-resize;\n align-self: stretch;\n flex: 0 0 10px;\n width: 10px;\n padding: 0;\n border: none;\n background: none;\n}\n\n.sd-width-handle::after {\n content: \"\";\n background: linear-gradient(\n to bottom,\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)\n );\n opacity: 0;\n pointer-events: none;\n border-radius: 2px;\n width: 2px;\n position: absolute;\n top: 0;\n bottom: 0;\n left: 4px;\n}\n\n.sd-width-handle:hover::after,\n.sd-width-handle[data-dragging]::after {\n opacity: 1;\n}\n\n.sd-width-handle:focus-visible {\n outline: none;\n}\n\n.sd-width-handle:focus-visible::after {\n opacity: 1;\n}\n\n/* 本插件自造的第一条(不是抄本,见文件头点名):拖动期间整块面板别把文字选上了。 */\n.sd-resizing {\n user-select: none;\n cursor: col-resize;\n}\n\n/* 本插件自造的第二条(不是抄本,见文件头点名):清单行是 `role=\"row\"` 的可聚焦 div,而这一代\n `--dsw-focus-ring-color` 逐字是 `transparent`(2026-10-15 实测)——不自己描一条,键盘走\n ↑↓ 选会话时就没有任何焦点可见性。描边**复用选中态那枚** `ghost-active-border`,不新造颜色;\n 0.5px 与 `outline-offset: 1px` 是行的描边宽度与一档外移,不动布局(outline 不进滚动区)。 */\n[data-dsh-session-delete=\"trash-row\"]:focus-visible {\n outline: 0.5px solid var(--dsw-alias-button-ghost-active-border);\n outline-offset: 1px;\n}\n\n/* 会话行尾悬停排那颗**兜底**勾选开关(状态点行 / 归档行;行首主勾选是原生 Checkbox)。\n 基底**逐字抄** `dsh-client-ui-workspace` 的 `Rows.module.css` 的 `.iconButton` / `.iconButton:hover`,\n 只改类名——兜底开关要跟同排宿主自己的归档/置顶同形,尺寸与色档都不自己发明。 */\n.sd-rowaction-button {\n border-radius: var(--dsw-radius-xs);\n cursor: pointer;\n width: 16px;\n height: 16px;\n color: var(--dsw-alias-label-tertiary);\n background: 0 0;\n border: none;\n flex: none;\n justify-content: center;\n align-items: center;\n padding: 0;\n display: inline-flex;\n}\n.sd-rowaction-button:hover {\n color: var(--dsw-alias-label-primary);\n}\n\n/* 本插件自造的第三条(不是抄本,见文件头点名):官方那颗只有常态与 hover,勾选开关还要有\n 「已按下」与「这条删不动」两档。按下档**复用回收站选中行那两枚** `ghost-active-*`,不新造颜色;\n 禁用档走 opacity。 */\n.sd-rowaction-button[aria-pressed='true'] {\n background: var(--dsw-alias-button-ghost-active-fill);\n box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);\n color: var(--dsw-alias-label-primary);\n}\n.sd-rowaction-button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n}\n\n/* 本插件自造的第⑥条(不是抄本,见文件头点名):行首 16px 格里的勾选框(2026-10-17 改版,**只在\n 多选模式内渲染**)。格本身由宿主居中(16×20),这里只把包装层压成同样大小;勾选框本体是回收站\n 多选同款原生 input(`.sd-checkbox`,见上)。 */\n.sd-batch-pick-leading {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n/* 本插件自造的第⑤条(不是抄本,见文件头点名):宿主行自己的选中态只归它自己,批量选择要有整行\n 底色;行首格在场时行尾兜底要退场(两条入口不许同屏)。两条都只认宿主行已有的\n `data-row-key`;将来这个属性改名,最差是底色不亮 / 兜底多露一颗,勾选本身仍在。 */\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-checked=\"true\"]) {\n background: var(--dsw-alias-button-ghost-active-fill);\n box-shadow: inset 0 0 0 0.5px var(--dsw-alias-button-ghost-active-border);\n}\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-seat=\"leading\"]) .sd-batch-pick-action {\n display: none;\n}\n\n/* 本插件自造的第四条(不是抄本,见文件头点名):批量删除的浮条。宿主没有\"浮条\"这件控件,形状照\n 本插件自己的浮层口径来——固定底部居中,表面/描边/圆角/高程四件全走 token(与单删那张目标卡同源)。\n **层级必须低于确认框(`.sd-modal-root` 1000)**:2026-10-17 真机发现浮条在 1100 时会盖住确认框、\n 还能抢它的按钮;撤销横幅与 Tooltip 是瞬时通告、保持 1100。列方向:清单在上、操作行在下;\n 清单限高可滚,默认收起(见 `batch-bar.tsx`)。**宽度按内容展开**(`max-content`,最多视口减 48):\n 只写 `left:50% + width:auto` 时 shrink-to-fit 只拿左半屏,新增「全选/取消全选」后会把「退出多选」\n 挤到第二行;窄到 max-width 还放不下才由 `.sd-batch-bar-main` 的 `flex-wrap` 兜底。 */\n.sd-batch-bar {\n position: fixed;\n bottom: 24px;\n left: 50%;\n transform: translateX(-50%);\n z-index: 900;\n width: max-content;\n max-width: calc(100vw - 48px);\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 8px;\n padding: 8px 12px;\n background: var(--dsw-alias-bg-layer-2);\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: var(--dsw-radius-lg);\n box-shadow: var(--dsw-elevation-prominent);\n font-size: 12px;\n line-height: 18px;\n}\n\n.sd-batch-bar-main {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.sd-batch-bar-hint {\n color: var(--dsw-alias-label-tertiary);\n}\n\n.sd-batch-bar-list-head {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n/* 汇总的第二句(含 K 个子会话)比条数轻一档,和列表行里的 chip 同级。 */\n.sd-batch-bar-list-head span + span {\n color: var(--dsw-alias-label-tertiary);\n}\n\n.sd-batch-bar-list {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 240px;\n overflow: auto;\n margin: 0;\n padding: 0 0 8px;\n border-bottom: 0.5px solid var(--dsw-alias-border-l4);\n list-style: none;\n}\n\n.sd-batch-bar-item {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n}\n\n.sd-batch-bar-title {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sd-batch-bar-meta {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.sd-batch-bar-remove {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: none;\n width: 16px;\n height: 16px;\n padding: 0;\n border: none;\n border-radius: var(--dsw-radius-xs);\n background: 0 0;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n\n.sd-batch-bar-remove:hover {\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-interactive-bg-hover);\n}\n";
1503
1681
  //#endregion
1504
1682
  //#region src/client/ui/markdown.css?dsh-css-text
1505
- 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";
1683
+ var markdown_default = "/**\n * 回收站详情里**会话正文**的排版(2026-10-15 真机反馈后新增)。\n *\n * 为什么单独一个文件而不并进 `ui.css`:`ui.css` 是\"照官方 primitives 逐字抄来的那一份表\",\n * 那条 fidelity 核对(`docs/development.md` 的界面保真复核门)要的是它与官方明文**逐属性零差异**,只许文件头点名的几类例外\n * (类名前缀、`@keyframes` 改名,外加它点名的自造与已声明的差别);条数不钉在这里,钉了就会漂成假基线。这一份抄的是\n * **另一个来源**(宿主 markdown 那一张表),混进去就把那份核对弄脏了。\n *\n * **来源(逐字抄,只改选择器与下面点名的三处)**:\n * `app.asar` 里 `@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css`\n * (`.markdown` 那一支;`.compact` 一支我们不用)+ `lib/markdown/CodeBlock.module.css` 的 `pre`。\n * token 名一个没换:`--dsw-font-markdown-*` / `--dsw-alias-markdown-*` / `--dsh-content-font-*`\n * 全是宿主会话区自己吃的那组,所以宿主调内容字号时,这里跟着一起动。\n *\n * 三处**有意**的差异,每条都是因为我们的 DOM 长不出宿主那个节点:\n * ① 宿主给表包一层 `.tableScroll`(div)再横向滚动;react-markdown 不产这层 ⇒ 滚动挂在 `table`\n * 自己身上(`display:block` + 匿名表盒)。这条得真机量过才算成立。\n * ② 宿主的代码卡有 banner 行(语言名 + 复制),四个圆角被拆成\"上圆角在 banner、下圆角在 pre\";\n * 我们没有 banner ⇒ 圆角整块给 `pre`。\n * ③ 宿主的链接可点(点开外链/文件),我们不碰宿主导航,链接渲成不可点的文本 ⇒ 保留链接色与字重,\n * **去掉** hover 的下划线(那是\"点我\"的邀请)。\n *\n * 抄过来但用不上的选择器已删:`.fileLink` / `.fileMention` / `.linkIcon` / `.image` / `.imageAlt`\n * (图片渲成本地化占位,见 `copy.ts` 的 `detail.image` / `detail.image.alt`)/ `.katex-display` /\n * `:global(.md-code-block)` / `.shiki`(高亮由宿主的 highlighter 提供,我们没有)。\n */\n.sd-md {\n min-width: 0;\n overflow-wrap: anywhere;\n font: var(--dsw-font-markdown-base);\n color: var(--dsw-alias-label-primary);\n}\n\n.sd-md strong {\n font-weight: 600;\n}\n\n.sd-md h1 {\n font: var(--dsw-font-markdown-h1);\n margin: 32px 0 16px;\n}\n\n.sd-md h2 {\n font: var(--dsw-font-markdown-h2);\n margin: 32px 0 16px;\n}\n\n.sd-md h3 {\n font: var(--dsw-font-markdown-h3);\n margin: 32px 0 16px;\n}\n\n.sd-md h4 {\n font: var(--dsw-font-markdown-h4);\n margin: 16px 0;\n}\n\n.sd-md :where(h5, h6) {\n font: var(--dsw-font-markdown-base-strong);\n margin: 16px 0;\n}\n\n.sd-md :where(h1, h2, h3, h4, h5, h6) strong {\n font-weight: inherit;\n}\n\n.sd-md p {\n margin: 16px 0;\n}\n\n/* Tighten h4–h6 against a following list (design: 8px gap). */\n.sd-md :where(h4, h5, h6) + :where(ul, ol) {\n margin-top: 8px;\n}\n\n.sd-md :where(h4, h5, h6):has(+ :where(ul, ol)) {\n margin-bottom: 8px;\n}\n\n/* 见文件头差异③:颜色与字重照抄,hover 的 dotted 下划线不抄——这里没有可点的东西。 */\n.sd-md-link {\n color: var(--dsw-alias-link);\n font-weight: 500;\n}\n\n.sd-md :where(ul, ol) {\n margin: 16px 0;\n padding-left: 18px;\n}\n\n.sd-md li:not(:first-child) {\n margin-top: 6px;\n}\n\n.sd-md li > :where(ul, ol) {\n margin-top: 4px;\n}\n\n.sd-md li::marker {\n line-height: 24px;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */\n.sd-md :where(ul, ol) ol {\n list-style-position: inside;\n padding-left: 0;\n}\n\n.sd-md :where(ul, ol) ol li p {\n display: inline;\n}\n\n.sd-md li > p {\n margin: 8px 0;\n}\n\n.sd-md li > *:first-child {\n margin-top: 0;\n}\n\n.sd-md li > *:last-child {\n margin-bottom: 0;\n}\n\n.sd-md hr {\n display: block;\n border: none;\n height: 0.5px;\n margin: 32px 0;\n background: var(--dsw-alias-border-l2);\n}\n\n.sd-md blockquote {\n border-left: 2px solid var(--dsw-alias-label-caption);\n margin: 16px 0 0;\n padding-left: 14px;\n}\n\n/* 代码卡:宿主的卡片由 `.block`(底色+圆角)与其内 `pre`(字号+内边距)两层拼成。\n 我们只有 `pre` 这一层,所以两层的声明合成在这里——见文件头差异②。 */\n.sd-md pre {\n font: var(--dsw-font-markdown-code-block);\n margin: 16px 0;\n padding: 16px;\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-markdown-code-block);\n border-radius: var(--dsw-radius-lg);\n overflow-x: auto;\n white-space: pre-wrap;\n word-break: break-all;\n}\n\n.sd-md pre code {\n font: inherit;\n background: none;\n padding: 0;\n}\n\n.sd-md :not(pre) > code {\n display: inline-flex;\n align-items: center;\n box-sizing: border-box;\n font: var(--dsw-font-markdown-code);\n font-family: var(--ds-font-family-code);\n font-size: 0.875em !important;\n background-color: var(--dsw-alias-markdown-inline-code);\n border: 0.5px solid var(--dsw-alias-border-l1);\n border-radius: var(--dsw-radius-sm);\n padding: 0 5px;\n}\n\n.sd-md :where(h1, h2, h3, h4, h5, h6) code {\n font: inherit;\n font-family: var(--ds-font-family-code);\n}\n\n.sd-md input[type='checkbox'] {\n margin: 0 8px 0 0;\n accent-color: var(--dsw-alias-label-secondary);\n}\n\n/* 见文件头差异①:宿主是 `.tableScroll > table`,我们没有那层 div。 */\n.sd-md table {\n display: block;\n box-sizing: border-box;\n max-width: 100%;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n border-collapse: collapse;\n width: max-content;\n}\n\n.sd-md th {\n text-align: start;\n padding: 10px 16px;\n border-bottom: 0.5px solid var(--dsw-alias-border-l3);\n border-top: none;\n font: var(--dsw-font-markdown-table-head);\n max-width: 320px;\n max-width: min(30vw, 320px);\n min-width: 100px;\n}\n\n.sd-md td {\n padding: 10px 16px;\n border-bottom: 0.5px solid var(--dsw-alias-border-l2);\n font: var(--dsw-font-markdown-table);\n max-width: 320px;\n max-width: min(30vw, 320px);\n min-width: 100px;\n}\n\n.sd-md th:first-child,\n.sd-md td:first-child {\n padding-left: 0;\n}\n\n.sd-md td:last-child {\n padding-right: 0;\n}\n\n.sd-md table code {\n font-size: 11px;\n}\n\n.sd-md > *:first-child,\n.sd-md p:first-child {\n margin-top: 0 !important;\n}\n\n.sd-md > *:last-child,\n.sd-md p:last-child {\n margin-bottom: 0 !important;\n}\n\n/* 图片在回收站里读不到(路径属于会话当时的 cwd),渲成一句说明,字色用宿主的 tertiary。 */\n.sd-md-image {\n color: var(--dsw-alias-label-tertiary);\n font-style: italic;\n}\n";
1506
1684
  //#endregion
1507
1685
  //#region src/client/ui/styles.ts
1508
1686
  /**
@@ -1759,6 +1937,9 @@ window.__ModuleLoader__.load({
1759
1937
  title,
1760
1938
  live
1761
1939
  });
1940
+ return () => {
1941
+ unregisterHostRowInfo(sessionId);
1942
+ };
1762
1943
  }, [
1763
1944
  sessionId,
1764
1945
  title,
@@ -1794,21 +1975,26 @@ window.__ModuleLoader__.load({
1794
1975
  //#endregion
1795
1976
  //#region src/client/busy-gate.ts
1796
1977
  /**
1797
- * 「这一趟还算忙」的一枚闸——面板与两个确认框共用的一份实现(各面各持一枚实例)。
1978
+ * 「这一趟还算忙」的一枚闸——面板、两个确认框与撤销横幅共用的一份实现(各面各持一枚实例)。
1798
1979
  *
1799
- * 为什么收回一处:三个面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有一条:
1800
- * 任务落地或抛错都把 busy 放回 false;不做引用计数——同一实例同一时刻只跑一趟写动作,多一层计数
1980
+ * 为什么收回一处:各面要的是同一套判据,各写一份只会漂移出第二套。闸的判据只有两条:
1981
+ * 任务落地或抛错都把 busy 放回 false;同一实例同一时刻只跑一趟——在飞时再包一趟会被**同步**
1982
+ * 挡回(`busy` 是 state,要等下一拍才上屏,同一 tick 的连点拦不住),不做引用计数,多一层计数
1801
1983
  * 只会把「这一趟什么时候算完」变成第二个判据。
1802
1984
  *
1803
- * 边界:闸只拥有 busy 的生命周期,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
1985
+ * 边界:闸只拥有 busy 的生命周期与同实例防重,不拥有防抖、不拥有排队、不拥有重读(那是 `runWrite` 的整趟)。
1804
1986
  */
1805
1987
  function useBusyGate() {
1806
1988
  const [busy, setBusy] = (0, react.useState)(false);
1989
+ const flying = (0, react.useRef)(false);
1807
1990
  const around = (0, react.useCallback)(async (task) => {
1991
+ if (flying.current) return void 0;
1992
+ flying.current = true;
1808
1993
  setBusy(true);
1809
1994
  try {
1810
1995
  return await task();
1811
1996
  } finally {
1997
+ flying.current = false;
1812
1998
  setBusy(false);
1813
1999
  }
1814
2000
  }, []);
@@ -2018,7 +2204,7 @@ window.__ModuleLoader__.load({
2018
2204
  /**
2019
2205
  * 回收站清单的**失效通告台**:动过回收站的动作戳一下,清单面板据此重新取数。
2020
2206
  *
2021
- * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(refreshAll)`),而删除发生在
2207
+ * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(reload)`),而删除发生在
2022
2208
  * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
2023
2209
  * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-notices.ts`)
2024
2210
  * 同理。
@@ -2115,13 +2301,18 @@ window.__ModuleLoader__.load({
2115
2301
  function bindMainView(ctx) {
2116
2302
  client = ctx;
2117
2303
  }
2304
+ /** 宿主服务是否可用:把检查写成类型谓词,调用点拿到的就是 `ReadyUiWorkspace`。
2305
+ * `ctx.get` 的窄镜像声明成可能回 null(宿主可回 null),所以空值判据必须同时认 undefined / null。 */
2306
+ function isReadyUiWorkspace(candidate) {
2307
+ if (candidate === void 0 || candidate === null) return false;
2308
+ if (typeof candidate.clearMain !== "function" || typeof candidate.openSession !== "function") return false;
2309
+ if (candidate.selection === void 0 || typeof candidate.selection.getSnapshot !== "function") return false;
2310
+ return true;
2311
+ }
2118
2312
  function uiWorkspaceOf() {
2119
2313
  try {
2120
2314
  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;
2315
+ return isReadyUiWorkspace(candidate) ? candidate : void 0;
2125
2316
  } catch {
2126
2317
  return;
2127
2318
  }
@@ -2162,7 +2353,9 @@ window.__ModuleLoader__.load({
2162
2353
  const targets = sessionIds.filter((id) => closedByUs.has(id));
2163
2354
  if (targets.length === 0) return;
2164
2355
  for (const id of targets) closedByUs.delete(id);
2165
- openWhenListed(targets[targets.length - 1]);
2356
+ const last = targets[targets.length - 1];
2357
+ if (last === void 0) return;
2358
+ openWhenListed(last);
2166
2359
  }
2167
2360
  /**
2168
2361
  * 面板恢复已经替这些会话完成了“回来”的结算:只忘掉我们关过的那几条,不重开、不抢焦点。
@@ -2198,7 +2391,7 @@ window.__ModuleLoader__.load({
2198
2391
  if (!ticket.current) return;
2199
2392
  if (!canTakeFocus()) return;
2200
2393
  try {
2201
- ui.openSession?.(sessionId);
2394
+ ui.openSession(sessionId);
2202
2395
  } catch {}
2203
2396
  };
2204
2397
  if (listReady()) {
@@ -2307,9 +2500,12 @@ window.__ModuleLoader__.load({
2307
2500
  }
2308
2501
  //#endregion
2309
2502
  //#region src/client/write-seats.ts
2310
- /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。 */
2503
+ /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。
2504
+ * 三种形状各有意思,不许互相冒充:字符串 = 这一条失败了(原文);`null` = 跑了、这一条没有失败要说;
2505
+ * `undefined` = 闸把这一趟挡回来了、什么都没跑(框不收,等在飞那一趟的结论——先收就会把它的失败原文
2506
+ * 落在已经卸载的确认框上)。 */
2311
2507
  async function deleteSessionFromMenu(deps, input, gate) {
2312
- return (await runWrite({
2508
+ const outcome = await runWrite({
2313
2509
  deps,
2314
2510
  action: "delete",
2315
2511
  ids: [input.sessionId],
@@ -2322,10 +2518,13 @@ window.__ModuleLoader__.load({
2322
2518
  })
2323
2519
  },
2324
2520
  gate
2325
- })).failures.get(input.sessionId) ?? null;
2521
+ });
2522
+ if (outcome === void 0) return void 0;
2523
+ return outcome.failures.get(input.sessionId) ?? null;
2326
2524
  }
2327
2525
  /** 批量删除座位(宿主会话列表的多选 → 批量确认框):一次 `/apply`,撤销名单只收 ok 支的条目 id(跟着组走的幂等条不单列)。
2328
- * 只收 id:标题是浮条的呈现,不参与这一次写入。 */
2526
+ * 只收 id:标题是浮条的呈现,不参与这一次写入。**被闸挡回时交回 `undefined`**——这一趟没跑,
2527
+ * 浮条与确认框都不许照着它收缩名单或关框(那会吞掉在飞那一趟的结果)。 */
2329
2528
  async function deleteSessionsFromBatch(deps, ids, gate) {
2330
2529
  return (await runWrite({
2331
2530
  deps,
@@ -2339,19 +2538,42 @@ window.__ModuleLoader__.load({
2339
2538
  })
2340
2539
  },
2341
2540
  gate
2342
- })).failures;
2343
- }
2344
- /** 外部恢复座位(撤销横幅):没有 busy 面,不传闸;主视图重开归 settlement,这里只让清单失效。 */
2345
- function restoreFromUndo(deps, input) {
2346
- return runWrite({
2541
+ }))?.failures;
2542
+ }
2543
+ /** 外部恢复座位:拥有整趟——ids 组装、runWrite、三种回执形状各改写一次横幅、成功收自己。
2544
+ * 闸在调用方(横幅组件每条通知一枚);座位不传闸(双套会让外层 busy 提前落)。
2545
+ * 单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
2546
+ * null 是协议错);失败就把原因换到同一条横幅上,不静默。多条逐条失败时横幅要说清**条数与各条
2547
+ * 原因**(原因去重)——只显第一条会把条数与其余原因折没(issue #8 第 5 条);单条失败仍原文上屏。
2548
+ * settlement 负责重开删除时被关掉的会话(只认 main-view 自己记过的)并让清单重读;
2549
+ * 座位**不传 `dismissUndo`**——部分失败的回滚会让 settlement 先熄灭横幅,随后 `failNotice` 因
2550
+ * `ownsNotice` 落空而吞掉失败原文。 */
2551
+ async function restoreFromUndo(deps, notice, t) {
2552
+ const ids = notice.kind === "one" ? [notice.entryId] : notice.entryIds;
2553
+ if (notice.kind === "one" && notice.entryId === "") return;
2554
+ const outcome = await runWrite({
2347
2555
  deps,
2348
2556
  action: "restore",
2349
- ids: input.ids,
2350
- single: input.single,
2557
+ ids,
2558
+ single: notice.kind === "one",
2351
2559
  presentation: { invalidate: invalidateTrash }
2352
2560
  });
2561
+ if (!outcome.ok) {
2562
+ failNotice(notice, outcome.failure.message);
2563
+ return;
2564
+ }
2565
+ const failure = outcome.failures.values().next().value;
2566
+ if (failure !== void 0) {
2567
+ failNotice(notice, outcome.failures.size > 1 ? t("undo.failedBatch", {
2568
+ n: outcome.failures.size,
2569
+ reasons: [...new Set(outcome.failures.values())].join(" · ")
2570
+ }) : failure);
2571
+ return;
2572
+ }
2573
+ if (ownsNotice(notice)) clearUndoNotice();
2353
2574
  }
2354
- /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。 */
2575
+ /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。
2576
+ * 闸被面板那一趟占着时交回 `undefined`(连点不发第二条),面板照它什么都不改。 */
2355
2577
  function restoreFromPanel(input) {
2356
2578
  return runWrite({
2357
2579
  deps: input.deps,
@@ -2368,7 +2590,8 @@ window.__ModuleLoader__.load({
2368
2590
  refresh: input.refreshAfterWrite
2369
2591
  });
2370
2592
  }
2371
- /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。 */
2593
+ /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。
2594
+ * 与恢复座位同一条:闸被占着时交回 `undefined`,这一趟不改任何界面后果。 */
2372
2595
  function purgeFromPanel(input) {
2373
2596
  return runWrite({
2374
2597
  deps: input.deps,
@@ -2466,6 +2689,7 @@ window.__ModuleLoader__.load({
2466
2689
  sessionId: current.sessionId,
2467
2690
  title: current.title
2468
2691
  }, gate);
2692
+ if (failure === void 0) return;
2469
2693
  if (failure !== null) {
2470
2694
  setFailure(failure);
2471
2695
  return;
@@ -2603,7 +2827,7 @@ window.__ModuleLoader__.load({
2603
2827
  const [counts, setCounts] = (0, react.useState)(null);
2604
2828
  const ids = current.items.map((item) => item.sessionId);
2605
2829
  const failedIds = [...failures.keys()];
2606
- const totalKids = counts === null ? 0 : [...counts.values()].reduce((sum, n) => sum + n, 0);
2830
+ const totalKids = counts === null ? 0 : current.items.reduce((sum, item) => sum + (counts.get(item.sessionId) ?? 0), 0);
2607
2831
  (0, react.useEffect)(() => {
2608
2832
  let alive = true;
2609
2833
  fetchCounts(deps, current.items.map((item) => item.sessionId)).then((result) => {
@@ -2616,6 +2840,7 @@ window.__ModuleLoader__.load({
2616
2840
  const run = async () => {
2617
2841
  setFailures(/* @__PURE__ */ new Map());
2618
2842
  const failed = await deleteSessionsFromBatch(deps, current.items.map((item) => item.sessionId), gate);
2843
+ if (failed === void 0) return;
2619
2844
  shrinkPicksToFailures([...failed.keys()]);
2620
2845
  if (failed.size === 0) {
2621
2846
  clearDeleteBatchRequest();
@@ -2923,12 +3148,16 @@ window.__ModuleLoader__.load({
2923
3148
  if (list === void 0) byProject.set(entry.projectDir, [entry]);
2924
3149
  else list.push(entry);
2925
3150
  }
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);
3151
+ return [...byProject].flatMap(([projectDir, list]) => {
3152
+ const first = list[0];
3153
+ if (first === void 0) return [];
3154
+ return [{
3155
+ projectDir,
3156
+ label: projectLabel(projectDir, t),
3157
+ latest: first.deletedAt,
3158
+ entries: list
3159
+ }];
3160
+ }).sort((a, b) => b.latest - a.latest);
2932
3161
  }
2933
3162
  //#endregion
2934
3163
  //#region src/client/attachment-view.tsx
@@ -2957,7 +3186,7 @@ window.__ModuleLoader__.load({
2957
3186
  [PARAMS.attachmentKind]: a.kind
2958
3187
  });
2959
3188
  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()}`;
3189
+ return `${SESSION_DELETE_API_PREFIX}${ROUTES.attachment.path}?${query.toString()}`;
2961
3190
  }
2962
3191
  /** 空串与缺席同义:宿主侧的 `name` 是可选建议名,空串不是名字(兜底文案与"不带 name 查询"都看它)。 */
2963
3192
  function nameOf(a) {
@@ -3047,7 +3276,8 @@ window.__ModuleLoader__.load({
3047
3276
  setSaveError(null);
3048
3277
  try {
3049
3278
  const response = await fetch(attachmentUrl(attachment));
3050
- const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
3279
+ const mediaType = response.headers.get("content-type")?.split(";")[0];
3280
+ const contentType = mediaType === void 0 ? "" : mediaType.trim();
3051
3281
  if (!response.ok || !contentType.startsWith("image/")) {
3052
3282
  let message = `HTTP ${String(response.status)}`;
3053
3283
  if (contentType === "application/json") try {
@@ -3495,21 +3725,21 @@ window.__ModuleLoader__.load({
3495
3725
  }
3496
3726
  //#endregion
3497
3727
  //#region src/client/orphan-maintenance.tsx
3498
- function OrphanMaintenance({ query, busy, t }) {
3499
- const { orphanReport, orphanFailure, orphanNotice, orphanConfirm } = query;
3728
+ function OrphanMaintenance({ section, t }) {
3729
+ const { report, failure, notice, confirm, busy } = section;
3500
3730
  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, {
3731
+ report !== null && report.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(MaintenanceBanner, {
3502
3732
  slot: "orphan-banner",
3503
3733
  busy,
3504
3734
  t,
3505
3735
  gapBelow: true,
3506
- onOpen: () => query.openOrphanPreview(),
3736
+ onOpen: () => section.openPreview(),
3507
3737
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3508
3738
  style: {
3509
3739
  fontSize: 13,
3510
3740
  lineHeight: "19px"
3511
3741
  },
3512
- children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
3742
+ children: t("trash.orphans.banner", { count: report.totalSessions })
3513
3743
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3514
3744
  style: {
3515
3745
  fontSize: 12,
@@ -3517,19 +3747,19 @@ window.__ModuleLoader__.load({
3517
3747
  color: "var(--dsw-alias-label-tertiary)"
3518
3748
  },
3519
3749
  children: t("trash.orphans.meta", {
3520
- size: fileSizeText(orphanReport.totalBytes),
3521
- parents: orphanReport.missingParents
3750
+ size: fileSizeText(report.totalBytes),
3751
+ parents: report.missingParents
3522
3752
  })
3523
3753
  })]
3524
3754
  }) : null,
3525
- orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3526
- message: orphanFailure,
3755
+ failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3756
+ message: failure,
3527
3757
  busy,
3528
3758
  t,
3529
3759
  gapBelow: true,
3530
- onRetry: () => void query.scanOrphans()
3760
+ onRetry: () => void section.scan()
3531
3761
  }) : null,
3532
- orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3762
+ notice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3533
3763
  "data-dsh-session-delete": "orphan-notice",
3534
3764
  role: "status",
3535
3765
  style: {
@@ -3557,29 +3787,127 @@ window.__ModuleLoader__.load({
3557
3787
  lineHeight: "17px",
3558
3788
  color: "var(--dsw-alias-label-secondary)"
3559
3789
  },
3560
- children: orphanNotice
3790
+ children: notice
3561
3791
  })]
3562
3792
  }) : null,
3563
- orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3564
- report: orphanReport,
3793
+ confirm && report !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3794
+ report,
3565
3795
  busy,
3566
3796
  t,
3567
- onCancel: () => query.setOrphanConfirm(false),
3568
- onConfirm: () => void query.cleanOrphans()
3797
+ onCancel: () => section.cancel(),
3798
+ onConfirm: () => void section.clean()
3569
3799
  }) : null
3570
3800
  ] });
3571
3801
  }
3572
3802
  //#endregion
3803
+ //#region src/client/use-maintenance-section.ts
3804
+ /**
3805
+ * 维护卡一段维护的**泛型状态机**:报告 / 失败 / 中性提示 / 确认框 / 在飞档位 / 最新票 / 清完必重查。
3806
+ * 孤儿会话与孤儿附件曾是这台机器按前缀各抄一份的两段——这里收成一台,两段各自只带自己的
3807
+ * 远程调用、判据与拼句(`use-maintenance.ts` 两次实例化)。
3808
+ *
3809
+ * 三条承重规矩(ADR-0021)在这一层保证,两段都不可绕过:
3810
+ * - **没有自动扫描**:本 module 不挂 effect、没有定时器;挂载只交初始态(报告 `null` = 尚未检查)。
3811
+ * - **读不到就说读不到**:检查失败回 `null` + 原文(`scanFailureText` 由各段拼),不折成"没有发现"。
3812
+ * - **清理后必重查**:清完立刻重读;回执句子由 `settle` 拼好后**等重查赢了才落地**——顺序反了,
3813
+ * 重查成功会清掉旧提示,把这一趟的后果吞掉。
3814
+ *
3815
+ * **不变量由构造保证**:开确认框只有 `openPreview()` 一条路(先清旧失败与旧提示再开),收框只有
3816
+ * `cancel()`(只收框,不碰提示 / 失败);界面上写不出"裸把 confirm 摆 true"这种开门,于是
3817
+ * "确认框开着、上一趟的失败句还挂着"的组合从类型上就不存在。`busy` 同理由段自己给
3818
+ * (在飞即忙),不再作为 prop 逐层传。
3819
+ *
3820
+ * 动作是每渲染重建的普通闭包、不包 `useCallback`:config 每渲染本来就要重建,包了也稳定不下来;
3821
+ * 这些动作只被点击触发,没有按身份依赖它们的 effect(`useIdleClose` 只在调用时读它们)。
3822
+ */
3823
+ function useMaintenanceSection(config) {
3824
+ const [report, setReport] = (0, react.useState)(null);
3825
+ const [failure, setFailure] = (0, react.useState)(null);
3826
+ const [notice, setNotice] = (0, react.useState)(null);
3827
+ const [confirm, setConfirm] = (0, react.useState)(false);
3828
+ const [phase, setPhase] = (0, react.useState)(null);
3829
+ const [latest] = (0, react.useState)(createLatestWins);
3830
+ /**
3831
+ * 一次检查(不碰 phase)。交回这张票是否仍是赢家:清理后的重查被后发的检查顶掉时,
3832
+ * 由赢家负责把读数与 busy 一起收尾,输家什么都不落地。**输家不许替赢家清 `phase`**——phase 归
3833
+ * 赢下最新票的那一趟收尾,输家提前清会在赢家还在飞时把界面摆成空闲(忙碌收尾与
3834
+ * `use-trash-query.ts` 的空闲判定是两套写法,判据不同源,别互相抄)。
3835
+ */
3836
+ async function rescan() {
3837
+ const ticket = latest.issue();
3838
+ const outcome = await config.scan();
3839
+ if (!ticket.current) return false;
3840
+ config.onScanWon?.();
3841
+ if (outcome.ok) {
3842
+ setReport(outcome.value);
3843
+ setFailure(null);
3844
+ setNotice(null);
3845
+ return true;
3846
+ }
3847
+ setReport(null);
3848
+ setNotice(null);
3849
+ setConfirm(false);
3850
+ setFailure(config.scanFailureText(outcome.failure.message));
3851
+ return true;
3852
+ }
3853
+ async function scan() {
3854
+ setPhase("scanning");
3855
+ if (await rescan()) setPhase(null);
3856
+ }
3857
+ async function clean() {
3858
+ const before = report;
3859
+ if (before === null) {
3860
+ setConfirm(false);
3861
+ return;
3862
+ }
3863
+ const pending = config.clean(before);
3864
+ if (pending === null) {
3865
+ setConfirm(false);
3866
+ return;
3867
+ }
3868
+ setPhase("cleaning");
3869
+ setFailure(null);
3870
+ setNotice(null);
3871
+ const outcome = await pending;
3872
+ setConfirm(false);
3873
+ const preset = config.beforeRescan?.(outcome);
3874
+ if (preset !== null && preset !== void 0) setReport(preset);
3875
+ if (!await rescan()) return;
3876
+ const plan = config.settle(outcome, before);
3877
+ if (plan.notice !== void 0) setNotice(plan.notice);
3878
+ if (plan.failure !== void 0) setFailure(plan.failure);
3879
+ setPhase(null);
3880
+ }
3881
+ function openPreview() {
3882
+ setFailure(null);
3883
+ setNotice(null);
3884
+ setConfirm(true);
3885
+ }
3886
+ function cancel() {
3887
+ setConfirm(false);
3888
+ }
3889
+ return {
3890
+ report,
3891
+ failure,
3892
+ notice,
3893
+ confirm,
3894
+ phase,
3895
+ busy: phase !== null,
3896
+ scan,
3897
+ openPreview,
3898
+ clean,
3899
+ cancel
3900
+ };
3901
+ }
3902
+ //#endregion
3573
3903
  //#region src/client/use-maintenance.ts
3574
3904
  /**
3575
- * 插件设置页「维护」卡的手动编排:孤儿会话 + 孤儿附件,各一组读数、提示与动作。
3905
+ * 插件设置页「维护」卡的手动编排:孤儿会话 + 孤儿附件两段。
3576
3906
  *
3577
- * 三条承重规矩:
3578
- * - **没有自动扫描**:本 module 不挂 `useEffect`、没有定时器;只有用户点「检查 / 查看并清理 / 清理」
3579
- * 才发请求。挂载只交初始态(报告 `null` = 尚未检查)。
3580
- * - **读不到就说读不到**:两份报告失败都回 `null` + 原文,不折成「没有发现」。
3581
- * - **清理后必重查**:报告说的是"报告那一刻"的盘;清完立刻重读。回执里的中性 / 失败 / 部分计数
3582
- * 先读出来、重查之后再上屏——重查成功会清掉旧提示,顺序反了会把这一趟的后果吞掉。
3907
+ * 状态机本身(报告 / 失败 / 中性提示 / 确认框 / 在飞档位 / 最新票 / 清完必重查)收在
3908
+ * `src/client/use-maintenance-section.ts` 的泛型段 module 里,两段各实例化一次;这一层只剩薄 glue:
3909
+ * 各段自己的远程调用、"没活可干"的判据与回执拼句。三条承重规矩(没有自动扫描 / 挂载只交初始态 /
3910
+ * 没有定时器)由段 module 保证,这一层同样不挂 effect。
3583
3911
  *
3584
3912
  * 写动作走既有的两段:孤儿会话是 `/apply` 的 `orphan-clean`(回执判读复用 `receipt.ts` 的读法),
3585
3913
  * 孤儿附件是聚合的 `POST /attachment-clean`(回执就是 `AttachmentCleanupWire` 本身)。
@@ -3603,178 +3931,63 @@ window.__ModuleLoader__.load({
3603
3931
  size: fileSizeText(a.freedBytes)
3604
3932
  }) : null;
3605
3933
  }
3934
+ /** orphan-clean 回执里的"没清成"句:服务端原文 + 部分计数("删了一半才炸"的那部分释放量)。
3935
+ * 首条失败由调用方在判过非空之后作为参数交进来——签名因此写明前提,函数体不必再赌 `failed[0]` 在。 */
3936
+ function orphanCleanFailureSentence(summary, first, t) {
3937
+ const message = first.error.message;
3938
+ return `${t("trash.orphans.cleanFailed", {
3939
+ count: summary.failed.length,
3940
+ message
3941
+ })}${summary.partial.removed > 0 || summary.partial.freedBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
3942
+ removed: summary.partial.removed,
3943
+ size: fileSizeText(summary.partial.freedBytes)
3944
+ })}` : ""}`;
3945
+ }
3606
3946
  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
3947
  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;
3948
+ const orphans = useMaintenanceSection({
3949
+ scan: () => callRemote(() => deps.scanOrphans()),
3950
+ onScanWon: () => {
3951
+ setOrphanAttachmentNotice(null);
3952
+ },
3953
+ clean: (report) => {
3954
+ if (report.roots.length === 0) return null;
3955
+ setOrphanAttachmentNotice(null);
3956
+ return callRemote(() => deps.applyOutcomes("orphan-clean", report.roots));
3957
+ },
3958
+ scanFailureText: (message) => t("trash.orphans.failure", { message }),
3959
+ settle: (outcome, report) => {
3960
+ if (!outcome.ok) return { failure: t("trash.orphans.failure", { message: outcome.failure.message }) };
3961
+ const { summary } = summarizeReceipt(readReceipt("orphan-clean", report.roots, outcome.value));
3962
+ if (summary.attachmentCleanup !== void 0) setOrphanAttachmentNotice(attachmentCleanupNotice(summary.attachmentCleanup, t));
3963
+ const plan = {};
3964
+ if (summary.neutralIds.length > 0) plan.notice = t("trash.orphans.cleanGone", { count: summary.neutralIds.length });
3965
+ const firstFailed = summary.failed[0];
3966
+ if (firstFailed !== void 0) plan.failure = orphanCleanFailureSentence(summary, firstFailed, t);
3967
+ return plan;
3743
3968
  }
3744
- setAttachmentNotice(attachmentCleanupNotice(outcome.value, t));
3745
- setAttachmentPhase(null);
3746
- }, [
3747
- attachmentReport,
3748
- deps,
3749
- rescanAttachments,
3750
- t
3751
- ]);
3969
+ });
3970
+ const attachments = useMaintenanceSection({
3971
+ scan: () => callRemote(() => deps.scanAttachmentOrphans()),
3972
+ clean: (report) => report.count === 0 ? null : callRemote(() => deps.cleanAttachmentOrphans()),
3973
+ beforeRescan: (outcome) => {
3974
+ if (!outcome.ok) return null;
3975
+ const receipt = outcome.value;
3976
+ return receipt.failed === 0 && receipt.skipped === void 0 ? {
3977
+ count: 0,
3978
+ totalBytes: 0,
3979
+ items: []
3980
+ } : null;
3981
+ },
3982
+ scanFailureText: (message) => t("maintenance.attachments.scanFailed", { message }),
3983
+ settle: (outcome) => outcome.ok ? { notice: attachmentCleanupNotice(outcome.value, t) } : { failure: t("maintenance.attachments.cleanError", { message: outcome.failure.message }) }
3984
+ });
3752
3985
  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
3986
+ orphans: {
3987
+ ...orphans,
3988
+ attachmentNotice: orphanAttachmentNotice
3989
+ },
3990
+ attachments
3778
3991
  };
3779
3992
  }
3780
3993
  //#endregion
@@ -3790,7 +4003,14 @@ window.__ModuleLoader__.load({
3790
4003
  padding: "14px 16px"
3791
4004
  };
3792
4005
  /** 一段的标题行:标题 + 说明 + 状态 + 检查按钮。状态按优先级:清理中 / 检查中 / 检查失败 / 尚未检查 / 没有发现 / N。 */
3793
- function MaintenanceSection({ slot, title, hint, status, busy, onScan, t, children }) {
4006
+ function MaintenanceSection({ slot, title, hint, count, countKey, section, onScan, t, children }) {
4007
+ const status = statusText({
4008
+ phase: section.phase,
4009
+ failure: section.failure,
4010
+ count,
4011
+ countKey,
4012
+ t
4013
+ });
3794
4014
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3795
4015
  "data-dsh-session-delete": slot,
3796
4016
  style: {
@@ -3847,7 +4067,7 @@ window.__ModuleLoader__.load({
3847
4067
  variant: "outline",
3848
4068
  size: "sm",
3849
4069
  style: { flex: "none" },
3850
- disabled: busy,
4070
+ disabled: section.busy,
3851
4071
  onClick: onScan,
3852
4072
  children: t("maintenance.scan")
3853
4073
  })
@@ -3871,41 +4091,41 @@ window.__ModuleLoader__.load({
3871
4091
  * 孤儿附件段:报告 >0 时出横幅(数量 + 体积 + 查看并清理);检查/清理失败在原位摆原文 + 重试;
3872
4092
  * 上一趟清理的回执单独一行(已清 / 跳过 / 逐文件失败)。确认框把三条边界说完再动手。
3873
4093
  */
3874
- function AttachmentMaintenance({ query, busy, t }) {
3875
- const { attachmentReport, attachmentFailure, attachmentNotice, attachmentConfirm } = query;
4094
+ function AttachmentMaintenance({ section, t }) {
4095
+ const { report, failure, notice, confirm, busy } = section;
3876
4096
  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, {
4097
+ !busy && report !== null && report.count > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceBanner, {
3878
4098
  slot: "attachment-banner",
3879
4099
  busy,
3880
4100
  t,
3881
- onOpen: () => query.openAttachmentPreview(),
4101
+ onOpen: () => section.openPreview(),
3882
4102
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3883
4103
  style: {
3884
4104
  fontSize: 13,
3885
4105
  lineHeight: "19px"
3886
4106
  },
3887
4107
  children: [
3888
- t("maintenance.attachments.count", { count: attachmentReport.count }),
4108
+ t("maintenance.attachments.count", { count: report.count }),
3889
4109
  " · ",
3890
- fileSizeText(attachmentReport.totalBytes)
4110
+ fileSizeText(report.totalBytes)
3891
4111
  ]
3892
4112
  })
3893
4113
  }) : null,
3894
- attachmentFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
4114
+ failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3895
4115
  slot: "attachment-failure",
3896
- message: attachmentFailure,
4116
+ message: failure,
3897
4117
  busy,
3898
4118
  t,
3899
- onRetry: () => void query.scanAttachmentOrphans()
4119
+ onRetry: () => void section.scan()
3900
4120
  }) : 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,
4121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: notice }),
4122
+ confirm && report !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentConfirm, {
4123
+ count: report.count,
4124
+ items: report.items,
3905
4125
  busy,
3906
4126
  t,
3907
- onCancel: () => query.setAttachmentConfirm(false),
3908
- onConfirm: () => void query.cleanAttachmentOrphans()
4127
+ onCancel: () => section.cancel(),
4128
+ onConfirm: () => void section.clean()
3909
4129
  }) : null
3910
4130
  ] });
3911
4131
  }
@@ -3993,39 +4213,27 @@ window.__ModuleLoader__.load({
3993
4213
  slot: "maintenance-orphans",
3994
4214
  title: t("maintenance.orphans.title"),
3995
4215
  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(),
4216
+ count: maintenance.orphans.report?.totalSessions ?? null,
4217
+ countKey: "maintenance.orphans.count",
4218
+ section: maintenance.orphans,
4219
+ onScan: () => void maintenance.orphans.scan(),
4005
4220
  t,
4006
4221
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
4007
- query: maintenance,
4008
- busy: maintenance.orphanBusy,
4222
+ section: maintenance.orphans,
4009
4223
  t
4010
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.orphanAttachmentNotice })]
4224
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.orphans.attachmentNotice })]
4011
4225
  }),
4012
4226
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceSection, {
4013
4227
  slot: "maintenance-attachments",
4014
4228
  title: t("maintenance.attachments.title"),
4015
4229
  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(),
4230
+ count: maintenance.attachments.report?.count ?? null,
4231
+ countKey: "maintenance.attachments.count",
4232
+ section: maintenance.attachments,
4233
+ onScan: () => void maintenance.attachments.scan(),
4025
4234
  t,
4026
4235
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentMaintenance, {
4027
- query: maintenance,
4028
- busy: maintenance.attachmentBusy,
4236
+ section: maintenance.attachments,
4029
4237
  t
4030
4238
  })
4031
4239
  })
@@ -25997,14 +26205,13 @@ window.__ModuleLoader__.load({
25997
26205
  }
25998
26206
  /**
25999
26207
  * `memo` 是必需的而不是优化:`react-markdown@10.1.0` 里**一处 memoization 都没有**(读实
26000
- * `node_modules/react-markdown/lib/index.js:175-178`——`Markdown()` 每次调用都 `createProcessor(options)`
26208
+ * `node_modules/react-markdown/lib/index.js`——`Markdown()` 每次调用都 `createProcessor(options)`
26001
26209
  * 再 `runSync(parse(file))`,`MarkdownHooks` 那一支也一样),而面板任何一次重渲(悬停、↑↓、
26002
26210
  * 「刚刚删除」那句要读 `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 的那份才是换语言那一刻的读数。
26211
+ * 按住的条件是 `text` 与**探针 `probe`**:一句按当前 locale 现算的译文,专给 comparator 当
26212
+ * 「换语言了没」的读数。为什么必须渲染时现算存进 props、为什么宿主 `t` 只能当稳定身份而不是
26213
+ * 语言读数、「借哪句」,这些都住 `copy.ts` 的 `localeProbe`——调用方与测试一律
26214
+ * `probe={localeProbe(t)}`,「借哪句」的知识不散到各调用方。
26008
26215
  * 探针没变 = 同语言同 `t` ⇒ 跳过重解析;变了(换语言或换 `t`)⇒ 重解析。
26009
26216
  */
26010
26217
  const MarkdownBody = (0, react.memo)(function MarkdownBody({ text, t }) {
@@ -26017,7 +26224,7 @@ window.__ModuleLoader__.load({
26017
26224
  children: text
26018
26225
  })
26019
26226
  });
26020
- }, (prev, next) => prev.text === next.text && prev.localeKey === next.localeKey);
26227
+ }, (prev, next) => prev.text === next.text && prev.probe === next.probe);
26021
26228
  //#endregion
26022
26229
  //#region src/client/trash-detail.tsx
26023
26230
  /**
@@ -26026,10 +26233,12 @@ window.__ModuleLoader__.load({
26026
26233
  * 三条界面约束:
26027
26234
  * - 动作(恢复/彻底删除)留在清单行右侧;详情只负责看。
26028
26235
  * - 标题优先用清册标题,空串才回落到日志的 session/title。
26029
- * - 注入条数与截断条数必须上屏,避免正文变少被误读成“会话只有这些内容”。
26236
+ * - 正文是**块**:用户/助手文字常显;思考、工具、注入、系统消息、压缩摘要默认折叠,折叠头给
26237
+ * 标签 + 首行摘要(工具另给状态)。注入也铺进正文——这是对宿主 Chat 的有意偏离(它隐藏普通注入,
26238
+ * 而这里的目标是「删之前看全」);条数另进元信息表。
26030
26239
  *
26031
- * 助手正文走 Markdown,用户提问保持原话;样式 token 与差异见 ui/markdown.css 文件头。
26032
- * 附件随消息位置渲染:用户气泡之上、助手正文之后;附件是那条消息的内容,不单开区块。
26240
+ * 助手正文与思考走 Markdown,用户提问保持原话;工具参数/结果走等宽纯文本(限高可滚)。
26241
+ * 附件随块位置渲染:用户气泡之上、助手正文之后、工具展开体末尾。
26033
26242
  */
26034
26243
  const SHELL = {
26035
26244
  display: "flex",
@@ -26123,6 +26332,107 @@ window.__ModuleLoader__.load({
26123
26332
  border: "0.5px solid var(--dsw-alias-border-l4)",
26124
26333
  borderRadius: "var(--dsw-radius-lg)"
26125
26334
  };
26335
+ const BLOCK = {
26336
+ display: "flex",
26337
+ flexDirection: "column",
26338
+ minWidth: 0
26339
+ };
26340
+ /** 折叠头:与清单组头共用 `sd-disclosure-row` 的布局/悬停/无障碍;这里把 button 的 UA 样式压掉。 */
26341
+ const BLOCK_ROW = {
26342
+ background: "none",
26343
+ border: "none",
26344
+ padding: 0,
26345
+ font: "inherit",
26346
+ textAlign: "start",
26347
+ width: "100%",
26348
+ minWidth: 0,
26349
+ gap: 6
26350
+ };
26351
+ /** 标签不参与收缩:摘要是 `1 1 auto`,标签若也可缩,长结果会把「工具调用 / 来源」挤成几个字。 */
26352
+ const BLOCK_TITLE = {
26353
+ ...ELLIPSIS,
26354
+ flex: "none",
26355
+ maxWidth: "50%",
26356
+ fontWeight: 500,
26357
+ fontSize: "var(--dsh-content-font-size-secondary, 13px)"
26358
+ };
26359
+ const BLOCK_SUMMARY = {
26360
+ ...ELLIPSIS,
26361
+ flex: "1 1 auto",
26362
+ minWidth: 0,
26363
+ fontSize: 12,
26364
+ lineHeight: "18px",
26365
+ color: "var(--dsw-alias-label-tertiary)"
26366
+ };
26367
+ const BLOCK_META = {
26368
+ flex: "none",
26369
+ fontSize: 11.5,
26370
+ lineHeight: "18px",
26371
+ color: "var(--dsw-alias-label-tertiary)",
26372
+ whiteSpace: "nowrap"
26373
+ };
26374
+ const BLOCK_BODY = {
26375
+ display: "flex",
26376
+ flexDirection: "column",
26377
+ gap: 8,
26378
+ padding: "8px 0 4px calc(22px + var(--dsh-content-font-delta, 0px))",
26379
+ minWidth: 0
26380
+ };
26381
+ const PLAIN_BODY = {
26382
+ whiteSpace: "pre-wrap",
26383
+ wordBreak: "break-word",
26384
+ fontSize: "var(--dsh-content-font-size-secondary, 13px)",
26385
+ lineHeight: "20px",
26386
+ color: "var(--dsw-alias-label-secondary)"
26387
+ };
26388
+ /** 工具参数/结果:等宽、保留换行、限高自滚——一段工具输出可能比整页还长。 */
26389
+ const CODE_BLOCK = {
26390
+ margin: 0,
26391
+ padding: "8px 10px",
26392
+ background: "var(--dsw-alias-bg-layer-2)",
26393
+ border: "0.5px solid var(--dsw-alias-border-l4)",
26394
+ borderRadius: "var(--dsw-radius-md)",
26395
+ fontFamily: "var(--ds-font-family-code)",
26396
+ fontSize: 12,
26397
+ lineHeight: "18px",
26398
+ whiteSpace: "pre-wrap",
26399
+ wordBreak: "break-word",
26400
+ maxHeight: 320,
26401
+ overflow: "auto"
26402
+ };
26403
+ const SECTION_LABEL = {
26404
+ fontSize: 11.5,
26405
+ lineHeight: "16px",
26406
+ color: "var(--dsw-alias-label-tertiary)"
26407
+ };
26408
+ /**
26409
+ * 子会话行:折叠头同族的可点行,卡片底 + 小圆角;悬停走 sd-disclosure-row 的宿主配方。
26410
+ * 两处内联覆盖各有理由:宿主类给折叠头钉死行高并随之裁溢出,导航行带内边距,须放开
26411
+ * 高度按内容撑开,否则裁字;指针光标宿主只发给带展开语义的行,导航行不借那套语义,
26412
+ * 光标就内联自给。
26413
+ */
26414
+ const SUBAGENT_ROW = {
26415
+ ...BLOCK_ROW,
26416
+ padding: "6px 10px",
26417
+ height: "auto",
26418
+ cursor: "pointer",
26419
+ background: "var(--dsw-alias-bg-layer-2)",
26420
+ border: "0.5px solid var(--dsw-alias-border-l4)",
26421
+ borderRadius: "var(--dsw-radius-md)"
26422
+ };
26423
+ const TOOLBAR$1 = {
26424
+ display: "flex",
26425
+ alignItems: "center",
26426
+ gap: 8,
26427
+ flexWrap: "wrap",
26428
+ minWidth: 0
26429
+ };
26430
+ const TRUNCATED = {
26431
+ fontSize: 11.5,
26432
+ lineHeight: "16px",
26433
+ color: "var(--dsw-alias-label-tertiary)"
26434
+ };
26435
+ const ERROR_TEXT = { color: "var(--dsw-alias-state-error-primary)" };
26126
26436
  const HOUR = 36e5;
26127
26437
  const DAY = 864e5;
26128
26438
  /** 日志里那两个时间戳缺省时的降级(`src/trash/detail.ts` 把初值写成 0,读不到就是 0)。 */
@@ -26130,11 +26440,13 @@ window.__ModuleLoader__.load({
26130
26440
  return ms === 0 ? "—" : absoluteText(ms);
26131
26441
  }
26132
26442
  /** 活跃跨度:不足一天给小时,超过给天——都是"这条会话活了多久"这一个问题的答案。
26133
- * 两端任一缺省就**不答**:`Math.max(1, …)` 会把"不知道"报成"1 小时",那是一条编出来的账。 */
26443
+ * 两端任一缺省就**不答**(不把"不知道"报成任何量级);不足一小时说"不足 1 小时",
26444
+ * 不把 10 分钟舍入成 1 小时冒充整点。 */
26134
26445
  function spanText(from, to, t) {
26135
26446
  if (from === 0 || to === 0) return void 0;
26136
26447
  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) });
26448
+ if (ms < HOUR) return t("detail.value.spanLessThanHour");
26449
+ return ms < DAY ? t("detail.value.spanHours", { n: Math.round(ms / HOUR) }) : t("detail.value.spanDays", { n: Math.round(ms / DAY) });
26138
26450
  }
26139
26451
  /** 详情头部显示的那句标题:清册优先,空串才回落到日志里的 `session/title`。 */
26140
26452
  function titleOf(entry, detail, t) {
@@ -26142,7 +26454,246 @@ window.__ModuleLoader__.load({
26142
26454
  if (detail !== void 0 && detail.logTitle !== "") return detail.logTitle;
26143
26455
  return t("common.untitled");
26144
26456
  }
26145
- function TrashDetail({ state, busy, wide, onRetry, onBack, t }) {
26457
+ /** 折叠头的摘要:第一行非空文字。 */
26458
+ function firstLine(text) {
26459
+ return (text.split("\n").find((candidate) => candidate.trim() !== "") ?? "").trim();
26460
+ }
26461
+ /** Markdown 块的摘要去掉粗体星号(与宿主 ReasoningRow 同一手法);纯文本块照原样。 */
26462
+ function markdownSummary(text) {
26463
+ return firstLine(text).replaceAll("**", "");
26464
+ }
26465
+ /** 宿主当前发过的 `source.kind` 全在这里;日志词表随宿主代次增长,新值先落到「其他上下文」。 */
26466
+ const INJECTION_LABEL_KEYS = {
26467
+ "runtime-context": "detail.injection.runtimeContext",
26468
+ "time-context": "detail.injection.timeContext",
26469
+ "agent-instructions": "detail.injection.agentInstructions",
26470
+ "skill-catalog": "detail.injection.skillCatalog",
26471
+ "agent-message": "detail.injection.agentMessage",
26472
+ "subagent-settled": "detail.injection.subagentSettled",
26473
+ "tool-jobs": "detail.injection.toolJobs",
26474
+ "skill-invocation": "detail.injection.skillInvocation",
26475
+ "user-approval": "detail.injection.userApproval",
26476
+ "goal": "detail.injection.goal"
26477
+ };
26478
+ /** 来源标签 + 悬停原串:认不出的 kind 收进「其他上下文」,原串留在 title。 */
26479
+ function injectionLabel(kind, t) {
26480
+ const key = INJECTION_LABEL_KEYS[kind];
26481
+ if (key !== void 0) {
26482
+ const text = t(key);
26483
+ return {
26484
+ text,
26485
+ title: text
26486
+ };
26487
+ }
26488
+ return {
26489
+ text: t("detail.injection.other"),
26490
+ title: kind === "" ? t("detail.injection.other") : kind
26491
+ };
26492
+ }
26493
+ function toolStatus(status, t) {
26494
+ if (status === "ok") return t("detail.block.status.ok");
26495
+ if (status === "error") return t("detail.block.status.error");
26496
+ return t("detail.block.status.unfinished");
26497
+ }
26498
+ function isDisclosureBlock(block) {
26499
+ return block.kind !== "user" && block.kind !== "assistant";
26500
+ }
26501
+ /** 一个可折叠块:折叠头一行(chevron + 标签 + 摘要 + 可选状态 + 时间),展开体由调用方给。 */
26502
+ function Disclosure({ block, label, title, summary, status, time, open, onToggle, children }) {
26503
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26504
+ "data-sd-block": block.kind,
26505
+ style: BLOCK,
26506
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
26507
+ type: "button",
26508
+ className: "sd-disclosure-row",
26509
+ "data-expandable": "",
26510
+ "aria-expanded": open,
26511
+ onClick: () => onToggle(block.id),
26512
+ style: BLOCK_ROW,
26513
+ children: [
26514
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26515
+ className: "sd-disclosure-leading",
26516
+ style: { marginRight: 0 },
26517
+ "aria-hidden": "true",
26518
+ children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 })
26519
+ }),
26520
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26521
+ style: BLOCK_TITLE,
26522
+ title: title ?? label,
26523
+ children: label
26524
+ }),
26525
+ summary === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26526
+ style: BLOCK_SUMMARY,
26527
+ title: summary,
26528
+ children: summary
26529
+ }),
26530
+ status === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26531
+ style: status.error ? {
26532
+ ...BLOCK_META,
26533
+ ...ERROR_TEXT
26534
+ } : BLOCK_META,
26535
+ children: status.text
26536
+ }),
26537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26538
+ style: BLOCK_META,
26539
+ children: time
26540
+ })
26541
+ ]
26542
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26543
+ "data-sd-block-body": true,
26544
+ style: BLOCK_BODY,
26545
+ children
26546
+ }) : null]
26547
+ });
26548
+ }
26549
+ /** 单块载荷闸截断后的提示:各块共用同一句,别有的画省略号、有的只画字。 */
26550
+ function TruncatedNote({ t }) {
26551
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26552
+ style: TRUNCATED,
26553
+ children: t("detail.block.truncated")
26554
+ });
26555
+ }
26556
+ /** 展开体走 Markdown 的折叠块(思考、压缩摘要)。 */
26557
+ function markdownFold(block, label, context) {
26558
+ const { open, toggle, probe, t } = context;
26559
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
26560
+ block,
26561
+ label,
26562
+ summary: markdownSummary(block.text),
26563
+ time: timeText(block.time),
26564
+ open: open.has(block.id),
26565
+ onToggle: toggle,
26566
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
26567
+ text: block.text,
26568
+ t,
26569
+ probe
26570
+ }), block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null]
26571
+ }, block.id);
26572
+ }
26573
+ /** 展开体走等宽原文的折叠块(注入、系统消息):这两类带附件。 */
26574
+ function plainFold(block, label, context, title) {
26575
+ const { open, toggle, t } = context;
26576
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
26577
+ block,
26578
+ label,
26579
+ title,
26580
+ summary: firstLine(block.text),
26581
+ time: timeText(block.time),
26582
+ open: open.has(block.id),
26583
+ onToggle: toggle,
26584
+ children: [
26585
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26586
+ style: PLAIN_BODY,
26587
+ children: block.text
26588
+ }),
26589
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26590
+ attachments: block.attachments,
26591
+ t,
26592
+ align: "start"
26593
+ }),
26594
+ block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
26595
+ ]
26596
+ }, block.id);
26597
+ }
26598
+ /** 块联合新增成员而 `renderBlock` 没跟上时这里编译失败;运行时只可能是前后半版本错配,画空不炸整栏。 */
26599
+ function unhandledBlock(block) {
26600
+ return null;
26601
+ }
26602
+ function renderBlock(block, context) {
26603
+ const { open, toggle, probe, t } = context;
26604
+ switch (block.kind) {
26605
+ case "user": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26606
+ "data-sd-msg": "user",
26607
+ style: USER_ROW,
26608
+ children: [
26609
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26610
+ style: META_LINE,
26611
+ children: `${t("detail.role.user")} · ${timeText(block.time)}`
26612
+ }),
26613
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26614
+ attachments: block.attachments,
26615
+ t,
26616
+ align: "end"
26617
+ }),
26618
+ block.text === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26619
+ "data-sd-bubble": true,
26620
+ style: BUBBLE,
26621
+ children: block.text
26622
+ }),
26623
+ block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
26624
+ ]
26625
+ }, block.id);
26626
+ case "assistant": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26627
+ "data-sd-msg": "assistant",
26628
+ style: ASSISTANT_ROW,
26629
+ children: [
26630
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26631
+ style: META_LINE,
26632
+ children: `${t("detail.role.assistant")} · ${timeText(block.time)}`
26633
+ }),
26634
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, {
26635
+ text: block.text,
26636
+ t,
26637
+ probe
26638
+ }),
26639
+ block.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null,
26640
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26641
+ attachments: block.attachments,
26642
+ t,
26643
+ align: "start"
26644
+ })
26645
+ ]
26646
+ }, block.id);
26647
+ case "reasoning": return markdownFold(block, t("detail.block.reasoning"), context);
26648
+ case "tool": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Disclosure, {
26649
+ block,
26650
+ label: block.name === "" ? t("detail.block.tool") : block.name,
26651
+ summary: firstLine(block.result) || firstLine(block.args),
26652
+ status: {
26653
+ text: toolStatus(block.status, t),
26654
+ error: block.status === "error"
26655
+ },
26656
+ time: timeText(block.time),
26657
+ open: open.has(block.id),
26658
+ onToggle: toggle,
26659
+ children: [
26660
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26661
+ style: SECTION_LABEL,
26662
+ children: t("detail.block.args")
26663
+ }),
26664
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
26665
+ style: CODE_BLOCK,
26666
+ children: block.args === "" ? "—" : block.args
26667
+ }),
26668
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26669
+ style: SECTION_LABEL,
26670
+ children: t("detail.block.result")
26671
+ }),
26672
+ block.status === "unfinished" && block.result === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26673
+ style: META_LINE,
26674
+ children: t("detail.block.noResult")
26675
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
26676
+ style: CODE_BLOCK,
26677
+ children: block.result === "" ? "—" : block.result
26678
+ }),
26679
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
26680
+ attachments: block.attachments,
26681
+ t,
26682
+ align: "start"
26683
+ }),
26684
+ block.argsTruncated || block.resultTruncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TruncatedNote, { t }) : null
26685
+ ]
26686
+ }, block.id);
26687
+ case "injection": {
26688
+ const label = injectionLabel(block.sourceKind, t);
26689
+ return plainFold(block, `${t("detail.block.injection")} · ${label.text}`, context, `${t("detail.block.injection")} · ${label.title}`);
26690
+ }
26691
+ case "system": return plainFold(block, t("detail.block.system"), context);
26692
+ case "compaction": return markdownFold(block, t("detail.block.compaction"), context);
26693
+ default: return unhandledBlock(block);
26694
+ }
26695
+ }
26696
+ function TrashDetail({ state, busy, wide, onRetry, onBack, detailNav, pager, t }) {
26146
26697
  if (state.kind === "idle") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26147
26698
  style: {
26148
26699
  display: "flex",
@@ -26159,12 +26710,20 @@ window.__ModuleLoader__.load({
26159
26710
  });
26160
26711
  const entry = state.entry;
26161
26712
  const detail = state.kind === "ready" ? state.detail : void 0;
26713
+ const title = titleOf(entry, detail, t);
26162
26714
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26163
26715
  style: SHELL,
26164
26716
  children: [
26165
26717
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26166
26718
  style: HEAD,
26167
- children: [wide ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26719
+ children: [detailNav.parentContext !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26720
+ variant: "ghost",
26721
+ size: "sm",
26722
+ style: { flex: "none" },
26723
+ onClick: detailNav.parentContext.onBack,
26724
+ title: detailNav.parentContext.title === "" ? t("common.untitled") : detailNav.parentContext.title,
26725
+ children: t("detail.backToParent")
26726
+ }) : wide ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
26168
26727
  variant: "ghost",
26169
26728
  size: "sm",
26170
26729
  style: { flex: "none" },
@@ -26179,8 +26738,8 @@ window.__ModuleLoader__.load({
26179
26738
  fontWeight: 600,
26180
26739
  lineHeight: "22px"
26181
26740
  },
26182
- title: titleOf(entry, detail, t),
26183
- children: titleOf(entry, detail, t)
26741
+ title,
26742
+ children: title
26184
26743
  })
26185
26744
  })]
26186
26745
  }),
@@ -26236,8 +26795,10 @@ window.__ModuleLoader__.load({
26236
26795
  detail === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReadyBody, {
26237
26796
  entry,
26238
26797
  detail,
26798
+ detailNav,
26799
+ pager,
26239
26800
  t
26240
- })
26801
+ }, entry.id)
26241
26802
  ]
26242
26803
  });
26243
26804
  }
@@ -26281,17 +26842,60 @@ window.__ModuleLoader__.load({
26281
26842
  })] });
26282
26843
  }
26283
26844
  /**
26284
- * 有内容:元信息(时间 → 体量 → 位置三组的顺序)+ 正文。
26845
+ * 有内容:元信息(时间 → 体量 → 位置三组的顺序)+ 子会话段 + 折叠块。
26285
26846
  *
26286
- * 页面上**两本账分开读**:`提问`/`回复` 是过滤后的条数(注入那些由 `injectedCount` 单独说清,不并进
26287
- * 这两个数),**不受载荷闸影响**;正文是闸过的那一份(单条超限截断、累计到顶后整条不显示)。
26288
- * 于是「计数 421、正文 200」可以同时成立——怎么过滤、两个闸各是多少归 `src/trash/detail.ts`,这里只摆数。
26847
+ * 页面上**两本账分开读**:元信息里的计数是整条日志的(注入条数也在表里);
26848
+ * 正文是分页取回来的块。`olderCursor` 非空时顶部出「加载更早」。
26849
+ * 折叠状态只活在这个组件里;切条目用 `key={entry.id}` 重挂载,不会把 A 的展开状态带到 B。
26289
26850
  */
26290
- function ReadyBody({ entry, detail, t }) {
26291
- const folder = entry.id;
26851
+ function ReadyBody({ entry, detail, detailNav, pager, t }) {
26852
+ const { loadingOlder, olderFailure, onLoadOlder, scrollRef } = pager;
26853
+ const [open, setOpen] = (0, react.useState)(() => /* @__PURE__ */ new Set());
26854
+ /** 更早一页要 prepend 时的块数;换页回来(组件重挂载)这份意图会丢,那是可接受的边界。 */
26855
+ const pendingScroll = (0, react.useRef)(null);
26856
+ /** 上一次布局后的滚动高度:合并更早一页时用它算**真正插进来的高度**,不把请求在飞期间的
26857
+ * 滚动或折叠变化也算进去。 */
26858
+ const lastHeight = (0, react.useRef)(null);
26859
+ const probe = localeProbe(t);
26292
26860
  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);
26861
+ const disclosureBlocks = detail.blocks.filter(isDisclosureBlock);
26862
+ const allOpen = disclosureBlocks.length > 0 && disclosureBlocks.every((block) => open.has(block.id));
26863
+ const openChild = detailNav.onOpenChild;
26864
+ const toggle = (id) => {
26865
+ setOpen((current) => {
26866
+ const next = new Set(current);
26867
+ if (next.has(id)) next.delete(id);
26868
+ else next.add(id);
26869
+ return next;
26870
+ });
26871
+ };
26872
+ const toggleAll = () => {
26873
+ setOpen(allOpen ? /* @__PURE__ */ new Set() : new Set(disclosureBlocks.map((block) => block.id)));
26874
+ };
26875
+ const loadOlder = () => {
26876
+ const element = scrollRef?.current;
26877
+ if (element !== null && element !== void 0) pendingScroll.current = { blocks: detail.blocks.length };
26878
+ onLoadOlder();
26879
+ };
26880
+ (0, react.useLayoutEffect)(() => {
26881
+ const element = scrollRef?.current;
26882
+ const pending = pendingScroll.current;
26883
+ if (element === null || element === void 0 || pending === null) return;
26884
+ if (detail.blocks.length !== pending.blocks) {
26885
+ pendingScroll.current = null;
26886
+ if (lastHeight.current !== null) element.scrollTop += element.scrollHeight - lastHeight.current;
26887
+ return;
26888
+ }
26889
+ if (!loadingOlder) pendingScroll.current = null;
26890
+ }, [
26891
+ detail.blocks.length,
26892
+ loadingOlder,
26893
+ scrollRef
26894
+ ]);
26895
+ (0, react.useLayoutEffect)(() => {
26896
+ const element = scrollRef?.current;
26897
+ if (element !== null && element !== void 0) lastHeight.current = element.scrollHeight;
26898
+ });
26295
26899
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
26296
26900
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", {
26297
26901
  style: META,
@@ -26335,6 +26939,10 @@ window.__ModuleLoader__.load({
26335
26939
  label: t("detail.field.toolCalls"),
26336
26940
  children: String(detail.toolCallCount)
26337
26941
  }),
26942
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26943
+ label: t("detail.field.injections"),
26944
+ children: String(detail.injectedCount)
26945
+ }),
26338
26946
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26339
26947
  label: t("detail.field.models"),
26340
26948
  children: detail.models.join(", ") || "—"
@@ -26351,8 +26959,8 @@ window.__ModuleLoader__.load({
26351
26959
  }),
26352
26960
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26353
26961
  label: t("detail.field.trashDir"),
26354
- title: folder,
26355
- children: folder
26962
+ title: entry.id,
26963
+ children: entry.id
26356
26964
  }),
26357
26965
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
26358
26966
  label: t("detail.field.wasArchived"),
@@ -26360,46 +26968,98 @@ window.__ModuleLoader__.load({
26360
26968
  })
26361
26969
  ]
26362
26970
  }),
26971
+ detail.subagents === void 0 || detail.subagents.length === 0 || openChild === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26972
+ "data-dsh-session-delete": "trash-subagents",
26973
+ style: {
26974
+ display: "flex",
26975
+ flexDirection: "column",
26976
+ gap: 6
26977
+ },
26978
+ children: [
26979
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26980
+ style: SECTION_LABEL,
26981
+ children: t("detail.subagents.header", {
26982
+ n: detail.subagents.length,
26983
+ size: fileSizeText(detail.subagents.reduce((sum, child) => sum + child.sizeBytes, 0))
26984
+ })
26985
+ }),
26986
+ detail.subagents.map((child) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
26987
+ type: "button",
26988
+ className: "sd-disclosure-row",
26989
+ "data-dsh-session-delete": "trash-subagent-row",
26990
+ "data-entry-id": child.id,
26991
+ style: SUBAGENT_ROW,
26992
+ onClick: () => openChild(child),
26993
+ children: [
26994
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26995
+ style: {
26996
+ ...ELLIPSIS,
26997
+ flex: "1 1 auto",
26998
+ minWidth: 0
26999
+ },
27000
+ children: child.title === "" ? t("common.untitled") : child.title
27001
+ }),
27002
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27003
+ style: BLOCK_META,
27004
+ children: fileSizeText(child.sizeBytes)
27005
+ }),
27006
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27007
+ style: BLOCK_META,
27008
+ children: agoText(child.deletedAt, Date.now(), t)
27009
+ })
27010
+ ]
27011
+ }, child.id)),
27012
+ detail.subagentsTotal === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27013
+ style: SECTION_LABEL,
27014
+ "data-dsh-session-delete": "trash-subagents-truncated",
27015
+ children: t("detail.subagents.truncated", {
27016
+ shown: detail.subagents.length,
27017
+ total: detail.subagentsTotal
27018
+ })
27019
+ })
27020
+ ]
27021
+ }),
26363
27022
  /* @__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 })}` : ""]
27023
+ style: TOOLBAR$1,
27024
+ children: [
27025
+ detail.olderCursor === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
27026
+ variant: "outline",
27027
+ size: "sm",
27028
+ disabled: loadingOlder,
27029
+ onClick: loadOlder,
27030
+ children: loadingOlder ? t("detail.loadOlder.loading") : t("detail.loadOlder")
27031
+ }),
27032
+ olderFailure === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27033
+ role: "status",
27034
+ style: {
27035
+ ...META_LINE,
27036
+ minWidth: 0,
27037
+ wordBreak: "break-word"
27038
+ },
27039
+ children: olderFailure
27040
+ }),
27041
+ disclosureBlocks.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27042
+ style: { marginLeft: "auto" },
27043
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
27044
+ variant: "ghost",
27045
+ size: "sm",
27046
+ onClick: toggleAll,
27047
+ children: allOpen ? t("detail.collapseAll") : t("detail.expandAll")
27048
+ })
27049
+ })
27050
+ ]
26366
27051
  }),
26367
- messages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27052
+ detail.blocks.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26368
27053
  style: META_LINE,
26369
27054
  children: t("detail.noBody")
26370
27055
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26371
27056
  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}`))
27057
+ children: detail.blocks.map((block) => renderBlock(block, {
27058
+ open,
27059
+ toggle,
27060
+ probe,
27061
+ t
27062
+ }))
26403
27063
  })
26404
27064
  ] });
26405
27065
  }
@@ -26496,7 +27156,7 @@ window.__ModuleLoader__.load({
26496
27156
  */
26497
27157
  function usePanelWidth(ref) {
26498
27158
  const [width, setWidth] = (0, react.useState)(0);
26499
- (0, react.useEffect)(() => {
27159
+ (0, react.useLayoutEffect)(() => {
26500
27160
  const el = ref.current;
26501
27161
  if (el === null) return;
26502
27162
  setWidth(el.getBoundingClientRect().width);
@@ -26515,7 +27175,7 @@ window.__ModuleLoader__.load({
26515
27175
  //#region src/client/trash-nav.ts
26516
27176
  /**
26517
27177
  * 清单的键盘导航面:方向键走环、Enter/Space 的双重语义、roving tabindex 的落点,
26518
- * 以及窄档下钻里按 Esc 退回清单并把焦点交还给那一行。
27178
+ * 以及窄档下钻里按 Esc 回退——孩子视图开着时先回父详情,再按一次才退出下钻并把焦点交还给那一行。
26519
27179
  *
26520
27180
  * 它不查自己的行、不认行选择器:焦点动作由调用方注入 focusRow,行的 DOM 查询留在面板;
26521
27181
  * 唯一的文档查询是 Esc 让路判据(问 ui/top-layer.ts 当前有没有打开的 dialog/menu)。
@@ -26524,17 +27184,18 @@ window.__ModuleLoader__.load({
26524
27184
  * 判据(界面现状见 docs/ui-spec.md「键盘」):
26525
27185
  * - 方向键走环:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷。
26526
27186
  * - aria-activedescendant 只指向在 DOM 里的行;tab 落点永远有一行拿着。
26527
- * - Esc 只在下钻期间生效;退回后把焦点交还选中行一次;最上层是确认框/菜单时让路。
27187
+ * - Esc 只在下钻期间生效,回退分两程(先收孩子视图、再退下钻);退出下钻后把焦点交还选中行一次;
27188
+ * 最上层是确认框/菜单时让路。
26528
27189
  */
26529
- /** 事件是不是清单自己的:从行(`role="option"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
27190
+ /** 事件是不是清单自己的:从行(`role="row"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
26530
27191
  * 归它们自己。测试里的窄事件没有 `target`,按清单自己的处理。 */
26531
27192
  function isListOwnedEvent(event) {
26532
27193
  const target = event.target;
26533
27194
  if (target == null || target === event.currentTarget) return true;
26534
- return typeof target.getAttribute === "function" && target.getAttribute("role") === "option";
27195
+ return typeof target.getAttribute === "function" && target.getAttribute("role") === "row";
26535
27196
  }
26536
27197
  function useTrashNav(input) {
26537
- const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, exitDrill, focusRow } = input;
27198
+ const { ordered, selectedId, selectMode, drillEnabled, drilled, select, toggleCheck, back, focusRow } = input;
26538
27199
  /** Esc 退出下钻后要交还焦点的那一行;等 `drilled` 落地、清单回到 DOM 之后再用一次。 */
26539
27200
  const refocusRow = (0, react.useRef)(null);
26540
27201
  /** 折叠起来的组只是收掉那一列,选中的 id 还留着——所以这条可见性得逐条对账,不能只看非空。 */
@@ -26548,6 +27209,7 @@ window.__ModuleLoader__.load({
26548
27209
  const step = event.key === "ArrowDown" ? 1 : -1;
26549
27210
  const next = index < 0 ? step > 0 ? 0 : ordered.length - 1 : (index + step + ordered.length) % ordered.length;
26550
27211
  const target = ordered[next];
27212
+ if (target === void 0) return;
26551
27213
  select(target);
26552
27214
  focusRow(target.id);
26553
27215
  return;
@@ -26578,7 +27240,7 @@ window.__ModuleLoader__.load({
26578
27240
  if (event.key !== "Escape" || event.defaultPrevented) return;
26579
27241
  if (hasTopLayer(document)) return;
26580
27242
  refocusRow.current = selectedId;
26581
- exitDrill();
27243
+ back();
26582
27244
  };
26583
27245
  document.addEventListener("keydown", onKey);
26584
27246
  return () => {
@@ -26587,7 +27249,7 @@ window.__ModuleLoader__.load({
26587
27249
  }, [
26588
27250
  drillEnabled,
26589
27251
  drilled,
26590
- exitDrill,
27252
+ back,
26591
27253
  selectedId
26592
27254
  ]);
26593
27255
  (0, react.useEffect)(() => {
@@ -26611,6 +27273,9 @@ window.__ModuleLoader__.load({
26611
27273
  * - 读不到就说读不到:失败进 `unknown`(原文上屏),不折成"空的"。
26612
27274
  * - 附件读数只住在写动作的回执里:清单重读读不出来,所以它单独一条提示、**最新的写赢**。
26613
27275
  *
27276
+ * 输入的引用**不需稳定**:`deps` / `t` / `gate` 经 `useLatest` 归一(判据住 `store.ts`),
27277
+ * 返回的三个回调身份恒稳,可安全进调用方 effect 的依赖。
27278
+ *
26614
27279
  * 无主子会话的检查/清理**不在这一面**:那是插件设置页维护卡的手动动作(`use-maintenance.ts`)。
26615
27280
  * 面板挂载、写后重读与清空都只碰清单,一次都不自动扫(ADR-0021)。
26616
27281
  */
@@ -26639,11 +27304,17 @@ window.__ModuleLoader__.load({
26639
27304
  function useTrashQuery({ deps, t, gate }) {
26640
27305
  const [list, setList] = (0, react.useState)({ status: "loading" });
26641
27306
  const [attachmentNotice, setAttachmentNotice] = (0, react.useState)(null);
27307
+ /** 输入引用归一(判据住 `store.ts` 的 `useLatest`):回调经它读最新输入,身份恒稳。 */
27308
+ const latest = useLatest({
27309
+ deps,
27310
+ t,
27311
+ gate
27312
+ });
26642
27313
  /** 清单的唯一读法:签到一张票再发请求;输给后发的读时这一次什么都不落地(不让旧响应盖掉新状态)。 */
26643
27314
  const [reloadLatest] = (0, react.useState)(createLatestWins);
26644
27315
  const reload = (0, react.useCallback)(async () => {
26645
27316
  const ticket = reloadLatest.issue();
26646
- const outcome = await callRemote(() => deps.list());
27317
+ const outcome = await callRemote(() => latest.current.deps.list());
26647
27318
  if (!ticket.current) return;
26648
27319
  if (!outcome.ok) {
26649
27320
  setList({
@@ -26656,7 +27327,7 @@ window.__ModuleLoader__.load({
26656
27327
  status: "ready",
26657
27328
  rows: outcome.value
26658
27329
  });
26659
- }, [deps, reloadLatest]);
27330
+ }, [reloadLatest]);
26660
27331
  /**
26661
27332
  * 附件读数上屏:`skipped`(这一轮部分或整体没敢清)优先于 `failed`(逐文件真出错)——前者是原因,
26662
27333
  * 后者只是件数。**`removed > 0` 时不许说成"整轮没清"**:那一轮清掉了 `removed` 件,只是没清完,
@@ -26668,21 +27339,20 @@ window.__ModuleLoader__.load({
26668
27339
  setAttachmentNotice(null);
26669
27340
  return;
26670
27341
  }
26671
- const skipped = attachmentSkippedSentence(a, t);
27342
+ const skipped = attachmentSkippedSentence(a, latest.current.t);
26672
27343
  if (skipped !== null) {
26673
27344
  setAttachmentNotice(skipped);
26674
27345
  return;
26675
27346
  }
26676
- setAttachmentNotice(a.failed > 0 ? t("trash.attachments.failed", { count: a.failed }) : null);
26677
- }, [t]);
27347
+ setAttachmentNotice(a.failed > 0 ? latest.current.t("trash.attachments.failed", { count: a.failed }) : null);
27348
+ }, []);
26678
27349
  return {
26679
27350
  list,
26680
27351
  attachmentNotice,
26681
27352
  noteAttachmentCleanup,
26682
- refreshAll: reload,
26683
- refreshAfterWrite: reload,
26684
- emptyTrash: (0, react.useCallback)(() => gate.around(async () => {
26685
- const outcome = await callRemote(() => deps.emptyTrash());
27353
+ reload,
27354
+ emptyTrash: (0, react.useCallback)(() => latest.current.gate.around(async () => {
27355
+ const outcome = await callRemote(() => latest.current.deps.emptyTrash());
26686
27356
  if (!outcome.ok) {
26687
27357
  reloadLatest.invalidate();
26688
27358
  setList({
@@ -26697,8 +27367,6 @@ window.__ModuleLoader__.load({
26697
27367
  noteAttachmentCleanup(outcome.value.attachments);
26698
27368
  return true;
26699
27369
  }), [
26700
- gate,
26701
- deps,
26702
27370
  reload,
26703
27371
  reloadLatest,
26704
27372
  noteAttachmentCleanup
@@ -26708,7 +27376,7 @@ window.__ModuleLoader__.load({
26708
27376
  //#endregion
26709
27377
  //#region src/client/use-trash-panel-controller.ts
26710
27378
  /**
26711
- * 回收站面板状态机:选中、详情缓存、窄档下钻、清单换版清场、宽档自动选中、
27379
+ * 回收站面板状态机:选中、孩子视图、详情缓存、窄档下钻、清单换版清场、宽档自动选中、
26712
27380
  * 失效重读,以及确认后的恢复/批量/清空与失败落点。
26713
27381
  *
26714
27382
  * 关键判据:
@@ -26716,36 +27384,87 @@ window.__ModuleLoader__.load({
26716
27384
  * - 详情成功读数按条目 id 缓存;失败不缓存,重试会重新读。
26717
27385
  * - 选中 id 不在新清单里 ⇒ 丢缓存、清选中、退出下钻;清单读不到(null)不算“没了”。
26718
27386
  * - 自动选中只在 selectedId 为 null 时接管。
26719
- * - 失效通告只触发 refreshAfterWrite();动作成功必须在同一趟里丢详情缓存。
26720
- * - back() 只收下钻,不碰焦点;失败原文不翻译。
27387
+ * - 失效通告与挂载都只触发 reload();动作成功必须在同一趟里丢详情缓存。
27388
+ * - back() 先收孩子视图、再收下钻,不碰焦点;失败原文不翻译。
27389
+ */
27390
+ /**
27391
+ * 「渲染态 + 同步镜像」成对出现时的唯一写口:setter 同步更新 ref。`setState` 要等下一拍,
27392
+ * 而落地判「用户还在不在这一条」(`loadOlder` 的晚到结果)是同一 tick 里的事。写只走 setter——
27393
+ * 再手写一处 `ref.current = x` 就是第二份同步,两处迟早漂移。
27394
+ */
27395
+ function useSyncedRef(initial) {
27396
+ const ref = (0, react.useRef)(initial);
27397
+ const [value, setValue] = (0, react.useState)(initial);
27398
+ return [
27399
+ value,
27400
+ (0, react.useCallback)((next) => {
27401
+ ref.current = next;
27402
+ setValue(next);
27403
+ }, []),
27404
+ ref
27405
+ ];
27406
+ }
27407
+ /**
27408
+ * 在飞句柄:`/detail` 与「更早一页」各持一份。两条时间线各有自己的载体,别混——判重在
27409
+ * **同步**这一侧(`begin`:同一 tick 连点不发第二条;登记按条目,A 在飞不挡 B,也绝不放
27410
+ * 同一条进两条),上屏在 **state** 那一侧(渲染位单槽要等下一拍,只回答「当前这条转不转圈」)。
27411
+ * 置位/清位只走这四个方法,不再一对 state+ref 镜像各写一份同步。
26721
27412
  */
27413
+ function useInFlight() {
27414
+ const flying = (0, react.useRef)(/* @__PURE__ */ new Set());
27415
+ const [shownId, setShownId] = (0, react.useState)(null);
27416
+ const show = (0, react.useCallback)((id) => setShownId(id), []);
27417
+ const begin = (0, react.useCallback)((id) => {
27418
+ if (flying.current.has(id)) return false;
27419
+ flying.current.add(id);
27420
+ return true;
27421
+ }, []);
27422
+ const end = (0, react.useCallback)((id) => {
27423
+ flying.current.delete(id);
27424
+ setShownId((current) => current === id ? null : current);
27425
+ }, []);
27426
+ const clear = (0, react.useCallback)(() => {
27427
+ flying.current.clear();
27428
+ setShownId(null);
27429
+ }, []);
27430
+ return [shownId, (0, react.useMemo)(() => ({
27431
+ show,
27432
+ begin,
27433
+ end,
27434
+ clear
27435
+ }), [
27436
+ show,
27437
+ begin,
27438
+ end,
27439
+ clear
27440
+ ])];
27441
+ }
26722
27442
  function useTrashPanelController(input) {
26723
27443
  const { deps, gate, query, ordered, canAutoSelect } = input;
26724
- const { list, refreshAll, refreshAfterWrite, emptyTrash, noteAttachmentCleanup } = query;
27444
+ const { list, reload, emptyTrash, noteAttachmentCleanup } = query;
26725
27445
  const entries = listView(list).rows;
26726
- const [selectedId, setSelectedId] = (0, react.useState)(null);
27446
+ const [selectedId, setSelectedId, selectedIdRef] = useSyncedRef(null);
27447
+ /** 详情主体可以离清单:孩子视图开着时画的是孩子,`selectedId` 仍指着父行。 */
27448
+ const [childView, setChildView, childViewRef] = useSyncedRef(null);
26727
27449
  const [drilled, setDrilled] = (0, react.useState)(false);
26728
- const [loadingId, setLoadingId] = (0, react.useState)(null);
27450
+ const [detailShown, detailFlight] = useInFlight();
26729
27451
  const [detailFailure, setDetailFailure] = (0, react.useState)(null);
27452
+ const [olderShown, olderFlight] = useInFlight();
27453
+ const [olderFailureState, setOlderFailure] = (0, react.useState)(null);
27454
+ /** 缓存是 ref:合并更早一页后要有一个**必然变化**的 state 把新对象推上屏(极快的响应会把
27455
+ * 分页渲染位的置位/清位合批成净零变化,React 会直接 bail out)。 */
27456
+ const [detailVersion, setDetailVersion] = (0, react.useState)(0);
26730
27457
  const [pending, setPending] = (0, react.useState)(null);
26731
27458
  const [actionFailure, setActionFailure] = (0, react.useState)({});
26732
27459
  /** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
26733
27460
  const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
26734
- /** 挂载那一拍读一次清单,之后失效通告只走写后刷新(两条名字不同,读法是同一条)。 */
26735
- const firstRefresh = (0, react.useRef)(true);
27461
+ /** 失效重读的载体:`reload` 回调经 `useLatest` 读最新那一份(判据住 `store.ts`),
27462
+ * 失效 effect 的判据只有 `revision`——挂载首拍与后续通告现在是同一个动作,不再分两支。 */
27463
+ const refreshers = useLatest({ reload });
26736
27464
  const revision = useTrashRevision();
26737
27465
  (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
- ]);
27466
+ refreshers.current.reload();
27467
+ }, [revision]);
26749
27468
  (0, react.useEffect)(() => {
26750
27469
  if (pending !== null && !gate.busy && list.status === "unknown") setPending(null);
26751
27470
  }, [
@@ -26753,40 +27472,108 @@ window.__ModuleLoader__.load({
26753
27472
  list.status,
26754
27473
  pending
26755
27474
  ]);
27475
+ /** 按 entryId 确保详情在缓存里:命中直接摆,没命中读一次(在飞闸挡重复)。
27476
+ * 选中(select)与孩子视图(openChild)共用这一条读路——缓存/在飞/失败都按 entryId 键控,
27477
+ * 谁在屏幕上是 subject 的事,不是读路的事。 */
27478
+ const ensureDetail = (0, react.useCallback)(async (entryId) => {
27479
+ if (detailCache.current.has(entryId)) {
27480
+ detailFlight.show(null);
27481
+ setDetailFailure(null);
27482
+ return;
27483
+ }
27484
+ detailFlight.show(entryId);
27485
+ setDetailFailure(null);
27486
+ if (!detailFlight.begin(entryId)) return;
27487
+ const outcome = await callRemote(() => deps.detail(entryId));
27488
+ detailFlight.end(entryId);
27489
+ if (outcome.ok) detailCache.current.set(entryId, outcome.value);
27490
+ else setDetailFailure({
27491
+ entryId,
27492
+ message: outcome.failure.message
27493
+ });
27494
+ }, [deps, detailFlight]);
26756
27495
  /**
26757
27496
  * 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
26758
27497
  * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
27498
+ * 选清单行就是回清单:孩子视图跟着收掉(详情主体交还给选中那条)。
26759
27499
  */
26760
27500
  const select = (0, react.useCallback)(async (entry, options = {}) => {
27501
+ setChildView(null);
26761
27502
  setSelectedId(entry.id);
27503
+ setOlderFailure(null);
26762
27504
  if (options.drill === true) setDrilled(true);
26763
- if (detailCache.current.has(entry.id)) {
26764
- setLoadingId(null);
26765
- setDetailFailure(null);
27505
+ await ensureDetail(entry.id);
27506
+ }, [ensureDetail]);
27507
+ const openChild = (0, react.useCallback)(async (child) => {
27508
+ setChildView(child);
27509
+ setOlderFailure(null);
27510
+ await ensureDetail(child.id);
27511
+ }, [ensureDetail]);
27512
+ const closeChild = (0, react.useCallback)(() => {
27513
+ setChildView(null);
27514
+ setOlderFailure(null);
27515
+ }, []);
27516
+ /**
27517
+ * 按当前详情的 `olderCursor` 取更早一页,拼到现有块前面。
27518
+ *
27519
+ * 三条判据:没有游标 / 这一条已有更早页在飞 ⇒ 不发请求(同一 tick 连点、或切走再切回都撞
27520
+ * 在飞登记——晚到的第二条注定落不了屏,白打一次往返);用户已经切走或缓存已被换掉 ⇒ 晚到的
27521
+ * 页**不落屏**(A 的更早页不许盖掉 B);失败只走 `olderFailure`,已读到的正文照旧是 ready。
27522
+ */
27523
+ const loadOlder = (0, react.useCallback)(async () => {
27524
+ const entryId = childView?.id ?? selectedId;
27525
+ if (entryId === null) return;
27526
+ const cached = detailCache.current.get(entryId);
27527
+ if (cached === void 0 || cached.olderCursor === null) return;
27528
+ olderFlight.show(entryId);
27529
+ setOlderFailure(null);
27530
+ if (!olderFlight.begin(entryId)) return;
27531
+ const cursor = cached.olderCursor;
27532
+ const outcome = await callRemote(() => deps.detail(entryId, cursor));
27533
+ olderFlight.end(entryId);
27534
+ if ((childViewRef.current?.id ?? selectedIdRef.current) !== entryId) return;
27535
+ const current = detailCache.current.get(entryId);
27536
+ if (current !== cached) return;
27537
+ if (outcome.ok) {
27538
+ detailCache.current.set(entryId, {
27539
+ ...current,
27540
+ blocks: [...outcome.value.blocks, ...current.blocks],
27541
+ olderCursor: outcome.value.olderCursor
27542
+ });
27543
+ setDetailVersion((version) => version + 1);
26766
27544
  return;
26767
27545
  }
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,
27546
+ setOlderFailure({
27547
+ entryId,
26775
27548
  message: outcome.failure.message
26776
27549
  });
26777
- }, [deps]);
27550
+ }, [
27551
+ deps,
27552
+ childView,
27553
+ selectedId,
27554
+ olderFlight
27555
+ ]);
26778
27556
  (0, react.useEffect)(() => {
26779
27557
  if (entries === null || selectedId === null) return;
26780
27558
  if (entries.some((entry) => entry.id === selectedId)) return;
26781
27559
  detailCache.current.delete(selectedId);
26782
27560
  setSelectedId(null);
27561
+ setChildView(null);
27562
+ olderFlight.clear();
26783
27563
  setDrilled(false);
26784
- }, [entries, selectedId]);
27564
+ setOlderFailure(null);
27565
+ }, [
27566
+ entries,
27567
+ selectedId,
27568
+ setSelectedId,
27569
+ olderFlight
27570
+ ]);
26785
27571
  (0, react.useEffect)(() => {
26786
27572
  if (canAutoSelect && drilled) setDrilled(false);
26787
27573
  }, [canAutoSelect, drilled]);
26788
27574
  (0, react.useEffect)(() => {
26789
- if (canAutoSelect && selectedId === null && ordered.length > 0) select(ordered[0]);
27575
+ const first = ordered[0];
27576
+ if (canAutoSelect && selectedId === null && first !== void 0) select(first);
26790
27577
  }, [
26791
27578
  canAutoSelect,
26792
27579
  selectedId,
@@ -26798,6 +27585,34 @@ window.__ModuleLoader__.load({
26798
27585
  * 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
26799
27586
  */
26800
27587
  const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
27588
+ /** 详情现在画谁:孩子视图开着就是孩子,否则是清单选中那条。孩子不在清单里,
27589
+ * 所以它是离清单的导航态——selectedId 不动,清场/自动选中/组动作全部照旧。 */
27590
+ const subject = childView ?? selected;
27591
+ /**
27592
+ * 详情的导航面(ADR-0029 单级判据的唯一实现,住控制器):
27593
+ * - 孩子视图没开(主体是清单选中那条)⇒ `onOpenChild` 在场、`parentContext` 缺席。
27594
+ * 在孩子视图里再开一层会造出第二套导航路径,还会让那颗返回指着孙辈说话(更深一代走清单可达,
27595
+ * 判据住 `src/trash/groups.ts`),所以入口只在这半边交出。
27596
+ * - 孩子视图开着 ⇒ `onOpenChild` 缺席、`parentContext` 交回父:title 是**选中行的原文**
27597
+ * (未命名兜底由视图按 t 处理,控制器不碰翻译),onBack 收孩子视图。
27598
+ * - `selected` 为 null(清单换版把父行收走的那一帧,清场 effect 随后也把孩子视图收掉)
27599
+ * ⇒ `parentContext` 缺席:返回键不能指着一个不存在的父。
27600
+ */
27601
+ const detailNav = (0, react.useMemo)(() => {
27602
+ if (childView === null) return { onOpenChild: (child) => {
27603
+ openChild(child);
27604
+ } };
27605
+ if (selected === null) return {};
27606
+ return { parentContext: {
27607
+ title: selected.title,
27608
+ onBack: closeChild
27609
+ } };
27610
+ }, [
27611
+ childView,
27612
+ selected,
27613
+ openChild,
27614
+ closeChild
27615
+ ]);
26801
27616
  /** 恢复确认框要摆的这一批:按勾选顺序,清单里已经找不到的 id 滤掉
26802
27617
  * (别的窗口刚动过;重读会把勾选也剔掉)。单条与整站确认框不摆条目,各自念自己的那一句。 */
26803
27618
  const pendingEntries = (0, react.useMemo)(() => {
@@ -26806,39 +27621,58 @@ window.__ModuleLoader__.load({
26806
27621
  return pending.ids.map((id) => byId.get(id)).filter((entry) => entry !== void 0);
26807
27622
  }, [entries, pending]);
26808
27623
  const detail = (0, react.useMemo)(() => {
26809
- if (selected === null) return { kind: "idle" };
26810
- if (loadingId === selected.id) return {
27624
+ if (subject === null) return { kind: "idle" };
27625
+ if (detailShown === subject.id) return {
26811
27626
  kind: "loading",
26812
- entry: selected
27627
+ entry: subject
26813
27628
  };
26814
- const cached = detailCache.current.get(selected.id);
27629
+ const cached = detailCache.current.get(subject.id);
26815
27630
  if (cached !== void 0) return {
26816
27631
  kind: "ready",
26817
- entry: selected,
27632
+ entry: subject,
26818
27633
  detail: cached
26819
27634
  };
26820
- if (detailFailure?.entryId === selected.id) return {
27635
+ if (detailFailure?.entryId === subject.id) return {
26821
27636
  kind: "failed",
26822
- entry: selected,
27637
+ entry: subject,
26823
27638
  message: detailFailure.message
26824
27639
  };
26825
27640
  return {
26826
27641
  kind: "loading",
26827
- entry: selected
27642
+ entry: subject
26828
27643
  };
26829
27644
  }, [
26830
- selected,
26831
- loadingId,
26832
- detailFailure
27645
+ subject,
27646
+ detailShown,
27647
+ detailFailure,
27648
+ detailVersion
26833
27649
  ]);
27650
+ /** 分页/失败都只对当前详情主体上屏(孩子视图开着就是孩子);切到别的条目时它们是别人家的旧账。 */
27651
+ const subjectId = subject?.id ?? null;
27652
+ const loadingOlder = subjectId !== null && olderShown === subjectId;
27653
+ const olderFailure = olderFailureState !== null && olderFailureState.entryId === subjectId ? olderFailureState.message : null;
26834
27654
  /** 「重试」只重读当前这一条,不牵清单。 */
26835
27655
  const retry = (0, react.useCallback)(() => {
27656
+ if (childView !== null) {
27657
+ ensureDetail(childView.id);
27658
+ return;
27659
+ }
26836
27660
  if (selected !== null) select(selected);
26837
- }, [selected, select]);
26838
- /** 退出下钻(「← 返回清单」):选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
27661
+ }, [
27662
+ childView,
27663
+ ensureDetail,
27664
+ selected,
27665
+ select
27666
+ ]);
27667
+ /** 窄档返回/Esc 的两程:孩子视图开着先回父详情(仍下钻),再按一次才退出下钻。
27668
+ * 选中留着,详情不清;焦点不动(Esc 那条归 `trash-nav.ts`)。 */
26839
27669
  const back = (0, react.useCallback)(() => {
27670
+ if (childView !== null) {
27671
+ closeChild();
27672
+ return;
27673
+ }
26840
27674
  setDrilled(false);
26841
- }, []);
27675
+ }, [childView, closeChild]);
26842
27676
  const request = (0, react.useCallback)((action) => {
26843
27677
  setPending(action);
26844
27678
  }, []);
@@ -26856,38 +27690,51 @@ window.__ModuleLoader__.load({
26856
27690
  const clearDetailCache = (0, react.useCallback)((successIds) => {
26857
27691
  for (const id of successIds) detailCache.current.delete(id);
26858
27692
  }, []);
27693
+ /** 清掉这些行的旧失败;没有任何一行挂着失败时原样返回,不触发多余的一拍。 */
27694
+ const clearRowFailures = (0, react.useCallback)((ids) => {
27695
+ setActionFailure((current) => {
27696
+ let changed = false;
27697
+ const next = { ...current };
27698
+ for (const id of ids) if (Object.hasOwn(next, id)) {
27699
+ delete next[id];
27700
+ changed = true;
27701
+ }
27702
+ return changed ? next : current;
27703
+ });
27704
+ }, []);
26859
27705
  /**
26860
27706
  * 单条恢复:**先清掉这一行的旧失败**(再点一次不该还挂着上一次的原文),再把整趟交给面板恢复座位。
26861
27707
  * 成功要丢详情缓存 + 重读清单——缓存不失效的话,这条会话再被删回来时会直接摆出上一世的正文。
26862
27708
  * 失败也重读:组回滚可能自己失败(`details.rollbackFailures`),清单不能继续摆旧行。
26863
- * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次。
27709
+ * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次——再点那一次
27710
+ * 被同步挡回、整趟不跑(座位交回 `undefined`),这一行保持原样,等在飞那一趟的结论。
26864
27711
  * 单条恢复走 `single` 读法,`already-gone` 不可能出现,座位里那条查找兜底在此不会命中。
26865
27712
  * 座位的 refresh 仍按 `WriteOutcome` 给(那只信封里没有附件读数要转交):这里只把它换成"重读"这一个动作,
26866
- * outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021)。
27713
+ * outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021)。座位字段仍叫 `refreshAfterWrite`
27714
+ * (`write-seats.ts` 的信封接口,写座位自己的名字),与清单重读已合一的旧双名无关。
26867
27715
  */
26868
27716
  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({
27717
+ clearRowFailures([entry.id]);
27718
+ const outcome = await restoreFromPanel({
26875
27719
  deps,
26876
27720
  ids: [entry.id],
26877
27721
  single: true,
26878
27722
  entries,
26879
27723
  gate,
26880
- refreshAfterWrite: () => refreshAfterWrite(),
27724
+ refreshAfterWrite: () => reload(),
26881
27725
  clearDetailCache
26882
- })).failures.get(entry.id);
27726
+ });
27727
+ if (outcome === void 0) return;
27728
+ const failure = outcome.failures.get(entry.id);
26883
27729
  if (failure !== void 0) failRow(entry.id, failure);
26884
27730
  }, [
26885
27731
  clearDetailCache,
27732
+ clearRowFailures,
26886
27733
  deps,
26887
27734
  entries,
26888
27735
  failRow,
26889
27736
  gate,
26890
- refreshAfterWrite
27737
+ reload
26891
27738
  ]);
26892
27739
  /**
26893
27740
  * 批量彻底删除 / 批量恢复**共用这一条**:一次 `apply` 把整批交给 Node 半,逐条成败住在
@@ -26895,10 +27742,11 @@ window.__ModuleLoader__.load({
26895
27742
  * 不能断言哪几条落地:原文保守地铺到每一个 id(别让清单看起来"删成功了"),清单照样重读。
26896
27743
  */
26897
27744
  const confirmApply = (0, react.useCallback)(async (action, ids) => {
27745
+ clearRowFailures(ids);
26898
27746
  const settle = (outcome) => {
26899
27747
  setPending(null);
26900
27748
  if (action === "purge") noteAttachmentCleanup(outcome.ok ? outcome.summary.attachmentCleanup : void 0);
26901
- return refreshAfterWrite();
27749
+ return reload();
26902
27750
  };
26903
27751
  const seat = {
26904
27752
  deps,
@@ -26911,6 +27759,7 @@ window.__ModuleLoader__.load({
26911
27759
  ...seat,
26912
27760
  entries
26913
27761
  });
27762
+ if (outcome === void 0) return;
26914
27763
  if (outcome.failures.size === 0) return;
26915
27764
  setActionFailure((current) => {
26916
27765
  const next = { ...current };
@@ -26919,10 +27768,11 @@ window.__ModuleLoader__.load({
26919
27768
  });
26920
27769
  }, [
26921
27770
  clearDetailCache,
27771
+ clearRowFailures,
26922
27772
  deps,
26923
27773
  entries,
26924
27774
  gate,
26925
- refreshAfterWrite,
27775
+ reload,
26926
27776
  noteAttachmentCleanup
26927
27777
  ]);
26928
27778
  /**
@@ -26937,6 +27787,7 @@ window.__ModuleLoader__.load({
26937
27787
  if (target.kind === "entries") return confirmApply("purge", target.ids);
26938
27788
  if (target.kind === "restore") return confirmApply("restore", target.ids);
26939
27789
  const emptied = await emptyTrash();
27790
+ if (emptied === void 0) return;
26940
27791
  setPending(null);
26941
27792
  if (emptied) detailCache.current.clear();
26942
27793
  }, [
@@ -26948,12 +27799,19 @@ window.__ModuleLoader__.load({
26948
27799
  busy: gate.busy,
26949
27800
  selected,
26950
27801
  selectedId,
27802
+ childView,
27803
+ detailNav,
27804
+ openChild,
27805
+ closeChild,
26951
27806
  drilled,
26952
27807
  detail,
27808
+ loadingOlder,
27809
+ olderFailure,
26953
27810
  pending,
26954
27811
  pendingEntries,
26955
27812
  actionFailure,
26956
27813
  select,
27814
+ loadOlder,
26957
27815
  retry,
26958
27816
  back,
26959
27817
  request,
@@ -27091,6 +27949,9 @@ window.__ModuleLoader__.load({
27091
27949
  const HANDLE_STEP = 20;
27092
27950
  /** 详情正文的收口宽度:中央列再宽也不把文字拉成长线。 */
27093
27951
  const DETAIL_TEXT_MAX_WIDTH = 860;
27952
+ /** 清单 grid 的列数:条目行恒为两格(常态:内容 + 动作;多选态:勾选框占内容前的一格、动作列退场);
27953
+ * 组头只有一格,用 colspan 横跨整行——各行列数一致是 grid 的播报契约(ADR-0028)。 */
27954
+ const LIST_COLUMN_COUNT = 2;
27094
27955
  /**
27095
27956
  * 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
27096
27957
  *
@@ -27177,6 +28038,15 @@ window.__ModuleLoader__.load({
27177
28038
  unit: unitWord(unit, t)
27178
28039
  });
27179
28040
  }
28041
+ /**
28042
+ * 行的 DOM id。entry id 里可能带空白/引号(目录名只挡路径分隔符),直接拼进 HTML id 是非法值、
28043
+ * `aria-activedescendant` 也会失配——所以**在 DOM 这一侧编码**(issue #8 第 7 条):`data-entry-id`
28044
+ * 仍留原始 id,焦点交还按属性值比对不受影响。entry id 本身不许改形:它是回收站目录名,
28045
+ * 恢复要按它反推原目录名放回(`trash/entry.ts` 的往返契约)。
28046
+ */
28047
+ function domRowId(entryId) {
28048
+ return `sd-trash-${encodeURIComponent(entryId)}`;
28049
+ }
27180
28050
  function TrashPanel({ deps, t = zhTranslate }) {
27181
28051
  /** 面板唯一的一枚全局闸(写动作在飞时按住一排按钮):本体住 `busy-gate.ts`,查询侧的写动作也吃它。 */
27182
28052
  const gate = useBusyGate();
@@ -27185,11 +28055,13 @@ window.__ModuleLoader__.load({
27185
28055
  t,
27186
28056
  gate
27187
28057
  });
27188
- const { list: listState, refreshAll } = query;
28058
+ const { list: listState, reload } = query;
27189
28059
  const view = listView(listState);
27190
28060
  const entries = view.rows;
27191
28061
  const listRef = (0, react.useRef)(null);
27192
28062
  const rootRef = (0, react.useRef)(null);
28063
+ /** 详情栏的滚动容器:分页 prepend 后按高度差补偿滚动位置(归 `trash-detail.tsx` 消费)。 */
28064
+ const detailScrollRef = (0, react.useRef)(null);
27193
28065
  const width = usePanelWidth(rootRef);
27194
28066
  /**
27195
28067
  * 真走两栏的条件:盒子够宽 **且清单里有东西可看**。空态与读数失败时右栏不出现——那一栏既没有
@@ -27211,9 +28083,16 @@ window.__ModuleLoader__.load({
27211
28083
  entries,
27212
28084
  groups
27213
28085
  });
27214
- /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。 */
28086
+ /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。
28087
+ * 按属性值自己比,不把 `entryId` 拼进选择器:id 只挡路径分隔符(`trash/entry.ts`),另一半是
28088
+ * 宿主给的目录名,带引号时选择器直接抛。 */
27215
28089
  const focusRow = (0, react.useCallback)((entryId) => {
27216
- (listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
28090
+ const rows = listRef.current?.querySelectorAll("[data-entry-id]");
28091
+ if (rows === void 0) return;
28092
+ for (const row of rows) if (row.getAttribute("data-entry-id") === entryId) {
28093
+ row.focus();
28094
+ return;
28095
+ }
27217
28096
  }, []);
27218
28097
  /**
27219
28098
  * 选中 / 详情缓存 / 清单换版清场 / 宽档自动选中 / 写动作与失败落点,整块住在
@@ -27240,7 +28119,7 @@ window.__ModuleLoader__.load({
27240
28119
  drilled: controller.drilled,
27241
28120
  select: controller.select,
27242
28121
  toggleCheck,
27243
- exitDrill: controller.back,
28122
+ back: controller.back,
27244
28123
  focusRow
27245
28124
  });
27246
28125
  const pending = controller.pending;
@@ -27256,14 +28135,19 @@ window.__ModuleLoader__.load({
27256
28135
  /**
27257
28136
  * 清单容器(roving tabindex 的唯一 tab 巢)。`flex:'1 1 auto'` 撑起空态居中的父链;
27258
28137
  * `min-height:auto` 让长清单保持内容高,滚动交给外层。
28138
+ *
28139
+ * ARIA 模式是 **grid** 而不是 listbox(为什么:裁决见 ADR-0028)。只有真有行时才挂 grid
28140
+ * 与它的 aria:失败/加载/空态是一块普通内容,不是「空的 grid」,generic div 不背 aria 名字。
27259
28141
  */
28142
+ const gridActive = view.failure === null && hasEntries;
27260
28143
  const list = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27261
28144
  ref: listRef,
27262
28145
  "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,
28146
+ role: gridActive ? "grid" : void 0,
28147
+ "aria-label": gridActive ? t("detail.list.label") : void 0,
28148
+ "aria-colcount": gridActive ? LIST_COLUMN_COUNT : void 0,
28149
+ "aria-activedescendant": gridActive && nav.activeDescendantId !== null ? domRowId(nav.activeDescendantId) : void 0,
28150
+ "aria-multiselectable": gridActive && selectMode ? true : void 0,
27267
28151
  tabIndex: -1,
27268
28152
  onKeyDown: nav.onListKeyDown,
27269
28153
  style: {
@@ -27320,7 +28204,7 @@ window.__ModuleLoader__.load({
27320
28204
  variant: "outline",
27321
28205
  size: "sm",
27322
28206
  style: { flex: "none" },
27323
- onClick: () => void refreshAll(),
28207
+ onClick: () => void reload(),
27324
28208
  children: t("common.retry")
27325
28209
  })
27326
28210
  ]
@@ -27376,6 +28260,7 @@ window.__ModuleLoader__.load({
27376
28260
  })
27377
28261
  ]
27378
28262
  }) : groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
28263
+ role: "rowgroup",
27379
28264
  "data-dsh-session-delete": "trash-group",
27380
28265
  "aria-label": group.label,
27381
28266
  style: {
@@ -27384,7 +28269,8 @@ window.__ModuleLoader__.load({
27384
28269
  gap: 8,
27385
28270
  minWidth: 0
27386
28271
  },
27387
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28272
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
28273
+ role: "row",
27388
28274
  "data-dsh-session-delete": "trash-group-row",
27389
28275
  style: {
27390
28276
  display: "flex",
@@ -27392,71 +28278,82 @@ window.__ModuleLoader__.load({
27392
28278
  gap: 6,
27393
28279
  minWidth: 0
27394
28280
  },
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
- },
28281
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28282
+ role: "gridcell",
28283
+ "aria-colspan": LIST_COLUMN_COUNT,
27414
28284
  style: {
27415
- background: "none",
27416
- border: "none",
27417
- padding: 0,
27418
- font: "inherit",
27419
- textAlign: "start",
28285
+ display: "flex",
28286
+ alignItems: "center",
28287
+ gap: 6,
27420
28288
  flex: "1 1 auto",
27421
- gap: 6
28289
+ minWidth: 0
27422
28290
  },
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 })
28291
+ children: [selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
28292
+ checked: groupFullyChecked(group),
28293
+ indeterminate: groupPartiallyChecked(group),
28294
+ label: t("trash.select.group", {
28295
+ project: group.label,
28296
+ count: group.entries.length
27437
28297
  }),
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
- })]
28298
+ onChange: () => {
28299
+ toggleGroupCheck(group);
28300
+ }
28301
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
28302
+ type: "button",
28303
+ className: "sd-disclosure-row",
28304
+ "data-dsh-session-delete": "trash-group-head",
28305
+ "data-expandable": "",
28306
+ "aria-expanded": !isCollapsed(group.projectDir),
28307
+ onClick: () => {
28308
+ toggleGroup(group.projectDir);
28309
+ },
28310
+ style: {
28311
+ background: "none",
28312
+ border: "none",
28313
+ padding: 0,
28314
+ font: "inherit",
28315
+ textAlign: "start",
28316
+ flex: "1 1 auto",
28317
+ gap: 6
28318
+ },
28319
+ children: [
28320
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28321
+ className: "sd-disclosure-leading",
28322
+ "aria-hidden": "true",
28323
+ children: isCollapsed(group.projectDir) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronRightOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconChevronDownOutlineRegular, { size: 14 })
28324
+ }),
28325
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28326
+ style: {
28327
+ display: "inline-flex",
28328
+ flex: "none",
28329
+ color: "var(--dsw-alias-label-tertiary)"
28330
+ },
28331
+ "aria-hidden": "true",
28332
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFolderOpenOutlineRegular, { size: 13 })
28333
+ }),
28334
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28335
+ className: "sd-disclosure-title",
28336
+ style: {
28337
+ ...ELLIPSIS,
28338
+ minWidth: 0,
28339
+ flex: "1 1 auto",
28340
+ fontWeight: 500
28341
+ },
28342
+ title: group.projectDir,
28343
+ children: group.label
28344
+ }),
28345
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
28346
+ style: {
28347
+ marginLeft: "auto",
28348
+ flex: "none",
28349
+ fontSize: 11.5,
28350
+ color: "var(--dsw-alias-label-tertiary)"
28351
+ },
28352
+ children: t("trash.group.count", { count: group.entries.length })
28353
+ })
28354
+ ]
28355
+ })]
28356
+ })
27460
28357
  }), isCollapsed(group.projectDir) ? null : group.entries.map((entry) => {
27461
28358
  const isSelected = entry.id === controller.selectedId;
27462
28359
  const isChecked = checked.has(entry.id);
@@ -27464,8 +28361,8 @@ window.__ModuleLoader__.load({
27464
28361
  const kids = entry.subagentCount ?? 0;
27465
28362
  const stranded = entry.strandedSubagents ?? 0;
27466
28363
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27467
- id: `sd-trash-${entry.id}`,
27468
- role: "option",
28364
+ id: domRowId(entry.id),
28365
+ role: "row",
27469
28366
  "aria-selected": selectMode ? isChecked : isSelected,
27470
28367
  tabIndex: entry.id === nav.tabStopId ? 0 : -1,
27471
28368
  "data-dsh-session-delete": "trash-row",
@@ -27479,14 +28376,22 @@ window.__ModuleLoader__.load({
27479
28376
  controller.select(entry, { drill: !split });
27480
28377
  },
27481
28378
  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
- }
28379
+ selectMode ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
28380
+ role: "gridcell",
28381
+ style: {
28382
+ display: "flex",
28383
+ alignItems: "center"
28384
+ },
28385
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
28386
+ checked: isChecked,
28387
+ label: t("trash.select.row", { title }),
28388
+ onChange: () => {
28389
+ toggleCheck(entry.id);
28390
+ }
28391
+ })
27488
28392
  }) : null,
27489
28393
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28394
+ role: "gridcell",
27490
28395
  style: {
27491
28396
  display: "flex",
27492
28397
  flexDirection: "column",
@@ -27551,6 +28456,7 @@ window.__ModuleLoader__.load({
27551
28456
  ]
27552
28457
  }),
27553
28458
  selectMode ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
28459
+ role: "gridcell",
27554
28460
  style: {
27555
28461
  display: "flex",
27556
28462
  gap: 6,
@@ -27586,7 +28492,12 @@ window.__ModuleLoader__.load({
27586
28492
  })]
27587
28493
  }, group.projectDir))
27588
28494
  });
28495
+ /**
28496
+ * 孩子视图的开与回(ADR-0029 单级判据)住控制器的 `detailNav`:这里只转交,不再自己拼
28497
+ * openChild / parentContext 两个派生块——判据的第二个家会让视图与测试各自复述一遍语义。
28498
+ */
27589
28499
  const detailPane = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
28500
+ ref: detailScrollRef,
27590
28501
  "data-dsh-session-delete": "trash-detail",
27591
28502
  style: DETAIL_COLUMN,
27592
28503
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -27597,6 +28508,15 @@ window.__ModuleLoader__.load({
27597
28508
  wide: split,
27598
28509
  onRetry: () => controller.retry(),
27599
28510
  onBack: () => controller.back(),
28511
+ detailNav: controller.detailNav,
28512
+ pager: {
28513
+ loadingOlder: controller.loadingOlder,
28514
+ olderFailure: controller.olderFailure,
28515
+ onLoadOlder: () => {
28516
+ controller.loadOlder();
28517
+ },
28518
+ scrollRef: detailScrollRef
28519
+ },
27600
28520
  t
27601
28521
  })
27602
28522
  })
@@ -27840,6 +28760,7 @@ window.__ModuleLoader__.load({
27840
28760
  children: [text, actions?.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
27841
28761
  type: "button",
27842
28762
  className: "sd-toast-action",
28763
+ disabled: action.disabled,
27843
28764
  onClick: action.onClick,
27844
28765
  children: action.label
27845
28766
  }, action.label))]
@@ -27851,31 +28772,12 @@ window.__ModuleLoader__.load({
27851
28772
  /**
27852
28773
  * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
27853
28774
  *
27854
- * 现场与发布住在 `undo-notices.ts`;本文件只做组件,并把通知台与外部恢复座位组合起来。
28775
+ * 现场与发布住在 `undo-notices.ts`;本文件只做组件:画通知台已有的事实,「撤销」整趟按下交给
28776
+ * `write-seats.ts` 的 `restoreFromUndo`(编排归恢复座位,见 ADR-0018 的边界)。
27855
28777
  * 为什么组件要独立:宿主的菜单行随菜单**一起卸载**(真产物 Menu 的注释原文
27856
28778
  * "the rows unmount with the list"),而提示要在菜单关掉之后还活着。宿主的官方配方同形——
27857
28779
  * 会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的 `workspace.row-toast` 读它。
27858
28780
  */
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
28781
  /**
27880
28782
  * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
27881
28783
  * pill all belong here"):没有待撤销的事时什么都不渲染。
@@ -27883,6 +28785,15 @@ window.__ModuleLoader__.load({
27883
28785
  function UndoToast({ deps, t = zhTranslate }) {
27884
28786
  const current = useUndoNotice();
27885
28787
  if (current === null) return null;
28788
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoticeToast, {
28789
+ deps,
28790
+ current,
28791
+ t
28792
+ }, current.seq);
28793
+ }
28794
+ /** 一条通知的躯体:busy 生命周期归 `useBusyGate`(连点的同步判重也在闸里),这里只组合呈现。 */
28795
+ function NoticeToast({ deps, current, t }) {
28796
+ const gate = useBusyGate();
27886
28797
  const { seq, failure } = current;
27887
28798
  if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
27888
28799
  text: failure,
@@ -27897,7 +28808,10 @@ window.__ModuleLoader__.load({
27897
28808
  holdMs: UNDO_HOLD_MS,
27898
28809
  actions: canUndo ? [{
27899
28810
  label: t("undo.undo"),
27900
- onClick: () => void undo(deps, current)
28811
+ disabled: gate.busy,
28812
+ onClick: () => {
28813
+ gate.around(() => restoreFromUndo(deps, current, t));
28814
+ }
27901
28815
  }] : void 0,
27902
28816
  onDone: clearUndoNotice
27903
28817
  }, seq);