dsh-config-manager 0.1.72 → 0.1.76

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 (153) hide show
  1. package/README.md +40 -4
  2. package/README.zh-CN.md +40 -4
  3. package/lib/adapters/index.d.ts +1 -0
  4. package/lib/adapters/index.js +5 -0
  5. package/lib/adapters/messages.d.ts +3 -0
  6. package/lib/adapters/messages.js +7 -0
  7. package/lib/adapters/plugin-data.d.ts +114 -0
  8. package/lib/adapters/plugin-data.js +309 -0
  9. package/lib/adapters/self.d.ts +1 -1
  10. package/lib/adapters/self.js +2 -1
  11. package/lib/adapters/test-helpers.d.ts +9 -0
  12. package/lib/adapters/test-helpers.js +14 -0
  13. package/lib/client.d.ts +103 -10
  14. package/lib/client.js +10748 -10119
  15. package/lib/core/analyzer.d.ts +11 -2
  16. package/lib/core/analyzer.js +31 -8
  17. package/lib/core/backup-plan.d.ts +10 -0
  18. package/lib/core/backup-plan.js +21 -3
  19. package/lib/core/backup.d.ts +20 -1
  20. package/lib/core/backup.js +48 -3
  21. package/lib/core/command-rollback.d.ts +6 -2
  22. package/lib/core/command-rollback.js +6 -2
  23. package/lib/core/messages-core.d.ts +2 -0
  24. package/lib/core/messages-core.js +4 -0
  25. package/lib/core/messages.d.ts +2 -1
  26. package/lib/core/messages.js +4 -2
  27. package/lib/core/plugin-data-registry.d.ts +124 -0
  28. package/lib/core/plugin-data-registry.js +198 -0
  29. package/lib/core/rollback-gate.d.ts +33 -0
  30. package/lib/core/rollback-gate.js +57 -0
  31. package/lib/core/rollback-ledger.d.ts +274 -0
  32. package/lib/core/rollback-ledger.js +1400 -0
  33. package/lib/core/rollback-point.d.ts +406 -5
  34. package/lib/core/rollback-point.js +1453 -306
  35. package/lib/core/rollback.js +6 -4
  36. package/lib/core/types.d.ts +19 -0
  37. package/lib/foreign/registry.d.ts +1 -1
  38. package/lib/foreign/registry.js +1 -1
  39. package/lib/index.d.ts +5 -1
  40. package/lib/index.js +83 -13
  41. package/lib/routes/messages.d.ts +1 -0
  42. package/lib/routes/messages.js +2 -0
  43. package/lib/routes/prefs.js +26 -3
  44. package/lib/routes/recovery.d.ts +21 -0
  45. package/lib/routes/recovery.js +127 -7
  46. package/lib/routes/sync.js +11 -1
  47. package/lib/schema/section-registry.d.ts +1 -1
  48. package/lib/schema/section-registry.js +32 -3
  49. package/lib/schema/types.d.ts +1 -1
  50. package/lib/sync/messages.d.ts +1 -0
  51. package/lib/sync/messages.js +3 -0
  52. package/lib/sync/sync-engine.d.ts +21 -7
  53. package/lib/sync/sync-engine.js +108 -18
  54. package/lib/sync/ui-prefs.d.ts +22 -0
  55. package/lib/sync/ui-prefs.js +28 -0
  56. package/lib/ui/diff-view.d.ts +10 -0
  57. package/lib/ui/diff-view.js +17 -0
  58. package/lib/ui/export-flow.js +2 -0
  59. package/lib/ui/i18n.d.ts +5 -3
  60. package/lib/ui/i18n.js +10 -6
  61. package/lib/ui/rollback-view.d.ts +59 -0
  62. package/lib/ui/rollback-view.js +14 -0
  63. package/lib/ui/test-helpers.js +2 -1
  64. package/lib/utils/bundle-scan.d.ts +5 -3
  65. package/lib/utils/bundle-scan.js +5 -3
  66. package/lib/utils/line-diff.d.ts +7 -1
  67. package/lib/utils/line-diff.js +8 -2
  68. package/package.json +1 -1
  69. package/src/adapters/index.ts +5 -0
  70. package/src/adapters/messages.ts +7 -0
  71. package/src/adapters/plugin-data.test.ts +225 -0
  72. package/src/adapters/plugin-data.ts +351 -0
  73. package/src/adapters/self.ts +2 -1
  74. package/src/adapters/test-helpers.ts +15 -0
  75. package/src/client/chat/ChatEntryView.tsx +59 -10
  76. package/src/client/chat/RollbackDiffTab.tsx +49 -2
  77. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  78. package/src/client/chat/RollbackReviewDialog.tsx +20 -4
  79. package/src/client/chat/chat-entry-view.test.ts +164 -1
  80. package/src/client/chat/chat-entry-view.ts +127 -10
  81. package/src/client/chat/chat-node-registration.ts +40 -6
  82. package/src/client/chat/chat-point-window.ts +11 -1
  83. package/src/client/chat/rollback-diff-tab.ts +13 -3
  84. package/src/client/chat/rollback-entry-view.ts +5 -0
  85. package/src/client/chat/sidebar-slots.ts +24 -2
  86. package/src/client/common/DiffTable.tsx +54 -29
  87. package/src/client/common/client-route-parity.test.ts +6 -0
  88. package/src/client/common/host-primitives.ts +78 -0
  89. package/src/client/common/routes.ts +10 -0
  90. package/src/client/common/section-labels.test.ts +1 -0
  91. package/src/client/common/section-labels.ts +2 -0
  92. package/src/client/config-manager.module.css +138 -14
  93. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  94. package/src/client/index.ts +54 -2
  95. package/src/client/library/RestorePlanView.tsx +0 -1
  96. package/src/client/locales.ts +8 -6
  97. package/src/client/recovery/RollbackCard.tsx +201 -7
  98. package/src/client/recovery/RollbackDiffPane.tsx +26 -8
  99. package/src/client/recovery/recovery-api.ts +28 -0
  100. package/src/client/recovery/recovery-locales.ts +36 -6
  101. package/src/client/recovery/rollback-enabled.ts +41 -0
  102. package/src/client/recovery/rollback-file-changes.test.ts +77 -0
  103. package/src/client/sync/PushPreviewCard.tsx +57 -0
  104. package/src/client/sync/SyncPanel.tsx +2 -37
  105. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  106. package/src/client/sync/sync-push-preview.test.ts +14 -0
  107. package/src/client/sync/sync-view.test.ts +1 -1
  108. package/src/client/sync/sync-view.ts +9 -1
  109. package/src/core/analyzer.ts +31 -9
  110. package/src/core/backup-plan.test.ts +20 -5
  111. package/src/core/backup-plan.ts +22 -3
  112. package/src/core/backup.ts +50 -4
  113. package/src/core/command-rollback.ts +6 -2
  114. package/src/core/messages-core.ts +4 -0
  115. package/src/core/messages.ts +4 -2
  116. package/src/core/plugin-data-registry.test.ts +113 -0
  117. package/src/core/plugin-data-registry.ts +261 -0
  118. package/src/core/rollback-gate.test.ts +66 -0
  119. package/src/core/rollback-gate.ts +61 -0
  120. package/src/core/rollback-ledger.test.ts +655 -0
  121. package/src/core/rollback-ledger.ts +1430 -0
  122. package/src/core/rollback-point.test.ts +866 -41
  123. package/src/core/rollback-point.ts +1582 -291
  124. package/src/core/rollback.ts +5 -3
  125. package/src/core/types.ts +19 -0
  126. package/src/foreign/registry.test.ts +1 -1
  127. package/src/foreign/registry.ts +1 -1
  128. package/src/index.ts +73 -10
  129. package/src/routes/messages.ts +2 -0
  130. package/src/routes/prefs.ts +31 -2
  131. package/src/routes/recovery.ts +122 -7
  132. package/src/routes/sync.ts +15 -1
  133. package/src/schema/manifest-schema.test.ts +4 -4
  134. package/src/schema/registry.test.ts +9 -2
  135. package/src/schema/section-registry.ts +32 -3
  136. package/src/schema/types.ts +2 -1
  137. package/src/sync/messages.ts +3 -0
  138. package/src/sync/sync-credentials.test.ts +119 -0
  139. package/src/sync/sync-engine.ts +106 -18
  140. package/src/sync/sync-preview-push.test.ts +158 -0
  141. package/src/sync/transport.test.ts +1 -0
  142. package/src/sync/ui-prefs.test.ts +44 -1
  143. package/src/sync/ui-prefs.ts +39 -0
  144. package/src/ui/diff-view.test.ts +9 -1
  145. package/src/ui/diff-view.ts +22 -0
  146. package/src/ui/export-flow.ts +2 -0
  147. package/src/ui/i18n.ts +10 -6
  148. package/src/ui/rollback-view.test.ts +17 -0
  149. package/src/ui/rollback-view.ts +66 -0
  150. package/src/ui/test-helpers.ts +2 -1
  151. package/src/utils/bundle-scan.ts +5 -3
  152. package/src/utils/line-diff.test.ts +12 -0
  153. package/src/utils/line-diff.ts +9 -3
