@xrn1997/dsh-session-delete 0.3.1 → 0.4.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 (64) hide show
  1. package/README.md +20 -14
  2. package/lib/client.js +1686 -1053
  3. package/lib/index.js +1387 -771
  4. package/lib/types/api/attachment-orphans.d.ts +11 -0
  5. package/lib/types/api/attachment.d.ts +23 -0
  6. package/lib/types/api/dispatch.d.ts +9 -29
  7. package/lib/types/api/request-trust.d.ts +45 -0
  8. package/lib/types/api/route-table.d.ts +28 -14
  9. package/lib/types/client/attachment-view.d.ts +27 -0
  10. package/lib/types/client/batch-bar.d.ts +1 -1
  11. package/lib/types/client/batch-confirm.d.ts +2 -2
  12. package/lib/types/client/batch-menu-item.d.ts +1 -1
  13. package/lib/types/client/batch-select.d.ts +1 -1
  14. package/lib/types/client/confirm-close.d.ts +11 -0
  15. package/lib/types/client/copy.d.ts +48 -38
  16. package/lib/types/client/delete-confirm.d.ts +0 -2
  17. package/lib/types/client/main-view.d.ts +1 -1
  18. package/lib/types/client/maintenance-parts.d.ts +25 -0
  19. package/lib/types/client/maintenance-settings.d.ts +22 -0
  20. package/lib/types/client/markdown.d.ts +9 -1
  21. package/lib/types/client/orphan-maintenance.d.ts +29 -11
  22. package/lib/types/client/receipt.d.ts +42 -2
  23. package/lib/types/client/remote.d.ts +32 -48
  24. package/lib/types/client/trash-confirm.d.ts +2 -15
  25. package/lib/types/client/trash-panel.d.ts +4 -3
  26. package/lib/types/client/ui/Checkbox.d.ts +1 -1
  27. package/lib/types/client/ui/Modal.d.ts +2 -2
  28. package/lib/types/client/ui/icons.d.ts +6 -11
  29. package/lib/types/client/ui/top-layer.d.ts +10 -0
  30. package/lib/types/client/undo-notices.d.ts +52 -0
  31. package/lib/types/client/undo-toast.d.ts +5 -59
  32. package/lib/types/client/use-column-resize.d.ts +9 -20
  33. package/lib/types/client/use-maintenance.d.ts +45 -0
  34. package/lib/types/client/use-panel-width.d.ts +1 -1
  35. package/lib/types/client/use-trash-panel-controller.d.ts +24 -7
  36. package/lib/types/client/use-trash-query.d.ts +35 -32
  37. package/lib/types/client/write-seats.d.ts +5 -13
  38. package/lib/types/client/write-settlement.d.ts +10 -16
  39. package/lib/types/client/write.d.ts +8 -5
  40. package/lib/types/deps.d.ts +18 -7
  41. package/lib/types/index.d.ts +6 -2
  42. package/lib/types/log/session-log.d.ts +4 -3
  43. package/lib/types/log/zstd-frames.d.ts +14 -8
  44. package/lib/types/ports/agent-handles.d.ts +1 -1
  45. package/lib/types/ports/connection-port.d.ts +33 -0
  46. package/lib/types/ports/fs-port.d.ts +13 -1
  47. package/lib/types/ports/host-port.d.ts +17 -45
  48. package/lib/types/ports/presence-port.d.ts +22 -0
  49. package/lib/types/shared/wire.d.ts +70 -26
  50. package/lib/types/trash/attachments.d.ts +85 -0
  51. package/lib/types/trash/detail.d.ts +28 -8
  52. package/lib/types/trash/entry.d.ts +3 -3
  53. package/lib/types/trash/groups.d.ts +4 -11
  54. package/lib/types/trash/storage-manifest.d.ts +1 -1
  55. package/lib/types/verbs/delete-session.d.ts +2 -2
  56. package/lib/types/verbs/errors.d.ts +2 -0
  57. package/lib/types/verbs/live-admission.d.ts +4 -7
  58. package/lib/types/verbs/member-journal.d.ts +3 -4
  59. package/lib/types/verbs/member-presence.d.ts +14 -37
  60. package/lib/types/verbs/orphan-subagents.d.ts +9 -8
  61. package/lib/types/verbs/permanent-removal.d.ts +24 -18
  62. package/lib/types/verbs/purge-trash.d.ts +8 -3
  63. package/lib/types/verbs/restore-session.d.ts +7 -13
  64. package/package.json +2 -1