@@ -9,56 +9,18 @@
9
9
  * ② **DSH 自带**:`@deepseek-ai/dsh-client-ui-primitives` 的 `FileTypeIcon` —— 这正是 DSH 左侧文件树
10
10
  * (`dsh-client-ui-sidebar-files` 的 FilesBody)逐字使用的那颗组件(同一份按类型着色的画集)。
11
11
  *
12
- * ## 为什么这里是「惰取 + try/catch」而不是顶层 import(**关键**)
13
- * 该包由 DSH shell 的模块表作为 **static module seed** 预置(`dsh-web-frontend` 的 `staticModules`
14
- * 里就有它),插件 bundle 的 `require` 能直接命中 —— 第三方插件 dsh-better-sidebar 走的就是这条路。
15
- * 但那是**较新宿主**才有的模块表:老宿主加载本插件时 `require` 会抛(missed the module table)。
16
- * 顶层 `import` 会被打包器改写成**模块体顶层**的 `require`,那一刻抛错会让**整个浏览器半加载失败**
17
- * (设置页一起没)—— 为了「一颗图标」赔掉整插件,绝不。所以这里:
18
- * · 只在**真要画 icon 时**才 require(帧内),
19
- * · 拿不到 / 形态不符 / 抛错一律回落一颗**极简的本地 SVG 文件图标**(`fallbackFileGlyph`),
20
- * 既不打挂任何东西,也不空着。
21
- * 该包因此不进 bundle:源码里只有 `require` 调用(bundle 白名单在 src/utils/bundle-scan.ts 登记)。
12
+ * ## 惰取门在 `common/host-primitives.ts`(2026-10-15 抽出)
13
+ * 那颗「帧内惰取 + try/catch + 三态缓存」的门改为全仓唯一出口(同一份门现在也供聊天气泡的
14
+ * 正文投影使用)——本文件只保留**两级回落**:外部来源(better-sidebar)→ 宿主 FileTypeIcon
15
+ * → 本地极简 SVG。为什么不能顶层 import、为什么必须帧内取,见那个文件的头注释。
22
16
  */
23
- import type { ComponentType, ReactNode } from 'react'
17
+ import type { ReactNode } from 'react'
24
18
  import { Icon } from '../common/Icon.tsx'
19
+ import { hostFileTypeIcon } from '../common/host-primitives.ts'
25
20
 
26
21
  /** 装配点注入的「外部图标来源」(dsh-better-sidebar 的 `fileIcon`);返回 null = 它不认这一条。 */
27
22
  export type RollbackFileIconRenderer = (path: string) => ReactNode | null
28
23
 
29
- /** `FileTypeIcon` 我们真的用到的入参面(只声明消费的字段,不复制第三方类型)。 */
30
- interface DshFileTypeIconProps {
31
- path: string
32
- size?: number
33
- className?: string
34
- }
35
-
36
- /** 惰取结果的三态缓存(undefined = 还没试过;null = 试过了,这台宿主没有)。 */
37
- let cachedDshFileTypeIcon: ComponentType<DshFileTypeIconProps> | null | undefined
38
-
39
- /**
40
- * 惰取 DSH 自带的 `FileTypeIcon`(**每次进程只试一次**,失败就记 null 不再重试)。
41
- *
42
- * 为什么不用 `import()`:动态 import 在 cjs 产物里同样要走模块表,且会把整个包拖进 chunk;
43
- * 这里的 `require` 是 DSH loader 交给插件工厂的那一个(banner 里 `factory: (require) => {...}`)。
44
- */
45
- function dshFileTypeIcon(): ComponentType<DshFileTypeIconProps> | null {
46
- if (cachedDshFileTypeIcon !== undefined) return cachedDshFileTypeIcon
47
- cachedDshFileTypeIcon = null
48
- try {
49
- const loaded = (typeof require === 'function' ? require('@deepseek-ai/dsh-client-ui-primitives') : null) as
50
- | { FileTypeIcon?: unknown }
51
- | null
52
- const icon = loaded?.FileTypeIcon
53
- if (typeof icon === 'function' || typeof icon === 'object') {
54
- cachedDshFileTypeIcon = icon as ComponentType<DshFileTypeIconProps>
55
- }
56
- } catch {
57
- cachedDshFileTypeIcon = null
58
- }
59
- return cachedDshFileTypeIcon
60
- }
61
-
62
24
  /**
63
25
  * 最后的兜底:共享图标原语的通用「文件」图标(`common/Icon.tsx` 的 `name="file"`)。
64
26
  * 只有「没有 better-sidebar + 宿主模块表里没有 ui-primitives」两条同时成立时才会出现 ——
@@ -90,7 +52,7 @@ export function RollbackFileIcon({ path, size = 14, render }: RollbackFileIconPr
90
52
  // null / undefined / false = 这一条外部来源接不住 ⇒ 回落 DSH 自带画法(不猜、不空着)
91
53
  if (node !== null && node !== undefined && node !== false) return <>{node}</>
92
54
  }
93
- const Dsh = dshFileTypeIcon()
55
+ const Dsh = hostFileTypeIcon()
94
56
  if (Dsh !== null) return <Dsh path={path} size={size} />
95
57
  return fallbackFileGlyph(size)
96
58
  }
@@ -34,7 +34,7 @@
34
34
  import { useEffect, useRef, useState } from 'react'
35
35
  import * as Dialog from '@radix-ui/react-dialog'
36
36
  import type { TranslateNS } from '../client-types.ts'
37
- import { rollbackPreviewScope } from '../../ui/rollback-view.ts'
37
+ import { rollbackPreviewScope, rollbackSkippedSegments } from '../../ui/rollback-view.ts'
38
38
  import type {
39
39
  RollbackPointPort,
40
40
  RollbackPreviewResult,
@@ -321,6 +321,12 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
321
321
  /** 选择框(第 1 条)当前是否「连文件一起退」以及它能不能被点。 */
322
322
  const revertFiles = revertFilesOn(draft)
323
323
  const revertFilesEnabled = canRevertFiles(preview)
324
+ /**
325
+ * 被跳过的段(C3):工作区段不可用时引擎会跳过它、结果为 partial —— 同一份事实在设置页会
326
+ * 渲染(RollbackCard 的 `rollback.rewind.result.skipped`),聊天侧此前只说「部分恢复 / 恢复 0 项」,
327
+ * 没人告诉用户**文件一个都没退、以及为什么**。判据与设置页同源(rollbackSkippedSegments)。
328
+ */
329
+ const skippedSegments = rollbackSkippedSegments(result)
324
330
  /**
325
331
  * t8:这条对话此刻正在运行(引擎 preview 的 `session.active`)。运行中时:
326
332
  * · 默认档 = 只回溯消息(`defaultDraft`);· 文件开关禁用(`canRevertFiles`);
@@ -393,7 +399,9 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
393
399
  工作区态没有可用记录时**禁用**(那时没有文件可退,开关点了也没有区别)。 */}
394
400
  <div className={css.previewChoiceRow}>
395
401
  <Checkbox
396
- checked={revertFiles}
402
+ // 工作区段不可用时显示为**未勾选**(C4):勾着却禁用会让人以为「文件会退、只是改不了」,
403
+ // 而实际提交的 workspace 段会被引擎跳过 —— 勾选态必须与真的会发生的事一致。
404
+ checked={revertFiles && revertFilesEnabled}
397
405
  disabled={busy || !revertFilesEnabled}
398
406
  onChange={(next) => { setDraft((previous) => setRevertFiles(previous, next) ?? previous) }}
399
407
  label={t('chat.preview.revertFiles')}
@@ -455,8 +463,8 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
455
463
  {inlineDiff !== null && (
456
464
  <div className={css.inlineDiffBox}>
457
465
  {inlineNotice !== null && <div className={css.hint}>{inlineNotice}</div>}
458
- <div className={`${css.hint} ${css.mono}`}>{inlineDiff.path}</div>
459
- <RollbackDiffPane state={inlineDiff.state} rt={rt} />
466
+ {/* 路径由差异表的文件头承载(.diffFileHead):这里不再画第二遍 */}
467
+ <RollbackDiffPane state={inlineDiff.state} rt={rt} title={inlineDiff.path} />
460
468
  </div>
461
469
  )}
462
470
  </>
@@ -516,6 +524,14 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
516
524
  </div>
517
525
  )}
518
526
 
527
+ {/* ⑤b 被跳过的段(C3):**部分恢复时最该说的一句** —— 哪一段没做。
528
+ 键与设置页共用(同一条事实只有一处文案)。 */}
529
+ {skippedSegments.length > 0 && (
530
+ <div className={css.hint}>
531
+ {rt('rollback.rewind.result.skipped', { list: skippedSegments.map((row) => rt(rk(row.key))).join(' / ') })}
532
+ </div>
533
+ )}
534
+
519
535
  {/* ⑥ fork 出来的子会话(有才显示;自动打开失败如实降级) */}