package/lib/client.js CHANGED
@@ -93,41 +93,14 @@ window.__ModuleLoader__.load({
93
93
  //#endregion
94
94
  //#region src/client/copy.ts
95
95
  /**
96
- * 可见文案(界面标签与提示)+接上宿主 Client locale 服务的那一步。
96
+ * 界面文案与宿主 locale 服务的接入。
97
97
  *
98
- * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id 与单条幂等空值、
99
- * `src/client/remote.ts` 的取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
100
- *
101
- * ## 为什么不是散在各组件里的中文字面量
102
- *
103
- * 官方给第三方 UI 插件的规则是「Route visible UI text through the Client locale service」
104
- * (宿主技能 `cordis-plugin-development/references/ui-plugin.md`)。宿主自己那套就是这么做的:
105
- * `ui-sidebar` 声明 `inject = ['slots','layout','uiWorkspace','locale','shortcuts']`,并在
106
- * `ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')` 里注册字典
107
- * (真产物原文)。所以本插件也把文案收成一份带**命名空间**的字典,`zh` 是完整真源、`en` 逐键镜像
108
- * ——宿主 `register` 的**对象形式要求两套内置语言都齐**(缺一个键就是运行期报错)。
109
- *
110
- * ## 服务是**可选读**的,不进 `inject`
111
- *
112
- * `ctx.get('locale')` 而不是把 `locale` 写进 `inject`。理由是本仓那条硬约束的另一面:**客户端半的
113
- * 条目激活失败 = 宿主进程死**(前端启动自检 `web boot: N entry did not activate` ⇒ 桌面壳
114
- * `reportFatal('web-boot')`)。宿主自己的 `ui-sidebar` 敢硬注入 `locale`(它是宿主的一部分,知道
115
- * 那一行一定在),而本插件要跨宿主代数活着 ⇒ 服务缺席时**降级到内置 zh 文案继续工作**,
116
- * 好过整个进程死。这与宿主对可选服务的官方写法同形(`ctx.get('…')?.…`)。
117
- *
118
- * 取数面:[`dsh-client-locale`] 的 `LocaleRuntime.register(ns, { zh, en })` 返回一个 disposer
119
- * (必须挂进 `ctx.effect`,否则卸载后字典留在注册表里,下次挂载会撞 "already has locale"),
120
- * `bind(ns)` 交回一个**按命名空间记忆化**的 `t(key, params)`(官方明说它"retain stable identity …
121
- * so they can ride inject surfaces without breaking memoization"——所以我们把它塞进槽位的
122
- * `inject` 载荷是正当用法)。
123
- *
124
- * ## 面板行那一行字为什么要用 thunk
125
- *
126
- * `sidebar.panellist` 的行名字由宿主 `syncPanels` 逐字 `label: resolveSlotLabel(options.label) ?? id`
127
- * 解析,而 `resolveSlotLabel` 的实现是 `typeof label === 'function' ? label() : label`,
128
- * 其 JSDoc 原文即 "thunks follow the active locale";宿主自己还挂了
129
- * `ctx.effect(() => ctx.locale.subscribe(syncPanels), 'ui-sidebar: panel labels')` 在语言切换时重读。
130
- * ⇒ 面板名传 `() => t('trash.title')` 就随语言走;**换成活计数就会 stale**(只在条目或语言变化时重读)。
98
+ * - zh 是完整真源,en 逐键镜像。
99
+ * - locale 服务通过 ctx.get('locale') 可选读,服务缺席时退回内置 zh;为什么不进 inject 见
100
+ * docs/architecture.md §3。
101
+ * - 面板行名传 thunk,语言切换时宿主会重读;其余文案走 t(key, params)。
102
+ * - 渠道错误原文(src/client/receipt.ts 的 missing-id/幂等空值、src/client/remote.ts 的取数通道错误)
103
+ * 不在此字典,它们是协议诊断,固定中文。
131
104
  */
132
105
  /** 本插件的文案命名空间(第三方必须自带前缀;官方占用的是 `common` / `settings` / `sidebar` 这些)。 */
133
106
  const COPY_NS = "xrn1997-session-delete";
@@ -144,7 +117,7 @@ window.__ModuleLoader__.load({
144
117
  "trash.readFailed": "回收站读不到",
145
118
  "trash.empty.title": "回收站是空的",
146
119
  "trash.empty.hint": "在会话条目的「…」菜单里选「删除」,会话会先进到这里,随时可以恢复。",
147
- "trash.summary": "{count} 个会话 · 共 {size}",
120
+ "trash.summary": "{count} 个会话 · 可释放 {size}",
148
121
  "trash.group.count": "{count} 个会话",
149
122
  "trash.project.unknown": "未记录项目",
150
123
  "trash.row.deletedNow": "刚刚删除",
@@ -164,7 +137,7 @@ window.__ModuleLoader__.load({
164
137
  "trash.confirm.all": "清空回收站?",
165
138
  "trash.confirm.one": "彻底删除 1 个会话?",
166
139
  "trash.confirm.one.meta": "{project} · 删除于 {at} · {size}",
167
- "trash.confirm.body": "这会从回收站移除文件,{strong}。",
140
+ "trash.confirm.body": "这会从回收站移除文件,{strong}。只清理会话独占的图片附件副本(共享副本保留);宿主投影缓存与你磁盘上的原图不受影响。",
168
141
  "trash.confirm.body.strong": "之后没有任何副本可以恢复",
169
142
  "trash.confirm.purge": "彻底删除",
170
143
  "trash.toolbar.collapseAll": "全部折叠",
@@ -186,14 +159,40 @@ window.__ModuleLoader__.load({
186
159
  "trash.orphans.meta": "共 {size},来自 {parents} 个失踪的父会话",
187
160
  "trash.orphans.open": "查看并清理",
188
161
  "trash.orphans.confirm": "清理 {count} 个无主子会话?",
189
- "trash.orphans.body": "这些子会话的父会话已经不在会话列表或回收站里,清理后不可恢复。正在运行的子会话会被拒绝,不会被删除。",
162
+ "trash.orphans.body": "这些子会话的父会话已经不在会话列表或回收站里,清理后不可恢复。正在运行的子会话会被拒绝,不会被删除。只清理会话独占的图片附件副本(共享副本保留);宿主投影缓存与你磁盘上的原图不受影响。若剩余引用源读不出,本次附件清理会跳过(会话目录仍会删除)。",
190
163
  "trash.orphans.group": "{project} · {count} 个 · {size}",
191
- "trash.orphans.cacheNote": "本次只清理会话目录,不包含宿主投影缓存和附件。",
164
+ "trash.orphans.cacheNote": "本次会一并清理会话独占的图片附件副本;宿主投影缓存不包含在内。若剩余引用源读不出,本次附件清理会跳过(会话目录仍会删除)。",
192
165
  "trash.orphans.clean": "清理",
193
166
  "trash.orphans.failure": "残留检查/清理失败:{message}",
194
167
  "trash.orphans.cleanFailed": "{count} 组未清理:{message}",
195
168
  "trash.orphans.cleanPartial": "已先删掉 {removed} 个会话目录({size})",
196
169
  "trash.orphans.cleanGone": "有 {count} 组已不在了(可能已被别处清理,或父会话已恢复)",
170
+ "trash.attachments.failed": "已删除会话,但 {count} 个附件未能清理",
171
+ "trash.attachments.skipped": "附件未清理:{reason}",
172
+ "trash.attachments.skippedPartial": "部分附件未清理:{reason}(已清理 {removed} 个)",
173
+ "trash.attachments.alsoFailed": ";另有 {count} 个附件删除失败",
174
+ "maintenance.title": "维护",
175
+ "maintenance.orphans.title": "孤儿会话",
176
+ "maintenance.orphans.hint": "父会话已不在会话列表或回收站里的子会话。检查只读,不会自动运行。",
177
+ "maintenance.attachments.title": "孤儿附件",
178
+ "maintenance.attachments.hint": "在 .dsh 里没有任何引用的图片副本。检查只读,清理只动这些副本。",
179
+ "maintenance.scan": "检查",
180
+ "maintenance.scanning": "检查中…",
181
+ "maintenance.cleaning": "清理中…",
182
+ "maintenance.checkFailed": "检查失败",
183
+ "maintenance.notScanned": "尚未检查",
184
+ "maintenance.none": "没有发现",
185
+ "maintenance.orphans.count": "{count} 个",
186
+ "maintenance.attachments.count": "{count} 个",
187
+ "maintenance.attachments.scanFailed": "检查失败:{message}",
188
+ "maintenance.attachments.cleaned": "已清理 {count} 个({size})",
189
+ "maintenance.attachments.cleanFailed": "{count} 个附件未能清理(已清理 {removed} 个)",
190
+ "maintenance.attachments.cleanError": "附件清理失败:{message}",
191
+ "maintenance.attachments.confirm": "查看并清理 {count} 个孤儿附件?",
192
+ "maintenance.attachments.body": "只清理 .dsh 内无引用的图片副本;源文件不受影响;共享引用保留。",
193
+ "maintenance.attachments.previewTruncated": "仅预览前 {shown} 个;清理会处理全部 {total} 个。",
194
+ "maintenance.attachments.previewHint": "点图片可放大;关闭这个框不会删除任何东西。",
195
+ "maintenance.attachments.clean": "清理这些附件",
197
196
  "detail.notSelected": "选中一条会话,看它讲了什么",
198
197
  "detail.loading": "正在读这条会话…",
199
198
  "detail.readFailed": "这条会话的正文读不出来",
@@ -201,6 +200,7 @@ window.__ModuleLoader__.load({
201
200
  "detail.field.created": "创建",
202
201
  "detail.field.lastActivity": "最后活动",
203
202
  "detail.field.deletedAt": "删除于",
203
+ "detail.deletedAt.combined": "{relative}({absolute})",
204
204
  "detail.field.span": "活跃跨度",
205
205
  "detail.field.size": "大小",
206
206
  "detail.field.turns": "轮数",
@@ -218,6 +218,16 @@ window.__ModuleLoader__.load({
218
218
  "detail.value.spanDays": "{n} 天",
219
219
  "detail.messages.hidden": "另有 {count} 条宿主注入的上下文未显示",
220
220
  "detail.messages.omitted": "还有 {count} 条未显示",
221
+ "detail.image": "[图片]",
222
+ "detail.image.alt": "[图片 · {alt}]",
223
+ "detail.attachment.file": "文件",
224
+ "detail.attachment.preview": "附件预览",
225
+ "detail.attachment.alt": "附件图片",
226
+ "detail.attachment.open": "打开图片「{name}」",
227
+ "detail.attachment.unavailable": "附件不可用",
228
+ "detail.attachment.save": "另存为",
229
+ "detail.attachment.saving": "保存中…",
230
+ "detail.attachment.saveFailed": "保存失败:{message}",
221
231
  "detail.back": "← 返回清单",
222
232
  "detail.list.label": "回收站清单",
223
233
  "detail.role.user": "你",
@@ -259,7 +269,7 @@ window.__ModuleLoader__.load({
259
269
  "trash.readFailed": "Could not read the trash",
260
270
  "trash.empty.title": "The trash is empty",
261
271
  "trash.empty.hint": "Pick Delete in a session’s “…” menu; the session lands here first and can be restored at any time.",
262
- "trash.summary": "{count} sessions · {size} total",
272
+ "trash.summary": "{count} sessions · {size} reclaimable",
263
273
  "trash.group.count": "{count} sessions",
264
274
  "trash.project.unknown": "Unrecorded project",
265
275
  "trash.row.deletedNow": "Deleted just now",
@@ -279,7 +289,7 @@ window.__ModuleLoader__.load({
279
289
  "trash.confirm.all": "Empty the trash?",
280
290
  "trash.confirm.one": "Delete this session permanently?",
281
291
  "trash.confirm.one.meta": "{project} · deleted {at} · {size}",
282
- "trash.confirm.body": "This removes the files from the trash; {strong}.",
292
+ "trash.confirm.body": "This removes the files from the trash; {strong}. Only session-exclusive image attachment copies are cleaned (shared copies are kept); host projection caches and your original files are untouched.",
283
293
  "trash.confirm.body.strong": "no copy remains to restore it",
284
294
  "trash.confirm.purge": "Delete permanently",
285
295
  "trash.toolbar.collapseAll": "Collapse all",
@@ -301,14 +311,40 @@ window.__ModuleLoader__.load({
301
311
  "trash.orphans.meta": "{size} total, from {parents} missing parent sessions",
302
312
  "trash.orphans.open": "Review and clean",
303
313
  "trash.orphans.confirm": "Clean {count} orphan subagent sessions?",
304
- "trash.orphans.body": "Their parent sessions are no longer in the session list or the trash; this cannot be undone. Running sessions are refused and left untouched.",
314
+ "trash.orphans.body": "Their parent sessions are no longer in the session list or the trash; this cannot be undone. Running sessions are refused and left untouched. Only session-exclusive image attachment copies are cleaned (shared copies are kept); host projection caches and your original files are untouched. If a remaining reference source cannot be read, attachment cleanup is skipped for that run (session directories are still deleted).",
305
315
  "trash.orphans.group": "{project} · {count} sessions · {size}",
306
- "trash.orphans.cacheNote": "This cleans session directories only; host projection caches and attachments are not included.",
316
+ "trash.orphans.cacheNote": "Session-exclusive image attachment copies inside .dsh are cleaned as well; host projection caches are not included. If a remaining reference source cannot be read, attachment cleanup is skipped for that run (session directories are still deleted).",
307
317
  "trash.orphans.clean": "Clean",
308
318
  "trash.orphans.failure": "Orphan check/cleanup failed: {message}",
309
319
  "trash.orphans.cleanFailed": "{count} groups were not cleaned: {message}",
310
320
  "trash.orphans.cleanPartial": "Removed {removed} session directories first ({size})",
311
321
  "trash.orphans.cleanGone": "{count} groups were already gone (cleaned elsewhere or their parent was restored)",
322
+ "trash.attachments.failed": "Sessions were deleted, but {count} attachments could not be cleaned",
323
+ "trash.attachments.skipped": "Attachments were not cleaned: {reason}",
324
+ "trash.attachments.skippedPartial": "Some attachments were not cleaned: {reason} ({removed} cleaned)",
325
+ "trash.attachments.alsoFailed": "; also {count} attachment deletions failed",
326
+ "maintenance.title": "Maintenance",
327
+ "maintenance.orphans.title": "Orphan sessions",
328
+ "maintenance.orphans.hint": "Subagent sessions whose parent is no longer in the session list or the trash. Checking only reads; it never runs automatically.",
329
+ "maintenance.attachments.title": "Orphan attachments",
330
+ "maintenance.attachments.hint": "Image copies inside .dsh that nothing references. Checking only reads; cleanup touches only these copies.",
331
+ "maintenance.scan": "Check",
332
+ "maintenance.scanning": "Checking…",
333
+ "maintenance.cleaning": "Cleaning…",
334
+ "maintenance.checkFailed": "Check failed",
335
+ "maintenance.notScanned": "Not checked yet",
336
+ "maintenance.none": "Nothing found",
337
+ "maintenance.orphans.count": "{count}",
338
+ "maintenance.attachments.count": "{count}",
339
+ "maintenance.attachments.scanFailed": "Check failed: {message}",
340
+ "maintenance.attachments.cleaned": "Cleaned {count} ({size})",
341
+ "maintenance.attachments.cleanFailed": "{count} attachments could not be cleaned ({removed} cleaned)",
342
+ "maintenance.attachments.cleanError": "Attachment cleanup failed: {message}",
343
+ "maintenance.attachments.confirm": "Review and clean {count} orphan attachments?",
344
+ "maintenance.attachments.body": "Only unreferenced image copies inside .dsh are cleaned; source files are untouched; shared references are kept.",
345
+ "maintenance.attachments.previewTruncated": "Only the first {shown} are previewed; cleanup handles all {total}.",
346
+ "maintenance.attachments.previewHint": "Click an image to enlarge it; closing this dialog deletes nothing.",
347
+ "maintenance.attachments.clean": "Clean these attachments",
312
348
  "detail.notSelected": "Select a session to see what it was about",
313
349
  "detail.loading": "Reading this session…",
314
350
  "detail.readFailed": "Could not read this session’s log",
@@ -316,6 +352,7 @@ window.__ModuleLoader__.load({
316
352
  "detail.field.created": "Created",
317
353
  "detail.field.lastActivity": "Last activity",
318
354
  "detail.field.deletedAt": "Deleted",
355
+ "detail.deletedAt.combined": "{relative} ({absolute})",
319
356
  "detail.field.span": "Active for",
320
357
  "detail.field.size": "Size",
321
358
  "detail.field.turns": "Turns",
@@ -333,6 +370,16 @@ window.__ModuleLoader__.load({
333
370
  "detail.value.spanDays": "{n} d",
334
371
  "detail.messages.hidden": "{count} injected context records not shown",
335
372
  "detail.messages.omitted": "{count} more messages not shown",
373
+ "detail.image": "[Image]",
374
+ "detail.image.alt": "[Image · {alt}]",
375
+ "detail.attachment.file": "File",
376
+ "detail.attachment.preview": "Attachment preview",
377
+ "detail.attachment.alt": "Attachment image",
378
+ "detail.attachment.open": "Open image “{name}”",
379
+ "detail.attachment.unavailable": "Attachment unavailable",
380
+ "detail.attachment.save": "Save as",
381
+ "detail.attachment.saving": "Saving…",
382
+ "detail.attachment.saveFailed": "Save failed: {message}",
336
383
  "detail.back": "← Back to list",
337
384
  "detail.list.label": "Trash list",
338
385
  "detail.role.user": "You",
@@ -436,10 +483,10 @@ window.__ModuleLoader__.load({
436
483
  //#endregion
437
484
  //#region src/client/store.ts
438
485
  /**
439
- * 客户端**模块级快照 store** 的唯一实现(2026-10-20)。
486
+ * 客户端**模块级快照 store** 的唯一实现。
440
487
  *
441
488
  * 为什么要有它:浏览器半有一批"跨卸载的现场"——回收站修订号(`trash-store.ts`)、单删请求
442
- * (`delete-confirm.tsx`)、批量删除请求(`batch-confirm.tsx`)、撤销提示(`undo-toast.tsx`)、
489
+ * (`delete-confirm.tsx`)、批量删除请求(`batch-confirm.tsx`)、撤销提示(`undo-notices.ts`)、
443
490
  * 批量选中集(`batch-selection-store.ts`)等。它们都挂在 `shell.overlay` / `main` / 菜单行这些
444
491
  * 互不相邻的座位上,模块级是唯一的共同点。
445
492
  * 在这之前每个文件各写一遍「`let` 现场 + `Set<() => void>` + `notify` + `useSyncExternalStore`」,
@@ -556,7 +603,7 @@ window.__ModuleLoader__.load({
556
603
  //#endregion
557
604
  //#region src/client/batch-selection-store.ts
558
605
  /**
559
- * 宿主列表批量删除的**选中集现场**(2026-10-20):多选模式 + 选中名单,一份合并快照。
606
+ * 宿主列表批量删除的**选中集现场**:多选模式 + 选中名单,一份合并快照。
560
607
  *
561
608
  * 从 `batch-select.tsx` 拆出来的理由:多选态与选中集是**同一次交互的两半**(进入模式才出现勾选框,
562
609
  * 退出模式连名单一起清),读写它们的却有四个座位——菜单里的入口(`batch-menu-item.tsx`)、行首
@@ -640,20 +687,12 @@ window.__ModuleLoader__.load({
640
687
  //#endregion
641
688
  //#region src/client/host-rows.ts
642
689
  /**
643
- * 宿主会话行的**DOM 适配**:可见行解析、全选判定、模式内的整行点击守卫。
644
- *
645
- * 从 `batch-select.tsx` 拆出来的理由:选中集本身(模式 + 名单)只住 `batch-selection-store.ts`;
646
- * 本文件只回答**页面上**的两件事——"现在 DOM 里有哪些可选的会话行"与"在这个行上点一下算不算选中"。
647
- * 两边的唯一交界是 `data-row-key`(宿主会话行的稳定标记)和行尾兜底登记的那张 `rowInfo` 表。
690
+ * 宿主会话行的 DOM 适配:可见行解析、全选判定、模式内整行点击守卫。
648
691
  *
649
- * **为什么渲染走快照**:可见行是组件树之外的状态(宿主的 DOM)。每个组件各自在渲染期查一遍,
650
- * 同一帧看到什么就取决于副作用顺序,而且 DOM 变了也不会触发重渲染。这里交给 `useSyncExternalStore`:
651
- * DOM 观察(`MutationObserver`)与行登记都留在本适配器里,只把缓存标脏并通告订阅者;组件读缓存快照,
652
- * 干净时拿到同一个引用(React 的 snapshot 契约),脏了才重读。事件路径(全选)仍走 `readVisibleSelectableRows`
653
- * 的当场读。裁决见 ADR-0015。
654
- *
655
- * **为什么整行点击要抢在 document 捕获阶段**:宿主的会话行自己带 `onClick = open(sessionId)`,
656
- * 第三方没有官方的"别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
692
+ * 选中集(模式 + 名单)只住 batch-selection-store.ts;本文件只回答页面上的两件事:
693
+ * 当前 DOM 里有哪些可选行,以及在某一行上点击是否算选中。交界是 data-row-key 与行尾登记的
694
+ * rowInfo。渲染走 useSyncExternalStore 快照(ADR-0015);事件路径(全选)走当场读。
695
+ * 模式内整行点击在 document 捕获阶段拦截,避免触发宿主的会话切换。
657
696
  */
658
697
  /**
659
698
  * 守卫从 `data-row-key` 反解出 sessionId,但拿不到槽位投影的标题/运行态 ⇒ 行尾兜底那颗
@@ -799,20 +838,11 @@ window.__ModuleLoader__.load({
799
838
  //#endregion
800
839
  //#region src/shared/wire.ts
801
840
  /**
802
- * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、六个端点段、请求字段名**——
803
- * Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
804
- * 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
805
- *
806
- * 两条构建期硬约束(不是偏好):
807
- * ① tsdown 的浏览器半纯度门拦 Node 内建与平台模块表外的 `@deepseek-ai/*` 值 import——
808
- * 本文件两者都不涉及,且**零运行时依赖**(client 半把它整个 inline 进 bundle);
809
- * ② 跨插件一律 `import type`——本文件干脆没有 import。
841
+ * 跨半 wire 契约:前缀、九个端点、请求/响应形状与失败码。
842
+ * Node 半(src/api/dispatch.ts)与浏览器半(src/client/remote.ts)共用这一份,不各写字面量。
810
843
  *
811
- * 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
812
- * Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那三件),浏览器半按它判别
813
- * (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
814
- * 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
815
- * 不是对宿主模块的 import。
844
+ * 构建约束:本文件零运行时依赖、无 import;浏览器半把整个文件 inline 进 bundle。
845
+ * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
816
846
  */
817
847
  /**
818
848
  * 插件专属前缀。
@@ -822,15 +852,18 @@ window.__ModuleLoader__.load({
822
852
  * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
823
853
  */
824
854
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
825
- /** 六个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
826
- * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
827
- * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
828
- * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
829
- * - `POST /detail`:单条详情(用 POST 表达一个读:判据就写在 `src/api/route-table.ts` 那一行的上方)。
855
+ /** 九个段名(相对前缀)。写动作统一走一段 `/apply`:一次 HTTP 带一组 id、逐条回结果;
856
+ * 单条就是长度为 1 的批量,仓里不留第二条执行路径。
857
+ * - `GET /list`:对账并读清单(`session-trash/` 里的目录是准)。
858
+ * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链),供客户端探测。
859
+ * - `POST /detail`:单条详情(用 POST 表达一个读,判据在 `src/api/route-table.ts`)。
830
860
  * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count` / `orphan-clean`),逐条回 `ApplyOutcome`。
831
- * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。
832
- * - `GET /orphans`:无主子会话的扫描报告(纯读,2026-10-19)。**清理不在这里**——它是 `/apply` 的
833
- * 第五个动作,这一段只负责"有多少、在哪、多大"。 */
861
+ * - `POST /empty`:清空回收站(聚合回执,不按条回)。
862
+ * - `GET /orphans`:无主子会话的扫描报告(纯读)。清理是 `/apply` 的第五个动作,
863
+ * 这一段只负责"有多少、在哪、多大"。
864
+ * - `GET /attachment`:附件原始字节(全仓唯一的二进制响应段;失败仍回 JSON 信封)。
865
+ * - `GET /attachment-orphans`:无引用图片对象的扫描报告(纯读;数量/字节 + 供预览的对象清单)。
866
+ * - `POST /attachment-clean`:清掉报告那一刻无引用的图片对象(聚合回执 `AttachmentCleanupWire`)。 */
834
867
  const ROUTES = {
835
868
  list: "/list",
836
869
  /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
@@ -842,13 +875,23 @@ window.__ModuleLoader__.load({
842
875
  /** 清空回收站。 */
843
876
  empty: "/empty",
844
877
  /** 纯读:扫描回收站之外的无主子会话(父已不在语料/清册)。 */
845
- orphans: "/orphans"
878
+ orphans: "/orphans",
879
+ /** 附件字节(图片/普通文件):全仓唯一的二进制响应段,读的是宿主附件库。 */
880
+ attachment: "/attachment",
881
+ /** 纯读:扫全部引用源与附件库 `objects`,报无引用图片的数量、字节与预览清单。 */
882
+ attachmentOrphans: "/attachment-orphans",
883
+ /** 聚合清理:只删 `objects` 下无引用的图片,回 `AttachmentCleanupWire`。 */
884
+ attachmentClean: "/attachment-clean"
846
885
  };
847
- /** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
886
+ /** 请求参数名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。
887
+ * 附件段是 **query 参数**(GET 二进制响应,没有请求体)。 */
848
888
  const PARAMS = {
849
889
  entryId: "entryId",
850
890
  action: "action",
851
- ids: "ids"
891
+ ids: "ids",
892
+ attachmentId: "id",
893
+ attachmentKind: "kind",
894
+ attachmentName: "name"
852
895
  };
853
896
  /**
854
897
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -899,9 +942,6 @@ window.__ModuleLoader__.load({
899
942
  function singleNullMessage(action, id) {
900
943
  return `单条动作回了幂等空值:${action} ${id}`;
901
944
  }
902
- function isFailureVerdict(verdict) {
903
- return verdict.kind === "failed" || verdict.kind === "neutral" || verdict.kind === "missing";
904
- }
905
945
  /** failed / neutral 取服务端原文 `error.message`;missing 取通道自己写的那句。 */
906
946
  function failureMessage(verdict) {
907
947
  return verdict.kind === "failed" || verdict.kind === "neutral" ? verdict.error.message : verdict.message;
@@ -951,6 +991,7 @@ window.__ModuleLoader__.load({
951
991
  message: missingOutcomeMessage(action, id)
952
992
  });
953
993
  return {
994
+ action,
954
995
  verdicts,
955
996
  byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
956
997
  };
@@ -966,6 +1007,116 @@ window.__ModuleLoader__.load({
966
1007
  byId: new Map(verdicts.map((verdict) => [verdict.id, verdict]))
967
1008
  };
968
1009
  }
1010
+ function detailNumber(details, key) {
1011
+ const value = details?.[key];
1012
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1013
+ }
1014
+ function isSuccessVerdict(verdict) {
1015
+ return verdict.kind === "ok" || verdict.kind === "already-gone";
1016
+ }
1017
+ /** purge / orphan-clean 的成功值里那半份附件读数;别的动作与没有读数的那几条回执都没有这一格。
1018
+ * `attachments: null` 这类畸形线上值也按"没有读数"处理:这一层只读回执,不替契约在加法里兜底。 */
1019
+ function statsAttachments(value) {
1020
+ if (typeof value !== "object" || value === null || !("attachments" in value)) return void 0;
1021
+ const attachments = value.attachments;
1022
+ return typeof attachments === "object" && attachments !== null ? attachments : void 0;
1023
+ }
1024
+ /**
1025
+ * 附件读数跨 `ok` 回执相加(一段 `/apply` 可能带多条),`skipped` 取第一条有的——它说的是这一轮
1026
+ * 为什么没敢清,不是能累加的数。`removed` 可能 > 0 而 `skipped` 有值:那是**部分**没清,上屏时要说清。
1027
+ */
1028
+ function summarizeAttachmentCleanup(receipt) {
1029
+ if (receipt.action !== "purge" && receipt.action !== "orphan-clean") return void 0;
1030
+ let removed = 0;
1031
+ let freedBytes = 0;
1032
+ let failedCount = 0;
1033
+ let skipped;
1034
+ let seen = false;
1035
+ for (const verdict of receipt.verdicts) {
1036
+ if (verdict.kind !== "ok") continue;
1037
+ const attachments = statsAttachments(verdict.value);
1038
+ if (attachments === void 0) continue;
1039
+ seen = true;
1040
+ removed += attachments.removed;
1041
+ freedBytes += attachments.freedBytes;
1042
+ failedCount += attachments.failed;
1043
+ skipped ??= attachments.skipped;
1044
+ }
1045
+ if (!seen) return void 0;
1046
+ return skipped === void 0 ? {
1047
+ removed,
1048
+ freedBytes,
1049
+ failed: failedCount
1050
+ } : {
1051
+ removed,
1052
+ freedBytes,
1053
+ failed: failedCount,
1054
+ skipped
1055
+ };
1056
+ }
1057
+ /**
1058
+ * 附件清理回执的「跳过半句」:`skipped` 说的是"这一轮为什么没敢清",不是失败——原因上屏,
1059
+ * `removed > 0` 时说「部分」,`failed` 件数追加在句尾(不被跳过原因吞掉)。
1060
+ * 面板(`use-trash-query.ts`)与维护卡(`use-maintenance.ts`)共用这一判据,不许各拼一份。
1061
+ * 没 `skipped` 时回 `null`:后半句(全清/失败怎么说)两个面各有各的口径,由调用方自己接。
1062
+ */
1063
+ function attachmentSkippedSentence(a, t) {
1064
+ if (a.skipped === void 0) return null;
1065
+ const failedSuffix = a.failed > 0 ? t("trash.attachments.alsoFailed", { count: a.failed }) : "";
1066
+ return `${a.removed > 0 ? t("trash.attachments.skippedPartial", {
1067
+ reason: a.skipped,
1068
+ removed: a.removed
1069
+ }) : t("trash.attachments.skipped", { reason: a.skipped })}${failedSuffix}`;
1070
+ }
1071
+ function summarizeReceipt(receipt, sessionIdOfEntry) {
1072
+ const successIds = receipt.verdicts.filter(isSuccessVerdict).map((verdict) => verdict.id);
1073
+ const failures = /* @__PURE__ */ new Map();
1074
+ const neutralIds = [];
1075
+ const failed = [];
1076
+ let partial = {
1077
+ removed: 0,
1078
+ freedBytes: 0
1079
+ };
1080
+ for (const verdict of receipt.verdicts) {
1081
+ if (isSuccessVerdict(verdict)) continue;
1082
+ failures.set(verdict.id, failureMessage(verdict));
1083
+ if (verdict.kind === "neutral") {
1084
+ neutralIds.push(verdict.id);
1085
+ continue;
1086
+ }
1087
+ if (verdict.kind === "missing") {
1088
+ failed.push({
1089
+ id: verdict.id,
1090
+ error: {
1091
+ code: CLIENT_UNKNOWN_CODE,
1092
+ message: verdict.message
1093
+ }
1094
+ });
1095
+ continue;
1096
+ }
1097
+ failed.push({
1098
+ id: verdict.id,
1099
+ error: verdict.error
1100
+ });
1101
+ partial = {
1102
+ removed: partial.removed + detailNumber(verdict.error.details, "removed"),
1103
+ freedBytes: partial.freedBytes + detailNumber(verdict.error.details, "freedBytes")
1104
+ };
1105
+ }
1106
+ const landedIds = receipt.action === "delete" ? landedEntryIds(receipt) : receipt.action === "restore" ? restoredSessionIds(receipt, sessionIdOfEntry) : [];
1107
+ return {
1108
+ summary: {
1109
+ successIds,
1110
+ neutralIds,
1111
+ failed,
1112
+ partial,
1113
+ landedIds,
1114
+ anySuccess: successIds.length > 0,
1115
+ attachmentCleanup: summarizeAttachmentCleanup(receipt)
1116
+ },
1117
+ failures
1118
+ };
1119
+ }
969
1120
  function isRestoreEntry(value) {
970
1121
  return typeof value === "object" && value !== null && "sessionId" in value;
971
1122
  }
@@ -1002,40 +1153,17 @@ window.__ModuleLoader__.load({
1002
1153
  //#endregion
1003
1154
  //#region src/client/remote.ts
1004
1155
  /**
1005
- * 浏览器半的**取数通道**:同源 fetch 宿主 web server 上的 `/dsh-session-delete-api`,
1006
- * 加上信封判别与注入面那一处「展开成裸值」的适配。
1007
- *
1008
- * 通道形状(裁决):真相/账本/判据全在宿主那边,Node 半用宿主**自己的 web server** 开一条
1009
- * 插件专属前缀路由把六个段名(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)搬出来
1010
- * (`src/api/dispatch.ts`),浏览器半同源调用。**不是重造后端**:
1011
- * 路由层只做搬运与信封。为什么不用官方的 `remote.<namespace>`:那条路要**生成的 Typert 远程描述符**
1012
- * 加一次 `$mount`,而盒内装配清单写死在 `dsh-api-remotes` 里、全宿主没有动态发现,第三方只能自挂自,
1013
- * 对一个薄插件不划算。
1014
- *
1015
- * 三条约束决定了这个文件长这样:
1016
- *
1017
- * 1. **信封刻意与官方 unary 面同形**:`{ ok: true, value }` / `{ ok: false, error }`。这不是"还留着
1018
- * 官方通道"——它现在是**我们两半之间**的 wire 契约(Node 半按它产出,见 `src/api/dispatch.ts`),
1019
- * 选它是为了让组件面、`unwrapRemoteResult` 与既有用例一行都不用改。失败是**返回**而不是
1020
- * reject(同宿主网关原文:"Every unary call resolves to `RemoteResult<T>` … and never rejects for a
1021
- * carrier problem");只有往返本身失败(打不通、回的不是信封)才 reject,那是**通道缺席**。
1022
- * 2. **不许值 import 跨插件包**。`error` 在线上就是一段 JSON(`{code, message, details?}`),不是
1023
- * `RemoteError` 实例——所以判别**所依据的那一个标记位**只镜一份,家在两半共用的
1024
- * `src/shared/wire.ts`(`isDSHRemoteError`)。浏览器半落到本地时用 `throwRemoteFailure` 就地补上
1025
- * Error 本体与标记位;Node 半(`src/remote/map-error.ts`)只用它做「这已经是一次远程失败」的判别,
1026
- * 产出的是线上那三件(`details` 可缺席)、不造任何 Error,更不 import 宿主那个类。
1027
- * 3. **失败原文必须能上屏**。调用方(`callRemote`)只把**抛出来的失败**收成判别式状态——
1028
- * `{ ok: false, error }` 不是第三种落地:它在注入面那一处已经被 `unwrapRemoteResult` 抛成了异常,
1029
- * 所以到了组件面前只剩「裸值 / throw」两支,调用方只写 `if (!outcome.ok)`,正常流里没有 try/catch;
1030
- * 非本信封形状的异常(网络层、TypeError…)也不丢原文(见 `failureOf`)。
1031
- *
1032
- * 面(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)与 Node 半路由的六个段名一一对应(段名本身的家是
1033
- * `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`orphans` 回一份**纯读**的
1034
- * 残留扫描报告,`detail` 回单条正文,
1035
- * `apply` 回**一组逐条结果**,`empty` 回聚合回执。2026-10-17 之前是六个动作段(一条一段),
1036
- * 批量只能由调用方循环拼——那正是「恢复 100 条很慢」的形状来源,收成一段之后这条通道不再有
1037
- * "第二条执行路径"。**清理无主子会话也不另开一段**:它是 `apply` 的第五个动作,`orphans` 只负责
1038
- * "有多少、在哪、多大"。
1156
+ * 浏览器半的取数通道:同源 fetch /dsh-session-delete-api,并处理信封与失败原文。
1157
+ *
1158
+ * - 路由是宿主 web server 上的插件前缀;官方 remote.<namespace> 需要生成的 Typert 描述符,
1159
+ * 第三方无法动态挂载,因此走这条薄线(ADR-0009)。
1160
+ * - 九段:list / ping / orphans / detail / apply / empty / attachment / attachment-orphans / attachment-clean;
1161
+ * 写动作统一走 apply;`attachment` 是唯一二进制段,由 `src/client/attachment-view.tsx` 直接拼地址,
1162
+ * 不经过本文件的信封通道;`attachment-orphans`(纯读)与 `attachment-clean`(聚合写)是孤儿附件的两段。
1163
+ * - 信封与官方 unary 面同形:{ok:true,value} / {ok:false,error}。HTTP 往返本身失败才 reject;
1164
+ * 业务失败以信封返回,由 unwrapRemoteResult 在注入面抛出。
1165
+ * - error 在线上是 {code,message,details?} 普通对象;判别依据 isDSHRemoteError 住在
1166
+ * src/shared/wire.ts,这里不 import 任何宿主客户端包。
1039
1167
  */
1040
1168
  /** 任意异常 → 失败面。非带标记的失败(网络层、TypeError…)也保留原文,绝不吞;带标记的失败把
1041
1169
  * `details` 第三件一起带出。 */
@@ -1117,8 +1245,9 @@ window.__ModuleLoader__.load({
1117
1245
  }
1118
1246
  /**
1119
1247
  * 前缀路由的 fetch 实现。**相对路径**:不拼 base、不认 origin——页面本身就是从宿主 web server
1120
- * 加载的,同源是这条薄线的全部前提(也是它敢不带凭据的原因:宿主那行 URL 上的 browser-trust token
1121
- * 不是这条通道的凭据,栅栏在 Node 半那一侧按 Origin/Referer 判)。
1248
+ * 加载的,同源是这条薄线的全部前提。本机(含转发落在回环的桌面壳)靠回环 + Origin/Referer 过栅栏;
1249
+ * 远程(LAN / 反代)客户端另由宿主 `connection` 判「已认证的 Web 身份」,所以这条通道**只对**
1250
+ * `dsh web` 打印的 URL 打开过的页面成立(凭据是那行 URL 换来的 cookie,不是插件自己的秘密)。
1122
1251
  */
1123
1252
  function createHttpRemote(prefix = SESSION_DELETE_API_PREFIX) {
1124
1253
  const post = (body) => ({
@@ -1130,6 +1259,8 @@ window.__ModuleLoader__.load({
1130
1259
  list: () => requestEnvelope(prefix + ROUTES.list, { method: "GET" }),
1131
1260
  ping: () => requestEnvelope(prefix + ROUTES.ping, { method: "GET" }),
1132
1261
  orphans: () => requestEnvelope(prefix + ROUTES.orphans, { method: "GET" }),
1262
+ attachmentOrphans: () => requestEnvelope(prefix + ROUTES.attachmentOrphans, { method: "GET" }),
1263
+ attachmentClean: () => requestEnvelope(prefix + ROUTES.attachmentClean, post({})),
1133
1264
  detail: (entryId) => requestEnvelope(prefix + ROUTES.detail, post({ [PARAMS.entryId]: entryId })),
1134
1265
  apply: (action, ids) => requestEnvelope(prefix + ROUTES.apply, post({
1135
1266
  [PARAMS.action]: action,
@@ -1145,8 +1276,7 @@ window.__ModuleLoader__.load({
1145
1276
  * 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
1146
1277
  * 一个槽位贡献都不注册(docs/architecture.md §3「通道探测决定入口出现与否」)。
1147
1278
  *
1148
- * 2026-10-17:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
1149
- * 而"信封到不到"这件事一次零成本的 GET 就答得出来。
1279
+ * 探测用一次零成本的 `GET /ping`,不读清单,避免每次页面加载都跑全表对账。
1150
1280
  */
1151
1281
  async function probeChannel(raw) {
1152
1282
  try {
@@ -1193,6 +1323,8 @@ window.__ModuleLoader__.load({
1193
1323
  return {
1194
1324
  list: () => raw.list().then((result) => unwrapRemoteResult(result)),
1195
1325
  scanOrphans: () => raw.orphans().then((result) => unwrapRemoteResult(result)),
1326
+ scanAttachmentOrphans: () => raw.attachmentOrphans().then((result) => unwrapRemoteResult(result)),
1327
+ cleanAttachmentOrphans: () => raw.attachmentClean().then((result) => unwrapRemoteResult(result)),
1196
1328
  detail: (entryId) => raw.detail(entryId).then((result) => unwrapRemoteResult(result)),
1197
1329
  applyOutcomes: applyOnce,
1198
1330
  emptyTrash: () => raw.empty().then((result) => unwrapRemoteResult(result))
@@ -1201,7 +1333,7 @@ window.__ModuleLoader__.load({
1201
1333
  //#endregion
1202
1334
  //#region src/client/ui/Checkbox.tsx
1203
1335
  /**
1204
- * 勾选框(2026-10-15 加,回收站多选态用)。
1336
+ * 勾选框,回收站多选态用。
1205
1337
  *
1206
1338
  * 样式与行为抄自 `@deepseek-ai/dsh-client-ui-primitives` 的 `lib/Checkbox.module.css`
1207
1339
  * (那四条声明逐字进了 `ui.css` 的 `.sd-checkbox`):**本体是原生 `input[type=checkbox]` +
@@ -1335,7 +1467,7 @@ window.__ModuleLoader__.load({
1335
1467
  })]
1336
1468
  });
1337
1469
  }
1338
- /** 行尾兜底「勾选开关」那枚对勾(状态点/归档行;官方 `IconCheckOutlineRegular`,`viewBox`/`d` 逐字;2026-10-17 加)。 */
1470
+ /** 行尾兜底勾选开关那枚对勾(状态点/归档行;官方几何逐字)。 */
1339
1471
  function IconCheckOutlineRegular({ size }) {
1340
1472
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconFrame, {
1341
1473
  size,
@@ -1367,10 +1499,10 @@ window.__ModuleLoader__.load({
1367
1499
  }
1368
1500
  //#endregion
1369
1501
  //#region src/client/ui/ui.css?dsh-css-text
1370
- var ui_default = "/*\r\n * 本插件自己的控件样式(**不再 require 宿主的 `@deepseek-ai/dsh-client-ui-primitives`**)。\r\n *\r\n * 出处:逐条抄自 `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 随包发布的明文 CSS\r\n * (`lib/Button.module.css`、`Tag.module.css`、`Modal.module.css`、`Tooltip.module.css`、\r\n * `Toast.module.css`、`Menu.module.css`;2026-10-15 又加两张:`lib/Checkbox.module.css`、\r\n * `lib/DisclosureRow.module.css`,以及 `dsh-client-ui-conversation` 里的\r\n * `ConversationRoot.module.css` 的 `.widthHandle` 那一族——宿主自己那根可拖的宽度把手;\r\n * 2026-10-17 又加一张,也是首张来自 ui-primitives 之外的包:`dsh-client-ui-workspace` 的\r\n * `Rows.module.css` 的 `.iconButton` 那一族——会话行尾悬停排那颗按钮的基底(行尾兜底勾选开关\r\n * 要跟宿主自己的归档/置顶同形)。\r\n * 这是官方给第三方 UI 插件的处方:\r\n * 「copy markup, CSS, and behavior from the primitive into the plugin … Rename copied classes under\r\n * your plugin's prefix, keep only `--dsw-alias-*` token references」(宿主技能\r\n * `cordis-plugin-development/references/practices.md` 原文)。理由是那个包**随宿主换代而变、且不经类型检查**,\r\n * 一个抛错的组件会把整个槽位条目打空(控制台 `slot entry crashed in '<slot>'`)。\r\n *\r\n * 三条改写规则:\r\n * 1. **类名全部加本插件前缀 `sd-`**(原来是 CSS module 的哈希名,全局无冲突由前缀承担)。\r\n * 2. **配色一律走 token**,一个颜色的字面量都没有——所以宿主换主题、改名 token 时最差是难看,\r\n * 不会崩。几何量(圆角/阴影/层级)同样取 token;**只有字号/行高/尺寸是字面量 px**,与官方 CSS 逐字一致。\r\n * 3. **`@keyframes` 全部改名**(`sd-` 前缀):它们是全局命名空间,撞名会互相覆盖。\r\n *\r\n * 只收本插件真正用到的部分:Button 的 `primary`/`ghost`/`outline` 三档与 `md`/`sm` 两号\r\n * (没有 `toolbar` 变体、没有 `lg`)、Tag 的 `danger` 一档、Modal 的全部骨架、Tooltip 的三个方位、\r\n * Toast 全部、Menu 行的全部(含 danger 态与 separator)、Checkbox 的全部、DisclosureRow 的\r\n * 折叠头那一半(没有 `data-state` 那档状态点)、宽度把手的视觉与光标。**没抄进来的就是本插件不用的**,\r\n * 不预先铺开——宿主那套里还有几十个变体与控件,抄全了等于把它的表面积整个搬进来。\r\n *\r\n * **五处不是抄本**:① `.sd-resizing`(拖动期间挂在面板根上、关掉文字选中)——宿主那份没有对应的类,\r\n * 它的把手由 JS 侧的 pointer capture 承担;② `[data-dsh-session-delete=\"trash-row\"]:focus-visible`\r\n * (清单那一行的键盘焦点描边,复用选中态那枚 `ghost-active-border`)——宿主那套表里行不是可聚焦\r\n * div,我们这一行是 `role=\"option\"` 且要吃 ↑↓,而这一代 `--dsw-focus-ring-color` 是 `transparent`;\r\n * ③ `.sd-rowaction-button` 的 `[aria-pressed='true']` 与 `:disabled` 两档(2026-10-17)——\r\n * 官方那颗 `iconButton` 只有常态与 hover,勾选开关要有\"按下\"和\"这条删不动\"两档,token 与回收站\r\n * 选中行同源(`--dsw-alias-button-ghost-active-*`);④ `.sd-batch-bar`(同日)——宿主没有\"浮条\"\r\n * 这件控件,四件 token 自拼;⑤ 宿主行的批量选中底色与兜底隐藏(同日改版)——宿主的行选中态不归\r\n * 第三方,整行可见性靠 `data-row-key` + `:has`,token 与回收站选中行同源;行首格在场时行尾兜底退场。\r\n * 整张表里就这五条是我们自造的,标在这里,\r\n * 下次做「与官方逐条比对」时它属于**已知差异**,不是漂移。\r\n *\r\n * **另有一处已知差异不住在本文件**:回收站组头那颗按钮的 `text-align: start`。宿主 `DisclosureRow` 那份\r\n * 是 `div`,没有这个坑;我们这行要做成可点的 `<button>`,UA 样式表的 `text-align:center` 会渗进里面的\r\n * 文字(真机上就是项目名整条居中,2026-10-15 截图)。它写在 `src/client/trash-panel.tsx` 组头那一行的\r\n * inline 样式上——**所以「与官方逐条比对」时本表仍然只数上面那五条**,这条不算进抄本差异。\r\n */\r\n\r\n/* ────────────────────────── Button ────────────────────────── */\r\n\r\n.sd-button {\r\n box-sizing: border-box;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 4px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n cursor: pointer;\r\n font-size: 14px;\r\n line-height: 22px;\r\n color: var(--dsw-alias-label-primary);\r\n background: transparent;\r\n padding: 0 14px;\r\n}\r\n\r\n.sd-button:disabled {\r\n cursor: not-allowed;\r\n opacity: 0.4;\r\n}\r\n\r\n.sd-button-md {\r\n height: 36px;\r\n}\r\n\r\n/* 紧凑档:尺寸不随填充/描边换档(官方原文的注释即此意)。 */\r\n.sd-button-sm {\r\n height: 28px;\r\n font-size: 12px;\r\n line-height: 18px;\r\n padding: 0 10px;\r\n border-radius: var(--dsw-radius-sm);\r\n}\r\n\r\n.sd-button-primary {\r\n background: var(--dsw-alias-button-primary-fill);\r\n color: var(--dsw-alias-label-primary-foreground);\r\n}\r\n\r\n.sd-button-primary:hover:not(:disabled) {\r\n background: var(--dsw-alias-button-primary-hover);\r\n}\r\n\r\n.sd-button-ghost:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-ghost:active:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-active);\r\n}\r\n\r\n.sd-button-outline {\r\n border: 0.5px solid var(--dsw-alias-border-l3);\r\n background: transparent;\r\n}\r\n\r\n.sd-button-outline:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-button-icon {\r\n display: inline-flex;\r\n width: 16px;\r\n height: 16px;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* ────────────────────────── Tag ────────────────────────── */\r\n\r\n/* 胶囊几何固定:一个 tag 在哪里都是同一个尺寸,只有色板随 tone 走。 */\r\n.sd-tag {\r\n display: inline-flex;\r\n align-items: center;\r\n border-radius: 999px;\r\n corner-shape: round;\r\n padding: 1px 8px;\r\n font-size: 11px;\r\n line-height: 17px;\r\n font-weight: 500;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 状态色自带填充:底色由自身颜色 10% 混出,换色板时底与字一起走,不需要第二个 token。 */\r\n.sd-tag-danger {\r\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n/* ────────────────────────── Modal ────────────────────────── */\r\n\r\n/* 整视口层(figma Mask + Dialog):蒙层 + 居中卡片。四边留 24px,上下按外壳公布的浮层内缩加宽,\r\n 高卡片才不会顶到外壳。卡片按这个内边距盒取尺寸:消费方用 `max-height:100%` 收口,不自己算视口。 */\r\n.sd-modal-root {\r\n pointer-events: auto;\r\n position: fixed;\r\n inset: 0;\r\n z-index: 1000;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;\r\n}\r\n\r\n/* 只有绘制避让原生标题栏;根仍然挡住背景点击。 */\r\n.sd-modal-mask {\r\n position: absolute;\r\n inset: var(--dsh-frame-chrome-top, 0px) 0 0;\r\n backdrop-filter: var(--dsw-mask-blur);\r\n}\r\n\r\n.sd-modal-mask::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n background: var(--dsw-alias-bg-mask-1);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-modal-dialog {\r\n box-sizing: border-box;\r\n position: relative;\r\n z-index: 1;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 20px;\r\n width: min(380px, 100%);\r\n padding: 0 0 24px;\r\n overflow: hidden;\r\n border: 0;\r\n border-radius: var(--dsw-radius-panel);\r\n background: var(--dsw-alias-bg-layer-2);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n animation: sd-modal-enter var(--ds-transition-duration) var(--ds-ease-in-out);\r\n}\r\n\r\n@keyframes sd-modal-enter {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-modal-mask::after,\r\n .sd-modal-dialog { animation: none; }\r\n}\r\n\r\n.sd-modal-dialog:focus {\r\n outline: none;\r\n}\r\n\r\n.sd-modal-content {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n}\r\n\r\n/* 头行:标题左、关闭钮右。 */\r\n.sd-modal-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 8px;\r\n padding: 22px 14px 12px 24px;\r\n}\r\n\r\n.sd-modal-title {\r\n margin: 0;\r\n font-size: 16px;\r\n line-height: 24px;\r\n font-weight: 500;\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-modal-close {\r\n flex: none;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 28px;\r\n height: 28px;\r\n border: none;\r\n border-radius: var(--dsw-radius-sm);\r\n background: transparent;\r\n cursor: pointer;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-modal-close:hover {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n.sd-modal-body {\r\n display: flex;\r\n flex-direction: column;\r\n min-width: 0;\r\n margin-top: 20px;\r\n padding: 0 24px;\r\n}\r\n\r\n.sd-modal-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: flex-end;\r\n gap: 8px;\r\n padding: 0 24px;\r\n}\r\n\r\n/* ────────────────────────── Tooltip ────────────────────────── */\r\n\r\n.sd-tooltip-bubble {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n position: fixed;\r\n z-index: 100;\r\n /* 定宽收缩只按 `left` 到视口右缘量,靠右的锚点会提前折行;\r\n `max-content` 只按文案定宽,再用半个视口收口。 */\r\n width: max-content;\r\n max-width: 50vw;\r\n padding: 3px 7px;\r\n border-radius: var(--dsw-radius-sm);\r\n background: var(--dsw-alias-tooltip-bg);\r\n color: var(--dsw-static-neutral-bluish-00);\r\n font-size: 13px;\r\n line-height: 20px;\r\n white-space: pre-line;\r\n /* 不可断的长 token(URL、路径)不许顶破 max-width。 */\r\n overflow-wrap: break-word;\r\n pointer-events: none;\r\n animation: sd-tooltip-in 150ms var(--ds-ease-in-out);\r\n}\r\n\r\n.sd-tooltip-bubble[data-portal] { z-index: 1100; }\r\n.sd-tooltip-label { min-width: 0; }\r\n\r\n.sd-tooltip-bubble[data-side='right'] {\r\n transform: translateY(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='bottom'] {\r\n transform: translateX(-50%);\r\n}\r\n\r\n.sd-tooltip-bubble[data-side='top'] {\r\n transform: translate(-50%, -100%);\r\n}\r\n\r\n@keyframes sd-tooltip-in {\r\n from { opacity: 0; }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-tooltip-bubble {\r\n animation: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── Toast ────────────────────────── */\r\n\r\n/* 淡出延迟由组件以 `--dsh-toast-hold` 给出 ⇒ 同一个值同时驱动卸载计时器与这段动画,\r\n 两者不可能再对不上。淡出**时长**仍需与组件里的 FADE_MS 一致(没有 owner 会改它)。 */\r\n.sd-toast {\r\n position: fixed;\r\n top: 40px;\r\n left: 50%;\r\n /* 高过图片灯箱底层的 1000:预览开着时报错也必须读得到。 */\r\n z-index: 1100;\r\n /* 通告不截点击。 */\r\n pointer-events: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n /* 定宽盒设了 `left` 只按右侧空间收缩(-50% 的位移发生在定尺之后)⇒ 靠窗边的横幅会提前折行。\r\n `max-content` 只按文案定宽,再由下面的 max-width 收口。 */\r\n width: max-content;\r\n max-width: min(640px, calc(100vw - 48px));\r\n padding: 12px 16px;\r\n border-radius: var(--dsw-radius-lg);\r\n /* 设计平台的 toast 表面在**两套主题下都是深的**(浅色 bluish-800 / 深色 bluish-750),\r\n 所以这里的字色与动作色是固定值,不随主题翻转。 */\r\n background: var(--dsw-alias-toast-bg);\r\n color: var(--dsw-alias-toast-label);\r\n font-size: 14px;\r\n line-height: 22px;\r\n box-shadow: var(--dsw-shadow-lv3);\r\n transform: translateX(-50%);\r\n animation:\r\n sd-toast-in 160ms ease-out,\r\n sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n}\r\n\r\n.sd-toast-icon {\r\n display: grid;\r\n place-items: center;\r\n flex: none;\r\n color: var(--dsw-alias-state-warn-label);\r\n}\r\n\r\n.sd-toast-icon-success {\r\n color: var(--dsw-alias-state-success-primary);\r\n}\r\n\r\n.sd-toast-text {\r\n min-width: 0;\r\n}\r\n\r\n.sd-toast-action {\r\n display: inline;\r\n padding: 0;\r\n border: 0;\r\n background: none;\r\n color: var(--dsw-static-deepseek-400);\r\n font: inherit;\r\n cursor: pointer;\r\n /* 动作文字与周围文案之间的呼吸位。 */\r\n margin-inline: 3px;\r\n /* 横幅表面整体是穿透点击的,只有动作文字接指针。 */\r\n pointer-events: auto;\r\n}\r\n\r\n.sd-toast-action:hover {\r\n opacity: 0.8;\r\n}\r\n\r\n@keyframes sd-toast-in {\r\n from {\r\n opacity: 0;\r\n transform: translate(-50%, -6px);\r\n }\r\n\r\n to {\r\n opacity: 1;\r\n transform: translate(-50%, 0);\r\n }\r\n}\r\n\r\n@keyframes sd-toast-fade {\r\n to {\r\n opacity: 0;\r\n visibility: hidden;\r\n }\r\n}\r\n\r\n/* 减动效去掉滑入;延迟淡出(透明度变化、不是位移)仍然在定时卸载前收掉横幅。 */\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-toast {\r\n animation: sd-toast-fade 1000ms ease var(--dsh-toast-hold, 3000ms) forwards;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 菜单行 ────────────────────────── */\r\n\r\n.sd-menu-item-wrap {\r\n position: relative;\r\n}\r\n\r\n/* 菜单格:紧凑文字、图标与选中间距。 */\r\n.sd-menu-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n width: 100%;\r\n min-height: 34px;\r\n padding: 6px 8px;\r\n border: none;\r\n border-radius: var(--dsw-radius-md);\r\n background: transparent;\r\n cursor: pointer;\r\n font-size: 13px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n text-align: left;\r\n}\r\n\r\n.sd-menu-item:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n\r\n/* 方向键移动的是真焦点 ⇒ 键盘所在的那一行拿指针同款填充;填充就是这一行的焦点呈现,\r\n 浏览器默认的 ring 再叠一层就重了。 */\r\n.sd-menu-item:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n outline: none;\r\n}\r\n\r\n.sd-menu-item:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n.sd-menu-item-icon {\r\n display: inline-flex;\r\n flex: none;\r\n width: 14px;\r\n height: 14px;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--dsw-alias-menu-icon);\r\n}\r\n\r\n.sd-menu-item-icon svg {\r\n width: 14px;\r\n height: 14px;\r\n}\r\n\r\n.sd-menu-item-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n\r\n/* 破坏性行:error 字/图标 + danger 悬停底。 */\r\n.sd-menu-item-danger {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger .sd-menu-item-icon {\r\n color: var(--dsw-alias-state-error-primary);\r\n}\r\n\r\n.sd-menu-item-danger:hover:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n}\r\n\r\n.sd-menu-item-danger:focus-visible:not(:disabled) {\r\n background: var(--dsw-alias-interactive-bg-hover-danger);\r\n outline: none;\r\n}\r\n\r\n/* 分隔格:发丝线上下的间距。取 l2 而不是 l1——l1 在这个表面上几乎看不见。 */\r\n.sd-menu-separator {\r\n height: 0.5px;\r\n margin: 3px 2px;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n/* ────────────────────────── Checkbox ────────────────────────── */\r\n/* 抄 `lib/Checkbox.module.css` 全部四条声明,逐字。勾选框本体是**原生 input** + `accent-color`,\r\n 不是自绘的圆框——回收站多选态用它(清单行左侧那一格)。 */\r\n\r\n.sd-checkbox {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 6px;\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--dsw-alias-label-primary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-checkbox input {\r\n flex: 0 0 auto;\r\n width: 16px;\r\n height: 16px;\r\n margin: 0;\r\n accent-color: var(--dsw-alias-brand-primary);\r\n cursor: inherit;\r\n}\r\n\r\n.sd-checkbox input:focus-visible {\r\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\r\n outline-offset: 2px;\r\n}\r\n\r\n.sd-checkbox:has(input:disabled) {\r\n cursor: default;\r\n opacity: 0.5;\r\n}\r\n\r\n/* ────────────────────────── DisclosureRow(分组折叠头) ────────────────────────── */\r\n/* 抄 `lib/DisclosureRow.module.css` 的 `.root`/`.row`/`.leading`/`.title` 与那两枚 glyph 的\r\n 显隐。没抄进来的是 `StateDot`(`svg[data-state]`)那一档——它说\"这是状态标记不是文字装饰\",\r\n 我们的组头没有状态点。行高与 leading 盒走 `--dsh-content-font-delta` 那根轴,\r\n 标题字号走 secondary 档:宿主的\"设置里调正文字号\"会一起推着它走。 */\r\n\r\n.sd-disclosure {\r\n display: flex;\r\n flex-direction: column;\r\n width: 100%;\r\n min-width: 0;\r\n}\r\n\r\n.sd-disclosure-row {\r\n position: relative;\r\n overflow: hidden;\r\n display: flex;\r\n align-items: center;\r\n height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n min-width: 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n transition: color 100ms ease;\r\n}\r\n\r\n.sd-disclosure-row[data-expandable] {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-row:hover {\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-disclosure-leading {\r\n position: relative;\r\n flex: none;\r\n width: calc(16px + var(--dsh-content-font-delta, 0px));\r\n height: calc(16px + var(--dsh-content-font-delta, 0px));\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n margin-right: 6px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n color: inherit;\r\n}\r\n\r\n.sd-disclosure-leading svg:not([data-state]) {\r\n width: calc(14px + var(--dsh-content-font-delta, 0px));\r\n height: calc(14px + var(--dsh-content-font-delta, 0px));\r\n}\r\n\r\nbutton.sd-disclosure-leading {\r\n cursor: pointer;\r\n}\r\n\r\n.sd-disclosure-title {\r\n flex: none;\r\n font-size: var(--dsh-content-font-size-secondary, 13px);\r\n line-height: calc(24px + var(--dsh-content-font-delta, 0px));\r\n color: inherit;\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .sd-disclosure-row {\r\n transition: none;\r\n }\r\n}\r\n\r\n/* ────────────────────────── 宽度把手(可拖的分栏线) ────────────────────────── */\r\n/* 抄 `ConversationRoot.module.css` 的 `.widthHandle` 一族:10px 的命中区、`col-resize` 光标、\r\n 平时不画、悬停或拖动时才浮出一条 2px 的 `--dsw-alias-scrollbar-bg-l1` 短标(那条渐变里\r\n `--dsh-width-handle-pointer-y` 的兜底是 50%,宿主靠 JS 跟着指针走;我们不跟,\r\n 停在中间——少一条 pointermove 上的写字,视觉上是同一族东西)。\r\n 与宿主的差别只有位置:它绝对定位在会话栏两侧,我们这根住在两栏之间的那一格 flex 位里,\r\n 所以 `top/bottom/position` 那三样交给内联布局,这里只留视觉与光标。 */\r\n\r\n.sd-width-handle {\r\n position: relative;\r\n z-index: 0;\r\n cursor: col-resize;\r\n align-self: stretch;\r\n flex: 0 0 10px;\r\n width: 10px;\r\n padding: 0;\r\n border: none;\r\n background: none;\r\n}\r\n\r\n.sd-width-handle::after {\r\n content: \"\";\r\n background: linear-gradient(\r\n to bottom,\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),\r\n var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),\r\n transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)\r\n );\r\n opacity: 0;\r\n pointer-events: none;\r\n border-radius: 2px;\r\n width: 2px;\r\n position: absolute;\r\n top: 0;\r\n bottom: 0;\r\n left: 4px;\r\n}\r\n\r\n.sd-width-handle:hover::after,\r\n.sd-width-handle[data-dragging]::after {\r\n opacity: 1;\r\n}\r\n\r\n.sd-width-handle:focus-visible {\r\n outline: none;\r\n}\r\n\r\n.sd-width-handle:focus-visible::after {\r\n opacity: 1;\r\n}\r\n\r\n/* 本插件自造的第一条(不是抄本,见文件头点名):拖动期间整块面板别把文字选上了。 */\r\n.sd-resizing {\r\n user-select: none;\r\n cursor: col-resize;\r\n}\r\n\r\n/* 本插件自造的第二条(不是抄本,见文件头点名):清单行是 `role=\"option\"` 的可聚焦 div,而这一代\r\n `--dsw-focus-ring-color` 逐字是 `transparent`(2026-10-15 实测)——不自己描一条,键盘走\r\n ↑↓ 选会话时就没有任何焦点可见性。描边**复用选中态那枚** `ghost-active-border`,不新造颜色;\r\n 0.5px 与 `outline-offset: 1px` 是行的描边宽度与一档外移,不动布局(outline 不进滚动区)。 */\r\n[data-dsh-session-delete=\"trash-row\"]:focus-visible {\r\n outline: 0.5px solid var(--dsw-alias-button-ghost-active-border);\r\n outline-offset: 1px;\r\n}\r\n\r\n/* 会话行尾悬停排那颗**兜底**勾选开关(状态点行 / 归档行;行首主勾选是原生 Checkbox)。\r\n 基底**逐字抄** `dsh-client-ui-workspace` 的 `Rows.module.css` 的 `.iconButton` / `.iconButton:hover`,\r\n 只改类名——兜底开关要跟同排宿主自己的归档/置顶同形,尺寸与色档都不自己发明。 */\r\n.sd-rowaction-button {\r\n border-radius: var(--dsw-radius-xs);\r\n cursor: pointer;\r\n width: 16px;\r\n height: 16px;\r\n color: var(--dsw-alias-label-tertiary);\r\n background: 0 0;\r\n border: none;\r\n flex: none;\r\n justify-content: center;\r\n align-items: center;\r\n padding: 0;\r\n display: inline-flex;\r\n}\r\n.sd-rowaction-button:hover {\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n/* 本插件自造的第三条(不是抄本,见文件头点名):官方那颗只有常态与 hover,勾选开关还要有\r\n 「已按下」与「这条删不动」两档。按下档**复用回收站选中行那两枚** `ghost-active-*`,不新造颜色;\r\n 禁用档走 opacity。 */\r\n.sd-rowaction-button[aria-pressed='true'] {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n.sd-rowaction-button:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n}\r\n\r\n/* 行首 16px 格里的勾选框(2026-10-17 改版,**只在多选模式内渲染**)。格本身由宿主居中(16×20),\r\n 这里只把包装层压成同样大小;勾选框本体是回收站多选同款原生 input(`.sd-checkbox`,见上)。 */\r\n.sd-batch-pick-leading {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n/* 本插件自造的第⑤条(不是抄本,见文件头点名):宿主行自己的选中态只归它自己,批量选择要有整行\r\n 底色;行首格在场时行尾兜底要退场(两条入口不许同屏)。两条都只认宿主行已有的\r\n `data-row-key`;将来这个属性改名,最差是底色不亮 / 兜底多露一颗,勾选本身仍在。 */\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-checked=\"true\"]) {\r\n background: var(--dsw-alias-button-ghost-active-fill);\r\n box-shadow: inset 0 0 0 0.5px var(--dsw-alias-button-ghost-active-border);\r\n}\r\n[data-row-key^=\"session:\"]:has([data-dsh-session-delete=\"batch-pick\"][data-seat=\"leading\"]) .sd-batch-pick-action {\r\n display: none;\r\n}\r\n\r\n/* 本插件自造的第四条(不是抄本,见文件头点名):批量删除的浮条。宿主没有\"浮条\"这件控件,形状照\r\n 本插件自己的浮层口径来——固定底部居中,表面/描边/圆角/高程四件全走 token(与单删那张目标卡同源)。\r\n **层级必须低于确认框(`.sd-modal-root` 1000)**:2026-10-17 真机发现浮条在 1100 时会盖住确认框、\r\n 还能抢它的按钮;撤销横幅与 Tooltip 是瞬时通告、保持 1100。列方向:清单在上、操作行在下;\r\n 清单限高可滚,默认收起(见 `batch-bar.tsx`)。**宽度按内容展开**(`max-content`,最多视口减 48):\r\n 只写 `left:50% + width:auto` 时 shrink-to-fit 只拿左半屏,新增「全选/取消全选」后会把「退出多选」\r\n 挤到第二行;窄到 max-width 还放不下才由 `.sd-batch-bar-main` 的 `flex-wrap` 兜底。 */\r\n.sd-batch-bar {\r\n position: fixed;\r\n bottom: 24px;\r\n left: 50%;\r\n transform: translateX(-50%);\r\n z-index: 900;\r\n width: max-content;\r\n max-width: calc(100vw - 48px);\r\n display: flex;\r\n flex-direction: column;\r\n align-items: stretch;\r\n gap: 8px;\r\n padding: 8px 12px;\r\n background: var(--dsw-alias-bg-layer-2);\r\n border: 0.5px solid var(--dsw-alias-border-l4);\r\n border-radius: var(--dsw-radius-lg);\r\n box-shadow: var(--dsw-elevation-prominent);\r\n font-size: 12px;\r\n line-height: 18px;\r\n}\r\n\r\n.sd-batch-bar-main {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n.sd-batch-bar-hint {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list-head {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n}\r\n\r\n/* 汇总的第二句(含 K 个子会话)比条数轻一档,和列表行里的 chip 同级。 */\r\n.sd-batch-bar-list-head span + span {\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n max-height: 240px;\r\n overflow: auto;\r\n margin: 0;\r\n padding: 0 0 8px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l4);\r\n list-style: none;\r\n}\r\n\r\n.sd-batch-bar-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n min-width: 0;\r\n}\r\n\r\n.sd-batch-bar-title {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n.sd-batch-bar-meta {\r\n flex: none;\r\n color: var(--dsw-alias-label-tertiary);\r\n}\r\n\r\n.sd-batch-bar-remove {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex: none;\r\n width: 16px;\r\n height: 16px;\r\n padding: 0;\r\n border: none;\r\n border-radius: var(--dsw-radius-xs);\r\n background: 0 0;\r\n color: var(--dsw-alias-label-tertiary);\r\n cursor: pointer;\r\n}\r\n\r\n.sd-batch-bar-remove:hover {\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-interactive-bg-hover);\r\n}\r\n";
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";
1371
1503
  //#endregion
1372
1504
  //#region src/client/ui/markdown.css?dsh-css-text
1373
- var markdown_default = "/**\r\n * 回收站详情里**会话正文**的排版(2026-10-15 真机反馈后新增)。\r\n *\r\n * 为什么单独一个文件而不并进 `ui.css`:`ui.css` 是\"照官方 primitives 逐字抄来的那一份表\",\r\n * 那条 fidelity 核对(`docs/development.md` 的界面保真复核门)要的是它与官方明文**逐属性零差异**,只许文件头点名的几类例外\r\n * (类名前缀、`@keyframes` 改名,外加它点名的自造与已声明的差别);条数不钉在这里,钉了就会漂成假基线。这一份抄的是\r\n * **另一个来源**(宿主 markdown 那一张表),混进去就把那份核对弄脏了。\r\n *\r\n * **来源(逐字抄,只改选择器与下面点名的三处)**:\r\n * `app.asar` 里 `@deepseek-ai/dsh-client-ui-primitives/lib/markdown/MarkdownText.module.css`\r\n * (`.markdown` 那一支;`.compact` 一支我们不用)+ `lib/markdown/CodeBlock.module.css` 的 `pre`。\r\n * token 名一个没换:`--dsw-font-markdown-*` / `--dsw-alias-markdown-*` / `--dsh-content-font-*`\r\n * 全是宿主会话区自己吃的那组,所以宿主调内容字号时,这里跟着一起动。\r\n *\r\n * 三处**有意**的差异,每条都是因为我们的 DOM 长不出宿主那个节点:\r\n * ① 宿主给表包一层 `.tableScroll`(div)再横向滚动;react-markdown 不产这层 ⇒ 滚动挂在 `table`\r\n * 自己身上(`display:block` + 匿名表盒)。这条得真机量过才算成立。\r\n * ② 宿主的代码卡有 banner 行(语言名 + 复制),四个圆角被拆成\"上圆角在 banner、下圆角在 pre\";\r\n * 我们没有 banner ⇒ 圆角整块给 `pre`。\r\n * ③ 宿主的链接可点(点开外链/文件),我们不碰宿主导航,链接渲成不可点的文本 ⇒ 保留链接色与字重,\r\n * **去掉** hover 的下划线(那是\"点我\"的邀请)。\r\n *\r\n * 抄过来但用不上的选择器已删:`.fileLink` / `.fileMention` / `.linkIcon` / `.image` / `.imageAlt`\r\n * (图片渲成 `[图片 · alt]`)/ `.katex-display` / `:global(.md-code-block)` / `.shiki`(高亮由宿主\r\n * 的 highlighter 提供,我们没有)。\r\n */\r\n.sd-md {\r\n min-width: 0;\r\n overflow-wrap: anywhere;\r\n font: var(--dsw-font-markdown-base);\r\n color: var(--dsw-alias-label-primary);\r\n}\r\n\r\n.sd-md strong {\r\n font-weight: 600;\r\n}\r\n\r\n.sd-md h1 {\r\n font: var(--dsw-font-markdown-h1);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h2 {\r\n font: var(--dsw-font-markdown-h2);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h3 {\r\n font: var(--dsw-font-markdown-h3);\r\n margin: 32px 0 16px;\r\n}\r\n\r\n.sd-md h4 {\r\n font: var(--dsw-font-markdown-h4);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h5, h6) {\r\n font: var(--dsw-font-markdown-base-strong);\r\n margin: 16px 0;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) strong {\r\n font-weight: inherit;\r\n}\r\n\r\n.sd-md p {\r\n margin: 16px 0;\r\n}\r\n\r\n/* Tighten h4–h6 against a following list (design: 8px gap). */\r\n.sd-md :where(h4, h5, h6) + :where(ul, ol) {\r\n margin-top: 8px;\r\n}\r\n\r\n.sd-md :where(h4, h5, h6):has(+ :where(ul, ol)) {\r\n margin-bottom: 8px;\r\n}\r\n\r\n/* 见文件头差异③:颜色与字重照抄,hover 的 dotted 下划线不抄——这里没有可点的东西。 */\r\n.sd-md-link {\r\n color: var(--dsw-alias-link);\r\n font-weight: 500;\r\n}\r\n\r\n.sd-md :where(ul, ol) {\r\n margin: 16px 0;\r\n padding-left: 18px;\r\n}\r\n\r\n.sd-md li:not(:first-child) {\r\n margin-top: 6px;\r\n}\r\n\r\n.sd-md li > :where(ul, ol) {\r\n margin-top: 4px;\r\n}\r\n\r\n.sd-md li::marker {\r\n line-height: 24px;\r\n color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */\r\n.sd-md :where(ul, ol) ol {\r\n list-style-position: inside;\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md :where(ul, ol) ol li p {\r\n display: inline;\r\n}\r\n\r\n.sd-md li > p {\r\n margin: 8px 0;\r\n}\r\n\r\n.sd-md li > *:first-child {\r\n margin-top: 0;\r\n}\r\n\r\n.sd-md li > *:last-child {\r\n margin-bottom: 0;\r\n}\r\n\r\n.sd-md hr {\r\n display: block;\r\n border: none;\r\n height: 0.5px;\r\n margin: 32px 0;\r\n background: var(--dsw-alias-border-l2);\r\n}\r\n\r\n.sd-md blockquote {\r\n border-left: 2px solid var(--dsw-alias-label-caption);\r\n margin: 16px 0 0;\r\n padding-left: 14px;\r\n}\r\n\r\n/* 代码卡:宿主的卡片由 `.block`(底色+圆角)与其内 `pre`(字号+内边距)两层拼成。\r\n 我们只有 `pre` 这一层,所以两层的声明合成在这里——见文件头差异②。 */\r\n.sd-md pre {\r\n font: var(--dsw-font-markdown-code-block);\r\n margin: 16px 0;\r\n padding: 16px;\r\n color: var(--dsw-alias-label-primary);\r\n background: var(--dsw-alias-markdown-code-block);\r\n border-radius: var(--dsw-radius-lg);\r\n overflow-x: auto;\r\n white-space: pre-wrap;\r\n word-break: break-all;\r\n}\r\n\r\n.sd-md pre code {\r\n font: inherit;\r\n background: none;\r\n padding: 0;\r\n}\r\n\r\n.sd-md :not(pre) > code {\r\n display: inline-flex;\r\n align-items: center;\r\n box-sizing: border-box;\r\n font: var(--dsw-font-markdown-code);\r\n font-family: var(--ds-font-family-code);\r\n font-size: 0.875em !important;\r\n background-color: var(--dsw-alias-markdown-inline-code);\r\n border: 0.5px solid var(--dsw-alias-border-l1);\r\n border-radius: var(--dsw-radius-sm);\r\n padding: 0 5px;\r\n}\r\n\r\n.sd-md :where(h1, h2, h3, h4, h5, h6) code {\r\n font: inherit;\r\n font-family: var(--ds-font-family-code);\r\n}\r\n\r\n.sd-md input[type='checkbox'] {\r\n margin: 0 8px 0 0;\r\n accent-color: var(--dsw-alias-label-secondary);\r\n}\r\n\r\n/* 见文件头差异①:宿主是 `.tableScroll > table`,我们没有那层 div。 */\r\n.sd-md table {\r\n display: block;\r\n box-sizing: border-box;\r\n max-width: 100%;\r\n overflow-x: auto;\r\n overscroll-behavior-x: contain;\r\n border-collapse: collapse;\r\n width: max-content;\r\n}\r\n\r\n.sd-md th {\r\n text-align: start;\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l3);\r\n border-top: none;\r\n font: var(--dsw-font-markdown-table-head);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md td {\r\n padding: 10px 16px;\r\n border-bottom: 0.5px solid var(--dsw-alias-border-l2);\r\n font: var(--dsw-font-markdown-table);\r\n max-width: 320px;\r\n max-width: min(30vw, 320px);\r\n min-width: 100px;\r\n}\r\n\r\n.sd-md th:first-child,\r\n.sd-md td:first-child {\r\n padding-left: 0;\r\n}\r\n\r\n.sd-md td:last-child {\r\n padding-right: 0;\r\n}\r\n\r\n.sd-md table code {\r\n font-size: 11px;\r\n}\r\n\r\n.sd-md > *:first-child,\r\n.sd-md p:first-child {\r\n margin-top: 0 !important;\r\n}\r\n\r\n.sd-md > *:last-child,\r\n.sd-md p:last-child {\r\n margin-bottom: 0 !important;\r\n}\r\n\r\n/* 图片在回收站里读不到(路径属于会话当时的 cwd),渲成一句说明,字色用宿主的 tertiary。 */\r\n.sd-md-image {\r\n color: var(--dsw-alias-label-tertiary);\r\n font-style: italic;\r\n}\r\n";
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";
1374
1506
  //#endregion
1375
1507
  //#region src/client/ui/styles.ts
1376
1508
  /**
@@ -1557,7 +1689,7 @@ window.__ModuleLoader__.load({
1557
1689
  //#endregion
1558
1690
  //#region src/client/batch-select.tsx
1559
1691
  /**
1560
- * 宿主列表批量删除的**控件面**(2026-10-17,2026-10-20 拆出状态与行适配):行首勾选框、行尾兜底开关,
1692
+ * 宿主列表批量删除的**控件面**:行首勾选框、行尾兜底开关,
1561
1693
  * 以及确认框/浮条读的那一份计数问句。
1562
1694
  *
1563
1695
  * 入口在每行的「⋯」菜单里(`batch-menu-item.tsx`):**按下才进入多选模式**,模式内才渲染勾选框、
@@ -1686,6 +1818,16 @@ window.__ModuleLoader__.load({
1686
1818
  }), [busy, around]);
1687
1819
  }
1688
1820
  //#endregion
1821
+ //#region src/client/confirm-close.ts
1822
+ function useIdleClose(busy, onClose) {
1823
+ return {
1824
+ close: (0, react.useCallback)(() => {
1825
+ if (!busy) onClose();
1826
+ }, [busy, onClose]),
1827
+ closeDisabled: busy
1828
+ };
1829
+ }
1830
+ //#endregion
1689
1831
  //#region src/client/ui/Button.tsx
1690
1832
  /**
1691
1833
  * `Button`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Button,出处与三条改写规则
@@ -1716,6 +1858,25 @@ window.__ModuleLoader__.load({
1716
1858
  });
1717
1859
  });
1718
1860
  //#endregion
1861
+ //#region src/client/ui/top-layer.ts
1862
+ /**
1863
+ * 「谁在最上层收 Escape」的名单与判据。`Modal` 的关闭键和清单的 Esc 退下钻共用这一份:
1864
+ * 两份各写一次选择器,菜单/对话框换代时必然只剩一处跟上。
1865
+ *
1866
+ * 名单与官方 `modalSelector` 同形:模态对话框 + 菜单。文档序里最后一个是「最上层」。
1867
+ */
1868
+ /** 对话框与菜单同一张名单。 */
1869
+ const TOP_LAYER = "[role=\"dialog\"][aria-modal=\"true\"], [role=\"menu\"]";
1870
+ /** 文档里是否还有打开的顶层浮层。清单只问「有没有」,它自己不是其中一员。 */
1871
+ function hasTopLayer(doc) {
1872
+ return doc.querySelector(TOP_LAYER) !== null;
1873
+ }
1874
+ /** 自己是不是文档里最上面的那一层。Modal 只在这时收 Escape。 */
1875
+ function isTopLayer(element) {
1876
+ const layers = element.ownerDocument.querySelectorAll(TOP_LAYER);
1877
+ return layers.length > 0 && layers[layers.length - 1] === element;
1878
+ }
1879
+ //#endregion
1719
1880
  //#region src/client/ui/Modal.tsx
1720
1881
  /**
1721
1882
  * `Modal`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Modal 与 useModalLayer,
@@ -1733,13 +1894,11 @@ window.__ModuleLoader__.load({
1733
1894
  * - **官方的多层栈**(`layers` WeakMap + `closeTopModal` / `isBehindModal`):那是给"对话框里还能再开
1734
1895
  * 对话框、且宿主菜单也在抢 Escape"的场景准备的。本插件同一时刻只会有一个确认框,所以这里只保留它
1735
1896
  * 的**判据本身**——Escape 只在"自己就是文档里最后一个 `[role="dialog"][aria-modal="true"]`
1736
- * 或 `[role="menu"]`"时生效(`isTopLayer`)。这一条不能省:菜单/对话框先后打开时,
1737
- * "谁在最上面谁收 Escape"是用户能看见的规矩。
1897
+ * 或 `[role="menu"]`"时生效(`isTopLayer`,名单与判据住 `ui/top-layer.ts`)。这一条不能省:
1898
+ * 菜单/对话框先后打开时,"谁在最上面谁收 Escape"是用户能看见的规矩。
1738
1899
  * - **输入法合成守卫**(`observeComposition`):本插件的对话框里没有文本输入框,不可能处在合成态。
1739
1900
  */
1740
1901
  const FOCUSABLE = "button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex=\"0\"]";
1741
- /** 文档序里排在前面的层决定谁拿快捷键:对话框与菜单同一张名单(官方 `modalSelector` 逐字)。 */
1742
- const TOP_LAYER = "[role=\"dialog\"][aria-modal=\"true\"], [role=\"menu\"]";
1743
1902
  /** 这些键一旦按下就不再是"自动焦点",焦点环恢复正常。 */
1744
1903
  const NAVIGATION_KEYS = /* @__PURE__ */ new Set([
1745
1904
  "Tab",
@@ -1750,11 +1909,6 @@ window.__ModuleLoader__.load({
1750
1909
  "Home",
1751
1910
  "End"
1752
1911
  ]);
1753
- /** 自己是不是文档里最上面的那一层。 */
1754
- function isTopLayer(element) {
1755
- const layers = element.ownerDocument.querySelectorAll(TOP_LAYER);
1756
- return layers.length > 0 && layers[layers.length - 1] === element;
1757
- }
1758
1912
  /** 自动聚焦不留环:宿主主题按 `data-dsh-automatic-focus` 抑制 outline,键盘导航或失焦即摘掉。 */
1759
1913
  function focusWithoutRing(element) {
1760
1914
  const release = () => {
@@ -1866,10 +2020,10 @@ window.__ModuleLoader__.load({
1866
2020
  *
1867
2021
  * 为什么需要它(真机反馈):回收站面板是「挂载时取一次数」(`useEffect(refreshAll)`),而删除发生在
1868
2022
  * **侧栏菜单**那边(`delete-confirm.tsx` 的常驻宿主),两者没有共同的父组件——删完之后中央那个
1869
- * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-toast.tsx`)
2023
+ * 面板(如果正开着)不会自己重读,用户得切走再切回来(= 重新挂载)才看得见新条目。撤销(`undo-notices.ts`)
1870
2024
  * 同理。
1871
2025
  *
1872
- * 形状与两个邻居同款(`undo-toast.tsx` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场**,
2026
+ * 形状与两个邻居同款(`undo-notices.ts` 的通知台、`delete-confirm.tsx` 的现场):**模块级现场**,
1873
2027
  * 订阅/通告那套只住在 `store.ts` 的 `createStore` 里——这些现场都挂在 `shell.overlay`/`main`
1874
2028
  * 这些各不相邻的座位上,模块级是它们唯一的共同点。
1875
2029
  *
@@ -1886,78 +2040,13 @@ window.__ModuleLoader__.load({
1886
2040
  return revisionStore.use();
1887
2041
  }
1888
2042
  //#endregion
1889
- //#region src/client/ui/Toast.tsx
1890
- /**
1891
- * `Toast`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Toast,出处与三条改写规则
1892
- * 见 `ui.css` 头注):顶部居中的一次性横幅——滑入、全不透明停留、淡出,然后回调 `onDone` 让 owner
1893
- * 卸载它。用 body portal 渲染,免得被有 transform / filter 的祖先困在它的盒子里。
1894
- *
1895
- * **保留下来的行为**:
1896
- * - 时长只有一个来源:`holdMs` 同时驱动卸载计时器与样式表的淡出延迟(后者以 `--dsh-toast-hold`
1897
- * 自定义属性读它)⇒ 两者不可能再对不上、把横幅卡在淡出中途卸载。
1898
- * - 同一个 `holdMs` 下,父组件重渲染**不延长**寿命(`onDone` 走 ref,不进 effect 依赖)。
1899
- * - 完成回调拿到的永远是最新的 `onDone`;已经淡完的动作不再接输入(CSS 里那张表面本就穿透点击)。
1900
- * - `tone="success"` 自带官方那枚圆圈对勾(此时忽略传入的 `icon`)。
1901
- *
1902
- * **砍掉的**:`anchor`(把横幅对齐到某个元素的水平中心——本插件的撤销提示就是窗口居中)、
1903
- * `actions[].prefix`(动作文案前的连接词,本插件只用「撤销」两个字)。**没砍 `text`/`icon`/`tone`/
1904
- * `holdMs`/`actions`/`onDone`**:唯一的调用点这几样都在用。
1905
- */
1906
- /** 不指定时全不透明的停留时长。 */
1907
- const DEFAULT_HOLD_MS = 3e3;
1908
- /** 淡出时长。必须与样式表里 `sd-toast-fade` 的时长一致。 */
1909
- const FADE_MS = 1e3;
1910
- function Toast({ text, icon, tone, holdMs = DEFAULT_HOLD_MS, actions, onDone }) {
1911
- const latestOnDone = (0, react.useRef)(onDone);
1912
- (0, react.useLayoutEffect)(() => {
1913
- latestOnDone.current = onDone;
1914
- }, [onDone]);
1915
- (0, react.useEffect)(() => {
1916
- const timer = setTimeout(() => {
1917
- latestOnDone.current();
1918
- }, holdMs + FADE_MS);
1919
- return () => {
1920
- clearTimeout(timer);
1921
- };
1922
- }, [holdMs]);
1923
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1924
- className: "sd-toast",
1925
- role: "alert",
1926
- style: { "--dsh-toast-hold": `${String(holdMs)}ms` },
1927
- children: [tone === "success" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1928
- className: "sd-toast-icon sd-toast-icon-success",
1929
- "aria-hidden": "true",
1930
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCheckCircleOutlineRegular, {})
1931
- }) : icon === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1932
- className: "sd-toast-icon",
1933
- "aria-hidden": "true",
1934
- children: icon
1935
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1936
- className: "sd-toast-text",
1937
- children: [text, actions?.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1938
- type: "button",
1939
- className: "sd-toast-action",
1940
- onClick: action.onClick,
1941
- children: action.label
1942
- }, action.label))]
1943
- })]
1944
- }), document.body);
1945
- }
1946
- //#endregion
1947
- //#region src/client/undo-toast.tsx
2043
+ //#region src/client/undo-notices.ts
1948
2044
  /**
1949
- * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
1950
- *
1951
- * 为什么是**独立组件 + 模块级通知台**,而不是死在菜单行里:宿主的菜单行随菜单**一起卸载**
1952
- * (真产物 Menu 的注释原文 "the rows unmount with the list"),而提示要在菜单关掉之后还活着。
1953
- * 宿主的官方配方同形——会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的
1954
- * `workspace.row-toast` 读它(asar 原文:`inject("shell.overlay", … id: "workspace.row-toast" …
1955
- * inject: rowToastInjected … }, RowActionToast)`,而
1956
- * `rowToastInjected = () => ({ hooks: { toast: rowToast }, dismissToast, undoArchive: unarchiveSession })`)。
1957
- * 本文件就是那份 store + 那个常驻组件。
2045
+ * 撤销横幅的**通知台**:现场、seq、发布 / 熄灭与停留时长。
1958
2046
  *
1959
- * 状态源走 `store.ts` 的 `createStore`(内部用 React 自带的 `useSyncExternalStore`)——
1960
- * 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
2047
+ * 为什么独立于 Toast 组件:提示的现场要跨槽位存活(菜单行随菜单卸载,`shell.overlay` 里的组件才常驻),
2048
+ * 而写入座位与取数编排只需要发布 / 熄灭,不需要 import 一个 `.tsx`。通知台放在这里之后,
2049
+ * `undo-toast.tsx` 只做组件并组合恢复座位,`write-seats.ts` 与 `use-trash-query.ts` 只依赖本 module。
1961
2050
  */
1962
2051
  const noticeStore = createStore(null);
1963
2052
  /** 删除成功后发布一条撤销提示(单条)。 */
@@ -1969,7 +2058,7 @@ window.__ModuleLoader__.load({
1969
2058
  title: input.title
1970
2059
  }));
1971
2060
  }
1972
- /** 批量删除成功后发布一条撤销提示(2026-10-17):覆盖上一条,只报这一次移走的条数。 */
2061
+ /** 批量删除成功后发布一条撤销提示:覆盖上一条,只报这一次移走的条数。 */
1973
2062
  function publishUndoBatchNotice(input) {
1974
2063
  noticeStore.publish((seq) => ({
1975
2064
  seq,
@@ -1979,7 +2068,7 @@ window.__ModuleLoader__.load({
1979
2068
  }));
1980
2069
  }
1981
2070
  /** 整条横幅不再留:撤销成了、停留时间走完,或清空回收站把所有落点一起清了。
1982
- * 与下面 `dismissUndoNotice` 的分别是「整条不再留」与「按条目匹配」——清空之后没有条目还能撤销,
2071
+ * 与 `dismissUndoNotice` 的分别是「整条不再留」与「按条目匹配」——清空之后没有条目还能撤销,
1983
2072
  * 所以那一路不需要名单。 */
1984
2073
  function clearUndoNotice() {
1985
2074
  noticeStore.set(null);
@@ -2015,52 +2104,6 @@ window.__ModuleLoader__.load({
2015
2104
  failure: message
2016
2105
  }));
2017
2106
  }
2018
- /** 撤销:单条与批量都走**一次** `applyOutcomes('restore', ids)`(`runWrite` 判读;单条带 `single: true`,
2019
- * null 是协议错);失败就把原因换到同一条横幅上,不静默。 */
2020
- async function undo(deps, current) {
2021
- const ids = current.kind === "one" ? [current.entryId] : current.entryIds;
2022
- if (current.kind === "one" && current.entryId === "") return;
2023
- const outcome = await restoreFromUndo(deps, {
2024
- ids,
2025
- single: current.kind === "one"
2026
- });
2027
- if (!outcome.ok) {
2028
- failNotice(current, outcome.failure.message);
2029
- return;
2030
- }
2031
- const failure = outcome.failures.values().next().value;
2032
- if (failure !== void 0) {
2033
- failNotice(current, failure);
2034
- return;
2035
- }
2036
- if (ownsNotice(current)) clearUndoNotice();
2037
- }
2038
- /**
2039
- * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
2040
- * pill all belong here",见 asar 里 `shell.overlay` 的声明):没有待撤销的事时什么都不渲染。
2041
- */
2042
- function UndoToast({ deps, t = zhTranslate }) {
2043
- const current = useUndoNotice();
2044
- if (current === null) return null;
2045
- const { seq, failure } = current;
2046
- if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
2047
- text: failure,
2048
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 }),
2049
- holdMs: UNDO_HOLD_MS,
2050
- onDone: clearUndoNotice
2051
- }, seq);
2052
- const canUndo = current.kind === "one" ? current.entryId !== "" : current.entryIds.length > 0;
2053
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
2054
- tone: "success",
2055
- text: current.kind === "one" ? t("undo.moved", { title: current.title === "" ? t("undo.session") : current.title }) : t("undo.movedBatch", { n: current.n }),
2056
- holdMs: UNDO_HOLD_MS,
2057
- actions: canUndo ? [{
2058
- label: t("undo.undo"),
2059
- onClick: () => void undo(deps, current)
2060
- }] : void 0,
2061
- onDone: clearUndoNotice
2062
- }, seq);
2063
- }
2064
2107
  //#endregion
2065
2108
  //#region src/client/main-view.ts
2066
2109
  let client;
@@ -2191,15 +2234,15 @@ window.__ModuleLoader__.load({
2191
2234
  switch (success.action) {
2192
2235
  case "delete": {
2193
2236
  const seat = presentation;
2194
- afterDelete(success.successIds);
2237
+ afterDelete(success.summary.successIds);
2195
2238
  seat.invalidate?.();
2196
- if (success.anySuccess) {
2197
- const [first, ...rest] = landedEntryIds(success.receipt);
2239
+ if (success.summary.anySuccess) {
2240
+ const [first, ...rest] = success.summary.landedIds;
2198
2241
  if (first !== void 0) {
2199
2242
  const entryIds = [first, ...rest];
2200
2243
  seat.publishUndo?.({
2201
2244
  entryIds,
2202
- n: success.successIds.length
2245
+ n: success.summary.successIds.length
2203
2246
  });
2204
2247
  }
2205
2248
  }
@@ -2207,20 +2250,20 @@ window.__ModuleLoader__.load({
2207
2250
  }
2208
2251
  case "restore": {
2209
2252
  const seat = presentation;
2210
- const restored = restoredSessionIds(success.receipt, seat.sessionIdOfEntry);
2253
+ const restored = success.summary.landedIds;
2211
2254
  if (seat.panelSeat !== true) afterRestore(restored);
2212
2255
  seat.invalidate?.();
2213
- if (!success.anySuccess) return;
2214
- seat.dismissUndo?.(success.successIds);
2256
+ if (!success.summary.anySuccess) return;
2257
+ seat.dismissUndo?.(success.summary.successIds);
2215
2258
  if (seat.panelSeat === true) forgetMainView(restored);
2216
- seat.clearDetailCache?.(success.successIds);
2259
+ seat.clearDetailCache?.(success.summary.successIds);
2217
2260
  return;
2218
2261
  }
2219
2262
  case "purge": {
2220
2263
  const seat = presentation;
2221
- if (success.anySuccess) {
2222
- seat.dismissUndo?.(success.successIds);
2223
- seat.clearDetailCache?.(success.successIds);
2264
+ if (success.summary.anySuccess) {
2265
+ seat.dismissUndo?.(success.summary.successIds);
2266
+ seat.clearDetailCache?.(success.summary.successIds);
2224
2267
  }
2225
2268
  return;
2226
2269
  }
@@ -2248,18 +2291,13 @@ window.__ModuleLoader__.load({
2248
2291
  return failed;
2249
2292
  }
2250
2293
  const single = input.single === true && ids.length === 1;
2251
- const receipt = readReceipt(input.action, ids, outcome.value, { single });
2252
- const failures = /* @__PURE__ */ new Map();
2253
- for (const verdict of receipt.verdicts) if (isFailureVerdict(verdict)) failures.set(verdict.id, failureMessage(verdict));
2254
- const successIds = receipt.verdicts.filter((verdict) => verdict.kind === "ok" || verdict.kind === "already-gone").map((verdict) => verdict.id);
2294
+ const { summary, failures } = summarizeReceipt(readReceipt(input.action, ids, outcome.value, { single }), input.sessionIdOfEntry);
2255
2295
  const success = {
2256
2296
  ok: true,
2257
2297
  action: input.action,
2258
2298
  ids,
2259
- receipt,
2260
- failures,
2261
- successIds,
2262
- anySuccess: successIds.length > 0
2299
+ summary,
2300
+ failures
2263
2301
  };
2264
2302
  settleWrite(success, input.presentation ?? noPresentation(input.action));
2265
2303
  await input.refresh?.(success);
@@ -2320,11 +2358,11 @@ window.__ModuleLoader__.load({
2320
2358
  action: "restore",
2321
2359
  ids: input.ids,
2322
2360
  single: input.single,
2361
+ sessionIdOfEntry: (entryId) => input.entries?.find((entry) => entry.id === entryId)?.sessionId,
2323
2362
  presentation: {
2324
2363
  panelSeat: true,
2325
2364
  dismissUndo: dismissUndoNotice,
2326
- clearDetailCache: input.clearDetailCache,
2327
- sessionIdOfEntry: (entryId) => input.entries?.find((entry) => entry.id === entryId)?.sessionId
2365
+ clearDetailCache: input.clearDetailCache
2328
2366
  },
2329
2367
  gate: input.gate,
2330
2368
  refresh: input.refreshAfterWrite
@@ -2344,42 +2382,14 @@ window.__ModuleLoader__.load({
2344
2382
  refresh: input.refreshAfterWrite
2345
2383
  });
2346
2384
  }
2347
- /** 无主子会话清理座位:不碰回收站失效与撤销,回执判读仍归 `use-trash-query.ts`。 */
2348
- function cleanOrphansFromPanel(input) {
2349
- return runWrite({
2350
- deps: input.deps,
2351
- action: "orphan-clean",
2352
- ids: input.ids,
2353
- gate: input.gate,
2354
- refresh: input.refreshAfterWrite
2355
- });
2356
- }
2357
2385
  //#endregion
2358
2386
  //#region src/client/delete-confirm.tsx
2359
2387
  /**
2360
- * 「删除」的确认框——**挂在 `shell.overlay` 上的宿主组件**,加它读的那个模块级现场。
2361
- *
2362
- * 为什么确认框不能住在菜单行里(这是本文件存在的唯一理由):**宿主的菜单行随菜单一起卸载**。
2363
- * 真产物 `Menu` 的渲染是
2364
- * `const list = open && jsxs(MenuSurface, { … children: [items.map(renderEntry), children] })`
2365
- * (asar @22074291 逐字;`children` 就是本插件那一行所在的槽位投影)——`open === false` 时
2366
- * **槽位行根本不渲染**,行连同它的整棵子树卸载。而按官方契约,按下这一行先 `setMenuOpen(false)`
2367
- * 收起菜单正是正解(官方 README 的第三方行示例逐字 `onSelect={() => { setMenuOpen(false); … }}`,
2368
- * 宿主自己的 pin / rename / fork / archive 四行同样这么做)。两条一起成立 ⇒ 确认框若住在行里,
2369
- * 它会在菜单收起的那一帧被**连坐卸载**(`createPortal` 只改 DOM 归属,**不改 React 生命周期**),
2370
- * 真机上「移入回收站」永远点不到。
2371
- *
2372
- * 所以按宿主自己的配方把"请求"与"确认"拆开(同一条路:宿主对会话行的撤销提示就是这么做的,
2373
- * `ctx.slots.inject("shell.overlay", … id: "workspace.row-toast" …, RowActionToast)`):
2388
+ * 「删除」确认框,挂在 shell.overlay 上;菜单行只负责发请求。
2374
2389
  *
2375
- * - **菜单行只发请求**:`DeleteMenuItem` 把 `{ sessionId, title }` 写进本文件的模块级现场;
2376
- * - **确认框由本文件的宿主渲染与提交**:`DeleteConfirmHost` 挂在 `shell.overlay` 上,从现场读请求,
2377
- * 自己持有这趟的闸与 failure。于是「提交删除 → 失败原文上屏 → 再点一次重试 → 成功后发撤销提示」
2378
- * 整条链路都在菜单子树之外,菜单关不关都不影响它。
2379
- *
2380
- * 现场是**模块级**的(本仓既有的客户端模式:视图内路由与跨卸载的现场都住在模块级 store,
2381
- * 见 `undo-toast.tsx`),状态源是 `store.ts` 的 `createStore`(内部用 React 自带的
2382
- * `useSyncExternalStore`)——不为此引新的状态库。
2390
+ * 宿主菜单行随菜单关闭而卸载,确认框若住在行内会被连坐卸载,因此把请求与确认拆开:
2391
+ * 菜单行把 {sessionId,title} 写进模块级 store,DeleteConfirmHost 在 overlay 读取并提交。
2392
+ * 这样提交、失败原文、重试与撤销提示都在菜单子树之外,开合菜单不影响它们。
2383
2393
  */
2384
2394
  /**
2385
2395
  * 被操作对象那张卡。`Modal`(`ui/Modal.tsx`,照官方抄的)的 `.sd-modal-body` 不设字号也不给层级 ⇒ 裸摆一行会话标题会和下面那句
@@ -2432,6 +2442,7 @@ window.__ModuleLoader__.load({
2432
2442
  function ConfirmDialog({ request: current, deps, t }) {
2433
2443
  /** 这一趟的闸:写入座位把 apply → 收尾 → 重读整趟包在里面,`busy` 期间主按钮按住。 */
2434
2444
  const gate = useBusyGate();
2445
+ const { close, closeDisabled } = useIdleClose(gate.busy, clearDeleteRequest);
2435
2446
  const [failure, setFailure] = (0, react.useState)(null);
2436
2447
  /**
2437
2448
  * 这条会话在盘上带着多少个后代。删除是按组走的(组语义见 `src/trash/groups.ts`),而宿主的客户端本来就把孩子那几行
@@ -2463,12 +2474,13 @@ window.__ModuleLoader__.load({
2463
2474
  };
2464
2475
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
2465
2476
  open: true,
2466
- onClose: clearDeleteRequest,
2477
+ onClose: close,
2467
2478
  title: t("dialog.title"),
2468
2479
  closeLabel: t("common.close"),
2469
2480
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2470
2481
  variant: "outline",
2471
- onClick: clearDeleteRequest,
2482
+ disabled: closeDisabled,
2483
+ onClick: close,
2472
2484
  children: t("common.cancel")
2473
2485
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2474
2486
  variant: "primary",
@@ -2504,14 +2516,14 @@ window.__ModuleLoader__.load({
2504
2516
  //#endregion
2505
2517
  //#region src/client/batch-confirm.tsx
2506
2518
  /**
2507
- * 批量删除的确认框(2026-10-17)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
2519
+ * 批量删除的确认框——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
2508
2520
  *
2509
2521
  * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(docs/ui-spec.md 状态 ②,
2510
2522
  * 用例钉着),批量形与它只有三件不同——标题随条数、目标卡逐条列、执行是一次批量 `apply`。共用一个壳
2511
2523
  * 意味着单删的 `busy` / 失败面要跟着学批量形,那是拿已交付的行为去换省一个文件。卡片样式(`TARGET_CARD`)
2512
2524
  * 与 hint、子会话那两句**复用**单删的,一句不造第二个键。
2513
2525
  *
2514
- * 现场是模块级的(同 `delete-confirm.tsx` / `undo-toast.tsx`):请求从浮条那颗按钮发来,确认框住在
2526
+ * 现场是模块级的(同 `delete-confirm.tsx` / `undo-notices.ts`):请求从浮条那颗按钮发来,确认框住在
2515
2527
  * `shell.overlay` 上——它必须活在行开关那棵子树之外(槽位行随宿主重排而卸载,见 `delete-confirm.tsx` 头注)。
2516
2528
  */
2517
2529
  const requestStore = createStore(null);
@@ -2573,7 +2585,7 @@ window.__ModuleLoader__.load({
2573
2585
  color: "var(--dsw-alias-label-tertiary)"
2574
2586
  };
2575
2587
  /**
2576
- * 目标卡要能装几十条:2026-10-17 真机一次选 64 条,卡片把弹框撑出视口,标题和底部按钮都被顶没了。
2588
+ * 目标卡要能装几十条:选中很多条时卡片不能把弹框撑出视口,标题与底部按钮必须保持可见。
2577
2589
  * 沿用回收站批量恢复确认框的 280px 先例——**只在卡片内部滚**,头与动作行留在框里。
2578
2590
  */
2579
2591
  const BATCH_TARGET_CARD = {
@@ -2584,6 +2596,7 @@ window.__ModuleLoader__.load({
2584
2596
  function BatchConfirmDialog({ request: current, deps, t }) {
2585
2597
  /** 这一趟的闸:写入座位把 apply → 收尾 → 重读整趟包在里面,`busy` 期间主按钮按住。 */
2586
2598
  const gate = useBusyGate();
2599
+ const { close, closeDisabled } = useIdleClose(gate.busy, clearDeleteBatchRequest);
2587
2600
  /** 逐条失败原文:`sessionId → 信封的 message`。失败**不关框**(与单删同款)。 */
2588
2601
  const [failures, setFailures] = (0, react.useState)(/* @__PURE__ */ new Map());
2589
2602
  /** 开框那一刻现问的子会话计数;`null` = 有一条读不到 ⇒ 那两半句整句不说(不许拿 0 冒充"没有")。 */
@@ -2613,12 +2626,13 @@ window.__ModuleLoader__.load({
2613
2626
  };
2614
2627
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
2615
2628
  open: true,
2616
- onClose: clearDeleteBatchRequest,
2629
+ onClose: close,
2617
2630
  title: current.items.length === 1 ? t("dialog.title") : t("dialog.batch.title", { n: current.items.length }),
2618
2631
  closeLabel: t("common.close"),
2619
2632
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2620
2633
  variant: "outline",
2621
- onClick: clearDeleteBatchRequest,
2634
+ disabled: closeDisabled,
2635
+ onClick: close,
2622
2636
  children: t("common.cancel")
2623
2637
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2624
2638
  variant: "primary",
@@ -2659,7 +2673,7 @@ window.__ModuleLoader__.load({
2659
2673
  //#endregion
2660
2674
  //#region src/client/batch-bar.tsx
2661
2675
  /**
2662
- * 批量删除的浮条(2026-10-17 `6f24fd4`):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2676
+ * 批量删除的浮条:**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2663
2677
  * 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
2664
2678
  * (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
2665
2679
  * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `host-rows.ts` 的 `useSelectionRowClickGuard`)。
@@ -2865,44 +2879,6 @@ window.__ModuleLoader__.load({
2865
2879
  });
2866
2880
  }
2867
2881
  //#endregion
2868
- //#region src/client/trash-entry.tsx
2869
- /**
2870
- * 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;docs/ui-spec.md 状态 ④ 的入口那一行)。
2871
- *
2872
- * **只出 glyph**——行本体由宿主 `PanelRow` 画:`<button class=panelRow aria-current
2873
- * onClick=selectPanel(id)>` 里一个 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18,
2874
- * active }, { only: id })`)加一个 `.panelTitle`(**名字从注册元数据来**:宿主 `syncPanels` 逐字
2875
- * `label: resolveSlotLabel(options.label) ?? id`,asar @44795361),外加折叠时的 `Tooltip`、
2876
- * hover 与选中底色(asar @44783481 逐字)。所以这里**不能**有按钮、文字或角标:多画一样,
2877
- * 这一行就与「插件 / 任务 / 小说」三行错位。
2878
- *
2879
- * `active`(本行是否被中央列选中)由 owner 一并给出,**刻意不用**:选中呈现已经是宿主行自己的事
2880
- * (`aria-current` + `.panelActive`),我们再加一层就会出现两种选中态。
2881
- *
2882
- * 计数角标**不做**(2026-09-30 裁决):这个座位只给一个 16/18px 的 glyph 盒(`.panelGlyph{flex:none}`),
2883
- * 角标只能溢出到行外(本仓量过:15px 的角标右缘超出 glyph 7px、顶缘高出行顶 5px,宽态就压到上一行;
2884
- * rail 态那行是 36px 圆、`padding:0`,更糟)。`label` 虽支持 thunk,但宿主只在**槽位条目变化或
2885
- * 语言变化**时重读(同上 @44795361),拿它当活计数必然 stale。条数与合计占用改在面板头部给
2886
- * (见 `trash-panel.tsx`)。
2887
- */
2888
- function TrashPanelIcon({ size = 16 }) {
2889
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size });
2890
- }
2891
- //#endregion
2892
- //#region src/client/ui/recipe.ts
2893
- /**
2894
- * 安静的红:**只给字色、不给底**。宿主控件的 `.sd-button-ghost:hover` / `:active` 是普通类,
2895
- * 而作者态的内联 `background` 永远赢过类规则 ⇒ 铺了底的红按钮在点错之前不给任何视觉回应。
2896
- * 整页唯一那块实心红另有写法(`variant="primary"` + 就地覆盖 primary 那对 token),不在这里。
2897
- */
2898
- const QUIET_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
2899
- /** 单行截断。宽档那一列与可拖的分栏天然会挤文字,逐处手写这三行就会各写各的。 */
2900
- const ELLIPSIS = {
2901
- overflow: "hidden",
2902
- textOverflow: "ellipsis",
2903
- whiteSpace: "nowrap"
2904
- };
2905
- //#endregion
2906
2882
  //#region src/client/trash-view.ts
2907
2883
  /** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
2908
2884
  const CARD = {
@@ -2955,38 +2931,406 @@ window.__ModuleLoader__.load({
2955
2931
  })).sort((a, b) => b.latest - a.latest);
2956
2932
  }
2957
2933
  //#endregion
2958
- //#region src/client/trash-confirm.tsx
2959
- /** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
2960
- const CARD_LIST = {
2961
- display: "flex",
2962
- flexDirection: "column",
2934
+ //#region src/client/attachment-view.tsx
2935
+ /**
2936
+ * 回收站详情与设置页维护卡预览里的一条附件:图片给缩略图 + 放大框,普通文件给下载 chip。
2937
+ *
2938
+ * 三条口径:
2939
+ * - **地址只由 `attachmentId` + `kind` 拼**:`/attachment` 段只认这两个参数寻址(`name` 只做下载名建议,
2940
+ * 不参与路径推导,见 `src/api/attachment.ts`)。空串与缺席同义——空串不是一个名字,不写进查询串。
2941
+ * - **图裂了就换占位**:缩略图与放大框共用同一份 `failed` 集合,`onError` 之后这一格改成「附件不可用」,
2942
+ * 不把破图留在屏上(放大框里裂了也换)。普通文件走 `<a download>`,没有页面内失败面:服务端失败回
2943
+ * 200 + JSON 信封,浏览器可能把这份错误响应存成文件。
2944
+ * - **放大框复用 `Modal`**:Escape、焦点归位、蒙层点击三件事都在那里,不另造一层浮层。
2945
+ *
2946
+ * 附件是**消息的一部分**,不单开一等区块:调用面把 `AttachmentStrip` 摆在自己那一行的位置
2947
+ * (用户:气泡之上;助手:Markdown 之后),并用 `align` 说自己那一行朝哪边靠——用户行右对齐
2948
+ * (气泡那一侧),助手行左对齐(正文那一侧)。条本身不自认角色,对齐由调用面给。
2949
+ */
2950
+ /**
2951
+ * 一张附件的取数地址。**只从校验过的 id 与 kind 派生**:不接受路径、不接受任意查询串,
2952
+ * 所以调用面把 URL 交给 `<img src>` / `<a href>` 时不存在"读任意文件"的入口。
2953
+ */
2954
+ function attachmentUrl(a) {
2955
+ const query = new URLSearchParams({
2956
+ [PARAMS.attachmentId]: a.attachmentId,
2957
+ [PARAMS.attachmentKind]: a.kind
2958
+ });
2959
+ 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()}`;
2961
+ }
2962
+ /** 空串与缺席同义:宿主侧的 `name` 是可选建议名,空串不是名字(兜底文案与"不带 name 查询"都看它)。 */
2963
+ function nameOf(a) {
2964
+ return a.name === void 0 || a.name === "" ? void 0 : a.name;
2965
+ }
2966
+ const IMAGE_BUTTON = {
2967
+ display: "block",
2968
+ padding: 0,
2969
+ border: 0,
2970
+ background: "none",
2971
+ cursor: "zoom-in",
2972
+ maxWidth: "100%"
2973
+ };
2974
+ const THUMB = {
2975
+ display: "block",
2976
+ maxWidth: "100%",
2977
+ maxHeight: 260,
2978
+ objectFit: "contain",
2979
+ borderRadius: "var(--dsw-radius-lg)",
2980
+ background: "var(--dsw-alias-bg-layer-2)"
2981
+ };
2982
+ const FILE_CHIP = {
2983
+ display: "inline-block",
2984
+ maxWidth: "100%",
2985
+ wordBreak: "break-all",
2986
+ fontSize: 12,
2987
+ color: "var(--dsw-alias-label-secondary)",
2988
+ textDecoration: "underline"
2989
+ };
2990
+ const UNAVAILABLE = {
2991
+ display: "inline-flex",
2992
+ alignItems: "center",
2963
2993
  gap: 6,
2964
- maxHeight: 280,
2965
- overflow: "auto",
2966
- marginBlockEnd: 10
2994
+ fontSize: 12,
2995
+ color: "var(--dsw-alias-label-tertiary)"
2967
2996
  };
2968
2997
  /**
2969
- * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
2970
- * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
2971
- * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
2972
- * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
2998
+ * 图裂之后的占位。**仍然说清是哪一件**:名字(没有就用「附件图片」/「文件」)+ 体量 + 「附件不可用」。
2999
+ * 只剩一句「附件不可用」等于把"哪张图没了"也一起丢了。
2973
3000
  */
2974
- function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
2975
- if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
2976
- open: true,
2977
- onClose: () => {
2978
- if (!busy) onCancel();
2979
- },
2980
- title: t("trash.confirm.restore", { count: pending.ids.length }),
2981
- closeLabel: t("common.close"),
2982
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2983
- variant: "outline",
2984
- disabled: busy,
2985
- onClick: onCancel,
2986
- children: t("common.cancel")
2987
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
2988
- variant: "primary",
2989
- disabled: busy,
3001
+ function Unavailable({ attachment, t }) {
3002
+ const label = nameOf(attachment) ?? (attachment.kind === "file" ? t("detail.attachment.file") : t("detail.attachment.alt"));
3003
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3004
+ style: UNAVAILABLE,
3005
+ children: [
3006
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 13 }),
3007
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3008
+ style: { wordBreak: "break-all" },
3009
+ children: label
3010
+ }),
3011
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: fileSizeText(attachment.bytes) }),
3012
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("detail.attachment.unavailable") })
3013
+ ]
3014
+ });
3015
+ }
3016
+ /**
3017
+ * 一条消息的全部附件。顺序照日志,不重排:用户先发图后发文件,读起来应是他当时的顺序。
3018
+ *
3019
+ * `align` 是这一行的对齐(默认 `end`,与用户气泡同侧):用户行靠右、助手行靠左,条跟着自己那条消息走。
3020
+ * `failed` 按 `attachmentId` 记:同一个内容哈希在一段会话里可能被引用多次,裂一次就都不再画破图。
3021
+ * 四个 `useState` 都在早退之前调用——附件从有到无时 hook 数量不变。
3022
+ */
3023
+ function AttachmentStrip({ attachments, t, align = "end" }) {
3024
+ const [preview, setPreview] = (0, react.useState)(null);
3025
+ const [failed, setFailed] = (0, react.useState)(/* @__PURE__ */ new Set());
3026
+ const [saving, setSaving] = (0, react.useState)(false);
3027
+ const [saveError, setSaveError] = (0, react.useState)(null);
3028
+ if (attachments.length === 0) return null;
3029
+ const markFailed = (id) => setFailed((prev) => new Set(prev).add(id));
3030
+ const openPreview = (attachment) => {
3031
+ setPreview(attachment);
3032
+ setSaveError(null);
3033
+ setSaving(false);
3034
+ };
3035
+ const closePreview = () => {
3036
+ setPreview(null);
3037
+ setSaveError(null);
3038
+ setSaving(false);
3039
+ };
3040
+ /**
3041
+ * 另存为:先把字节取回来拿到真实 `Content-Type`,再用 blob URL + `download` 落盘。
3042
+ * 不直接把 `<a download>` 指到 `/attachment`:图片响应是 inline,且仓库里没有原始扩展名,
3043
+ * 直接下载会得到没有扩展名的哈希文件。这里按响应类型补 `.png/.jpg/.webp/.gif`。
3044
+ */
3045
+ const savePreview = async (attachment) => {
3046
+ setSaving(true);
3047
+ setSaveError(null);
3048
+ try {
3049
+ const response = await fetch(attachmentUrl(attachment));
3050
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
3051
+ if (!response.ok || !contentType.startsWith("image/")) {
3052
+ let message = `HTTP ${String(response.status)}`;
3053
+ if (contentType === "application/json") try {
3054
+ const body = await response.json();
3055
+ if (typeof body.error?.message === "string" && body.error.message !== "") message = body.error.message;
3056
+ } catch {}
3057
+ throw new Error(message);
3058
+ }
3059
+ const blob = await response.blob();
3060
+ const ext = contentType.split("/")[1]?.replace("jpeg", "jpg") ?? "bin";
3061
+ const base = nameOf(attachment) ?? `attachment-${attachment.attachmentId.slice(7, 15)}`;
3062
+ const objectUrl = URL.createObjectURL(blob);
3063
+ const link = document.createElement("a");
3064
+ link.href = objectUrl;
3065
+ link.download = /\.\w+$/u.test(base) ? base : `${base}.${ext}`;
3066
+ document.body.appendChild(link);
3067
+ link.click();
3068
+ link.remove();
3069
+ const revoke = URL.revokeObjectURL;
3070
+ if (typeof revoke === "function") setTimeout(() => revoke.call(URL, objectUrl), 0);
3071
+ } catch (error) {
3072
+ setSaveError(t("detail.attachment.saveFailed", { message: error instanceof Error ? error.message : String(error) }));
3073
+ } finally {
3074
+ setSaving(false);
3075
+ }
3076
+ };
3077
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3078
+ "data-sd-attachment-strip": true,
3079
+ style: {
3080
+ display: "flex",
3081
+ flexWrap: "wrap",
3082
+ gap: 8,
3083
+ justifyContent: align === "start" ? "flex-start" : "flex-end",
3084
+ minWidth: 0
3085
+ },
3086
+ children: attachments.map((a, index) => {
3087
+ const key = `${a.attachmentId}:${String(index)}`;
3088
+ const name = nameOf(a);
3089
+ if (a.kind === "file") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3090
+ href: attachmentUrl(a),
3091
+ download: name ?? "",
3092
+ style: FILE_CHIP,
3093
+ children: [
3094
+ name ?? t("detail.attachment.file"),
3095
+ " · ",
3096
+ fileSizeText(a.bytes)
3097
+ ]
3098
+ }, key);
3099
+ if (failed.has(a.attachmentId)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Unavailable, {
3100
+ attachment: a,
3101
+ t
3102
+ }) }, key);
3103
+ const alt = name ?? t("detail.attachment.alt");
3104
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3105
+ type: "button",
3106
+ style: IMAGE_BUTTON,
3107
+ "aria-label": t("detail.attachment.open", { name: alt }),
3108
+ onClick: () => openPreview(a),
3109
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
3110
+ src: attachmentUrl(a),
3111
+ alt,
3112
+ loading: "lazy",
3113
+ style: THUMB,
3114
+ onError: () => markFailed(a.attachmentId)
3115
+ })
3116
+ }, key);
3117
+ })
3118
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Modal, {
3119
+ open: preview !== null,
3120
+ onClose: closePreview,
3121
+ title: preview === null ? t("detail.attachment.preview") : nameOf(preview) ?? t("detail.attachment.preview"),
3122
+ closeLabel: t("common.close"),
3123
+ footer: preview === null ? void 0 : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3124
+ style: {
3125
+ display: "flex",
3126
+ alignItems: "center",
3127
+ gap: 8,
3128
+ width: "100%"
3129
+ },
3130
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3131
+ style: {
3132
+ flex: "1 1 auto",
3133
+ minWidth: 0,
3134
+ fontSize: 12,
3135
+ lineHeight: "17px",
3136
+ color: "var(--dsw-alias-state-error-primary)",
3137
+ wordBreak: "break-word"
3138
+ },
3139
+ children: saveError ?? ""
3140
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3141
+ variant: "outline",
3142
+ size: "sm",
3143
+ disabled: saving,
3144
+ onClick: () => void savePreview(preview),
3145
+ children: saving ? t("detail.attachment.saving") : t("detail.attachment.save")
3146
+ })]
3147
+ }),
3148
+ children: preview === null ? null : failed.has(preview.attachmentId) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Unavailable, {
3149
+ attachment: preview,
3150
+ t
3151
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
3152
+ src: attachmentUrl(preview),
3153
+ alt: nameOf(preview) ?? t("detail.attachment.alt"),
3154
+ style: {
3155
+ display: "block",
3156
+ maxWidth: "100%",
3157
+ maxHeight: "80vh",
3158
+ margin: "0 auto"
3159
+ },
3160
+ onError: () => markFailed(preview.attachmentId)
3161
+ })
3162
+ })] });
3163
+ }
3164
+ /**
3165
+ * 附件清理回执那一行提示(「N 个附件未能清理」/「附件未清理:原因」)。表现层只收一句已经拼好的话;
3166
+ * 什么时候该有这句话、失败与跳过怎么拼,归接线的那一侧(判据住 `receipt.ts` 与 `use-trash-query.ts`)。
3167
+ */
3168
+ function AttachmentNotice({ message }) {
3169
+ if (message === null) return null;
3170
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3171
+ role: "status",
3172
+ style: {
3173
+ ...CARD,
3174
+ alignItems: "flex-start",
3175
+ gap: 10,
3176
+ marginBottom: 10,
3177
+ padding: "10px 12px"
3178
+ },
3179
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3180
+ style: {
3181
+ display: "inline-flex",
3182
+ flex: "none",
3183
+ color: "var(--dsw-alias-state-warn-primary)"
3184
+ },
3185
+ "aria-hidden": "true",
3186
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3187
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3188
+ style: {
3189
+ flex: "1 1 auto",
3190
+ minWidth: 0,
3191
+ wordBreak: "break-word",
3192
+ fontSize: 12,
3193
+ lineHeight: "17px"
3194
+ },
3195
+ children: message
3196
+ })]
3197
+ });
3198
+ }
3199
+ //#endregion
3200
+ //#region src/client/maintenance-parts.tsx
3201
+ function MaintenanceBanner({ slot, busy, t, gapBelow = false, onOpen, children }) {
3202
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3203
+ "data-dsh-session-delete": slot,
3204
+ style: {
3205
+ ...CARD,
3206
+ alignItems: "flex-start",
3207
+ gap: 12,
3208
+ ...gapBelow ? { marginBottom: 10 } : {}
3209
+ },
3210
+ children: [
3211
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3212
+ style: {
3213
+ display: "inline-flex",
3214
+ flex: "none",
3215
+ paddingTop: 2,
3216
+ color: "var(--dsw-alias-state-warn-primary)"
3217
+ },
3218
+ "aria-hidden": "true",
3219
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
3220
+ }),
3221
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3222
+ style: {
3223
+ display: "flex",
3224
+ flexDirection: "column",
3225
+ gap: 2,
3226
+ flex: "1 1 auto",
3227
+ minWidth: 0
3228
+ },
3229
+ children
3230
+ }),
3231
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3232
+ variant: "outline",
3233
+ size: "sm",
3234
+ style: { flex: "none" },
3235
+ disabled: busy,
3236
+ onClick: () => {
3237
+ if (busy) return;
3238
+ onOpen();
3239
+ },
3240
+ children: t("trash.orphans.open")
3241
+ })
3242
+ ]
3243
+ });
3244
+ }
3245
+ function MaintenanceFailure({ slot, message, busy, t, gapBelow = false, onRetry }) {
3246
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3247
+ "data-dsh-session-delete": slot,
3248
+ role: "status",
3249
+ style: {
3250
+ ...CARD,
3251
+ alignItems: "flex-start",
3252
+ gap: 10,
3253
+ padding: "10px 12px",
3254
+ ...gapBelow ? { marginBottom: 10 } : {}
3255
+ },
3256
+ children: [
3257
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3258
+ style: {
3259
+ display: "inline-flex",
3260
+ flex: "none",
3261
+ color: "var(--dsw-alias-state-error-primary)"
3262
+ },
3263
+ "aria-hidden": "true",
3264
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3265
+ }),
3266
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3267
+ style: {
3268
+ flex: "1 1 auto",
3269
+ minWidth: 0,
3270
+ wordBreak: "break-word",
3271
+ fontSize: 12,
3272
+ lineHeight: "17px",
3273
+ color: "var(--dsw-alias-state-error-primary)"
3274
+ },
3275
+ children: message
3276
+ }),
3277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3278
+ variant: "outline",
3279
+ size: "sm",
3280
+ style: { flex: "none" },
3281
+ disabled: busy,
3282
+ onClick: onRetry,
3283
+ children: t("common.retry")
3284
+ })
3285
+ ]
3286
+ });
3287
+ }
3288
+ //#endregion
3289
+ //#region src/client/ui/recipe.ts
3290
+ /**
3291
+ * 安静的红:**只给字色、不给底**。宿主控件的 `.sd-button-ghost:hover` / `:active` 是普通类,
3292
+ * 而作者态的内联 `background` 永远赢过类规则 ⇒ 铺了底的红按钮在点错之前不给任何视觉回应。
3293
+ * 整页唯一那块实心红另有写法(`variant="primary"` + 就地覆盖 primary 那对 token),不在这里。
3294
+ */
3295
+ const QUIET_DANGER = { color: "var(--dsw-alias-state-error-primary)" };
3296
+ /** 单行截断。宽档那一列与可拖的分栏天然会挤文字,逐处手写这三行就会各写各的。 */
3297
+ const ELLIPSIS = {
3298
+ overflow: "hidden",
3299
+ textOverflow: "ellipsis",
3300
+ whiteSpace: "nowrap"
3301
+ };
3302
+ //#endregion
3303
+ //#region src/client/trash-confirm.tsx
3304
+ /** 弹出的一批卡片(逐条说清这一批有哪些):长清单在框内滚,标题与动作行留在框里。 */
3305
+ const CARD_LIST = {
3306
+ display: "flex",
3307
+ flexDirection: "column",
3308
+ gap: 6,
3309
+ maxHeight: 280,
3310
+ overflow: "auto",
3311
+ marginBlockEnd: 10
3312
+ };
3313
+ /**
3314
+ * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
3315
+ * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
3316
+ * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
3317
+ * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
3318
+ */
3319
+ function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore }) {
3320
+ const { close, closeDisabled } = useIdleClose(busy, onCancel);
3321
+ if (pending.kind === "restore") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3322
+ open: true,
3323
+ onClose: close,
3324
+ title: t("trash.confirm.restore", { count: pending.ids.length }),
3325
+ closeLabel: t("common.close"),
3326
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3327
+ variant: "outline",
3328
+ disabled: closeDisabled,
3329
+ onClick: close,
3330
+ children: t("common.cancel")
3331
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3332
+ variant: "primary",
3333
+ disabled: busy,
2990
3334
  onClick: () => onConfirmRestore(pending.ids),
2991
3335
  children: t("trash.confirm.restoreNow")
2992
3336
  })] }),
@@ -3035,15 +3379,13 @@ window.__ModuleLoader__.load({
3035
3379
  const body = splitOn(t("trash.confirm.body", { strong }), strong);
3036
3380
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3037
3381
  open: true,
3038
- onClose: () => {
3039
- if (!busy) onCancel();
3040
- },
3382
+ onClose: close,
3041
3383
  title,
3042
3384
  closeLabel: t("common.close"),
3043
3385
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3044
3386
  variant: "outline",
3045
- disabled: busy,
3046
- onClick: onCancel,
3387
+ disabled: closeDisabled,
3388
+ onClick: close,
3047
3389
  children: t("common.cancel")
3048
3390
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3049
3391
  variant: "primary",
@@ -3091,19 +3433,18 @@ window.__ModuleLoader__.load({
3091
3433
  ] })]
3092
3434
  });
3093
3435
  }
3094
- /** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
3436
+ /** 无主子会话的清理确认框。载荷是刚扫出来的那份报告,界面不重算。 */
3095
3437
  function OrphanConfirm({ report, busy, t, onCancel, onConfirm }) {
3438
+ const { close, closeDisabled } = useIdleClose(busy, onCancel);
3096
3439
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3097
3440
  open: true,
3098
- onClose: () => {
3099
- if (!busy) onCancel();
3100
- },
3441
+ onClose: close,
3101
3442
  title: t("trash.orphans.confirm", { count: report.totalSessions }),
3102
3443
  closeLabel: t("common.close"),
3103
3444
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3104
3445
  variant: "outline",
3105
- disabled: busy,
3106
- onClick: onCancel,
3446
+ disabled: closeDisabled,
3447
+ onClick: close,
3107
3448
  children: t("common.cancel")
3108
3449
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3109
3450
  variant: "primary",
@@ -3153,29 +3494,319 @@ window.__ModuleLoader__.load({
3153
3494
  });
3154
3495
  }
3155
3496
  //#endregion
3156
- //#region src/client/orphan-maintenance.tsx
3157
- function OrphanMaintenance({ query, busy, t }) {
3158
- const { orphanReport, orphanFailure, orphanNotice, orphanConfirm } = query;
3159
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3160
- orphanReport !== null && orphanReport.totalSessions > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3161
- "data-dsh-session-delete": "orphan-banner",
3497
+ //#region src/client/orphan-maintenance.tsx
3498
+ function OrphanMaintenance({ query, busy, t }) {
3499
+ const { orphanReport, orphanFailure, orphanNotice, orphanConfirm } = query;
3500
+ 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, {
3502
+ slot: "orphan-banner",
3503
+ busy,
3504
+ t,
3505
+ gapBelow: true,
3506
+ onOpen: () => query.openOrphanPreview(),
3507
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3508
+ style: {
3509
+ fontSize: 13,
3510
+ lineHeight: "19px"
3511
+ },
3512
+ children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
3513
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3514
+ style: {
3515
+ fontSize: 12,
3516
+ lineHeight: "16px",
3517
+ color: "var(--dsw-alias-label-tertiary)"
3518
+ },
3519
+ children: t("trash.orphans.meta", {
3520
+ size: fileSizeText(orphanReport.totalBytes),
3521
+ parents: orphanReport.missingParents
3522
+ })
3523
+ })]
3524
+ }) : null,
3525
+ orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3526
+ message: orphanFailure,
3527
+ busy,
3528
+ t,
3529
+ gapBelow: true,
3530
+ onRetry: () => void query.scanOrphans()
3531
+ }) : null,
3532
+ orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3533
+ "data-dsh-session-delete": "orphan-notice",
3534
+ role: "status",
3535
+ style: {
3536
+ ...CARD,
3537
+ alignItems: "flex-start",
3538
+ gap: 10,
3539
+ marginBottom: 10,
3540
+ padding: "10px 12px"
3541
+ },
3542
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3543
+ style: {
3544
+ display: "inline-flex",
3545
+ flex: "none",
3546
+ paddingTop: 2,
3547
+ color: "var(--dsw-alias-label-tertiary)"
3548
+ },
3549
+ "aria-hidden": "true",
3550
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3551
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3552
+ style: {
3553
+ flex: "1 1 auto",
3554
+ minWidth: 0,
3555
+ wordBreak: "break-word",
3556
+ fontSize: 12,
3557
+ lineHeight: "17px",
3558
+ color: "var(--dsw-alias-label-secondary)"
3559
+ },
3560
+ children: orphanNotice
3561
+ })]
3562
+ }) : null,
3563
+ orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3564
+ report: orphanReport,
3565
+ busy,
3566
+ t,
3567
+ onCancel: () => query.setOrphanConfirm(false),
3568
+ onConfirm: () => void query.cleanOrphans()
3569
+ }) : null
3570
+ ] });
3571
+ }
3572
+ //#endregion
3573
+ //#region src/client/use-maintenance.ts
3574
+ /**
3575
+ * 插件设置页「维护」卡的手动编排:孤儿会话 + 孤儿附件,各一组读数、提示与动作。
3576
+ *
3577
+ * 三条承重规矩:
3578
+ * - **没有自动扫描**:本 module 不挂 `useEffect`、没有定时器;只有用户点「检查 / 查看并清理 / 清理」
3579
+ * 才发请求。挂载只交初始态(报告 `null` = 尚未检查)。
3580
+ * - **读不到就说读不到**:两份报告失败都回 `null` + 原文,不折成「没有发现」。
3581
+ * - **清理后必重查**:报告说的是"报告那一刻"的盘;清完立刻重读。回执里的中性 / 失败 / 部分计数
3582
+ * 先读出来、重查之后再上屏——重查成功会清掉旧提示,顺序反了会把这一趟的后果吞掉。
3583
+ *
3584
+ * 写动作走既有的两段:孤儿会话是 `/apply` 的 `orphan-clean`(回执判读复用 `receipt.ts` 的读法),
3585
+ * 孤儿附件是聚合的 `POST /attachment-clean`(回执就是 `AttachmentCleanupWire` 本身)。
3586
+ * 面板的 `useTrashQuery` 与本 hook 互不引用:维护卡是手动面,面板是挂载即读的清单面。
3587
+ */
3588
+ /**
3589
+ * 附件清理回执 → 一句上屏的话。跳过半句与面板共用同一判据(`receipt.ts` 的
3590
+ * `attachmentSkippedSentence`),这里只接非跳过的后半句:
3591
+ * - 有逐文件失败:未清件数与已清件数都在句里。
3592
+ * - 全清干净且没有失败:只说清了几件;一件都没动就是 `null`(不拿"清了 0 件"占一行)。
3593
+ */
3594
+ function attachmentCleanupNotice(a, t) {
3595
+ const skipped = attachmentSkippedSentence(a, t);
3596
+ if (skipped !== null) return skipped;
3597
+ if (a.failed > 0) return t("maintenance.attachments.cleanFailed", {
3598
+ count: a.failed,
3599
+ removed: a.removed
3600
+ });
3601
+ return a.removed > 0 ? t("maintenance.attachments.cleaned", {
3602
+ count: a.removed,
3603
+ size: fileSizeText(a.freedBytes)
3604
+ }) : null;
3605
+ }
3606
+ 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
+ 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;
3743
+ }
3744
+ setAttachmentNotice(attachmentCleanupNotice(outcome.value, t));
3745
+ setAttachmentPhase(null);
3746
+ }, [
3747
+ attachmentReport,
3748
+ deps,
3749
+ rescanAttachments,
3750
+ t
3751
+ ]);
3752
+ 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
3778
+ };
3779
+ }
3780
+ //#endregion
3781
+ //#region src/client/maintenance-settings.tsx
3782
+ /** 卡片外壳:按内容高、铺满宿主给的宽度;不假设自己住在中央面板里(没有 `height:100%`)。 */
3783
+ const CARD_SHELL = {
3784
+ ...CARD,
3785
+ flexDirection: "column",
3786
+ alignItems: "stretch",
3787
+ gap: 18,
3788
+ width: "100%",
3789
+ boxSizing: "border-box",
3790
+ padding: "14px 16px"
3791
+ };
3792
+ /** 一段的标题行:标题 + 说明 + 状态 + 检查按钮。状态按优先级:清理中 / 检查中 / 检查失败 / 尚未检查 / 没有发现 / N。 */
3793
+ function MaintenanceSection({ slot, title, hint, status, busy, onScan, t, children }) {
3794
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3795
+ "data-dsh-session-delete": slot,
3796
+ style: {
3797
+ display: "flex",
3798
+ flexDirection: "column",
3799
+ gap: 8,
3800
+ minWidth: 0
3801
+ },
3802
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3162
3803
  style: {
3163
- ...CARD,
3804
+ display: "flex",
3164
3805
  alignItems: "flex-start",
3165
- gap: 12,
3166
- marginBottom: 10
3806
+ gap: 10,
3807
+ minWidth: 0
3167
3808
  },
3168
3809
  children: [
3169
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3170
- style: {
3171
- display: "inline-flex",
3172
- flex: "none",
3173
- paddingTop: 2,
3174
- color: "var(--dsw-alias-state-warn-primary)"
3175
- },
3176
- "aria-hidden": "true",
3177
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 })
3178
- }),
3179
3810
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3180
3811
  style: {
3181
3812
  display: "flex",
@@ -3186,114 +3817,244 @@ window.__ModuleLoader__.load({
3186
3817
  },
3187
3818
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3188
3819
  style: {
3189
- fontSize: 13,
3190
- lineHeight: "19px"
3820
+ fontSize: 13.5,
3821
+ lineHeight: "19px",
3822
+ fontWeight: 500
3191
3823
  },
3192
- children: t("trash.orphans.banner", { count: orphanReport.totalSessions })
3824
+ children: title
3193
3825
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3194
3826
  style: {
3195
3827
  fontSize: 12,
3196
- lineHeight: "16px",
3828
+ lineHeight: "17px",
3197
3829
  color: "var(--dsw-alias-label-tertiary)"
3198
3830
  },
3199
- children: t("trash.orphans.meta", {
3200
- size: fileSizeText(orphanReport.totalBytes),
3201
- parents: orphanReport.missingParents
3202
- })
3831
+ children: hint
3203
3832
  })]
3204
3833
  }),
3205
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3206
- variant: "outline",
3207
- size: "sm",
3208
- style: { flex: "none" },
3209
- disabled: busy,
3210
- onClick: () => {
3211
- if (busy) return;
3212
- query.openOrphanPreview();
3213
- },
3214
- children: t("trash.orphans.open")
3215
- })
3216
- ]
3217
- }) : null,
3218
- orphanFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3219
- role: "status",
3220
- style: {
3221
- ...CARD,
3222
- alignItems: "flex-start",
3223
- gap: 10,
3224
- marginBottom: 10,
3225
- padding: "10px 12px"
3226
- },
3227
- children: [
3228
3834
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3835
+ "data-dsh-session-delete": "maintenance-status",
3229
3836
  style: {
3230
- display: "inline-flex",
3231
3837
  flex: "none",
3232
- color: "var(--dsw-alias-state-error-primary)"
3233
- },
3234
- "aria-hidden": "true",
3235
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3236
- }),
3237
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3238
- style: {
3239
- flex: "1 1 auto",
3240
- minWidth: 0,
3241
- wordBreak: "break-word",
3838
+ paddingTop: 1,
3242
3839
  fontSize: 12,
3243
3840
  lineHeight: "17px",
3244
- color: "var(--dsw-alias-state-error-primary)"
3841
+ color: "var(--dsw-alias-label-tertiary)",
3842
+ whiteSpace: "nowrap"
3245
3843
  },
3246
- children: orphanFailure
3844
+ children: status
3247
3845
  }),
3248
3846
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3249
3847
  variant: "outline",
3250
3848
  size: "sm",
3251
3849
  style: { flex: "none" },
3252
3850
  disabled: busy,
3253
- onClick: () => void query.refreshAll(),
3254
- children: t("common.retry")
3851
+ onClick: onScan,
3852
+ children: t("maintenance.scan")
3255
3853
  })
3256
3854
  ]
3855
+ }), children]
3856
+ });
3857
+ }
3858
+ /**
3859
+ * 状态那一行只有一个判据,顺序固定:
3860
+ * 在飞的动作(清理中 / 检查中)→ 失败(检查失败,不是"尚未检查")→ 报告在不在、是否为 0(尚未检查 / 没有发现 / N)。
3861
+ */
3862
+ function statusText(input) {
3863
+ if (input.phase === "cleaning") return input.t("maintenance.cleaning");
3864
+ if (input.phase === "scanning") return input.t("maintenance.scanning");
3865
+ if (input.failure !== null) return input.t("maintenance.checkFailed");
3866
+ if (input.count === null) return input.t("maintenance.notScanned");
3867
+ if (input.count === 0) return input.t("maintenance.none");
3868
+ return input.t(input.countKey, { count: input.count });
3869
+ }
3870
+ /**
3871
+ * 孤儿附件段:报告 >0 时出横幅(数量 + 体积 + 查看并清理);检查/清理失败在原位摆原文 + 重试;
3872
+ * 上一趟清理的回执单独一行(已清 / 跳过 / 逐文件失败)。确认框把三条边界说完再动手。
3873
+ */
3874
+ function AttachmentMaintenance({ query, busy, t }) {
3875
+ const { attachmentReport, attachmentFailure, attachmentNotice, attachmentConfirm } = query;
3876
+ 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, {
3878
+ slot: "attachment-banner",
3879
+ busy,
3880
+ t,
3881
+ onOpen: () => query.openAttachmentPreview(),
3882
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3883
+ style: {
3884
+ fontSize: 13,
3885
+ lineHeight: "19px"
3886
+ },
3887
+ children: [
3888
+ t("maintenance.attachments.count", { count: attachmentReport.count }),
3889
+ " · ",
3890
+ fileSizeText(attachmentReport.totalBytes)
3891
+ ]
3892
+ })
3257
3893
  }) : null,
3258
- orphanNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3259
- "data-dsh-session-delete": "orphan-notice",
3260
- role: "status",
3261
- style: {
3262
- ...CARD,
3263
- alignItems: "flex-start",
3264
- gap: 10,
3265
- marginBottom: 10,
3266
- padding: "10px 12px"
3267
- },
3268
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3894
+ attachmentFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceFailure, {
3895
+ slot: "attachment-failure",
3896
+ message: attachmentFailure,
3897
+ busy,
3898
+ t,
3899
+ onRetry: () => void query.scanAttachmentOrphans()
3900
+ }) : 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,
3905
+ busy,
3906
+ t,
3907
+ onCancel: () => query.setAttachmentConfirm(false),
3908
+ onConfirm: () => void query.cleanAttachmentOrphans()
3909
+ }) : null
3910
+ ] });
3911
+ }
3912
+ /** 预览 + 确认:先摆出这些图片(点开可放大),再决定清不清;只清 `.dsh` 内无引用的图片副本。
3913
+ * `items` 只是预览清单(服务端最多给 200 条),`count` 才是这次要清理的总数。 */
3914
+ function AttachmentConfirm({ count, items, busy, t, onCancel, onConfirm }) {
3915
+ const { close, closeDisabled } = useIdleClose(busy, onCancel);
3916
+ const attachments = items.map((item) => ({
3917
+ attachmentId: item.attachmentId,
3918
+ kind: "image",
3919
+ bytes: item.bytes
3920
+ }));
3921
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Modal, {
3922
+ open: true,
3923
+ onClose: close,
3924
+ title: t("maintenance.attachments.confirm", { count }),
3925
+ closeLabel: t("common.close"),
3926
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3927
+ variant: "outline",
3928
+ disabled: closeDisabled,
3929
+ onClick: close,
3930
+ children: t("common.cancel")
3931
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
3932
+ variant: "primary",
3933
+ style: BULK_DANGER,
3934
+ disabled: busy,
3935
+ onClick: onConfirm,
3936
+ children: t("maintenance.attachments.clean")
3937
+ })] }),
3938
+ children: [
3939
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("maintenance.attachments.body") }),
3940
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3269
3941
  style: {
3270
- display: "inline-flex",
3271
- flex: "none",
3272
- paddingTop: 2,
3942
+ maxHeight: "55vh",
3943
+ overflow: "auto",
3944
+ margin: "10px 0",
3945
+ paddingRight: 4
3946
+ },
3947
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentStrip, {
3948
+ attachments,
3949
+ t,
3950
+ align: "start"
3951
+ })
3952
+ }),
3953
+ items.length < count ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3954
+ style: {
3955
+ fontSize: 12,
3956
+ lineHeight: "17px",
3273
3957
  color: "var(--dsw-alias-label-tertiary)"
3274
3958
  },
3275
- "aria-hidden": "true",
3276
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 15 })
3277
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3959
+ children: t("maintenance.attachments.previewTruncated", {
3960
+ shown: items.length,
3961
+ total: count
3962
+ })
3963
+ }) : null,
3964
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3278
3965
  style: {
3279
- flex: "1 1 auto",
3280
- minWidth: 0,
3281
- wordBreak: "break-word",
3282
3966
  fontSize: 12,
3283
3967
  lineHeight: "17px",
3284
- color: "var(--dsw-alias-label-secondary)"
3968
+ color: "var(--dsw-alias-label-tertiary)"
3285
3969
  },
3286
- children: orphanNotice
3287
- })]
3288
- }) : null,
3289
- orphanConfirm && orphanReport !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanConfirm, {
3290
- report: orphanReport,
3291
- busy,
3292
- t,
3293
- onCancel: () => query.setOrphanConfirm(false),
3294
- onConfirm: () => void query.cleanOrphans()
3295
- }) : null
3296
- ] });
3970
+ children: t("maintenance.attachments.previewHint")
3971
+ })
3972
+ ]
3973
+ });
3974
+ }
3975
+ function MaintenanceSettings({ deps, t = zhTranslate }) {
3976
+ const maintenance = useMaintenance({
3977
+ deps,
3978
+ t
3979
+ });
3980
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3981
+ "data-dsh-session-delete": "maintenance-card",
3982
+ style: CARD_SHELL,
3983
+ children: [
3984
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3985
+ style: {
3986
+ fontSize: 16,
3987
+ fontWeight: 600,
3988
+ lineHeight: "24px"
3989
+ },
3990
+ children: t("maintenance.title")
3991
+ }),
3992
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(MaintenanceSection, {
3993
+ slot: "maintenance-orphans",
3994
+ title: t("maintenance.orphans.title"),
3995
+ 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(),
4005
+ t,
4006
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
4007
+ query: maintenance,
4008
+ busy: maintenance.orphanBusy,
4009
+ t
4010
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: maintenance.orphanAttachmentNotice })]
4011
+ }),
4012
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaintenanceSection, {
4013
+ slot: "maintenance-attachments",
4014
+ title: t("maintenance.attachments.title"),
4015
+ 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(),
4025
+ t,
4026
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentMaintenance, {
4027
+ query: maintenance,
4028
+ busy: maintenance.attachmentBusy,
4029
+ t
4030
+ })
4031
+ })
4032
+ ]
4033
+ });
4034
+ }
4035
+ //#endregion
4036
+ //#region src/client/trash-entry.tsx
4037
+ /**
4038
+ * 侧栏面板列表里的「回收站」行图标(`sidebar.panellist` 的占用者;docs/ui-spec.md 状态 ④ 的入口那一行)。
4039
+ *
4040
+ * **只出 glyph**——行本体由宿主 `PanelRow` 画:`<button class=panelRow aria-current
4041
+ * onClick=selectPanel(id)>` 里一个 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18,
4042
+ * active }, { only: id })`)加一个 `.panelTitle`(**名字从注册元数据来**:宿主 `syncPanels` 逐字
4043
+ * `label: resolveSlotLabel(options.label) ?? id`,asar @44795361),外加折叠时的 `Tooltip`、
4044
+ * hover 与选中底色(asar @44783481 逐字)。所以这里**不能**有按钮、文字或角标:多画一样,
4045
+ * 这一行就与「插件 / 任务 / 小说」三行错位。
4046
+ *
4047
+ * `active`(本行是否被中央列选中)由 owner 一并给出,**刻意不用**:选中呈现已经是宿主行自己的事
4048
+ * (`aria-current` + `.panelActive`),我们再加一层就会出现两种选中态。
4049
+ *
4050
+ * 计数角标**不做**:这个座位只给一个 16/18px 的 glyph 盒(`.panelGlyph{flex:none}`),
4051
+ * 角标只能溢出到行外(本仓量过:15px 的角标右缘超出 glyph 7px、顶缘高出行顶 5px,宽态就压到上一行;
4052
+ * rail 态那行是 36px 圆、`padding:0`,更糟)。`label` 虽支持 thunk,但宿主只在**槽位条目变化或
4053
+ * 语言变化**时重读(同上 @44795361),拿它当活计数必然 stale。条数与共可释放量改在面板头部给
4054
+ * (见 `trash-panel.tsx`)。
4055
+ */
4056
+ function TrashPanelIcon({ size = 16 }) {
4057
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size });
3297
4058
  }
3298
4059
  //#endregion
3299
4060
  //#region node_modules/.pnpm/comma-separated-tokens@2.0.3/node_modules/comma-separated-tokens/index.js
@@ -25194,7 +25955,7 @@ window.__ModuleLoader__.load({
25194
25955
  //#endregion
25195
25956
  //#region src/client/markdown.tsx
25196
25957
  /**
25197
- * 回收站详情里的**一段会话正文 → 排版后的 DOM**(2026-10-15 真机反馈后新增)。
25958
+ * 回收站详情里的**一段会话正文 → 排版后的 DOM**。
25198
25959
  *
25199
25960
  * 为什么用 `react-markdown` 而不是自己写解析器:助手回复本身就是 markdown 源,宿主平时也是这么渲的;
25200
25961
  * 自己写只能覆盖到我记得的那几种语法,表格、嵌套列表、引用一深就漏(漏出来的样子是"少渲了半段",
@@ -25208,8 +25969,9 @@ window.__ModuleLoader__.load({
25208
25969
  * 实测(10.1)补一句:默认那条路是**转义成字面文本**(页面上看得见 `<img …>` 这串字符),不是丢掉;
25209
25970
  * 看得见是诚实(用户本来就能从会话里读到它),关键是它落成不了节点。
25210
25971
  * ② **链接与图片不给它开新面**:链接渲染成纯文本 + `title` 带上原 URL(面板在宿主中央列里,
25211
- * 我们不去碰宿主的导航与 `window.open`,那是别人的地盘);图片渲染成 `[图片] alt`,
25212
- * 因为正文里的图片路径指向会话当时的 cwd,在回收站里既读不到也不该去读。
25972
+ * 我们不去碰宿主的导航与 `window.open`,那是别人的地盘);图片渲染成本地化占位(`copy.ts` 的
25973
+ * `detail.image` / `detail.image.alt`),因为正文里的图片路径指向会话当时的 cwd,在回收站里
25974
+ * 既读不到也不该去读。
25213
25975
  * ③ **GFM 打开**:会话里最常出现的是表格与任务列表(`remark-gfm`)。
25214
25976
  */
25215
25977
  /** 锚文本保留,URL 只进 `title`——不产生任何可跳转的东西。 */
@@ -25219,60 +25981,55 @@ window.__ModuleLoader__.load({
25219
25981
  children
25220
25982
  });
25221
25983
  /** 图片在回收站里读不到(路径属于会话当时的 cwd),只把 alt 说清楚。 */
25222
- const ImageAsText = ({ alt, src }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
25984
+ const ImageAsText = ({ alt, src, t }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25223
25985
  className: "sd-md-image",
25224
25986
  title: src ?? "",
25225
- children: [
25226
- "[图片",
25227
- alt && alt !== "" ? ` · ${alt}` : "",
25228
- "]"
25229
- ]
25987
+ children: alt && alt !== "" ? t("detail.image.alt", { alt }) : t("detail.image")
25230
25988
  });
25231
- const COMPONENTS = {
25232
- a: AnchorAsText,
25233
- img: ImageAsText
25234
- };
25989
+ function componentsFor(t) {
25990
+ return {
25991
+ a: AnchorAsText,
25992
+ img: ((props) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageAsText, {
25993
+ ...props,
25994
+ t
25995
+ }))
25996
+ };
25997
+ }
25235
25998
  /**
25236
25999
  * `memo` 是必需的而不是优化:`react-markdown@10.1.0` 里**一处 memoization 都没有**(读实
25237
26000
  * `node_modules/react-markdown/lib/index.js:175-178`——`Markdown()` 每次调用都 `createProcessor(options)`
25238
26001
  * 再 `runSync(parse(file))`,`MarkdownHooks` 那一支也一样),而面板任何一次重渲(悬停、↑↓、
25239
26002
  * 「刚刚删除」那句要读 `Date.now()`)都会走到这里 ⇒ 不按住就是每条助手回复每拍重解析一遍。
25240
- * 按住的条件就只有 `text`:它是这里唯一的输入,样式挂在 `.sd-md` 上、不随渲染变。
25241
- */
25242
- const MarkdownBody = (0, react.memo)(function MarkdownBody({ text }) {
26003
+ * 按住的条件是 `text` 与**探针 `localeKey`**(调用方在渲染时算好的一句固定键译文 `t('common.close')`)。
26004
+ * `t` 本身是宿主 `bind` 出来的**稳定身份**(`copy.ts` 头注),一次客户端激活只造一个——拿身份当判据,
26005
+ * 换语言就漏了:同一个 `t` 换 locale 后吐另一套词,烘进正文树的那句图片占位会滞留旧语言,而周围的
26006
+ * 标签那一拍已经改口。探针也不能在 comparator 里现调 `t` 取——宿主 `t` 读的是**当前** locale,
26007
+ * prev/next 那一拍都取到新值、恒等;只有渲染时算好存进 props 的那份才是换语言那一刻的读数。
26008
+ * 探针没变 = 同语言同 `t` ⇒ 跳过重解析;变了(换语言或换 `t`)⇒ 重解析。
26009
+ */
26010
+ const MarkdownBody = (0, react.memo)(function MarkdownBody({ text, t }) {
25243
26011
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25244
26012
  className: "sd-md",
25245
26013
  "data-dsh-session-delete": "md",
25246
26014
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Markdown, {
25247
26015
  remarkPlugins: [remarkGfm],
25248
- components: COMPONENTS,
26016
+ components: componentsFor(t),
25249
26017
  children: text
25250
26018
  })
25251
26019
  });
25252
- });
26020
+ }, (prev, next) => prev.text === next.text && prev.localeKey === next.localeKey);
25253
26021
  //#endregion
25254
26022
  //#region src/client/trash-detail.tsx
25255
26023
  /**
25256
- * 回收站的**详情块**(docs/ui-spec.md 的四态)。
26024
+ * 回收站详情块:只画状态,不取数。选中、缓存、失败与重读都在面板控制器里。
25257
26025
  *
25258
- * 它**不自己取数**:选中、缓存、失败与重读全在面板控制器(`use-trash-panel-controller.ts`)手里,
25259
- * 这里只把一个状态画出来——
25260
- * 所以它能被单独测穿(喂四种状态,断四张形状)。
26026
+ * 三条界面约束:
26027
+ * - 动作(恢复/彻底删除)留在清单行右侧;详情只负责看。
26028
+ * - 标题优先用清册标题,空串才回落到日志的 session/title。
26029
+ * - 注入条数与截断条数必须上屏,避免正文变少被误读成“会话只有这些内容”。
25261
26030
  *
25262
- * 三个不能让步的点:
25263
- * ① **动作不住在这里**(2026-10-15 真机反馈后定,与 2026-10-01 那版同形):「恢复」「彻底删除…」
25264
- * 在清单行右侧。搬进详情那一阵子,左栏为了只放标题被压到 316px,读起来就是"左侧太窄"——
25265
- * 动作回行内之后左栏需要的宽度也回来了(420px,理由写在面板常量处)。
25266
- * ② **标题优先用清册那一份**:保证「行里叫 A、点开也叫 A」,清册标题是空串才回落到日志里的
25267
- * `session/title`。
25268
- * ③ **注入条数与截断条数必须上屏**:正文比日志里的 `user/message` 少一大截是**过滤的结果**,
25269
- * 页面上要说清少掉的是什么、多少条,不能让它读起来像"这条会话就这么点内容"。
25270
- *
25271
- * 皮肤**只由 token 决定**,且 token 与消息行的声明都来自宿主自己那两张表(`MessageItem.module.css` /
25272
- * `AssistantMarkdown.module.css` / `MarkdownText.module.css`,逐字抄,来源与差异记在 `ui/markdown.css`
25273
- * 文件头)——字号一律走 `--dsh-content-font-*` 那根轴,不再写宿主同类面的字面量 px。
25274
- * **助手正文走 Markdown,用户提问走原话**(2026-10-15 真机反馈后改;见 `markdown.tsx` 那三行收口):
25275
- * 助手的 `text` 段本身就是 markdown 源,用 `pre-wrap` 摆出来就是用户说的"对话内容没渲染、是一整块纯文本"。
26031
+ * 助手正文走 Markdown,用户提问保持原话;样式 token 与差异见 ui/markdown.css 文件头。
26032
+ * 附件随消息位置渲染:用户气泡之上、助手正文之后;附件是那条消息的内容,不单开区块。
25276
26033
  */
25277
26034
  const SHELL = {
25278
26035
  display: "flex",
@@ -25316,12 +26073,11 @@ window.__ModuleLoader__.load({
25316
26073
  minWidth: 0
25317
26074
  };
25318
26075
  /**
25319
- * 消息行照宿主拄(2026-10-15 真机反馈后重写;来源 `app.asar` 里
26076
+ * 消息行照宿主抄(来源 `app.asar` 里
25320
26077
  * `@deepseek-ai/dsh-client-ui-chat/lib/client.js` 内联的 `MessageItem.module.css` 与
25321
26078
  * `AssistantMarkdown.module.css`,逐字声明)。两条关键事实:
25322
- * **只有用户有气泡**,助手那侧宿主不铺底、不给边框,正文直接铺满栏——上一版给助手画了带边框的卡片,
25323
- * 就是"右侧 UI 风格问题很多"里最难看的那一处。字号也不再用字面量:走 `--dsh-content-font-size` 那根轴,
25324
- * 宿主调内容字号时这里一起动。
26079
+ * **只有用户有气泡**,助手那侧宿主不铺底、不给边框,正文直接铺满栏;给助手画带边框的卡片
26080
+ * 会与宿主消息行不同形。字号走 `--dsh-content-font-size` 那根轴,宿主调内容字号时这里一起动。
25325
26081
  */
25326
26082
  const USER_ROW = {
25327
26083
  display: "flex",
@@ -25357,7 +26113,7 @@ window.__ModuleLoader__.load({
25357
26113
  lineHeight: "calc(18px + var(--dsh-content-font-delta-secondary, 0px))",
25358
26114
  color: "var(--dsw-alias-label-tertiary)"
25359
26115
  };
25360
- /** 读失败与写失败共用的那块(`role="status"`:读屏走到这里会当状态消息念)。 */
26116
+ /** 读失败与写失败共用的那块(`role="status"`;播报口径与其未验证的边界见 docs/ui-spec.md)。 */
25361
26117
  const STATUS_BLOCK = {
25362
26118
  display: "flex",
25363
26119
  alignItems: "flex-start",
@@ -25527,12 +26283,15 @@ window.__ModuleLoader__.load({
25527
26283
  /**
25528
26284
  * 有内容:元信息(时间 → 体量 → 位置三组的顺序)+ 正文。
25529
26285
  *
25530
- * 计数与正文**同源**:`提问`/`回复` 就是进了正文的那两类条数,不是日志里的 `user/message` 总数——
25531
- * 后者被 `injectedCount` 单独说清,页面上不出现两本对不上的账。
26286
+ * 页面上**两本账分开读**:`提问`/`回复` 是过滤后的条数(注入那些由 `injectedCount` 单独说清,不并进
26287
+ * 这两个数),**不受载荷闸影响**;正文是闸过的那一份(单条超限截断、累计到顶后整条不显示)。
26288
+ * 于是「计数 421、正文 200」可以同时成立——怎么过滤、两个闸各是多少归 `src/trash/detail.ts`,这里只摆数。
25532
26289
  */
25533
26290
  function ReadyBody({ entry, detail, t }) {
25534
26291
  const folder = entry.id;
25535
26292
  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);
25536
26295
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
25537
26296
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", {
25538
26297
  style: META,
@@ -25551,7 +26310,10 @@ window.__ModuleLoader__.load({
25551
26310
  }),
25552
26311
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
25553
26312
  label: t("detail.field.deletedAt"),
25554
- children: `${agoText(entry.deletedAt, Date.now(), t)}(${absoluteText(entry.deletedAt)})`
26313
+ children: t("detail.deletedAt.combined", {
26314
+ relative: agoText(entry.deletedAt, Date.now(), t),
26315
+ absolute: absoluteText(entry.deletedAt)
26316
+ })
25555
26317
  }),
25556
26318
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Meta, {
25557
26319
  label: t("detail.field.size"),
@@ -25602,25 +26364,41 @@ window.__ModuleLoader__.load({
25602
26364
  style: META_LINE,
25603
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 })}` : ""]
25604
26366
  }),
25605
- detail.messages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26367
+ messages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25606
26368
  style: META_LINE,
25607
26369
  children: t("detail.noBody")
25608
26370
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25609
26371
  style: THREAD,
25610
- children: detail.messages.map((message, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26372
+ children: messages.map((message, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25611
26373
  "data-sd-msg": message.role,
25612
26374
  style: message.role === "user" ? USER_ROW : ASSISTANT_ROW,
25613
26375
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25614
26376
  style: META_LINE,
25615
26377
  children: `${message.role === "user" ? t("detail.role.user") : t("detail.role.assistant")} · ${timeText(message.time)}`
25616
- }), message.role === "user" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
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", {
25617
26383
  "data-sd-bubble": true,
25618
26384
  style: BUBBLE,
25619
26385
  children: [message.text, message.truncated ? "…" : ""]
25620
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkdownBody, { text: message.text }), message.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25621
- style: META_LINE,
25622
- children: "…"
25623
- }) : null] })]
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
+ ] })]
25624
26402
  }, `${message.time}-${index}`))
25625
26403
  })
25626
26404
  ] });
@@ -25628,28 +26406,17 @@ window.__ModuleLoader__.load({
25628
26406
  //#endregion
25629
26407
  //#region src/client/use-column-resize.ts
25630
26408
  /**
25631
- * 清单列与详情列之间那根**可拖的宽度把手**(2026-10-15 加,`docs/ui-spec.md` 的「面板与清单」)。
25632
- *
25633
- * 三件事住在这里,因为它们互相咬合、分开测各自都不成立:
25634
- * ① **宽度夹在两个界之间**。下限由调用方给(回收站那条的来历写在 `trash-panel.tsx`:真 Chromium
25635
- * 上扫出来的,约束不是"两颗按钮放得下"——它们 `flex:none`,再窄也是文字列缩;而是「删除于 … ·
25636
- * 大小」那一行不再被省略号截断),上限 = 面板宽 − `reserve`。**`reserve` 的数值与来历都住在调用方**
25637
- * (`DETAIL_RESERVE`,那三件东西怎么加出来的见 `trash-panel.tsx` 该常量的注释与几何用例):
25638
- * 这一层不复制那份算式——上一版在这里钉过一个"480 ⇒ 900 那档正好回 420",调用方改到 489 之后
25639
- * 它就成了假数,而 900 那档的真实读数是**清单被夹到 411**(比默认窄 9px,还能往里拖)。
25640
- * ② **夹在"读"的时候也做一次**。窗口变窄不触发任何写入,若只在写入时夹,之前拖出来的超宽
25641
- * 清单会一直留到下次交互 ⇒ `listWidth` 每次渲染都按当前 `panelWidth` 重算。
25642
- * ③ **按下才算在拖**。指针捕获挂在把手元素上(`setPointerCapture`),move 只可能来自这一根把手;
25643
- * `origin === null` 时 move 直接返回——鼠标扫过不该改布局。抬起不用显式 release:规范里
25644
- * pointerup/pointercancel 会隐式释放捕获,多写那一行只会引入"指针已不活跃"的抛错面。
25645
- * **但"结束"不能只挂在把手上**:拖动途中把手会被卸载(面板窄过断点、或清单被读空 ⇒ `split`
25646
- * 翻了),而卸载后的节点收不到任何事件 ⇒ `resizing` 永远挂着:面板根留着 `.sd-resizing`
25647
- * (关文字选中),下次变宽时把手还带着 `data-dragging` 长回来。所以拖住期间另在 window 上兜
25648
- * 一层 `pointerup`/`pointercancel`(复用同一个 `stop`)。move 仍然只认把手,捕获那套一点没动。
25649
- *
25650
- * 键盘那条不是装饰:`role="separator"` 可聚焦、←→ 各一格、Home 回默认、End 到上限。
25651
- * 这一代的 `--dsw-focus-ring-color` 是 transparent(宿主自己那份焦点环靠别的属性撑),
25652
- * 所以焦点态的可见性由 `ui.css` 里 `.sd-width-handle:focus-visible::after` 那一条负责。
26409
+ * 清单列与详情列之间那根可拖的宽度把手(docs/ui-spec.md 的「面板与清单」)。
26410
+ *
26411
+ * 三件事住在这里:
26412
+ * ① 宽度夹在两个界之间:下限由调用方给,上限 = 面板宽 − reserve。
26413
+ * reserve 的数值与来历住在调用方(DETAIL_RESERVE),这一层不复制算式。
26414
+ * ② 夹在“读”的时候也做一次:窗口变窄不触发写入,listWidth 每次渲染按当前 panelWidth 重算。
26415
+ * ③ 按下才算在拖:指针捕获挂在把手元素上;拖动途中把手可能卸载,所以 window 上另兜一层
26416
+ * pointerup/pointercancel 复用 stop;move 仍只认把手。
26417
+ *
26418
+ * 键盘:role=separator、←→ 各一格、Home 回默认、End 到上限;焦点态可见性由 ui.css 的
26419
+ * .sd-width-handle:focus-visible::after 负责。
25653
26420
  */
25654
26421
  function useColumnResize({ panelWidth, defaultValue, min, reserve, step, label }) {
25655
26422
  const [dragged, setDragged] = (0, react.useState)(null);
@@ -25722,7 +26489,7 @@ window.__ModuleLoader__.load({
25722
26489
  *
25723
26490
  * 量的是**盒子的 border-box 宽**(`getBoundingClientRect().width`),不是 `contentRect.width`:
25724
26491
  * 后者已经扣掉内边距,而"多宽才分两栏"那个阈值说的是整块盒子——拿扣过内边垫的数去比就是**扣两次**,
25725
- * 结果明明够宽却退化成下钻(2026-10-15 真机反馈后定的判据)。
26492
+ * 结果明明够宽却退化成下钻(真机反馈后定的判据)。
25726
26493
  *
25727
26494
  * 首帧读一次当前宽度,之后跟着 RO 走;读到 0 就是窄档 ⇒ **窄档是默认、宽档要被证明**。
25728
26495
  * RO 缺席(不可能的宿主)时停在窄档:功能一条不少,只是同屏两栏变成下钻。
@@ -25747,27 +26514,17 @@ window.__ModuleLoader__.load({
25747
26514
  //#endregion
25748
26515
  //#region src/client/trash-nav.ts
25749
26516
  /**
25750
- * 清单的**键盘导航面**:方向键走环、Enter / Space 的双重语义、roving tabindex 的落点,
26517
+ * 清单的键盘导航面:方向键走环、Enter/Space 的双重语义、roving tabindex 的落点,
25751
26518
  * 以及窄档下钻里按 Esc 退回清单并把焦点交还给那一行。
25752
26519
  *
25753
- * 为什么收回一处:这几条判据此前劈在 `trash-panel.tsx`(键盘分支与 `activeDescendantId` /
25754
- * `tabStopId` 两枚只读)与 `use-trash-panel-controller.ts`(Esc 的两个 effect 与那笔焦点账)之间。
25755
- * 它们说的其实是同一件事——"屏幕上从上到下的那一串行里,谁是光标、谁拿 tab 落点、Esc 之后焦点
25756
- * 还给谁"——而那份顺序只在面板手里(`ordered` 只含展开的组)。收进一个 module 之后面板只交输入
25757
- * 与四个动作,控制器不再知道焦点这回事。
25758
- *
25759
- * 它**不查文档、不认选择器**:焦点动作由调用方注入 `focusRow`,DOM 查询仍留在面板——行是在那儿
25760
- * 渲染出来的,选择器只在那儿有权威。它只读事件的 `target` 来判断这一下该不该归清单:从行内按钮 /
25761
- * 勾选框起来的 Enter / Space 是人家的激活键,不是清单的下钻或勾选。
25762
- *
25763
- * 三条原样搬来的判据(界面现状住 `docs/ui-spec.md` 的「键盘」那条;这里只留「为什么这么定」):
25764
- * - **方向键走环**:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷,不是撞墙。
25765
- * - **`aria-activedescendant` 只指向在 DOM 里的行**:选中那行被折叠起来时它就不存在,指过去是
25766
- * 假引用。tab 落点(清单里唯一的 tab 巢)走同一条可见性规则,但**永远有一行拿着**——否则一次
25767
- * 没点过时全行都是 `-1`,键盘根本进不来清单。焦点所在的行就是激活目标:选中行被折叠时,
25768
- * Enter / Space 落在拿 tab 落点的那一条上,而不是一声不响。
25769
- * - **Esc 只在下钻期间生效**:监听挂 `document`,因为窄档下钻时清单**不在 DOM 里**;退回后把焦点
25770
- * 交还选中那一行**一次**,交完就清账,后续重渲染不再重复抢焦点。
26520
+ * 它不查自己的行、不认行选择器:焦点动作由调用方注入 focusRow,行的 DOM 查询留在面板;
26521
+ * 唯一的文档查询是 Esc 让路判据(问 ui/top-layer.ts 当前有没有打开的 dialog/menu)。
26522
+ * 只读事件的 target 判断这一下该不该归清单:行内按钮/勾选框的 Enter/Space 是人家的激活键。
26523
+ *
26524
+ * 判据(界面现状见 docs/ui-spec.md「键盘」):
26525
+ * - 方向键走环:未选中时 ↓ 落第一条、↑ 落最后一条;到端回卷。
26526
+ * - aria-activedescendant 只指向在 DOM 里的行;tab 落点永远有一行拿着。
26527
+ * - Esc 只在下钻期间生效;退回后把焦点交还选中行一次;最上层是确认框/菜单时让路。
25771
26528
  */
25772
26529
  /** 事件是不是清单自己的:从行(`role="option"`)或容器起来才算;行内按钮 / 勾选框的 Enter / Space
25773
26530
  * 归它们自己。测试里的窄事件没有 `target`,按清单自己的处理。 */
@@ -25818,7 +26575,8 @@ window.__ModuleLoader__.load({
25818
26575
  (0, react.useEffect)(() => {
25819
26576
  if (!drilled || !drillEnabled) return;
25820
26577
  const onKey = (event) => {
25821
- if (event.key !== "Escape") return;
26578
+ if (event.key !== "Escape" || event.defaultPrevented) return;
26579
+ if (hasTopLayer(document)) return;
25822
26580
  refocusRow.current = selectedId;
25823
26581
  exitDrill();
25824
26582
  };
@@ -25838,46 +26596,133 @@ window.__ModuleLoader__.load({
25838
26596
  refocusRow.current = null;
25839
26597
  }, [drilled, focusRow]);
25840
26598
  return {
25841
- activeDescendantId,
25842
- tabStopId,
25843
- onListKeyDown
26599
+ activeDescendantId,
26600
+ tabStopId,
26601
+ onListKeyDown
26602
+ };
26603
+ }
26604
+ //#endregion
26605
+ //#region src/client/use-trash-query.ts
26606
+ /**
26607
+ * 清册读数的取数编排:一次 `/list`、一张最新票、一句附件回执。
26608
+ *
26609
+ * 三条承重规矩:
26610
+ * - 最新票:在飞的旧清单读不许盖掉新状态。
26611
+ * - 读不到就说读不到:失败进 `unknown`(原文上屏),不折成"空的"。
26612
+ * - 附件读数只住在写动作的回执里:清单重读读不出来,所以它单独一条提示、**最新的写赢**。
26613
+ *
26614
+ * 无主子会话的检查/清理**不在这一面**:那是插件设置页维护卡的手动动作(`use-maintenance.ts`)。
26615
+ * 面板挂载、写后重读与清空都只碰清单,一次都不自动扫(ADR-0021)。
26616
+ */
26617
+ function listView(list) {
26618
+ switch (list.status) {
26619
+ case "loading": return {
26620
+ rows: null,
26621
+ failure: null,
26622
+ loading: true,
26623
+ empty: false
26624
+ };
26625
+ case "unknown": return {
26626
+ rows: null,
26627
+ failure: list.failure,
26628
+ loading: false,
26629
+ empty: false
26630
+ };
26631
+ case "ready": return {
26632
+ rows: list.rows,
26633
+ failure: null,
26634
+ loading: false,
26635
+ empty: list.rows.length === 0
26636
+ };
26637
+ }
26638
+ }
26639
+ function useTrashQuery({ deps, t, gate }) {
26640
+ const [list, setList] = (0, react.useState)({ status: "loading" });
26641
+ const [attachmentNotice, setAttachmentNotice] = (0, react.useState)(null);
26642
+ /** 清单的唯一读法:签到一张票再发请求;输给后发的读时这一次什么都不落地(不让旧响应盖掉新状态)。 */
26643
+ const [reloadLatest] = (0, react.useState)(createLatestWins);
26644
+ const reload = (0, react.useCallback)(async () => {
26645
+ const ticket = reloadLatest.issue();
26646
+ const outcome = await callRemote(() => deps.list());
26647
+ if (!ticket.current) return;
26648
+ if (!outcome.ok) {
26649
+ setList({
26650
+ status: "unknown",
26651
+ failure: outcome.failure.message
26652
+ });
26653
+ return;
26654
+ }
26655
+ setList({
26656
+ status: "ready",
26657
+ rows: outcome.value
26658
+ });
26659
+ }, [deps, reloadLatest]);
26660
+ /**
26661
+ * 附件读数上屏:`skipped`(这一轮部分或整体没敢清)优先于 `failed`(逐文件真出错)——前者是原因,
26662
+ * 后者只是件数。**`removed > 0` 时不许说成"整轮没清"**:那一轮清掉了 `removed` 件,只是没清完,
26663
+ * 句子要把已清的那几件一起说出来。两者同时出现时 `failed` 追加在句尾:跳过原因不许把真失败吞掉。
26664
+ * 没有读数 = 这一趟没得说,旧提示收掉。
26665
+ */
26666
+ const noteAttachmentCleanup = (0, react.useCallback)((a) => {
26667
+ if (a === void 0) {
26668
+ setAttachmentNotice(null);
26669
+ return;
26670
+ }
26671
+ const skipped = attachmentSkippedSentence(a, t);
26672
+ if (skipped !== null) {
26673
+ setAttachmentNotice(skipped);
26674
+ return;
26675
+ }
26676
+ setAttachmentNotice(a.failed > 0 ? t("trash.attachments.failed", { count: a.failed }) : null);
26677
+ }, [t]);
26678
+ return {
26679
+ list,
26680
+ attachmentNotice,
26681
+ noteAttachmentCleanup,
26682
+ refreshAll: reload,
26683
+ refreshAfterWrite: reload,
26684
+ emptyTrash: (0, react.useCallback)(() => gate.around(async () => {
26685
+ const outcome = await callRemote(() => deps.emptyTrash());
26686
+ if (!outcome.ok) {
26687
+ reloadLatest.invalidate();
26688
+ setList({
26689
+ status: "unknown",
26690
+ failure: outcome.failure.message
26691
+ });
26692
+ noteAttachmentCleanup(void 0);
26693
+ return false;
26694
+ }
26695
+ clearUndoNotice();
26696
+ await reload();
26697
+ noteAttachmentCleanup(outcome.value.attachments);
26698
+ return true;
26699
+ }), [
26700
+ gate,
26701
+ deps,
26702
+ reload,
26703
+ reloadLatest,
26704
+ noteAttachmentCleanup
26705
+ ])
25844
26706
  };
25845
26707
  }
25846
26708
  //#endregion
25847
26709
  //#region src/client/use-trash-panel-controller.ts
25848
26710
  /**
25849
- * 回收站面板的**状态机**:谁被选中、详情缓存、窄档下钻、清单换版后的清场、
25850
- * 宽档自动选中、"别处动过回收站"的失效重读,以及确认后的写动作(恢复/批量/清空)与失败落点。
25851
- *
25852
- * 面板接入后,这套语义只住这里;面板自己只留布局与行内动作。搬过来的判据一条不改:
25853
- *
25854
- * - **选中从全量条目里找**(`query.entries`,不是键盘序列 `ordered`):把某一组折叠起来只是收掉
25855
- * 清单那一列,右栏读的那条不该跟着消失。
25856
- * - **一条只读一次**:面板存活期内按条目 id 缓存成功读数;**失败不缓存**——「重试」与切走再切回
25857
- * 都会重新读那一条(缓存里存一次失败,界面就再也等不到它自己好了)。
25858
- * - **清单换版就让位**:选中的 id 不在新清单里 ⇒ 丢它的缓存、清选中、退出下钻。清单读不到
25859
- * (`entries === null`)**不算"没了"**:那一拍只是不知道,选中 id 留着,详情回 idle。
25860
- * - **自动选中只认 `selectedId === null`**:同一拍里选中的那条消失时,自动选中看到的是旧选中
25861
- * (非 null)⇒ 不抢;清场落地后的下一拍才接住新清单的第一条。判据是这道 null 门,不是两个
25862
- * effect 的声明顺序(顺序反过来也拦得住)。
25863
- * - **失效通告只戳重读**:`useTrashRevision` 变了就 `query.refreshAfterWrite()`;挂载那拍才走
25864
- * `refreshAll()`。它不碰选中、不碰详情缓存(清单换了自会有上面的清场来收尾)。
25865
- * - **动作成功就丢缓存**:恢复/批量彻底删除成功的条目、清空成功的全部条目,详情缓存必须在同一趟里
25866
- * 失效——否则这条会话再被删回来时会摆着上一世的正文。失败只按 `actionFailure[id]` 留原文,不碰缓存。
25867
- * - **`back()` 只收下钻、不动焦点**:Esc 那条(下钻期间挂 document 监听、退回后把焦点交还那一行
25868
- * 一次)住 `trash-nav.ts`;控制器不查 DOM、也不认识焦点。
25869
- *
25870
- * **失败原文不翻译**:`detailFailure.message` 与 `actionFailure[id]` 都是 Node 半给的原文
25871
- * (`callRemote` 的 `failure.message`),原样交给详情块与清单行上屏。
25872
- *
25873
- * `busy` 是面板那枚全局闸(写动作在飞)的读数,闸本体住 `busy-gate.ts`;`restore` / `confirmApply`
25874
- * 把闸交给写入座位(`write-seats.ts`),由 `runWrite` 把「动作 + 收尾 + 清单重读」整趟包住——控制器
25875
- * 不再自己 `around`;闸开着时谁该禁用由面板决定。`kind:'all'` 的清空不是 `/apply` 座位:
25876
- * `query.emptyTrash` 自己包同一枚闸。
26711
+ * 回收站面板状态机:选中、详情缓存、窄档下钻、清单换版清场、宽档自动选中、
26712
+ * 失效重读,以及确认后的恢复/批量/清空与失败落点。
26713
+ *
26714
+ * 关键判据:
26715
+ * - 选中从全量条目里找,不从键盘序列 ordered 找;折叠组只影响清单列。
26716
+ * - 详情成功读数按条目 id 缓存;失败不缓存,重试会重新读。
26717
+ * - 选中 id 不在新清单里 ⇒ 丢缓存、清选中、退出下钻;清单读不到(null)不算“没了”。
26718
+ * - 自动选中只在 selectedId 为 null 时接管。
26719
+ * - 失效通告只触发 refreshAfterWrite();动作成功必须在同一趟里丢详情缓存。
26720
+ * - back() 只收下钻,不碰焦点;失败原文不翻译。
25877
26721
  */
25878
26722
  function useTrashPanelController(input) {
25879
26723
  const { deps, gate, query, ordered, canAutoSelect } = input;
25880
- const { entries, refreshAll, refreshAfterWrite, emptyTrash } = query;
26724
+ const { list, refreshAll, refreshAfterWrite, emptyTrash, noteAttachmentCleanup } = query;
26725
+ const entries = listView(list).rows;
25881
26726
  const [selectedId, setSelectedId] = (0, react.useState)(null);
25882
26727
  const [drilled, setDrilled] = (0, react.useState)(false);
25883
26728
  const [loadingId, setLoadingId] = (0, react.useState)(null);
@@ -25886,7 +26731,7 @@ window.__ModuleLoader__.load({
25886
26731
  const [actionFailure, setActionFailure] = (0, react.useState)({});
25887
26732
  /** 面板存活期内每条只读一次(本机量级不值得预读,也不值得重复读)。 */
25888
26733
  const detailCache = (0, react.useRef)(/* @__PURE__ */ new Map());
25889
- /** 挂载那一拍做完整刷新(清单 + 残留),之后失效通告只走写后刷新。 */
26734
+ /** 挂载那一拍读一次清单,之后失效通告只走写后刷新(两条名字不同,读法是同一条)。 */
25890
26735
  const firstRefresh = (0, react.useRef)(true);
25891
26736
  const revision = useTrashRevision();
25892
26737
  (0, react.useEffect)(() => {
@@ -25901,6 +26746,13 @@ window.__ModuleLoader__.load({
25901
26746
  refreshAfterWrite,
25902
26747
  revision
25903
26748
  ]);
26749
+ (0, react.useEffect)(() => {
26750
+ if (pending !== null && !gate.busy && list.status === "unknown") setPending(null);
26751
+ }, [
26752
+ gate.busy,
26753
+ list.status,
26754
+ pending
26755
+ ]);
25904
26756
  /**
25905
26757
  * 选一条:命中缓存就直接摆(不经过 loading),没命中才去读那条会话自己的日志。
25906
26758
  * `drill` 是窄档的第二步语义(点行 = 选中并下钻;方向键 = 只移动选中)。
@@ -25946,6 +26798,13 @@ window.__ModuleLoader__.load({
25946
26798
  * 右栏那条不该跟着消失——详情读的是这条会话,跟清单怎么分段无关。
25947
26799
  */
25948
26800
  const selected = (0, react.useMemo)(() => (entries ?? []).find((entry) => entry.id === selectedId) ?? null, [entries, selectedId]);
26801
+ /** 恢复确认框要摆的这一批:按勾选顺序,清单里已经找不到的 id 滤掉
26802
+ * (别的窗口刚动过;重读会把勾选也剔掉)。单条与整站确认框不摆条目,各自念自己的那一句。 */
26803
+ const pendingEntries = (0, react.useMemo)(() => {
26804
+ if (pending?.kind !== "restore") return [];
26805
+ const byId = new Map((entries ?? []).map((entry) => [entry.id, entry]));
26806
+ return pending.ids.map((id) => byId.get(id)).filter((entry) => entry !== void 0);
26807
+ }, [entries, pending]);
25949
26808
  const detail = (0, react.useMemo)(() => {
25950
26809
  if (selected === null) return { kind: "idle" };
25951
26810
  if (loadingId === selected.id) return {
@@ -25980,6 +26839,12 @@ window.__ModuleLoader__.load({
25980
26839
  const back = (0, react.useCallback)(() => {
25981
26840
  setDrilled(false);
25982
26841
  }, []);
26842
+ const request = (0, react.useCallback)((action) => {
26843
+ setPending(action);
26844
+ }, []);
26845
+ const cancelPending = (0, react.useCallback)(() => {
26846
+ setPending(null);
26847
+ }, []);
25983
26848
  /** 一次动作的失败原文落回它自己那一行(按条目 id 存,别条目的成败不会串台)。 */
25984
26849
  const failRow = (0, react.useCallback)((entryId, message) => {
25985
26850
  setActionFailure((current) => ({
@@ -25997,6 +26862,8 @@ window.__ModuleLoader__.load({
25997
26862
  * 失败也重读:组回滚可能自己失败(`details.rollbackFailures`),清单不能继续摆旧行。
25998
26863
  * 「重读也在这趟里」由 `runWrite` 的闸保证:旧清单还在屏上时同一个动作不能被再点一次。
25999
26864
  * 单条恢复走 `single` 读法,`already-gone` 不可能出现,座位里那条查找兜底在此不会命中。
26865
+ * 座位的 refresh 仍按 `WriteOutcome` 给(那只信封里没有附件读数要转交):这里只把它换成"重读"这一个动作,
26866
+ * outcome 不再流向 query——孤儿重扫判据已不在面板(ADR-0021)。
26000
26867
  */
26001
26868
  const restore = (0, react.useCallback)(async (entry) => {
26002
26869
  setActionFailure((current) => {
@@ -26010,7 +26877,7 @@ window.__ModuleLoader__.load({
26010
26877
  single: true,
26011
26878
  entries,
26012
26879
  gate,
26013
- refreshAfterWrite,
26880
+ refreshAfterWrite: () => refreshAfterWrite(),
26014
26881
  clearDetailCache
26015
26882
  })).failures.get(entry.id);
26016
26883
  if (failure !== void 0) failRow(entry.id, failure);
@@ -26030,7 +26897,8 @@ window.__ModuleLoader__.load({
26030
26897
  const confirmApply = (0, react.useCallback)(async (action, ids) => {
26031
26898
  const settle = (outcome) => {
26032
26899
  setPending(null);
26033
- return refreshAfterWrite(outcome);
26900
+ if (action === "purge") noteAttachmentCleanup(outcome.ok ? outcome.summary.attachmentCleanup : void 0);
26901
+ return refreshAfterWrite();
26034
26902
  };
26035
26903
  const seat = {
26036
26904
  deps,
@@ -26054,7 +26922,8 @@ window.__ModuleLoader__.load({
26054
26922
  deps,
26055
26923
  entries,
26056
26924
  gate,
26057
- refreshAfterWrite
26925
+ refreshAfterWrite,
26926
+ noteAttachmentCleanup
26058
26927
  ]);
26059
26928
  /**
26060
26929
  * 确认框上的「确认」按 `pending` 的形状分派:三条按条目标都委托 `confirmApply`(一段 `/apply`);
@@ -26082,254 +26951,19 @@ window.__ModuleLoader__.load({
26082
26951
  drilled,
26083
26952
  detail,
26084
26953
  pending,
26954
+ pendingEntries,
26085
26955
  actionFailure,
26086
26956
  select,
26087
26957
  retry,
26088
26958
  back,
26089
- setPending,
26959
+ request,
26960
+ cancelPending,
26090
26961
  restore,
26091
26962
  confirmPurge,
26092
26963
  confirmApply
26093
26964
  };
26094
26965
  }
26095
26966
  //#endregion
26096
- //#region src/client/use-trash-query.ts
26097
- /**
26098
- * 清册读数与无主子会话的**取数编排**。
26099
- *
26100
- * 它从 `trash-panel.tsx` 的函数体里整块搬出来,连同三条承重规矩:
26101
- * ① **最新票**:在飞的 `list` / `orphans` 不保证按发起顺序落地(对账耗时随盘上条目数摆动),
26102
- * 后到的旧响应不许盖掉刚发生的动作带来的新状态;
26103
- * ② **先清单后扫描**:`GET /orphans` 判"父没了"吃的是清册,而清册要由 `/list` 那次对账才与
26104
- * `session-trash/` 的目录持平——反过来接,刚被手工搬进回收站的父会被报成失踪父;
26105
- * ③ **读不到就说读不到**:`/orphans` 回 `sessiondelete/io` 是一次**失败的检查**,不许折成"零条残留"。
26106
- *
26107
- * 清理(`/apply` 的 `orphan-clean`)也住这里:它与扫描共用同一份报告与同一组提示,"读"与"写"分家
26108
- * 会让 `busy` 与重读顺序那条判据劈成两半。`busy` 本身不归它管——那是面板的一枚全局闸,通过
26109
- * `gate` 传进来(闸里跑的就是"这一趟还算忙")。
26110
- */
26111
- function useTrashQuery({ deps, t, gate }) {
26112
- const [entries, setEntries] = (0, react.useState)(null);
26113
- const [listFailure, setListFailure] = (0, react.useState)(null);
26114
- const [orphanReport, setOrphanReport] = (0, react.useState)(null);
26115
- const [orphanFailure, setOrphanFailure] = (0, react.useState)(null);
26116
- const [orphanNotice, setOrphanNotice] = (0, react.useState)(null);
26117
- const [orphanConfirm, setOrphanConfirm] = (0, react.useState)(false);
26118
- /** 报告的同步只读镜像:`refreshAfterWrite` 需要区分「未知」与「已有读数」,又不把 identity 绑在 state 上。 */
26119
- const orphanReportRef = (0, react.useRef)(null);
26120
- const setReport = (0, react.useCallback)((report) => {
26121
- orphanReportRef.current = report;
26122
- setOrphanReport(report);
26123
- }, []);
26124
- /** 清单的同步只读镜像:`refreshAfterWrite` 要在发下一次读**之前**按"当前清单"判要不要强制重扫,
26125
- * 又不能让回调跟着渲染期 state 换代。 */
26126
- const entriesRef = (0, react.useRef)(null);
26127
- const [reloadLatest] = (0, react.useState)(createLatestWins);
26128
- const reloadRows = (0, react.useCallback)(async () => {
26129
- const ticket = reloadLatest.issue();
26130
- const outcome = await callRemote(() => deps.list());
26131
- if (!ticket.current) return "stale";
26132
- if (!outcome.ok) {
26133
- setListFailure(outcome.failure.message);
26134
- entriesRef.current = null;
26135
- setEntries(null);
26136
- return "failed";
26137
- }
26138
- setListFailure(null);
26139
- entriesRef.current = outcome.value;
26140
- setEntries(outcome.value);
26141
- return "ok";
26142
- }, [deps, reloadLatest]);
26143
- const [orphanLatest] = (0, react.useState)(createLatestWins);
26144
- /** 强制重扫标记:先记再读清单;这次读输给后发读时,由赢家消费,强制不丢。 */
26145
- const pendingForcedRescan = (0, react.useRef)(false);
26146
- /** 清单这一拍读失败:清掉报告与提示、作废在飞的旧扫描(晚到的读数不能把报告写回来),
26147
- * 并丢掉挂起的强制重扫(报告已回 null,下一次成功刷新本来就会重扫)。 */
26148
- const clearOrphanState = (0, react.useCallback)(() => {
26149
- orphanLatest.invalidate();
26150
- pendingForcedRescan.current = false;
26151
- setReport(null);
26152
- setOrphanFailure(null);
26153
- setOrphanNotice(null);
26154
- setOrphanConfirm(false);
26155
- }, [orphanLatest, setReport]);
26156
- const rescanOrphans = (0, react.useCallback)(async () => {
26157
- const ticket = orphanLatest.issue();
26158
- const outcome = await callRemote(() => deps.scanOrphans());
26159
- if (!ticket.current) return;
26160
- if (!outcome.ok) {
26161
- setReport(null);
26162
- setOrphanNotice(null);
26163
- setOrphanConfirm(false);
26164
- setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
26165
- return;
26166
- }
26167
- setReport(outcome.value);
26168
- setOrphanFailure(null);
26169
- setOrphanNotice(null);
26170
- }, [
26171
- deps,
26172
- orphanLatest,
26173
- setReport,
26174
- t
26175
- ]);
26176
- /** 显式全量刷新:清单读失败时连残留报告一起清掉(这一拍不知道盘上长什么样);被顶掉则让位。
26177
- * 它总要扫残留,所以顺手消费掉之前挂起的强制重扫。 */
26178
- const refreshAll = (0, react.useCallback)(async () => {
26179
- const listed = await reloadRows();
26180
- if (listed === "stale") return;
26181
- if (listed === "failed") {
26182
- clearOrphanState();
26183
- return;
26184
- }
26185
- pendingForcedRescan.current = false;
26186
- await rescanOrphans();
26187
- }, [
26188
- clearOrphanState,
26189
- reloadRows,
26190
- rescanOrphans
26191
- ]);
26192
- /**
26193
- * 写后与失效通告:清单必重读;要不要顺带重扫残留由 `needsOrphanRescan` 从这次的 outcome 派生:
26194
- * purge 与 orphan-clean 总要扫(撤掉的父、被带走的孩子都可能留下新孤儿,报告为空也只是这一拍为空);
26195
- * delete 与组根恢复可以省——孩子跟着父走,报告为空时扫了也只会得到空报告。**报告 `null` 不是"空的"**:
26196
- * 它表示从没扫到(挂载刷新被顶掉 / 扫描失败),这时重扫是唯一能把未知变成已知的路;失效通告没有
26197
- * outcome,仍按报告未知或非空才扫。
26198
- * 强制重扫先记标记再读清单:若这次读输给后发的 refresh,赢家会看到标记并补扫,强制不丢。
26199
- * 清单读失败时清空报告与提示:这一拍不知道盘上长什么样,旧读数不能继续当现状。
26200
- */
26201
- const refreshAfterWriteInternal = (0, react.useCallback)(async (forceRescan) => {
26202
- if (forceRescan) pendingForcedRescan.current = true;
26203
- const listed = await reloadRows();
26204
- if (listed === "stale") return;
26205
- if (listed === "failed") {
26206
- clearOrphanState();
26207
- return;
26208
- }
26209
- const pending = pendingForcedRescan.current;
26210
- pendingForcedRescan.current = false;
26211
- if (pending) {
26212
- await rescanOrphans();
26213
- return;
26214
- }
26215
- const current = orphanReportRef.current;
26216
- if (current === null || current.totalSessions > 0) await rescanOrphans();
26217
- }, [
26218
- clearOrphanState,
26219
- reloadRows,
26220
- rescanOrphans
26221
- ]);
26222
- const refreshAfterWrite = (0, react.useCallback)((outcome) => refreshAfterWriteInternal(outcome !== void 0 && needsOrphanRescan(outcome, entriesRef.current)), [refreshAfterWriteInternal]);
26223
- const emptyTrash = (0, react.useCallback)(() => gate.around(async () => {
26224
- const outcome = await callRemote(() => deps.emptyTrash());
26225
- if (!outcome.ok) {
26226
- setListFailure(outcome.failure.message);
26227
- return false;
26228
- }
26229
- clearUndoNotice();
26230
- await refreshAfterWriteInternal(true);
26231
- return true;
26232
- }), [
26233
- gate,
26234
- deps,
26235
- refreshAfterWriteInternal
26236
- ]);
26237
- return {
26238
- entries,
26239
- listFailure,
26240
- orphanReport,
26241
- orphanFailure,
26242
- orphanNotice,
26243
- orphanConfirm,
26244
- setOrphanConfirm,
26245
- openOrphanPreview: () => {
26246
- setOrphanFailure(null);
26247
- setOrphanNotice(null);
26248
- setOrphanConfirm(true);
26249
- },
26250
- reloadRows,
26251
- rescanOrphans,
26252
- refreshAll,
26253
- refreshAfterWrite,
26254
- cleanOrphans: (0, react.useCallback)(async () => {
26255
- if (orphanReport === null || orphanReport.roots.length === 0) {
26256
- setOrphanConfirm(false);
26257
- return;
26258
- }
26259
- setOrphanFailure(null);
26260
- setOrphanNotice(null);
26261
- const settle = (outcome) => {
26262
- setOrphanConfirm(false);
26263
- return refreshAfterWrite(outcome);
26264
- };
26265
- const outcome = await cleanOrphansFromPanel({
26266
- deps,
26267
- ids: orphanReport.roots,
26268
- gate,
26269
- refreshAfterWrite: settle
26270
- });
26271
- if (!outcome.ok) {
26272
- setOrphanFailure(t("trash.orphans.failure", { message: outcome.failure.message }));
26273
- return;
26274
- }
26275
- const failures = outcome.receipt.verdicts.filter(isFailureVerdict);
26276
- const gone = failures.filter((verdict) => verdict.kind === "neutral");
26277
- const failed = failures.filter((verdict) => verdict.kind !== "neutral");
26278
- const partialRemoved = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "removed"), 0);
26279
- const partialBytes = failed.reduce((sum, verdict) => sum + detailNumber(verdict.kind === "failed" ? verdict.error.details : void 0, "freedBytes"), 0);
26280
- if (gone.length > 0) setOrphanNotice(t("trash.orphans.cleanGone", { count: gone.length }));
26281
- if (failed.length > 0) {
26282
- const message = failureMessage(failed[0]);
26283
- const base = t("trash.orphans.cleanFailed", {
26284
- count: failed.length,
26285
- message
26286
- });
26287
- const partial = partialRemoved > 0 || partialBytes > 0 ? ` ${t("trash.orphans.cleanPartial", {
26288
- removed: partialRemoved,
26289
- size: fileSizeText(partialBytes)
26290
- })}` : "";
26291
- setOrphanFailure(`${base}${partial}`);
26292
- }
26293
- }, [
26294
- deps,
26295
- gate,
26296
- orphanReport,
26297
- refreshAfterWrite,
26298
- t
26299
- ]),
26300
- emptyTrash
26301
- };
26302
- }
26303
- /** 失败 `details` 里的非负数字(`orphan-clean` 的部分回执 `removed` / `freedBytes`);其余一律当 0。 */
26304
- function detailNumber(details, key) {
26305
- const value = details?.[key];
26306
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
26307
- }
26308
- /**
26309
- * 写后要不要强制重扫残留:只看这次写的动作、它请求的 id 与**发读前**的清单。
26310
- * - purge / orphan-clean 总要扫:语料缺席时故意留下的盘上后代、被带走父行留下的孩子,报告为空也可能是新孤儿。
26311
- * - restore 只在清单未知(不知道恢复的是什么)、清单里已经找不到这个 id(这一拍不能断言它不是孩子),
26312
- * 或恢复的是露面的孩子(父行已摘,回到语料后可能无主)时扫;组根恢复把孩子一起带回,不新增孤儿。
26313
- * - delete / count 不扫。新动作必须在这里选一条,别静默落到"不扫"。裁决见 ADR-0016。
26314
- */
26315
- function needsOrphanRescan(outcome, entries) {
26316
- switch (outcome.action) {
26317
- case "purge":
26318
- case "orphan-clean": return true;
26319
- case "restore": {
26320
- if (entries === null) return true;
26321
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
26322
- return outcome.ids.some((id) => {
26323
- const entry = byId.get(id);
26324
- return entry === void 0 || entry.parentEntryId !== void 0;
26325
- });
26326
- }
26327
- case "delete":
26328
- case "count": return false;
26329
- default: return outcome.action;
26330
- }
26331
- }
26332
- //#endregion
26333
26967
  //#region src/client/use-trash-selection.ts
26334
26968
  /**
26335
26969
  * 清单上的**选择面**:多选态、折叠起来的组、勾选集合,
@@ -26420,96 +27054,42 @@ window.__ModuleLoader__.load({
26420
27054
  //#endregion
26421
27055
  //#region src/client/trash-panel.tsx
26422
27056
  /**
26423
- * 回收站清单面板(`docs/ui-spec.md` 的状态 ④⑤ + 两栏形态)——**中央面板的本体**(`main` 槽的占用者)。
26424
- *
26425
- * 面板自带页面骨架(标题 / 条数与合计 / 工具排 / 内容区 / 页脚),因为宿主给中央列的那个盒子
26426
- * (`AppFrame` 的 `.centerCol`)只有背景与圆角,**没有 padding、也不是滚动容器**——asar 里那条
26427
- * `.BynINW_centerCol{background:var(--dsw-alias-bg-base);border-radius:…;corner-shape:round}` 逐字
26428
- * 如此。回收站可以很长(本机 125 个会话合计 85M),不自己收滚动就会把整列顶长。
26429
- *
26430
- * **2026-10-15 形态改版:居中一列 → 清单在左、单条详情在右。**三条决定摆在这里(现行界面规格住
26431
- * `docs/ui-spec.md`,本文件只记这三条与实现约束):
26432
- * ① 动机:一条只有一个标题,判断不了该恢复还是该彻底删除 ⇒ 详情要摆出这条会话自己日志里的元信息
26433
- * 与正文,而详情只能我们自己画(宿主读不到回收站目录里的文件);
26434
- * ② 断点 900px(盒子宽,不是窗口宽):中央列宽由 `usePanelWidth`(ResizeObserver)观测,**只听
26435
- * window.resize 会漏**——宿主侧栏折叠/展开也改中央列宽,而且量的是面板自己那块的 **border-box 宽**。
26436
- * 窄于此走下钻(详情铺满 + 返回),因为 2026-10-01 真机在 520px 窗量过中央列只有 405px,两栏在那
26437
- * 儿两栏都读不了;900 这个数是按两栏各自要读得开算出来的(见 `SPLIT_MIN_WIDTH` 处);
26438
- * ③ **动作留在清单行右侧**(2026-10-15 试过"退出行、住进详情",按真机反馈回到 2026-10-01 的形状):
26439
- * 那一版左栏为了只放标题被压到 316px,用户第一眼就是"左侧太窄"。详情只负责看,失败原文也跟着
26440
- * 动作留在它自己那一行。
26441
- *
26442
- * **卡片配色沿用 2026-10-01 那批**(宿主设置面卡片的底层 token,不是随包发布的 settings 别名):
26443
- * `background:var(--dsw-alias-bg-layer-2)` + `border:.5px solid var(--dsw-alias-border-l4)` +
26444
- * `border-radius:var(--dsw-radius-lg)`(20px 的 `xl` 会把 60px 上下的行吹成胶囊)。
26445
- * 选中态换一枚 token(2026-10-15 从 asar 的 `dsh-client-ui-theme/lib/client.js` 逐字读出):
26446
- * `--dsw-alias-button-ghost-active-fill`(浅 `#ebeef2` / 深 `#43454a`)配
26447
- * `--dsw-alias-button-ghost-active-border`(`#979da6` / `#81858c`)——宿主自己侧栏选中项用的
26448
- * `--dsw-specific-sidebar-nav-item-active` 与前者取值逐字相同,所以这是宿主"选中"那一档,不是我挑的。
26449
- *
26450
- * 时间分桶走 `relativeTime`(逐字抄自官方,见 `ui/format.ts`),项目名从宿主 `projectKey` 反推
26451
- * (`unescapeProjectKey`,两半共用),组头 `title` 给完整原串。**行分隔**交给卡片自己的边框。
26452
- *
26453
- * 三条界面规则没有因为改版让路:
26454
- * - **空态**:`回收站是空的`,且底部**不出现**「清空回收站…」(没有东西可清)。
26455
- * - **失败写在出事的那一块上**:清单读不到 ⇒ 单独一块摆原文 + 重试(不是空态,"读不到"与"空的"是
26456
- * 两件事);某条会话的正文读不到 ⇒ 只在详情那块说,清单照常可选;恢复/彻底删除失败 ⇒ 原因留在
26457
- * **清单那一行**(按条目 id 存,切走再回来还在);无主子会话的检查/清理读不到 ⇒ 面板顶端那块摆原文 + 重试
26458
- * (那是"没能回答有多少残留",与"没有残留"是两件事,不许折成空报告);清理发现"这一组已经不在了" ⇒
26459
- * 单独一条中性提示,不计入失败。这些失败块都标 `role="status"`——但**插入即播报并不保证**
26460
- * (`aria-live` 看的是"已在场的区域发生了变更"),要稳得常驻一个只给读屏看的播报台,本机没有 AT
26461
- * 可验,所以留在这里当已知边界,不写成已经做到。
26462
- * - **危险层级**:整页只有一块实心红(页脚「清空回收站…」,确认框里那块是它的后续);行与详情里的
26463
- * 「彻底删除…」是**安静的红**——只写 `color:--dsw-alias-state-error-primary`、**不写 background**
26464
- * (作者态内联 `background` 永远赢过类规则,而 `ui.css` 里 `.sd-button-ghost:hover` / `:active` 都是
26465
- * 普通类 ⇒ 铺了底的动作在点错之前不给任何视觉回应)。批量与确认框那款实心红的写法(`variant="primary"`
26466
- * + 就地覆盖 `--dsw-alias-button-primary-fill` / `-hover`)照抄宿主插件管理页 @21658274。
26467
- *
26468
- * 布局只用**结构**内联样式,**配色与圆角一律走 token**,**字号是字面量 px**(宿主同类面也这么写)。
26469
- * 结构与层级归 docs/ui-spec.md,皮肤只由 token 决定。控件样式住在 `src/client/ui/ui.css`,
26470
- * 由 `ui/styles.ts` 在浏览器半模块物化时挂成一个 `<style>`——这是宿主给客户端插件的一等公民通道。
26471
- *
26472
- * **浅色主题已在真机看过(2026-10-01)**:浅色下 `bg-base` 与 `bg-layer-2` 同值(都是 `bluish-00`)
26473
- * ⇒ 卡片只剩一根 `border-l4` 描边与页面分隔,分组靠组头 + 间距站住。**选中态在两档下都要另量**
26474
- * (浅 `#fff` → `#ebeef2`、深 `#2c2c2e` → `#43454a`,2026-10-01/10-15 真机量的);那两档取自同一个
26475
- * token,来源表见 `src/client/ui/ui.css` 文件头。
27057
+ * 回收站中央面板(main 槽占用者):页面骨架、清单/详情两栏或下钻、清单行与确认框开合。
27058
+ *
27059
+ * 布局与行为:
27060
+ * - 中央列本身没有 padding 也不是滚动容器,面板自己收滚动。
27061
+ * - 面板 border-box 宽达到 SPLIT_MIN_WIDTH 且清单有条目时走左右两栏;否则单列/下钻,
27062
+ * 详情铺满并给返回。
27063
+ * - 动作留在清单行右侧;详情只负责看,失败原文留在出事的那一行。
27064
+ * - 空态不出现「清空回收站…」;「读不到」与「空的」分开。
27065
+ * - 危险层级:页脚那排的最后一颗是整页唯一的实心红(非多选是「清空回收站…」,
27066
+ * 多选是「彻底删除 N 条」);行内「彻底删除…」只给字色,不给底色。
27067
+ *
27068
+ * 样式:结构与布局用内联样式;配色与圆角走宿主 token;控件样式在 src/client/ui/ui.css,
27069
+ * 由 ui/styles.ts 在模块物化时注入 style。界面规格见 docs/ui-spec.md;
27070
+ * 改断点或列宽前先看 SPLIT_MIN_WIDTH、LIST_COLUMN_DEFAULT / LIST_COLUMN_MIN、DETAIL_RESERVE 与 usePanelWidth。
26476
27071
  */
26477
27072
  /** 回收站相对宿主**数据根**的位置。**不写 `~/.dsh/`**:数据根可以是 `$DSH_HOME` 指的别处。
26478
27073
  * 字面量住 `shared/wire.ts`(`TRASH_DIR_LABEL`)——Node 半拼的就是同一个名字(ADR-0003)。 */
26479
27074
  const TRASH_DIR = TRASH_DIR_LABEL;
26480
27075
  /**
26481
- * 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(docs/ui-spec.md,2026-10-15 真机后定值)。
26482
- *
26483
- * 为什么是 900 而不是当初那个 760:760 是从"正文列限宽"那条关心得搬来的,**不是两栏所需宽度**。
26484
- * 两栏要成立,详情那一栏得真的读得下去:那一格固定要吃掉 90(两道 gap 32 + 把手 10 + 页内边距 48),
26485
- * 详情至少要 399 ⇒ 地板 489,剩下的给清单。窄于此与其挤成两条读不了的柱子,不如下钻。
26486
- * 900 这一档清单被夹到 411(比默认窄 9px),是 2026-10-15 按真 Chromium 量过之后定的:当时按"分隔 1px"
26487
- * 算成 420 + 32 + 1 + 399 + 48,实际那格是 10px,于是清单让 9px 给详情,两边的可读下限都守住。
27076
+ * 断点:面板盒子宽 ≥ 此值走两栏,窄于此走下钻(docs/ui-spec.md)。
27077
+ * 两栏要成立,详情列至少 399,加两道 gap 32、把手 10、页内边距 48 ⇒ 地板 489;
27078
+ * 900 这一档清单为 411,刚好守住两边可读下限。改数字前先同步 DETAIL_RESERVE 与 usePanelWidth。
26488
27079
  */
26489
27080
  const SPLIT_MIN_WIDTH = 900;
26490
- /**
26491
- * 左栏的**默认**宽(2026-10-15 起可拖,见下面 `LIST_COLUMN_MIN` / `DETAIL_RESERVE`)。
26492
- * 这个数是**为"行内有动作"设计的**(2026-10-15 真机反馈后定):两颗按钮(「恢复」描边 +
26493
- * 「彻底删除…」安静的红)连 gap 约 152px,卡片左右内边距 28px,剩下 ~240px 给标题与
26494
- * 「删除于 … · 大小」那一行——316 是"只放标题"那一版的宽度,动作搬回来之后它就不成立了。
26495
- */
27081
+ /** 左栏默认宽:为“行内有动作”设计——两颗按钮约 152px,卡片内边距 28px,
27082
+ * 剩下约 240px 给标题与元信息。可拖范围见 LIST_COLUMN_MIN / DETAIL_RESERVE。 */
26496
27083
  const LIST_COLUMN_DEFAULT = 420;
26497
- /**
26498
- * 左栏下限。**量出来的,不是算出来的**(2026-10-15,真 Chromium 上按 10px 一档扫 240→420):
26499
- * 两颗按钮从来不会被挤掉——它们 `flex:none`,再窄也是**文字列**缩,所以"放得下按钮"根本不是约束
26500
- * (我第一版按这个算出 280,是错的)。真正的下沿是「删除于 … · 224KB」那一行什么时候不再被省略号
26501
- * 截断:280 差 50px、320 差 10px、**330 归零**。定在 330。
26502
- */
27084
+ /** 左栏下限 330:再窄时「删除于 … · 224KB」那行就会开始被省略号截断。
27085
+ * 按钮 flex:none,不参与这个下限。 */
26503
27086
  const LIST_COLUMN_MIN = 330;
26504
- /**
26505
- * 拖到最右时给详情那一侧留的地板:正文可读最小宽 399 + 那一格的 90(两道 gap 32、把手 10、页内边距 48)。
26506
- * 于是 `900 − 489 = 411`——断点下沿那一档**清单抢不动宽度**(而且它比默认 420 还窄 9px),这是对的:
26507
- * 那一档两栏刚好塞满,再让清单抢宽就是把详情挤回"读不开"的样子(上一轮"右侧没有渲染"就是这么来的)。
26508
- */
27087
+ /** 拖到最右时给详情留的地板:正文最小宽 399 + 两道 gap 32 + 把手 10 + 页内边距 48 = 489。
27088
+ * 900 − 489 = 411,断点下沿清单抢不动宽度。 */
26509
27089
  const DETAIL_RESERVE = 489;
26510
27090
  /** 分隔线键盘一格的像素数(←→)。 */
26511
27091
  const HANDLE_STEP = 20;
26512
- /** 详情正文的收口宽度:中央列再宽也不把文字拉成长线(原来那条"整列限宽 760"的关心得换成只限正文)。 */
27092
+ /** 详情正文的收口宽度:中央列再宽也不把文字拉成长线。 */
26513
27093
  const DETAIL_TEXT_MAX_WIDTH = 860;
26514
27094
  /**
26515
27095
  * 页面骨架:撑满中央列(`height:100%` 落在 `.centerCol` 这个有定高的网格项上),自收滚动。
@@ -26574,10 +27154,8 @@ window.__ModuleLoader__.load({
26574
27154
  width: "100%"
26575
27155
  };
26576
27156
  /**
26577
- * 那一排视图开关(全部折叠 / 全部展开、多选;「刷新」图标 2026-10-16 已删,见 `toolbar` 那处注释)。
26578
- * 它**住在面板体顶端、不在清单列里**:原先在滚动容器内,长清单滚到底会把开关滚走(2026-10-15
26579
- * 真机反馈后搬出来的理由,与已经删掉的「整条左栏收起」无关)。宽度上限由用它的地方现给——两栏时
26580
- * 跟着可拖的清单列宽,单列时不设上限。
27157
+ * 视图开关(全部折叠 / 全部展开、多选)。住在面板体顶端,不进滚动容器,长清单滚到底也不会滚走。
27158
+ * 宽度上限由用它的地方现给:两栏时跟清单列宽,单列时不设上限。
26581
27159
  */
26582
27160
  const TOOLBAR = {
26583
27161
  display: "flex",
@@ -26607,7 +27185,9 @@ window.__ModuleLoader__.load({
26607
27185
  t,
26608
27186
  gate
26609
27187
  });
26610
- const { entries, listFailure, refreshAll } = query;
27188
+ const { list: listState, refreshAll } = query;
27189
+ const view = listView(listState);
27190
+ const entries = view.rows;
26611
27191
  const listRef = (0, react.useRef)(null);
26612
27192
  const rootRef = (0, react.useRef)(null);
26613
27193
  const width = usePanelWidth(rootRef);
@@ -26631,7 +27211,7 @@ window.__ModuleLoader__.load({
26631
27211
  entries,
26632
27212
  groups
26633
27213
  });
26634
- /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认选择器)。 */
27214
+ /** 行的 DOM 查询留给面板;导航只拿"把焦点还给哪一行"这个动作(不认行选择器)。 */
26635
27215
  const focusRow = (0, react.useCallback)((entryId) => {
26636
27216
  (listRef.current?.querySelector(`[data-entry-id="${entryId}"]`))?.focus();
26637
27217
  }, []);
@@ -26649,7 +27229,8 @@ window.__ModuleLoader__.load({
26649
27229
  });
26650
27230
  /**
26651
27231
  * 键盘导航(↑↓ 走环、Enter/Space、tab 落点、Esc 交还焦点)整块住在 `trash-nav.ts`。
26652
- * 屏幕顺序与四个动作从这里进去;它不查 DOM——`focusRow` 是面板交出去的那一动作。
27232
+ * 屏幕顺序与四个动作从这里进去;它不查行 DOM——`focusRow` 是面板交出去的那一动作,
27233
+ * 顶层浮层的让路判据由 `ui/top-layer.ts` 回答。
26653
27234
  */
26654
27235
  const nav = useTrashNav({
26655
27236
  ordered,
@@ -26662,14 +27243,8 @@ window.__ModuleLoader__.load({
26662
27243
  exitDrill: controller.back,
26663
27244
  focusRow
26664
27245
  });
26665
- /**
26666
- * 详细信息那块的选中集合:确认框要把"这一批有哪些、要去哪"摆出来,靠的全量条目是从勾选 id 反查
26667
- * (顺序已经是"清单从上往下")——清单里找不到的 id 是别的窗口刚动过,滤掉不等于丢动作
26668
- * (重读会把它从勾选里也剔掉)。
26669
- */
26670
- const byIdEntries = (0, react.useMemo)(() => new Map((entries ?? []).map((entry) => [entry.id, entry])), [entries]);
26671
27246
  const pending = controller.pending;
26672
- const restoreEntries = pending?.kind === "restore" ? pending.ids.map((id) => byIdEntries.get(id)).filter((entry) => entry !== void 0) : [];
27247
+ const restoreEntries = controller.pendingEntries;
26673
27248
  const totalBytes = (entries ?? []).reduce((sum, entry) => sum + entry.releaseBytes, 0);
26674
27249
  const now = Date.now();
26675
27250
  const hasEntries = entries !== null && entries.length > 0;
@@ -26679,10 +27254,8 @@ window.__ModuleLoader__.load({
26679
27254
  */
26680
27255
  const noteParts = splitOn(t("trash.footer.note", { dir: TRASH_DIR }), TRASH_DIR);
26681
27256
  /**
26682
- * 清单容器(roving tabindex 那个唯一的 tab 巢)。`flex:'1 1 auto'` 是**空态居中链**的一节:
26683
- * 空态块自己有 `flex:1` + `justifyContent:center`,但要有**有高度的**父链才居得起来——本层是
26684
- * 两栏改造(2026-10-15)新插进来的 div,缺这条 flex 就把链断在"内容高"上、空态图标贴左上角
26685
- * (2026-10-16 真机反馈)。长清单不受影响:`min-height:auto` 把它钉在内容高,滚动照旧归外层。
27257
+ * 清单容器(roving tabindex 的唯一 tab 巢)。`flex:'1 1 auto'` 撑起空态居中的父链;
27258
+ * `min-height:auto` 让长清单保持内容高,滚动交给外层。
26686
27259
  */
26687
27260
  const list = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26688
27261
  ref: listRef,
@@ -26700,7 +27273,7 @@ window.__ModuleLoader__.load({
26700
27273
  flex: "1 1 auto",
26701
27274
  minWidth: 0
26702
27275
  },
26703
- children: listFailure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27276
+ children: view.failure !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26704
27277
  role: "status",
26705
27278
  style: {
26706
27279
  ...CARD,
@@ -26740,7 +27313,7 @@ window.__ModuleLoader__.load({
26740
27313
  color: "var(--dsw-alias-label-tertiary)",
26741
27314
  wordBreak: "break-word"
26742
27315
  },
26743
- children: listFailure
27316
+ children: view.failure
26744
27317
  })]
26745
27318
  }),
26746
27319
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
@@ -26751,14 +27324,14 @@ window.__ModuleLoader__.load({
26751
27324
  children: t("common.retry")
26752
27325
  })
26753
27326
  ]
26754
- }) : entries === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27327
+ }) : view.loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26755
27328
  style: {
26756
27329
  padding: "20px 2px",
26757
27330
  fontSize: 12.5,
26758
27331
  color: "var(--dsw-alias-label-tertiary)"
26759
27332
  },
26760
27333
  children: t("trash.loading")
26761
- }) : entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27334
+ }) : view.empty ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26762
27335
  style: {
26763
27336
  display: "flex",
26764
27337
  flexDirection: "column",
@@ -27000,7 +27573,7 @@ window.__ModuleLoader__.load({
27000
27573
  disabled: controller.busy,
27001
27574
  onClick: (event) => {
27002
27575
  event.stopPropagation();
27003
- controller.setPending({
27576
+ controller.request({
27004
27577
  kind: "entry",
27005
27578
  entry
27006
27579
  });
@@ -27029,12 +27602,8 @@ window.__ModuleLoader__.load({
27029
27602
  })
27030
27603
  });
27031
27604
  /**
27032
- * 那一排**常驻面板体顶端**,不住清单列里:它原先在滚动容器内,长清单滚到底会把开关滚走。
27033
- * 宽度上限跟着清单列走,所以它在视觉上仍然"属于清单"。
27034
- *
27035
- * 这里曾有一颗「刷新」图标(2026-10-15 收进那一排、同日降级成图标,**2026-10-16
27036
- * 按用户裁定删除**):清单现在是"每次读都对账"(见 `reconcile.ts`),开面板、切走再切回、
27037
- * 重启——任何一次读都会先同步,那颗按钮不再承担别人替代不了的活。
27605
+ * 视图开关常驻面板体顶端,不进滚动容器;宽度上限跟清单列走,视觉上仍属于清单。
27606
+ * 不设「刷新」按钮:每次读清单都会先对账(见 reconcile.ts),开面板、切回、重启都会同步。
27038
27607
  */
27039
27608
  const toolbar = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27040
27609
  style: {
@@ -27051,7 +27620,7 @@ window.__ModuleLoader__.load({
27051
27620
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
27052
27621
  variant: "ghost",
27053
27622
  size: "sm",
27054
- disabled: !hasEntries,
27623
+ disabled: !hasEntries && !selectMode,
27055
27624
  "aria-pressed": selectMode,
27056
27625
  onClick: () => {
27057
27626
  if (selectMode) exitSelect();
@@ -27110,11 +27679,7 @@ window.__ModuleLoader__.load({
27110
27679
  }) : null]
27111
27680
  })
27112
27681
  }),
27113
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OrphanMaintenance, {
27114
- query,
27115
- busy: controller.busy,
27116
- t
27117
- }),
27682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentNotice, { message: query.attachmentNotice }),
27118
27683
  toolbar,
27119
27684
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
27120
27685
  style: BODY,
@@ -27177,7 +27742,7 @@ window.__ModuleLoader__.load({
27177
27742
  variant: "outline",
27178
27743
  size: "sm",
27179
27744
  disabled: checkedIds.length === 0 || controller.busy,
27180
- onClick: () => controller.setPending({
27745
+ onClick: () => controller.request({
27181
27746
  kind: "restore",
27182
27747
  ids: checkedIds
27183
27748
  }),
@@ -27194,7 +27759,7 @@ window.__ModuleLoader__.load({
27194
27759
  style: BULK_DANGER,
27195
27760
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
27196
27761
  disabled: checkedIds.length === 0 || controller.busy,
27197
- onClick: () => controller.setPending({
27762
+ onClick: () => controller.request({
27198
27763
  kind: "entries",
27199
27764
  ids: checkedIds
27200
27765
  }),
@@ -27206,7 +27771,7 @@ window.__ModuleLoader__.load({
27206
27771
  style: BULK_DANGER,
27207
27772
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconTrashOutlineRegular, { size: 13 }),
27208
27773
  disabled: controller.busy,
27209
- onClick: () => controller.setPending({ kind: "all" }),
27774
+ onClick: () => controller.request({ kind: "all" }),
27210
27775
  children: t("trash.emptyAll")
27211
27776
  })
27212
27777
  })]
@@ -27216,7 +27781,7 @@ window.__ModuleLoader__.load({
27216
27781
  restoreEntries,
27217
27782
  busy: controller.busy,
27218
27783
  t,
27219
- onCancel: () => controller.setPending(null),
27784
+ onCancel: controller.cancelPending,
27220
27785
  onConfirmPurge: () => void controller.confirmPurge(),
27221
27786
  onConfirmRestore: (ids) => void controller.confirmApply("restore", ids)
27222
27787
  })
@@ -27224,82 +27789,142 @@ window.__ModuleLoader__.load({
27224
27789
  });
27225
27790
  }
27226
27791
  //#endregion
27792
+ //#region src/client/ui/Toast.tsx
27793
+ /**
27794
+ * `Toast`(**照抄** `@deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2` 的 Toast,出处与三条改写规则
27795
+ * 见 `ui.css` 头注):顶部居中的一次性横幅——滑入、全不透明停留、淡出,然后回调 `onDone` 让 owner
27796
+ * 卸载它。用 body portal 渲染,免得被有 transform / filter 的祖先困在它的盒子里。
27797
+ *
27798
+ * **保留下来的行为**:
27799
+ * - 时长只有一个来源:`holdMs` 同时驱动卸载计时器与样式表的淡出延迟(后者以 `--dsh-toast-hold`
27800
+ * 自定义属性读它)⇒ 两者不可能再对不上、把横幅卡在淡出中途卸载。
27801
+ * - 同一个 `holdMs` 下,父组件重渲染**不延长**寿命(`onDone` 走 ref,不进 effect 依赖)。
27802
+ * - 完成回调拿到的永远是最新的 `onDone`;已经淡完的动作不再接输入(CSS 里那张表面本就穿透点击)。
27803
+ * - `tone="success"` 自带官方那枚圆圈对勾(此时忽略传入的 `icon`)。
27804
+ *
27805
+ * **砍掉的**:`anchor`(把横幅对齐到某个元素的水平中心——本插件的撤销提示就是窗口居中)、
27806
+ * `actions[].prefix`(动作文案前的连接词,本插件只用「撤销」两个字)。**没砍 `text`/`icon`/`tone`/
27807
+ * `holdMs`/`actions`/`onDone`**:唯一的调用点这几样都在用。
27808
+ */
27809
+ /** 不指定时全不透明的停留时长。 */
27810
+ const DEFAULT_HOLD_MS = 3e3;
27811
+ /** 淡出时长。必须与样式表里 `sd-toast-fade` 的时长一致。 */
27812
+ const FADE_MS = 1e3;
27813
+ function Toast({ text, icon, tone, holdMs = DEFAULT_HOLD_MS, actions, onDone }) {
27814
+ const latestOnDone = (0, react.useRef)(onDone);
27815
+ (0, react.useLayoutEffect)(() => {
27816
+ latestOnDone.current = onDone;
27817
+ }, [onDone]);
27818
+ (0, react.useEffect)(() => {
27819
+ const timer = setTimeout(() => {
27820
+ latestOnDone.current();
27821
+ }, holdMs + FADE_MS);
27822
+ return () => {
27823
+ clearTimeout(timer);
27824
+ };
27825
+ }, [holdMs]);
27826
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
27827
+ className: "sd-toast",
27828
+ role: "alert",
27829
+ style: { "--dsh-toast-hold": `${String(holdMs)}ms` },
27830
+ children: [tone === "success" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27831
+ className: "sd-toast-icon sd-toast-icon-success",
27832
+ "aria-hidden": "true",
27833
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCheckCircleOutlineRegular, {})
27834
+ }) : icon === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
27835
+ className: "sd-toast-icon",
27836
+ "aria-hidden": "true",
27837
+ children: icon
27838
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
27839
+ className: "sd-toast-text",
27840
+ children: [text, actions?.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
27841
+ type: "button",
27842
+ className: "sd-toast-action",
27843
+ onClick: action.onClick,
27844
+ children: action.label
27845
+ }, action.label))]
27846
+ })]
27847
+ }), document.body);
27848
+ }
27849
+ //#endregion
27850
+ //#region src/client/undo-toast.tsx
27851
+ /**
27852
+ * 撤销提示(docs/ui-spec.md 状态 ③):删除成功后一条可撤销的横幅。
27853
+ *
27854
+ * 现场与发布住在 `undo-notices.ts`;本文件只做组件,并把通知台与外部恢复座位组合起来。
27855
+ * 为什么组件要独立:宿主的菜单行随菜单**一起卸载**(真产物 Menu 的注释原文
27856
+ * "the rows unmount with the list"),而提示要在菜单关掉之后还活着。宿主的官方配方同形——
27857
+ * 会话行的动作把请求写进一个模块级 store,`shell.overlay` 里的 `workspace.row-toast` 读它。
27858
+ */
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
+ /**
27880
+ * 常驻的撤销提示宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
27881
+ * pill all belong here"):没有待撤销的事时什么都不渲染。
27882
+ */
27883
+ function UndoToast({ deps, t = zhTranslate }) {
27884
+ const current = useUndoNotice();
27885
+ if (current === null) return null;
27886
+ const { seq, failure } = current;
27887
+ if (failure !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
27888
+ text: failure,
27889
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconWarningOutlineRegular, { size: 16 }),
27890
+ holdMs: UNDO_HOLD_MS,
27891
+ onDone: clearUndoNotice
27892
+ }, seq);
27893
+ const canUndo = current.kind === "one" ? current.entryId !== "" : current.entryIds.length > 0;
27894
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toast, {
27895
+ tone: "success",
27896
+ text: current.kind === "one" ? t("undo.moved", { title: current.title === "" ? t("undo.session") : current.title }) : t("undo.movedBatch", { n: current.n }),
27897
+ holdMs: UNDO_HOLD_MS,
27898
+ actions: canUndo ? [{
27899
+ label: t("undo.undo"),
27900
+ onClick: () => void undo(deps, current)
27901
+ }] : void 0,
27902
+ onDone: clearUndoNotice
27903
+ }, seq);
27904
+ }
27905
+ //#endregion
27227
27906
  //#region src/client/index.tsx
27228
27907
  /**
27229
- * 浏览器半入口(CJS 单文件闭包工厂的 `factory` 体,构建契约见 tsdown.config.ts)。
27230
- *
27231
- * 共交付**十个**槽位贡献(2026-10-17 起,批量删除再加五件),分四类:
27232
- *
27233
- * - `sidebar.workspaces.session.menu.item` —— 两行:「多选」入口(`BatchSelectMenuItem`,order 850)
27234
- * 与「删除」菜单行(`DeleteMenuItem`,order 900)。多选入口按下后进入多选模式(再按一次退出)
27235
- * 并收菜单;删除行**只发请求**(把 `{sessionId, title}` 写进 `delete-confirm.tsx` 的模块级现场)。
27236
- * - `sidebar.session.row.leading` —— 行首 16px 格的勾选框(`BatchPickCheckbox`,2026-10-17)。
27237
- * **只在多选模式内渲染**(入口在「⋯ → 多选」,不常驻)。宿主在「有状态点(运行中/待确认/未读完成)」
27238
- * 与归档行不渲染这一格 ⇒ 那些行由下面行尾那颗兜底;槽位只投 `sessionId`,标题
27239
- * (`displayTitle`)从 root hook 的 `byId[id].title` 读。
27240
- * - `sidebar.workspaces.session.row.action` —— 行尾悬停排的**兜底勾选开关**(`BatchPickButton`)。
27241
- * 座位原文:"一行一颗图标按钮(动作不适用于这一行时可以不渲染)、动作归自己",且**排内点击不误开
27242
- * 会话** ⇒ 兜底开关不需要处理事件传播。两条与前一条不会同时出现(`ui.css` 的 `:has` 隐藏规则)。
27243
- * 它还负责把每行的标题/运行态登记给模式内的整行点击守卫(`host-rows.ts`)。
27244
- * 与 overlay 上的**浮条**(`BatchBar`)、**批量确认框**(`BatchConfirmHost`)合起来才是批量删除那一套。
27245
- * 模式内点整行会拦截宿主的会话切换(document 捕获守卫);执行仍走 `POST /apply`,单删那三件一律不动。
27246
- * - `shell.overlay` —— 菜单之外的**四个**常驻宿主(各一次 `slots.inject`,与宿主自己对
27247
- * `conversation.chat.node` 连写十几次 `inject` 的写法同形):
27248
- * ① 撤销提示 `UndoToast`;② 删除确认框 `DeleteConfirmHost`;③ 批量浮条 `BatchBar`;
27249
- * ④ 批量确认框 `BatchConfirmHost`。**它们不是额外入口**:四样东西都必须
27250
- * 活在菜单之外(真产物 `Menu` 是 `open && jsxs(MenuSurface, { … children: [items.map(renderEntry),
27251
- * children] })`,`open === false` 时槽位行根本不渲染、行随菜单一起卸载,见 delete-confirm.tsx 的头注)。
27252
- * - **回收站入口 = 一对座位**(同一次探测之后、同一个同步块里注册):
27253
- * ① `sidebar.panellist` —— 全局面板列表里的一行(占用者 `TrashPanelIcon`,只出 glyph);
27254
- * ② `main` —— 同字 key 的中央面板(占用者 `TrashPanel`,清单本体)。
27255
- * **两条必须同批**:`ctx.layout.selectPanel(panelId)` 逐字 `if (panelId !== null &&
27256
- * !this.hasMainPanel(panelId)) throw new Error(...)`(asar @21199710)——只有行、没有面板时,
27257
- * 用户点一下就是运行期抛错;反过来先有面板也没有入口。
27258
- *
27259
- * **为什么不是底栏(2026-09-30 实测裁决,判据全在下面三行)**:`sidebar.footer.action` 的**惯用形**是
27260
- * 「42px 整行 + 图标 + 文字 + 右对齐 12px 计数」——官方 `client-ui-cordis` 的占用者 CSS 是
27261
- * `.layer{flex:none;width:100%;height:42px}`,而座位容器只有 `.footerActions{display:flex}`
27262
- * (无 gap、无 wrap)⇒ 我们交的 `Button ghost/sm` 小药丸贴底栏,与下方 42px 的「设置」行不同形。
27263
- * **判据随宿主组成走,不是常量**:2026-09-30 隔离活宿主实测里 `ui-cordis` 虽在组成树里,它的
27264
- * 客户端半并没有挂上那条占用者(槽位渲染为空、零子节点)——所以"被第二个租户整条挤出去"只在
27265
- * 占用者真挂上时才发生;不变的是我们**不是这条座位的惯用形**。
27266
- *
27267
- * 槽位名与注册形状是从宿主产物里**读出来**的,不是猜的:
27268
- * - 菜单槽声明:asar @46391872 `ctx.slots.declare({ name: "sidebar.workspaces", children: {
27269
- * "sidebar.workspaces.session.menu.item": { kind: "list", scope: "root", inject: { hooks: {
27270
- * menuOpenState: menuOpenStateFactory, shortcuts: ctx.shortcuts.catalog } } } …`;
27271
- * 数据面投影 @46271806 `renderSlot("sidebar.workspaces.session.menu.item", { sessionId: node.id,
27272
- * displayTitle: row.title }, { hookContext: menuOpenState })`——`menuOpenStateFactory = (_standard,
27273
- * state) => () => state`(@46195835),所以这一行拿到的 prop 是 hook `useMenuOpenState`;
27274
- * 宿主自己的四行注册 @46392466 起(pin 100 / rename 200 / fork 300 / archive 400)——本插件取
27275
- * **order 900**,落在官方动作之后,并用 `separatorBefore` 与它们隔一条分隔线。
27276
- * - 浮层槽声明:asar @21206189 `"shell.overlay": { kind: "list", scope: "root" }`。
27277
- * - 面板行槽:座位目录原文「Global panel icons. Each list id addresses the matching main panel;
27278
- * the sidebar owns the button and resolves its label from list metadata」;声明在 `ui-sidebar`
27279
- * 的 `sidebar` 注册项 children 里(asar @44796980 区)`"sidebar.panellist": { kind: "list",
27280
- * scope: "root" }`;行本体渲染 @44783481 `PanelRow` → `<Tooltip disabled={wide}>` 包一个
27281
- * `<button class=panelRow aria-current={active ? "page" : undefined} onClick={selectPanel(id)}>`,
27282
- * 内含 `.panelGlyph`(`renderSlot("sidebar.panellist", { size: wide ? 16 : 18, active }, { only: id })`)
27283
- * 与 `.panelTitle`(`wide` 时才有)。**名字取自注册元数据**:`syncPanels` 逐字
27284
- * `label: resolveSlotLabel(options.label) ?? id`(@44795361)——所以本插件把 `label` 写进注册项。
27285
- * - 中央面板槽:座位目录 `main`(`kind: "keyed"`, `scope: "root"`,"Central panel selected by
27286
- * sidebar entry id",保留键 `conversation`,`replaceRisk: shadows-shipped-ui`);宿主渲染
27287
- * @21185725 `renderSlot("main", {}, { entryKey: usePanelInfo((info) => info.activePanelId) ??
27288
- * "conversation" })`。**id 不得撞车**:座位目录原文「reusing a shipped id puts you in THAT cell
27289
- * and replaces it」——静默替换比抛错更难发现,所以两个 id 都带插件专属前缀。
27290
- * - **面板 id 与 main 的 key 是同一个字**(官方:list id 寻址同 id 的中央面板)。`selectPanel`
27291
- * 只校验 main 里有没有注册、不校验 id 形状(@21199710),点号斜杠都不是问题。
27292
- *
27293
- * 宿主 ctx 用**本地窄镜像**(AGENTS.md:只声明用到的成员 + 一次强转;不装宿主类型包、
27294
- * 不做 `declare module` 增强)。
27295
- *
27296
- * **`inject` 只声明一定存在的东西**:全仓只有 `slots` 一项——它是浏览器侧槽位注册表、
27297
- * 宿主必给。**取数通道不进 `inject`**:通道是"同源 fetch 我们自己的前缀路由"(见 `remote.ts` 头注),
27298
- * 它是不是在**只有探测才知道**(`await probeChannel(...)`),
27299
- * 声明它既表达不了这件事,写错的代价又极高——前端启动自检对「有客户端模块但没激活成功」走的是
27300
- * 致命路径(`Error: web boot: 1 entry did not activate` ⇒ 桌面壳 `reportFatal("web-boot")`
27301
- * 杀掉整个进程)。
27302
- * 所以:**`apply` 同步返回、绝不抛**,注册挂在探测的续拍上;探测不过就一个贡献都不注册。
27908
+ * 浏览器半入口:注册全部槽位贡献,并在注册前探测取数通道。
27909
+ *
27910
+ * 槽位贡献的注册面见 docs/architecture.md §3,语义归属见 §4 的主人清单;
27911
+ * 本文件只做注册与槽位接线。
27912
+ * 两行菜单项的 order 与分隔线由下方注册调用和行包装拥有,均排在宿主官方四行之后。
27913
+ *
27914
+ * 槽位出处(宿主产物,不是猜的;换代先复核):
27915
+ * - sidebar.workspaces.session.menu.item:声明 asar @46391872;数据面投影 @46271806;
27916
+ * menuOpenState hook 工厂 @46195835;宿主四行 @46392466 起。
27917
+ * - shell.overlay:声明 asar @21206189。
27918
+ * - sidebar.panellist:声明 @44796980;行渲染 @44783481;label 解析 @44795361(thunk 随 locale)。
27919
+ * - main:渲染 @21185725;id 与 panellist 同字,selectPanel 只校验已注册 @21199710。
27920
+ * - plugins.bundle.config:插件详情页的 keyed 子槽位,宿主页面挂载时才声明(ADR-0021)。
27921
+ *
27922
+ * 硬约束:
27923
+ * - inject 只写宿主必给的 slots;可选服务走 ctx.get。理由与代价见 docs/architecture.md §3。
27924
+ * - apply 同步返回、绝不抛;先探 GET /ping,探测不过则不注册任何贡献(用入口不出现表达不可用)。
27925
+ *
27926
+ * 宿主 ctx 用本地窄镜像(AGENTS.md:只声明用到的成员 + 一次强转;不装宿主类型包、
27927
+ * 不做 declare module 增强)。
27303
27928
  */
27304
27929
  const name = "@xrn1997/dsh-session-delete/client";
27305
27930
  /**
@@ -27319,6 +27944,8 @@ window.__ModuleLoader__.load({
27319
27944
  const OVERLAY_SLOT = "shell.overlay";
27320
27945
  const PANEL_SLOT = "sidebar.panellist";
27321
27946
  const MAIN_SLOT = "main";
27947
+ /** 插件详情页的子槽位:宿主页面挂载时才声明,未声明前 `inject` 等待(更早的宿主没有这张卡)。 */
27948
+ const SETTINGS_SLOT = "plugins.bundle.config";
27322
27949
  /** 本插件在槽位里的稳定 id 前缀(宿主对重名直接抛错、对重 id 静默替换,必须带插件专属前缀)。 */
27323
27950
  const PLUGIN_ID = "@xrn1997/dsh-session-delete";
27324
27951
  /** 面板 id 与 `main` 的 key 同字:官方按 list id 去 `main` 里寻址同 id 的占用者。 */
@@ -27489,6 +28116,14 @@ window.__ModuleLoader__.load({
27489
28116
  t
27490
28117
  })
27491
28118
  }, TrashPanel));
28119
+ client.slots.inject(SETTINGS_SLOT, () => client.slots.register({
28120
+ name: SETTINGS_SLOT,
28121
+ key: PLUGIN_ID,
28122
+ inject: () => ({
28123
+ deps,
28124
+ t
28125
+ })
28126
+ }, MaintenanceSettings));
27492
28127
  }
27493
28128
  /**
27494
28129
  * 入口:**同步返回、绝不抛**(见文件头注)。
@@ -27510,5 +28145,3 @@ window.__ModuleLoader__.load({
27510
28145
  return module.exports;
27511
28146
  }
27512
28147
  });
27513
-
27514
- //# sourceMappingURL=client.js.map