520
536
  {childId !== null && (
521
537
  <div className={css.statRow}>
@@ -42,6 +42,9 @@ import {
42
42
  planChatEntryAttachments,
43
43
  planChatNodeRegistration,
44
44
  projectChatEntryText,
45
+ projectChatEntryTextWithHost,
46
+ chatNodeUserOccupant,
47
+ CHAT_NODE_YIELD_WARN_PREFIX,
45
48
  resetChatPointCache,
46
49
  shouldRetryChatPoint,
47
50
  CHAT_POINT_LOOKUP_WINDOW_MS,
@@ -55,7 +58,7 @@ import {
55
58
  retryChatPointUntilHit,
56
59
  type ChatPointWindowState,
57
60
  } from './chat-point-window.ts'
58
- import { declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
61
+ import { chatNodeOccupantOf, declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
59
62
  import { en as chatEn, zh as chatZh } from './chat-locales.ts'
60
63
  import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
61
64
 
@@ -110,6 +113,52 @@ test('registration plan: a keyed declaration registers; anything else is skipped
110
113
  assert.deepEqual(planChatNodeRegistration('list'), { kind: 'skip', reason: 'slot-kind-mismatch', declared: 'list' })
111
114
  })
112
115
 
116
+ /**
117
+ * 让位判据(2026-10-15,跨插件冲突):keyed cell「最低的渲染」——别人 priority 更低时我们必定轮不到渲染,
118
+ * 所以**先读占用再决定注册**:更低 → 让位并告警(不抢格、不静默);相同(只可能是残留的自己)→ 照常注册
119
+ * (真撞上由 try/catch 兜住);宿主那条 priority 0 → 我们 -1 本来就要遮蔽它,当然注册。
120
+ */
121
+ test('registration plan: yields when another renderer already owns the user cell at a LOWER priority', () => {
122
+ const host = { priority: 0, registrant: null }
123
+ const oursOwn = { priority: -1, registrant: 'dsh-config-manager' }
124
+ const theirs = { priority: -2, registrant: '@someone/other-rewind' }
125
+ assert.deepEqual(planChatNodeRegistration('keyed', host), { kind: 'register' })
126
+ assert.deepEqual(planChatNodeRegistration('keyed', oursOwn), { kind: 'register' })
127
+ assert.deepEqual(planChatNodeRegistration('keyed', theirs), {
128
+ kind: 'skip',
129
+ reason: 'shadowed',
130
+ declared: 'keyed',
131
+ occupant: theirs,
132
+ })
133
+ // 形态不符的判据优先于占用(按 keyed 参数注册本来就会抛)
134
+ assert.equal(planChatNodeRegistration('list', theirs).kind, 'skip')
135
+ })
136
+
137
+ test('occupant reader: reads the winning entry per cell from entriesOfSlot shape; anything else is null', () => {
138
+ const entry = (key: string, priority?: number, registrant?: string) => ({
139
+ options: { key, ...(priority === undefined ? {} : { priority }) },
140
+ ...(registrant === undefined ? {} : { registrant }),
141
+ })
142
+ assert.equal(chatNodeUserOccupant(null), null)
143
+ assert.equal(chatNodeUserOccupant({}), null)
144
+ assert.equal(chatNodeUserOccupant([]), null)
145
+ assert.deepEqual(chatNodeUserOccupant([entry('agent-teams', -5)]), null, 'other keys are not our cell')
146
+ // 缺省 priority = 0(宿主缺省),registrant 读不到如实回 null(绝不猜)
147
+ assert.deepEqual(chatNodeUserOccupant([entry('user')]), { priority: 0, registrant: null })
148
+ // 多条同 key:取最低的那条(= 会被渲染的那条)
149
+ assert.deepEqual(
150
+ chatNodeUserOccupant([entry('user', 0, 'host'), entry('user', -2, 'other')]),
151
+ { priority: -2, registrant: 'other' },
152
+ )
153
+ // 非数值 priority 当成缺省 0;空 registrant 归 null
154
+ assert.deepEqual(chatNodeUserOccupant([entry('user', Number.NaN, '')]), { priority: 0, registrant: null })
155
+ // registrant 也可能挂在 options 上(宿主两个位置都读过)
156
+ assert.deepEqual(chatNodeUserOccupant([{ options: { key: 'user', priority: -3, registrant: 'x' } }]), {
157
+ priority: -3,
158
+ registrant: 'x',
159
+ })
160
+ })
161
+
113
162
  test('register shell: keyed declaration registers exactly once; non-keyed declaration is skipped, not thrown', () => {
114
163
  const warn: string[] = []
115
164
  let called = 0
@@ -136,6 +185,31 @@ test('register shell: keyed declaration registers exactly once; non-keyed declar
136
185
  assert.equal(declaredSlotKind({ spec: () => { throw new Error('boom') } }), null)
137
186
  })
138
187
 
188
+ test('register shell: yields (does NOT register) when a lower-priority renderer owns the user cell', () => {
189
+ const warn: string[] = []
190
+ let called = 0
191
+ const disposer = registerChatNodeEntry({
192
+ probe: {
193
+ spec: () => ({ kind: 'keyed', scope: 'session' }),
194
+ entriesOfSlot: () => [{ options: { key: 'user', priority: -2 }, registrant: '@someone/other-rewind' }],
195
+ },
196
+ register: () => { called += 1; return () => {} },
197
+ warn: (message) => { warn.push(message) },
198
+ })
199
+ assert.equal(called, 0, 'we must not fight for a cell another renderer already owns at a lower priority')
200
+ assert.equal(typeof disposer, 'function')
201
+ assert.ok(warn.some((line) => line.startsWith(CHAT_NODE_YIELD_WARN_PREFIX)), 'the yield must be logged, never silent')
202
+ assert.ok(warn.some((line) => line.includes('-2') && line.includes('@someone/other-rewind')))
203
+ })
204
+
205
+ test('occupant probe: missing entriesOfSlot / a throwing probe / a foreign shape all mean “nobody occupies it”', () => {
206
+ assert.equal(chatNodeOccupantOf(undefined), null)
207
+ assert.equal(chatNodeOccupantOf({ spec: () => ({ kind: 'keyed' }) }), null, 'old hosts expose no entriesOfSlot')
208
+ assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => { throw new Error('boom') } }), null)
209
+ assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => ({ not: 'an array' }) }), null)
210
+ assert.deepEqual(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => [{ options: { key: 'user', priority: 0 } }] }), { priority: 0, registrant: null })
211
+ })
212
+
139
213
  test('register shell: a throwing register() is contained (never takes the plugin down)', () => {
140
214
  const warn: string[] = []
141
215
  const disposer = registerChatNodeEntry({
@@ -204,6 +278,95 @@ test('样式纪律:icon 入口与预览文件行只用既有 token/类,未
204
278
  }
205
279
  })
206
280
 
281
+ /**
282
+ * 底色必须跟随宿主(2026-10-15 用户反馈「浅色主题下用户消息气泡看不见」)。
283
+ *
284
+ * 背景:接管 user 渲染器后我们自复刻那条气泡,底色曾写 --cm-surface-2(= --dsw-alias-bg-layer-2)——
285
+ * 浅色主题下它 = #fff,而页面底 --dsw-alias-bg-base 也是 #fff ⇒ 与页面同色(对比度 0);
286
+ * 宿主真机用的是 --dsw-specific-bubble(浅色 = --dsw-static-deepseek-50 = #edf3fe)。
287
+ * 深色下两者恰好同值,所以这条差距当时被误判成「可接受的近似档」。
288
+ */
289
+ test('气泡底色跟随宿主的 --dsw-specific-bubble(不得再用 --cm-surface-2 近似)', () => {
290
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
291
+ // ① 定义行:宿主 token + **可见**兜底(无兜底的 var() 在宿主删档时会整条失效 → transparent;
292
+ // 兜成 bg-layer-2 也没用 —— 浅色下它同样是 #fff,气泡照样消失)
293
+ const defs = cssSheet.match(/--cm-chat-bubble:\s*var\(--dsw-specific-bubble,\s*color-mix\(in srgb, var\(--cm-text-1\) 12%, transparent\)\)/g) ?? []
294
+ assert.ok(defs.length >= 2, '--cm-chat-bubble 必须在 §1 body 与 .chatEntryScope 各定义一次(且兜底是可见色),实际 ' + String(defs.length) + ' 处')
295
+ assert.equal(
296
+ (cssSheet.match(/--cm-chat-bubble:[^;]*--dsw-alias-bg-layer-2/g) ?? []).length,
297
+ 0,
298
+ '兜底不得用 --dsw-alias-bg-layer-2(浅色下 = #fff ⇒ 等于没兜底)',
299
+ )
300
+ // ② 消费点:.chatBubble 只写 --cm-*(三层单向依赖),且不得再回到 --cm-surface-2
301
+ const bubble = cssSheet.match(/\.chatBubble\s*\{[^}]*\}/)?.[0] ?? ''
302
+ assert.match(bubble, /background:\s*var\(--cm-chat-bubble\)/, '.chatBubble 的底色必须消费 --cm-chat-bubble')
303
+ assert.doesNotMatch(bubble, /--cm-surface-2|--dsw-/, '.chatBubble 不得再用 --cm-surface-2 近似、也不得裸写 --dsw-*')
304
+ // ③ 作用域重声明:聊天流不在插件设置页子树内
305
+ const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
306
+ assert.match(scope, /--cm-chat-bubble:\s*var\(--dsw-specific-bubble/, '.chatEntryScope 必须就地重声明 --cm-chat-bubble')
307
+ })
308
+
309
+ /** 文件卡描边同轮对齐(2026-10-15):宿主 = `.5px solid --dsw-alias-border-l2`,我们此前用更淡的 border-l1。 */
310
+ test('文件卡描边与宿主同档:.5px + --cm-line-2(不是更淡的 --cm-line-1)', () => {
311
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
312
+ // 注释里会出现 token 名(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard.test.ts 同法
313
+ const card = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '').match(/\.chatFileCard\s*\{[^}]*\}/)?.[0] ?? ''
314
+ assert.match(card, /border:\s*0\.5px solid var\(--cm-line-2\)/, '文件卡描边必须与宿主一致:.5px + --cm-line-2')
315
+ assert.doesNotMatch(card, /--cm-line-1/, '文件卡描边不得再退回更淡的 --cm-line-1')
316
+ const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
317
+ assert.match(scope, /--cm-line-2:\s*var\(--dsw-alias-border-l2\)/, '.chatEntryScope 必须就地重声明 --cm-line-2')
318
+ })
319
+
320
+ /**
321
+ * 宿主正文投影(2026-10-15):气泡正文优先交给宿主自己的 projectUserText(会话引用/技能 → chip)。
322
+ * 四条回落判据必须逐条可证 —— 任一条不成立都回落我们自己的纯文本投影(绝不空白、绝不抛)。
323
+ */
324
+ test('host text projection: four fallback criteria, and every argument goes through verbatim', () => {
325
+ const calls: unknown[][] = []
326
+ // 返回值用字符串(合法的 ReactNode)—— 纯层只要求「非空节点」,chip 的真实形状由宿主决定
327
+ const projector = (text: string, labels: readonly string[], skills: readonly string[], kind: string, references?: { openFile?: unknown; openSkill?: unknown }): string => {
328
+ calls.push([text, labels, skills, kind, references])
329
+ return '[chip]'
330
+ }
331
+ const options = { sessionLabels: ['会话甲'], skillNames: ['i-have-adhd'], openFile: 'of', openSkill: 'os' }
332
+ assert.deepEqual(projectChatEntryTextWithHost(projector, 'raw', options), '[chip]')
333
+ assert.deepEqual(calls[0], ['raw', ['会话甲'], ['i-have-adhd'], 'skill', { openFile: 'of', openSkill: 'os' }])
334
+ // ① 没有投影器(老宿主模块表里没有 ui-primitives)
335
+ assert.equal(projectChatEntryTextWithHost(null, 'raw', options), null)
336
+ assert.equal(projectChatEntryTextWithHost(undefined, 'raw', options), null)
337
+ // ② 原文为空
338
+ assert.equal(projectChatEntryTextWithHost(projector, '', options), null)
339
+ // ③ 投影自己抛错
340
+ assert.equal(projectChatEntryTextWithHost(() => { throw new Error('boom') }, 'raw', options), null)
341
+ // ④ 投影拒绝了这段文本(null / undefined / false)
342
+ assert.equal(projectChatEntryTextWithHost(() => null, 'raw', options), null)
343
+ assert.equal(projectChatEntryTextWithHost(() => undefined, 'raw', options), null)
344
+ assert.equal(projectChatEntryTextWithHost(() => false, 'raw', options), null)
345
+ // 缺省选项也要把完整参数交给投影器(空数组 + 空打开器),绝不缺参
346
+ calls.length = 0
347
+ assert.deepEqual(projectChatEntryTextWithHost(projector, 'x'), '[chip]')
348
+ assert.deepEqual(calls[0], ['x', [], [], 'skill', { openFile: undefined, openSkill: undefined }])
349
+ })
350
+
351
+ test('bubble keeps the UNprojected text for the host projector and the projected text as the fallback', () => {
352
+ const view = buildChatEntryBubble([{ type: 'text', text: '看这条 @[会话甲](dsh-session:s9) 引用' }])
353
+ assert.equal(view.text, '看这条 会话甲 引用')
354
+ assert.equal(view.rawText, '看这条 @[会话甲](dsh-session:s9) 引用')
355
+ // 多个文本块:两边都按 content 顺序拼接(非文本块不参与拼接)
356
+ const multi = buildChatEntryBubble([
357
+ { type: 'text', text: 'A' },
358
+ { type: 'image', attachment: { id: 'i' } },
359
+ { type: 'text', text: 'B' },
360
+ ])
361
+ assert.equal(multi.text, 'AB')
362
+ assert.equal(multi.rawText, 'AB')
363
+ // 怪形状:字符串 content 两边都有值;空 content 两边都是空串(不编造)
364
+ assert.deepEqual([buildChatEntryBubble('hi').text, buildChatEntryBubble('hi').rawText], ['hi', 'hi'])
365
+ const empty = buildChatEntryBubble(null)
366
+ assert.equal(empty.text, '')
367
+ assert.equal(empty.rawText, '')
368
+ })
369
+
207
370
  /* ------------------------------------------------------------ ⑤ 回合号只认可核实字段 */
208
371
 
209
372
  test('turn number: only node.location.kind turn|step counts (same read as the host turnTail)', () => {
@@ -45,20 +45,63 @@ export const CHAT_NODE_USER_KEY = 'user'
45
45
  */
46
46
  export const CHAT_NODE_PRIORITY = -1
47
47
 
48
+ /**
49
+ * 同一个 cell(key=user)当前**会被渲染的那一条**(= priority 最小的那一条)。
50
+ *
51
+ * 为什么要读它(2026-10-15,跨插件冲突排查的落地):`conversation.chat.node` 是 keyed 遮蔽位,
52
+ * 「最低的渲染」——别的插件若也用 key=user 且 priority 比我们更低(≤ -2),它会赢而我们**静默失效**
53
+ * (框架不报错)。所以我们在注册**之前**读一次占用:赢家比我们更低就让位并告警,不去抢。
54
+ */
55
+ export interface ChatNodeOccupant {
56
+ /** 该条的 priority(宿主缺省 0)。 */
57
+ priority: number
58
+ /** 诊断用标识(宿主用 fiber 名盖的戳);读不到回 null,**绝不猜**。 */
59
+ registrant: string | null
60
+ }
61
+
62
+ /** 从 `entriesOfSlot('conversation.chat.node')` 的返回值里读 user cell 的赢家(形状不符一律 null)。 */
63
+ export function chatNodeUserOccupant(entries: unknown): ChatNodeOccupant | null {
64
+ if (!Array.isArray(entries)) return null
65
+ let winner: ChatNodeOccupant | null = null
66
+ for (const entry of entries) {
67
+ const record = asRecord(entry)
68
+ const options = asRecord(record?.['options'])
69
+ if (options === null || options['key'] !== CHAT_NODE_USER_KEY) continue
70
+ const raw = options['priority']
71
+ const priority = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
72
+ const label = record?.['registrant'] ?? options['registrant']
73
+ const registrant = typeof label === 'string' && label !== '' ? label : null
74
+ if (winner === null || priority < winner.priority) winner = { priority, registrant }
75
+ }
76
+ return winner
77
+ }
78
+
48
79
  /**
49
80
  * 槽位登记计划:**只在 0.2 的 keyed 形态时接管**(2026-10-12:入口只剩「每条用户消息」一种形态,
50
81
  * 旧的「整回合」档与偏好门已随 chat-entry-pref / chat-entry-mount 一起删除)。
51
82
  *
52
83
  * 跳过必须是**失败软化**(只丢这一个入口,不动本插件其余槽位、不动宿主启动):
53
84
  * · `slot-kind-mismatch`:0.1.x 的 ui-chat 把同一 key 声明成别的形态(对照 turnTail 的
54
- * list/chain 双形态实测),按 keyed 参数(key/priority)注册会抛。
85
+ * list/chain 双形态实测),按 keyed 参数(key/priority)注册会抛;
86
+ * · `shadowed`(2026-10-15):user cell 已被**更低优先级**的条目占住 —— 我们注册也永远轮不到渲染
87
+ * (「最低的渲染」),不如让位并如实告警(不静默、也不去抢别人的格子)。
88
+ *
89
+ * 注意 `occupant.priority === CHAT_NODE_PRIORITY` **不算遮蔽**:同一 cell 同 priority 只可能有一条
90
+ * (再注册会抛,由登记处的 try/catch 兜住),所以那时该试的是注册而不是让位。
55
91
  */
56
92
  export type ChatNodeRegistrationPlan =
57
93
  | { kind: 'register' }
58
94
  | { kind: 'skip'; reason: 'slot-kind-mismatch'; declared: string | null }
95
+ | { kind: 'skip'; reason: 'shadowed'; declared: string | null; occupant: ChatNodeOccupant }
59
96
 
60
- export function planChatNodeRegistration(declaredKind: string | null): ChatNodeRegistrationPlan {
97
+ export function planChatNodeRegistration(
98
+ declaredKind: string | null,
99
+ occupant: ChatNodeOccupant | null = null,
100
+ ): ChatNodeRegistrationPlan {
61
101
  if (declaredKind !== 'keyed') return { kind: 'skip', reason: 'slot-kind-mismatch', declared: declaredKind }
102
+ if (occupant !== null && occupant.priority < CHAT_NODE_PRIORITY) {
103
+ return { kind: 'skip', reason: 'shadowed', declared: declaredKind, occupant }
104
+ }
62
105
  return { kind: 'register' }
63
106
  }
64
107
 
@@ -151,6 +194,11 @@ export type ChatEntryAttachment =
151
194
  export interface ChatEntryBubbleView {
152
195
  /** 文本块拼接结果(宿主 `texts.join('')`,逐字保留换行与空白;会话引用的线格式已投影成 label)。 */
153
196
  text: string
197
+ /**
198
+ * **未投影**的文本拼接(会话线格式原样保留)—— 只给宿主 `projectUserText` 用:
199
+ * 它自己认 `@[label](dsh-session:…)` 并把引用/技能渲染成 chip;我们自己的 `text` 是它的兜底。
200
+ */
201
+ rawText: string
154
202
  /** **有序附件列表**:渲染层必须按这个顺序输出(宿主不区分「先图片后文件」)。 */
155
203
  attachments: ChatEntryAttachment[]
156
204
  /** 图片附件(= `attachments` 里的 image 项,顺序一致;用于计数与「无渲染位」提示)。 */
@@ -244,11 +292,12 @@ export function formatChatEntryClock(time: number | null): string | null {
244
292
  export const CHAT_SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
245
293
 
246
294
  /**
247
- * 正文的**最小文本投影**:把会话引用线格式换成它的人类可读 label。
295
+ * 正文的**最小文本投影**(宿主投影不可用时的兜底):把会话引用线格式换成它的人类可读 label。
248
296
  *
249
- * 宿主做的是「换成 chip 节点」(`projectUserText`,还会把裸 `@name` 也装饰成 chip、把引用汇总成
250
- * 一行 referenceSummary);我们**不做 chip**(ui-primitives 包私有、不可导入,见 G-39 已追认差距),
251
- * 但**绝不把线格式原样打给用户看** —— 那是机器的编码,正常人读不了(t4 复核 D5,medium)。
297
+ * 2026-10-15 起:气泡正文**优先走宿主自己的** `projectUserText`(见 `projectChatEntryTextWithHost`
298
+ * —— 它能把会话引用与技能渲染成 chip,并在给了打开器时可点开);本函数是**兜底与纯文本路径**
299
+ * (复制按钮、错误边界降级、老宿主没有原语包时)的唯一实现:任何情况下都**不把线格式原样打给用户看**
300
+ * (那是机器的编码,正常人读不了 —— t4 复核 D5,medium)。
252
301
  * 与宿主一致的边界:只认 `dsh-session:` 的括号形式;`g` flag 下逐处替换,正则自身 lastIndex 置零。
253
302
  */
254
303
  export function projectChatEntryText(text: string): string {
@@ -257,6 +306,61 @@ export function projectChatEntryText(text: string): string {
257
306
  return text.replace(CHAT_SESSION_WIRE_RE, '$1')
258
307
  }
259
308
 
309
+ /**
310
+ * 宿主 `projectUserText` 的**可注入面**(宽类型:本仓库不引用 ui-primitives 的类型,也不 import 它)。
311
+ * 返回值是不透明 ReactNode(chip 节点等);缺省 null = 这台宿主没有该能力。
312
+ */
313
+ export type ChatEntryTextProjector = (
314
+ text: string,
315
+ sessionLabels: readonly string[],
316
+ slashNames: readonly string[],
317
+ slashKind: string,
318
+ references?: { openFile?: unknown; openSkill?: unknown },
319
+ ) => ReactNode
320
+
321
+ /** 宿主投影的入参(全部来自节点 data 与 owner props;缺省即空)。 */
322
+ export interface ChatEntryHostTextOptions {
323
+ /** `data.referenceLabels`(引用会话的 label 列表)。 */
324
+ sessionLabels?: readonly string[]
325
+ /** `data.skillNames`(宿主把它们渲染成 chip —— 我们此前一个元素都不产出)。 */
326
+ skillNames?: readonly string[]
327
+ /** 宿主给的文件 / 技能打开器(给了才能点开 chip;没有就只显示 chip 外观)。 */
328
+ openFile?: unknown
329
+ openSkill?: unknown
330
+ }
331
+
332
+ /**
333
+ * 用**宿主自己**的 `projectUserText` 投影正文(2026-10-15:按生态既有做法补齐 G-39 差距①)。
334
+ *
335
+ * 实证(为什么这条现在成立):ui-primitives 由 DSH shell 的模块表作为 static module seed 预置,
336
+ * 插件 bundle 的 `require` 能命中 —— 第三方插件 dsh-reedit 就是这么拿 `projectUserText` 的,
337
+ * 本仓的 `RollbackFileIcon` 也早就这样拿 `FileTypeIcon`。**只能帧内惰取**(顶层 import 在老宿主
338
+ * 会打挂整个浏览器半加载),入口在 `common/host-primitives.ts`。
339
+ *
340
+ * 四条判据缺一不可(本函数**绝不抛**,任一条不满足就回 null ⇒ 调用方回落 `projectChatEntryText`):
341
+ * ① 有投影器(老宿主没有原语包 → null);② 原文非空;
342
+ * ③ 投影不抛错;④ 投影返回**非空节点**(null/undefined/false = 宿主拒绝了这段文本)。
343
+ *
344
+ * 为什么投影要当参数传进来:纯层与单测都不认识模块表 —— 抽成参数后,上面四条判据能逐条覆盖。
345
+ */
346
+ export function projectChatEntryTextWithHost(
347
+ projector: ChatEntryTextProjector | null | undefined,
348
+ rawText: string,
349
+ options: ChatEntryHostTextOptions = {},
350
+ ): ReactNode | null {
351
+ if (typeof projector !== 'function') return null
352
+ if (rawText === '') return null
353
+ try {
354
+ const node = projector(rawText, options.sessionLabels ?? [], options.skillNames ?? [], 'skill', {
355
+ openFile: options.openFile,
356
+ openSkill: options.openSkill,
357
+ })
358
+ return node === null || node === undefined || node === false ? null : node
359
+ } catch {
360
+ return null
361
+ }
362
+ }
363
+
260
364
  /**
261
365
  * 扩展名 → 文件卡上的短徽标:宿主 `fileExtension` + 宿主那行 meta 的同一截断
262
366
  * (`fileExtension(name).toUpperCase().slice(0, 8)`);后缀为空(无点 / 点在末尾)回 null。
@@ -310,12 +414,13 @@ export function fileSizeTextOf(bytes: number | null): string | null {
310
414
  export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
311
415
  if (typeof content === 'string') {
312
416
  const text = projectChatEntryText(content)
313
- return { text, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
417
+ return { text, rawText: content, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
314
418
  }
315
419
  if (!Array.isArray(content)) {
316
- return { text: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
420
+ return { text: '', rawText: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
317
421
  }
318
422
  const texts: string[] = []
423
+ const rawTexts: string[] = []
319
424
  const attachments: ChatEntryAttachment[] = []
320
425
  const raw: ChatEntryRawBlock[] = []
321
426
  content.forEach((block, index) => {
@@ -323,6 +428,8 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
323
428
  const type = record?.['type']
324
429
  if (type === 'text' && typeof record?.['text'] === 'string') {
325
430
  // 宿主把这条文本交给 projectUserText(会话引用渲染成 chip);我们做**最小文本投影**:线格式 → label
431
+ // (rawTexts 同时留一份未投影原文,宿主投影可用时优先用它 —— 见 ChatEntryBubbleView.rawText)
432
+ rawTexts.push(record['text'] as string)
326
433
  texts.push(projectChatEntryText(record['text'] as string))
327
434
  return
328
435
  }
@@ -347,10 +454,11 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
347
454
  raw.push({ id: String(index), payload: block })
348
455
  })
349
456
  const text = texts.join('')
457
+ const rawText = rawTexts.join('')
350
458
  // images / files 是 attachments 的**派生视图**(不独立积累,避免两处真相与顺序漂移)
351
459
  const images = attachments.flatMap((item) => (item.kind === 'image' ? [item.image] : []))
352
460
  const files = attachments.flatMap((item) => (item.kind === 'file' ? [item.file] : []))
353
- return { text, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
461
+ return { text, rawText, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
354
462
  }
355
463
 
356
464
  /** 渲染计划的一项:图片带 owner 载荷,文件带名称/扩展名/体积文本。 */
@@ -517,7 +625,10 @@ export interface ChatEntryView {
517
625
  bubble: ChatEntryBubbleView
518
626
  /** 动作行的时间文本(null = 节点没给可用的时间戳)。 */
519
627
  clock: string | null
520
- /** 宿主 data 的另外两个字段(我们**不**接线到 ui-primitives,渲染层如实记成差距)。 */
628
+ /**
629
+ * 宿主 data 的另外两个字段:`referenceLabels` 进引用汇总行;两者都作为 `projectUserText`
630
+ * 的入参传给**宿主的**正文投影(2026-10-15 起 —— 此前它们只是「如实记下的差距」)。
631
+ */
521
632
  referenceLabels: string[]
522
633
  skillNames: string[]
523
634
  }
@@ -600,6 +711,12 @@ export function rawBlockText(payload: unknown): string {
600
711
  /** 错误边界的 ASCII 诊断前缀(console.* 实参不触发 i18n 守卫;界面文案一律在字典里)。 */
601
712
  export const CHAT_ENTRY_WARN_PREFIX = '[config-manager] chat user node renderer degraded to plain text: '
602
713
 
714
+ /**
715
+ * 让位告警的 ASCII 前缀(诊断用,绝不当作界面文案)—— 见 `planChatNodeRegistration` 的 `shadowed`:
716
+ * 别的插件用更低优先级占住了 key=user 时,我们**不注册**(注册也永远轮不到渲染),只留这条日志。
717
+ */
718
+ export const CHAT_NODE_YIELD_WARN_PREFIX = '[config-manager] chat user node entry yielded: another renderer owns key "user" at a lower priority: '
719
+
603
720
  /* ------------------------------------------------------------ 回滚点惰取(按会话缓存 + 在途去重) */
604
721
 
605
722
  /** 缓存 TTL:同一会话里的所有按钮共用一次列表读取;跨分钟自然过期(列表变化由宿主写,非热点)。 */
@@ -9,11 +9,23 @@
9
9
  * (`chat-entry-pref` / `chat-entry-mount`)与回合尾入口(`conversation.chat.turnTail`)一并删除 ——
10
10
  * 入口恒为每条用户消息气泡里的那一颗,于是这里不再有偏好门,也不再需要「切档时重新评估」的订阅。
11
11
  */
12
- import { CHAT_NODE_SLOT, planChatNodeRegistration } from './chat-entry-view.ts'
12
+ import {
13
+ CHAT_NODE_SLOT,
14
+ CHAT_NODE_YIELD_WARN_PREFIX,
15
+ chatNodeUserOccupant,
16
+ planChatNodeRegistration,
17
+ type ChatNodeOccupant,
18
+ } from './chat-entry-view.ts'
13
19
 
14
- /** 槽位声明形态探测面(`ctx.slots`):只读 spec,宽类型读回 kind。 */
20
+ /** 槽位探测面(`ctx.slots`):读声明的 kind,以及该 cell 当前会被渲染的那一条。 */
15
21
  export interface ChatNodeSlotProbe {
22
+ /** 只读 spec(宽类型读回 kind)。 */
16
23
  spec: (key: string) => unknown
24
+ /**
25
+ * 每个 cell 当前会被渲染的那一条(SlotRegistry.entriesOfSlot)。
26
+ * **可选**:老宿主没有这个方法(那时不做占用探测,照常注册)。
27
+ */
28
+ entriesOfSlot?: (key: string) => unknown
17
29
  }
18
30
 
19
31
  /** 诊断(ASCII,与界面文案无关;console.* 实参不触发 i18n 守卫)。 */
@@ -33,12 +45,24 @@ export function declaredSlotKind(probe: ChatNodeSlotProbe | undefined | null): s
33
45
  }
34
46
  }
35
47
 
48
+ /** user cell 的当前赢家(没有探测面 / 形状不符 / 探测抛错都回 null —— 探测失败不当成「有人占」)。 */
49
+ export function chatNodeOccupantOf(probe: ChatNodeSlotProbe | undefined | null): ChatNodeOccupant | null {
50
+ if (probe === undefined || probe === null || typeof probe.entriesOfSlot !== 'function') return null
51
+ try {
52
+ return chatNodeUserOccupant(probe.entriesOfSlot(CHAT_NODE_SLOT))
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
36
58
  /**
37
59
  * 登记「接管 conversation.chat.node / user」的渲染器。
38
60
  *
39
- * 两道门(顺序即优先级):
61
+ * 三道门(顺序即优先级):
40
62
  * ① 声明形态不是 `keyed`(0.1.x 的 ui-chat 契约不同)→ 跳过(按 keyed 参数注册会抛);
41
- * ② 注册本身抛错 → 吞掉并告警(只丢这一个入口)。
63
+ * ② **user cell 已被更低优先级占住**(别的插件也在接管用户消息,2026-10-15)→ 让位并如实告警:
64
+ * 注册也永远轮不到渲染(keyed cell「最低的渲染」),不抢别人的格子、也不静默;
65
+ * ③ 注册本身抛错 → 吞掉并告警(只丢这一个入口)。
42
66
  *
43
67
  * @returns disposer;跳过 / 失败时是 no-op。
44
68
  */
@@ -51,8 +75,18 @@ export function registerChatNodeEntry(options: {
51
75
  }): () => void {
52
76
  const warn = options.warn ?? warnChatNode
53
77
  const declared = declaredSlotKind(options.probe)
54
- if (planChatNodeRegistration(declared).kind === 'skip') {
55
- warn('[config-manager] chat user node entry skipped: slot declaration is ' + (declared === null ? 'missing' : '"' + declared + '"') + ' (expected "keyed")')
78
+ const plan = planChatNodeRegistration(declared, chatNodeOccupantOf(options.probe))
79
+ if (plan.kind === 'skip') {
80
+ if (plan.reason === 'shadowed') {
81
+ warn(
82
+ CHAT_NODE_YIELD_WARN_PREFIX +
83
+ 'priority ' + String(plan.occupant.priority) +
84
+ (plan.occupant.registrant === null ? '' : ' (registered by ' + plan.occupant.registrant + ')') +
85
+ ' — entry not registered',
86
+ )
87
+ } else {
88
+ warn('[config-manager] chat user node entry skipped: slot declaration is ' + (declared === null ? 'missing' : '"' + declared + '"') + ' (expected "keyed")')
89
+ }
56
90
  return () => {}
57
91
  }
58
92
  try {
@@ -119,11 +119,16 @@ export class ChatPointWindowController {
119
119
  private current: ChatPointWindowState = { lookup: null, open: false }
120
120
  private started = false
121
121
  private cancelled = false
122
+ /** 默认排程器留下的待触发定时器(≤12 s);dispose 清掉,避免悬空 timer 与晚到的回调 */
123
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null
122
124
 
123
125
  constructor(options: ChatPointWindowOptions) {
124
126
  // 参数属性(`constructor(private readonly x)`)会产出运行期语法,erasableSyntaxOnly 下不允许 —— 显式赋值
125
127
  this.options = options
126
- this.schedule = options.retry ?? ((fn: () => void, delayMs: number) => { setTimeout(fn, delayMs) })
128
+ // 默认排程器把句柄留下来,dispose 时清掉(注入的 retry 由调用方自理)
129
+ this.schedule = options.retry ?? ((fn: () => void, delayMs: number) => {
130
+ this.pendingTimer = setTimeout(() => { this.pendingTimer = null; fn() }, delayMs)
131
+ })
127
132
  }
128
133
 
129
134
  /** 当前状态(与最后一次 onState 回调同形)。 */
@@ -141,6 +146,11 @@ export class ChatPointWindowController {
141
146
  */
142
147
  dispose(): void {
143
148
  this.cancelled = true
149
+ // 取消后**不再需要任何晚到的回调**:默认排程器的定时器一并清掉(注入的 retry 由调用方负责)
150
+ if (this.pendingTimer !== null) {
151
+ clearTimeout(this.pendingTimer)
152
+ this.pendingTimer = null
153
+ }
144
154
  this.current = { lookup: this.current.lookup, open: false }
145
155
  }
146
156