dsh-config-manager 0.1.76 → 0.1.78

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 (84) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.d.ts +16 -0
  6. package/lib/adapters/plugins.js +223 -16
  7. package/lib/client.d.ts +104 -9
  8. package/lib/client.js +1800 -718
  9. package/lib/core/analyzer.js +3 -1
  10. package/lib/core/backup.d.ts +9 -0
  11. package/lib/core/backup.js +16 -1
  12. package/lib/core/messages.d.ts +11 -0
  13. package/lib/core/messages.js +22 -0
  14. package/lib/core/plugin-cli.d.ts +26 -0
  15. package/lib/core/plugin-cli.js +62 -2
  16. package/lib/core/rollback-attribution.d.ts +81 -0
  17. package/lib/core/rollback-attribution.js +236 -0
  18. package/lib/core/rollback-merge.d.ts +22 -0
  19. package/lib/core/rollback-merge.js +203 -0
  20. package/lib/core/rollback-point.d.ts +114 -0
  21. package/lib/core/rollback-point.js +534 -17
  22. package/lib/core/types.d.ts +28 -0
  23. package/lib/index.d.ts +26 -3
  24. package/lib/index.js +129 -16
  25. package/lib/routes/recovery.js +11 -0
  26. package/lib/schema/section-registry.js +5 -5
  27. package/lib/schema/types.d.ts +14 -0
  28. package/lib/ui/badge-tone.d.ts +16 -0
  29. package/lib/ui/badge-tone.js +20 -0
  30. package/lib/ui/i18n.d.ts +4 -4
  31. package/lib/ui/i18n.js +8 -8
  32. package/lib/ui/rollback-view.d.ts +95 -0
  33. package/lib/ui/rollback-view.js +55 -0
  34. package/lib/ui/selection-model.d.ts +140 -1
  35. package/lib/ui/selection-model.js +241 -9
  36. package/package.json +1 -1
  37. package/src/adapters/plugin-data-declared.ts +60 -0
  38. package/src/adapters/plugin-data.ts +7 -33
  39. package/src/adapters/plugins.test.ts +162 -0
  40. package/src/adapters/plugins.ts +236 -17
  41. package/src/adapters/units.test.ts +26 -0
  42. package/src/client/chat/ChatEntryView.tsx +147 -46
  43. package/src/client/chat/chat-entry-view.test.ts +277 -1
  44. package/src/client/chat/chat-entry-view.ts +98 -4
  45. package/src/client/chat/chat-node-registration.ts +71 -0
  46. package/src/client/chat/chat-slots.ts +30 -12
  47. package/src/client/common/ContentPicker.tsx +581 -175
  48. package/src/client/common/host-primitives.ts +64 -1
  49. package/src/client/common/ui.tsx +17 -3
  50. package/src/client/config-manager.module.css +630 -397
  51. package/src/client/css-scale-guard.test.ts +41 -3
  52. package/src/client/css-token-guard.test.ts +10 -4
  53. package/src/client/export/ExportView.tsx +2 -0
  54. package/src/client/index.ts +15 -3
  55. package/src/client/locales.ts +54 -10
  56. package/src/client/recovery/RollbackCard.tsx +56 -2
  57. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  58. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  59. package/src/client/recovery/recovery-locales.ts +22 -0
  60. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  61. package/src/core/analyzer.ts +3 -1
  62. package/src/core/backup.ts +16 -1
  63. package/src/core/messages.ts +22 -0
  64. package/src/core/plugin-cli.fs.test.ts +65 -3
  65. package/src/core/plugin-cli.ts +55 -2
  66. package/src/core/rollback-attribution.test.ts +120 -0
  67. package/src/core/rollback-attribution.ts +267 -0
  68. package/src/core/rollback-merge.test.ts +133 -0
  69. package/src/core/rollback-merge.ts +213 -0
  70. package/src/core/rollback-point.test.ts +412 -0
  71. package/src/core/rollback-point.ts +560 -20
  72. package/src/core/types.ts +29 -0
  73. package/src/index.facade.test.ts +60 -0
  74. package/src/index.ts +133 -19
  75. package/src/routes/recovery.ts +11 -0
  76. package/src/schema/section-registry.ts +5 -5
  77. package/src/schema/types.ts +14 -0
  78. package/src/ui/badge-tone.test.ts +23 -0
  79. package/src/ui/badge-tone.ts +27 -0
  80. package/src/ui/i18n.ts +8 -8
  81. package/src/ui/rollback-view.test.ts +27 -0
  82. package/src/ui/rollback-view.ts +139 -0
  83. package/src/ui/selection-model.test.ts +240 -2
  84. package/src/ui/selection-model.ts +349 -1
@@ -3,9 +3,15 @@
3
3
  *
4
4
  * 为什么必须接管(而不是追加):该 key 没有 children 表(宿主只对 turn-tail 声明了子位),
5
5
  * framework 没有「往用户消息里加 UI」的位子;唯一合法做法是 priority -1 遮蔽宿主的
6
- * `UserMessageNodeView`,然后在自己的渲染里把那条气泡**复刻**一遍(宿主组件 / CSS Modules
7
- * 全是 ui-chat 私有、不可导入 —— 外观靠本仓库 CSS 逼近,图片**必须**走 owner 给的
8
- * `renderMessageImages`)。
6
+ * `UserMessageNodeView`,然后在自己的渲染里产出那条气泡。
7
+ *
8
+ * 正文**谁来画**在 0.2.1-alpha.3 换过一代(issue #92):宿主把图片位搬进
9
+ * `conversation.chat.user.content` factory 的 children,并删掉了节点 props 上的 `renderMessageImages`。
10
+ * 因此现在的口径是 **优先委托宿主的 content factory**(`renderFactorySlot(CHAT_USER_CONTENT_FACTORY, …)`),
11
+ * 只有在宿主**没有**注册那条 factory 时(≤0.2.0)才回落到自绘气泡 —— 自绘气泡同时是那次调用的
12
+ * `options.fallback`,所以三格(老宿主 / 新宿主 / 宿主中途撤掉 factory)都有确定行为,
13
+ * 不会出现空白气泡。自绘那一格图片只能走 owner 的 `renderMessageImages`(宿主的 CSS Modules 与组件
14
+ * 全是 ui-chat 私有、不可导入,外观靠本仓库 CSS 逼近)。
9
15
  *
10
16
  * 三件硬约束(本文件是它们的落点):
11
17
  * ① **用户消息绝不消失**:整个子树包在 `ChatEntryErrorBoundary` 里,抛错就降级成
@@ -36,13 +42,14 @@ import {
36
42
  lookupPointForTurn,
37
43
  rawBlockText,
38
44
  CHAT_ENTRY_WARN_PREFIX,
45
+ CHAT_USER_CONTENT_FACTORY,
39
46
  type ChatEntryBubbleView,
40
47
  type ChatEntryFallbackRow,
41
48
  type ChatEntryFile,
42
49
  type ChatPointLookup,
43
50
  } from './chat-entry-view.ts'
44
51
  import { ChatPointWindowController, type ChatPointWindowState } from './chat-point-window.ts'
45
- import { hostFileTypeIcon, hostUserTextProjector } from '../common/host-primitives.ts'
52
+ import { hostFileTypeIcon, hostIcon, hostUserTextProjector } from '../common/host-primitives.ts'
46
53
  import type { ArchiveOutcome } from './rollback-entry-view.ts'
47
54
  import type { ChildOpenState } from './rollback-entry-view.ts'
48
55
  import type { RollbackFileIconRenderer } from './RollbackFileIcon.tsx'
@@ -62,6 +69,23 @@ const NEWLINE = String.fromCharCode(10)
62
69
  */
63
70
  export type MessageImagesRenderer = (owner: unknown) => ReactNode
64
71
 
72
+ /**
73
+ * 宿主 content factory 渲染位(0.2.1-alpha.3 起节点渲染器 props 上的 `renderFactorySlot`,
74
+ * 由 ui-slots 的 `PropsRenderFactories` 挂在**每一个**渲染组件上)。
75
+ *
76
+ * 为什么签名这么宽:真实类型是 `RenderFactorySlot`(按 SlotFactoryMap 泛型收窄),本仓库没有
77
+ * ui-chat 的类型;我们只承诺「按名字原样转发入参 + 收 `options.fallback`」,返回值当不透明 ReactNode。
78
+ *
79
+ * 注意它**在新旧宿主上都存在**(ui-renderer 的 standardKit 无条件发放):所以「走不走委托」不能只判
80
+ * 它是不是函数 —— 真正的判据是宿主到底注册没注册那条 factory,由 `FactoryOutlet` 在定义缺失时
81
+ * 回落我们给的 fallback 来裁决(见 ChatEntryBody)。
82
+ */
83
+ export type HostContentFactoryRenderer = (
84
+ name: string,
85
+ props: unknown,
86
+ options?: { fallback?: ReactNode },
87
+ ) => ReactNode
88
+
65
89
  /** 本条目经 inject 面拿到的业务能力(与回合尾入口同一个 inject 形状)。 */
66
90
  export interface ChatEntryViewInjected {
67
91
  /** 回退端口(T2 的 RecoveryApi;组件**不直接 fetch**)。 */
@@ -105,13 +129,22 @@ export type ChatEntryViewProps = PropsRuntime<'conversation.chat.node', 'user'>
105
129
  */
106
130
  function FileCard({ file, size }: { file: ChatEntryFile; size: string | null }) {
107
131
  const DshIcon = hostFileTypeIcon()
132
+ /**
133
+ * 元信息 = 宿主 `.fileMeta` 的**同一组合**:大写的扩展名(≤8 字符)+ 体积文本,空格连接。
134
+ * 宿主那行是「EXT SIZE」一行的次级小字(不是我们此前那个「文件名旁边挂一个体积」的排法)。
135
+ */
136
+ const meta = [file.ext === null ? '' : file.ext.toUpperCase().slice(0, 8), size ?? '']
137
+ .filter((part) => part !== '')
138
+ .join(' ')
108
139
  return (
109
140
  <span className={css.chatFileCard} title={file.name !== '' ? file.name : undefined}>
110
141
  {DshIcon !== null && file.name !== ''
111
- ? <DshIcon path={file.name} size={14} />
142
+ ? <DshIcon path={file.name} size={28} className={css.chatFileIcon} />
112
143
  : file.ext !== null && <span className={css.chatFileExt}>{file.ext}</span>}
113
- <span className={css.chatFileName}>{file.name !== '' ? file.name : '?'}</span>
114
- {size !== null && <span className={css.chatFileMeta}>{size}</span>}
144
+ <span className={css.chatFileContent}>
145
+ <span className={css.chatFileName}>{file.name !== '' ? file.name : '?'}</span>
146
+ {meta !== '' && <span className={css.chatFileMeta}>{meta}</span>}
147
+ </span>
115
148
  </span>
116
149
  )
117
150
  }
@@ -141,6 +174,14 @@ function ChatEntryCopyAction({ text, t }: { text: string; t: TranslateNS<'config
141
174
  if (copied) return
142
175
  void copyTextToClipboard(text).then((ok) => { if (ok) setCopied(true) })
143
176
  }, [copied, text])
177
+ /**
178
+ * 图标 = **DSH 原生那两颗**(2026-10-17 用户要求「复制 icon 与动画都用 DSH 原生的」):
179
+ * 宿主 MessageIconActions 的复制反馈就是 `IconCopyOutlineRegular → IconCheckOutlineRegular` 的
180
+ * **瞬时切换**(宿主 `.action svg` 没有任何 transition/animation —— 它的「动画」就是这一次换图)。
181
+ * 本仓库的 `CopyStateIcon`(morphicons 形变)只在**拿不到宿主图标的老宿主**上兜底。
182
+ */
183
+ const HostCopyIcon = hostIcon('IconCopyOutlineRegular')
184
+ const HostCheckIcon = hostIcon('IconCheckOutlineRegular')
144
185
  return (
145
186
  <button
146
187
  type="button"
@@ -148,8 +189,11 @@ function ChatEntryCopyAction({ text, t }: { text: string; t: TranslateNS<'config
148
189
  aria-label={copied ? t('chat.entry.copied') : t('chat.entry.copy')}
149
190
  title={copied ? t('chat.entry.copied') : t('chat.entry.copy')}
150
191
  onClick={onCopy}
192
+ data-copy-state={copied ? 'copied' : 'idle'}
151
193
  >
152
- <CopyStateIcon done={copied} size={13} />
194
+ {HostCopyIcon !== null && HostCheckIcon !== null
195
+ ? (copied ? <HostCheckIcon size={15} /> : <HostCopyIcon size={15} />)
196
+ : <CopyStateIcon done={copied} size={15} />}
153
197
  </button>
154
198
  )
155
199
  }
@@ -162,6 +206,10 @@ interface ChatEntryBodyProps extends ChatEntryViewInjected {
162
206
  openSkill?: unknown
163
207
  /** 宿主图片渲染位(SlotMap 的 keyProps 把返回值声明成 unknown ⇒ 用它当 `MessageImagesRenderer` 必须过一层窄化)。 */
164
208
  renderMessageImages?: unknown
209
+ /** 宿主授权过的图片加载器(0.2.1+ 节点渲染器 props;原样转发给 content factory)。 */
210
+ loadImage?: unknown
211
+ /** 宿主 content factory 渲染位(0.2.1+;老宿主拿不到 ⇒ 直接走自绘气泡。见 HostContentFactoryRenderer)。 */
212
+ renderFactorySlot?: unknown
165
213
  t: TranslateNS<'config-manager-chat'>
166
214
  }
167
215
 
@@ -185,7 +233,10 @@ interface ChatEntryBodyProps extends ChatEntryViewInjected {
185
233
  * 订阅入口可见性信号(issue #88)。
186
234
  *
187
235
  * 为什么要在组件里订阅而不是把开关当 prop 往下传:入口是**遮蔽宿主渲染器**注册的(无常),
188
- * 开关变化时没有任何父级会重渲染它 —— DNS 状态只能靠这条信号推。订阅开销 = 一个 Set 里的回调。
236
+ * 开关变化时没有任何父级会重渲染它 —— 状态只能靠这条信号推。订阅开销 = 一个 Set 里的回调。
237
+ *
238
+ * 2026-10-17 起登记本身随开关撤销(关闭 ⇒ 让出 user cell、本组件直接卸载,宿主原生气泡接管回来),
239
+ * 所以这里是**兜底**:万一信号已翻转而本组件还活着,也绝不画出那颗按钮。
189
240
  */
190
241
  function useRollbackEntryEnabled(): boolean {
191
242
  const [value, setValue] = useState(() => isRollbackEntryEnabled())
@@ -193,7 +244,7 @@ function useRollbackEntryEnabled(): boolean {
193
244
  return value
194
245
  }
195
246
 
196
- function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImages, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
247
+ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImages, loadImage, renderFactorySlot, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
197
248
  const view = useMemo(() => buildChatEntryView(node), [node])
198
249
  const [lookup, setLookup] = useState<ChatPointLookup | null>(null)
199
250
  /** 有界重试窗口是否还开着(**状态诚实**的唯一开关,R1)—— 由状态机的 `onState` 驱动。 */
@@ -231,6 +282,7 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
231
282
  * 「用户再次触发」= 重新挂载(切档 / 重进会话)或会话/回合变化 ⇒ 新实例;不需要刷新页面。
232
283
  */
233
284
  // issue #88:功能关闭时连**查询都不发**(不只是不画按钮)—— 入口收起 = 这一整个入口链路停摆。
285
+ // 关掉功能后登记会被撤销(本组件随之卸载),这两道门因此是兜底而非唯一机制。
234
286
  const entryEnabled = useRollbackEntryEnabled()
235
287
  const canLookup = entryEnabled && turn !== null && phase === 'closed' && sessionId !== ''
236
288
  useEffect(() => {
@@ -254,10 +306,16 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
254
306
  }, [button])
255
307
 
256
308
  const bubble: ChatEntryBubbleView = view.bubble
309
+ /** 回退图标同样走宿主(`IconRefreshOutlineRegular`):与复制图标同一套字形/描边;拿不到回落 RefreshIcon。 */
310
+ const HostRefreshIcon = hostIcon('IconRefreshOutlineRegular')
257
311
  /**
258
312
  * 附件按**content 原始顺序**输出(宿主 `attachments.map` 同构):图片走 owner 的
259
313
  * `renderMessageImages`(每张一次调用),文件走文件卡 —— 两类**共用同一条序列**,
260
314
  * 绝不重排成「先图片后文件」(t4 复核 D2)。
315
+ *
316
+ * 这条自绘路径**只在宿主没有 content factory 时**才会上屏(见下面的 `content`):
317
+ * 0.2.1 起 `renderMessageImages` 已不存在,此时图片位置如实显示提示文本 —— 但那一格
318
+ * 正常情况下走的是宿主自己的 factory,根本到不了这里。
261
319
  */
262
320
  const canRenderImages = typeof renderMessageImages === 'function'
263
321
  const attachmentNodes = planChatEntryAttachments(bubble).map((item) => item.kind === 'image'
@@ -283,43 +341,85 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
283
341
  })
284
342
  const reasonKey = button.kind === 'disabled' ? button.key : null
285
343
 
344
+ /**
345
+ * **自绘气泡**(= 0.2.1 之前那条路径,style 与宿主 .userStack/.bubble 逐条对齐见本文件头注释)。
346
+ *
347
+ * 两个身份:① 老宿主(≤0.2.0)没有 `conversation.chat.user.content` 这条 factory,正文由它承担;
348
+ * ② 新宿主上它是那次 factory 调用的 **fallback** —— 定义缺失(或宿主中途撤掉)时由
349
+ * `FactoryOutlet` 回落,用户消息因此**永远不会**变成空白。
350
+ *
351
+ * 图片:这条路径只能走 owner 的 `renderMessageImages`(0.2.1 起已不存在)⇒ 那一格如实显示提示文本,
352
+ * 而不是静默消失;新宿主正常情况下根本不会渲染到这里(走的是宿主自己的 content factory)。
353
+ */
354
+ const ownContent = (
355
+ <div className={css.chatEntryStack}>
356
+ {hasAttachments && (
357
+ <div className={css.chatAttachmentRow} data-message-attachments="">
358
+ {attachmentNodes}
359
+ </div>
360
+ )}
361
+ {bubble.showBubble && (
362
+ <div className={css.chatBubble}>
363
+ {bubble.text !== '' && (
364
+ <span className={css.chatBubbleText}>{hostText === null ? bubble.text : hostText}</span>
365
+ )}
366
+ {bubble.raw.map((block) => (
367
+ <RawBlock
368
+ key={'raw-' + block.id}
369
+ payload={block.payload}
370
+ label={t('chat.entry.extraBlock')}
371
+ truncatedLabel={(total) => t('chat.entry.truncated', { total })}
372
+ />
373
+ ))}
374
+ </div>
375
+ )}
376
+ {!canRenderImages && bubble.images.length > 0 && (
377
+ <div className={css.chatEntryNotice}>{t('chat.entry.noImageRenderer')}</div>
378
+ )}
379
+ {/* 引用会话汇总行:宿主在气泡下方渲染 referenceSummary(`引用会话 · {labels}`)。
380
+ 我们复刻这一行(文案在 chat 字典里,**不依赖 ui-primitives**);`data-ref-chip` 供探针定位。 */}
381
+ {view.referenceLabels.length > 0 && (
382
+ <div className={css.chatEntryReference} data-ref-chip="">
383
+ {t('chat.entry.referenceSummary', {
384
+ labels: view.referenceLabels.join(t('chat.entry.referenceSeparator')),
385
+ })}
386
+ </div>
387
+ )}
388
+ </div>
389
+ )
390
+
391
+ /**
392
+ * 正文 / 附件 / 引用(issue #92):**优先委托宿主自己的 content factory**。
393
+ *
394
+ * 为什么不能再自绘:0.2.1-alpha.3 起宿主把图片位搬进 `conversation.chat.user.content` 的 children
395
+ * (`conversation.chat.user.images`),并在节点 props 上删掉了 `renderMessageImages` —— 自己复刻气泡
396
+ * 就必然拿不到图片位。委托之后,图片由 ui-attachment 在该 factory 的渲染上下文里 renderSlot,
397
+ * 正文/文件卡/引用/未知块也全部回到宿主实现 ⇒ 与宿主原生气泡**同构**,且以后宿主再改图片实现也不用跟。
398
+ *
399
+ * 入参逐字对齐宿主 `UserStyleBubble`(`content` / `loadImage` / `facts` / `interactive: true`),
400
+ * 组装全在纯层 `buildHostUserContentInput`。**判据不是 `typeof renderFactorySlot === 'function'`** ——
401
+ * 该席位在新旧宿主上都存在(ui-renderer 的 standardKit 无条件发放),真正的裁决点是这条 factory
402
+ * 注册没注册:`FactoryOutlet` 在读不到定义时渲染我们给的 `fallback`(= 上面的自绘气泡)——
403
+ * 于是「老宿主 ≤0.2.0」「新宿主 ≥0.2.1」「宿主中途撤掉 factory」三格都有确定行为,不必开两条发布线。
404
+ * 调用本身不吃任何副作用,返回不透明 ReactNode(React 元素),空定义时是 fallback 的元素包装。
405
+ */
406
+ const content = typeof renderFactorySlot === 'function'
407
+ ? (renderFactorySlot as HostContentFactoryRenderer)(
408
+ CHAT_USER_CONTENT_FACTORY,
409
+ {
410
+ content: view.hostContent.content,
411
+ loadImage,
412
+ facts: view.hostContent.facts,
413
+ interactive: true,
414
+ },
415
+ { fallback: ownContent },
416
+ )
417
+ : ownContent
418
+
286
419
  return (
287
420
  <div className={css.chatEntryScope}>
288
421
  <div className={css.chatEntryRow}>
289
- <div className={css.chatEntryStack}>
290
- {hasAttachments && (
291
- <div className={css.chatAttachmentRow} data-message-attachments="">
292
- {attachmentNodes}
293
- </div>
294
- )}
295
- {bubble.showBubble && (
296
- <div className={css.chatBubble}>
297
- {bubble.text !== '' && (
298
- <span className={css.chatBubbleText}>{hostText === null ? bubble.text : hostText}</span>
299
- )}
300
- {bubble.raw.map((block) => (
301
- <RawBlock
302
- key={'raw-' + block.id}
303
- payload={block.payload}
304
- label={t('chat.entry.extraBlock')}
305
- truncatedLabel={(total) => t('chat.entry.truncated', { total })}
306
- />
307
- ))}
308
- </div>
309
- )}
310
- {!canRenderImages && bubble.images.length > 0 && (
311
- <div className={css.chatEntryNotice}>{t('chat.entry.noImageRenderer')}</div>
312
- )}
313
- {/* 引用会话汇总行:宿主在气泡下方渲染 referenceSummary(`引用会话 · {labels}`)。
314
- 我们复刻这一行(文案在 chat 字典里,**不依赖 ui-primitives**);`data-ref-chip` 供探针定位。 */}
315
- {view.referenceLabels.length > 0 && (
316
- <div className={css.chatEntryReference} data-ref-chip="">
317
- {t('chat.entry.referenceSummary', {
318
- labels: view.referenceLabels.join(t('chat.entry.referenceSeparator')),
319
- })}
320
- </div>
321
- )}
322
- </div>
422
+ {content}
323
423
  <div className={css.chatEntryActions}>
324
424
  {/* 时间在动作行**最前**(宿主把 MessageIconActions 的 clock 设为 "start",与它同构) */}
325
425
  {view.clock !== null && <span className={css.chatEntryTime}>{view.clock}</span>}
@@ -328,7 +428,8 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
328
428
  2026-10-10 用户第二条要求「回退按钮只有一个 icon 而不是一个按钮」。可见文案退到
329
429
  aria-label/title;禁用原因是原生 title 里的一句话(InfoHint 的 Portal 钉在插件设置页
330
430
  根节点,聊天区不存在)。按钮始终留在原地(禁用而不是消失):入口可见性不随判据漂移。 */}
331
- {/* 开关**关闭时整颗收起**(issue #88 明确要求「入口收起」,不是置灰)。
431
+ {/* 开关**关闭时整颗收起**(issue #88 明确要求「入口收起」,不是置灰;2026-10-17 起连登记
432
+ 都会被撤销,整条气泡交回宿主原生渲染,这里只是兜底的那一层)。
332
433
  与上面那条「入口可见性不随判据漂移」不冲突:那说的是**暂时读不到点**这类判据,
333
434
  而这里是「用户把这个功能关了」——此时入口本身就不该存在。 */}
334
435
  {entryEnabled && (
@@ -343,7 +444,7 @@ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImag
343
444
  data-chat-entry-window-open={windowOpen ? '' : undefined}
344
445
  data-chat-entry-window-closed={windowOpen ? undefined : ''}
345
446
  >
346
- <RefreshIcon size={13} />
447
+ {HostRefreshIcon === null ? <RefreshIcon size={15} /> : <HostRefreshIcon size={15} />}
347
448
  </button>
348
449
  )}
349
450
  </div>
@@ -58,7 +58,12 @@ import {
58
58
  retryChatPointUntilHit,
59
59
  type ChatPointWindowState,
60
60
  } from './chat-point-window.ts'
61
- import { chatNodeOccupantOf, declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
61
+ import {
62
+ chatNodeOccupantOf,
63
+ declaredSlotKind,
64
+ registerChatNodeEntry,
65
+ registerChatNodeEntryWhenEnabled,
66
+ } from './chat-node-registration.ts'
62
67
  import { en as chatEn, zh as chatZh } from './chat-locales.ts'
63
68
  import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
64
69
 
@@ -236,6 +241,95 @@ test('steering is a DIFFERENT key: we never register it (host renderer unchanged
236
241
  assert.equal(chatNodeKind(nodeOf({ kind: 'user' })), 'user')
237
242
  })
238
243
 
244
+
245
+ test('gate: 关闭时一次都不注册;打开即登记;关掉立刻撤销;再打开再登记', () => {
246
+ let enabled = false
247
+ const listeners = new Set<() => void>()
248
+ const disposed: number[] = []
249
+ let registrations = 0
250
+ let live = 0
251
+ const teardown = registerChatNodeEntryWhenEnabled({
252
+ probe: { spec: () => ({ kind: 'keyed', scope: 'session' }) },
253
+ register: () => {
254
+ registrations += 1
255
+ live += 1
256
+ const id = registrations
257
+ return () => { disposed.push(id); live -= 1 }
258
+ },
259
+ isEnabled: () => enabled,
260
+ subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
261
+ warn: () => {},
262
+ })
263
+ const flip = (value: boolean): void => {
264
+ enabled = value
265
+ for (const listener of [...listeners]) listener()
266
+ }
267
+
268
+ assert.equal(registrations, 0, '缺省关闭:绝不接管 user cell(气泡留给宿主原生渲染)')
269
+ assert.equal(listeners.size, 1, '无论开关如何都要订阅,否则功能打开时补不上登记')
270
+
271
+ flip(true)
272
+ assert.equal(registrations, 1)
273
+ assert.equal(live, 1)
274
+ flip(true)
275
+ assert.equal(registrations, 1, '重复通知必须幂等(同一时间只能有一条登记)')
276
+
277
+ flip(false)
278
+ assert.equal(live, 0, '关闭 = 撤销登记 ⇒ cell 回到宿主 priority 0')
279
+ assert.deepEqual(disposed, [1])
280
+
281
+ flip(true)
282
+ assert.equal(registrations, 2, '重新打开必须当场补登记(不重载插件)')
283
+ assert.equal(live, 1)
284
+
285
+ teardown()
286
+ assert.equal(live, 0, '装配卸载必须撤销登记,不留悬挂注册')
287
+ assert.equal(listeners.size, 0, '装配卸载必须解绑订阅')
288
+ assert.deepEqual(disposed, [1, 2])
289
+ })
290
+
291
+ test('gate: 一直关闭时卸载是安全的(没有登记可撤)', () => {
292
+ let unsubscribed = 0
293
+ const teardown = registerChatNodeEntryWhenEnabled({
294
+ probe: { spec: () => ({ kind: 'keyed', scope: 'session' }) },
295
+ register: () => { throw new Error('must not register while disabled') },
296
+ isEnabled: () => false,
297
+ subscribe: () => () => { unsubscribed += 1 },
298
+ warn: () => {},
299
+ })
300
+ teardown()
301
+ assert.equal(unsubscribed, 1)
302
+ })
303
+
304
+ test('gate: 让位时登记返回的 no-op 也照样能撤销,且不重复探测', () => {
305
+ const warns: string[] = []
306
+ let enabled = true
307
+ let registrations = 0
308
+ const teardown = registerChatNodeEntryWhenEnabled({
309
+ probe: {
310
+ spec: () => ({ kind: 'keyed', scope: 'session' }),
311
+ entriesOfSlot: () => [{ options: { key: 'user', priority: -2 }, registrant: '@someone/other-rewind' }],
312
+ },
313
+ register: () => { registrations += 1; return () => {} },
314
+ isEnabled: () => enabled,
315
+ subscribe: () => () => {},
316
+ warn: (message) => { warns.push(message) },
317
+ })
318
+ assert.equal(registrations, 0, '别的插件以更低优先级占着 user cell:让位')
319
+ assert.ok(warns.some((line) => line.startsWith(CHAT_NODE_YIELD_WARN_PREFIX)))
320
+ enabled = false
321
+ teardown()
322
+ assert.equal(registrations, 0)
323
+ })
324
+
325
+ test('装配点:user cell 的登记走开关闸门(不是无条件遮蔽宿主渲染器)', () => {
326
+ const src = readFileSync(join(HERE, '..', 'index.ts'), 'utf8')
327
+ assert.match(src, /ctx\.slots\.inject\(CHAT_NODE_SLOT, \(\) => registerChatNodeEntryWhenEnabled\(\{/)
328
+ assert.match(src, /isEnabled: isRollbackEntryEnabled/)
329
+ assert.match(src, /subscribe: subscribeRollbackEntryEnabled/)
330
+ assert.doesNotMatch(src, /ctx\.slots\.inject\(CHAT_NODE_SLOT, \(\) => registerChatNodeEntry\(/)
331
+ })
332
+
239
333
  const HERE = fileURLToPath(new URL('.', import.meta.url))
240
334
 
241
335
  /** 读一个组件源码(*.tsx 不能在 node 下 type-strip 加载,故走源码级断言)。 */
@@ -317,6 +411,118 @@ test('文件卡描边与宿主同档:.5px + --cm-line-2(不是更淡的 --cm
317
411
  assert.match(scope, /--cm-line-2:\s*var\(--dsw-alias-border-l2\)/, '.chatEntryScope 必须就地重声明 --cm-line-2')
318
412
  })
319
413
 
414
+ /**
415
+ * 字号 / 行高 / 限宽必须跟随宿主的「内容字号」(2026-10-17 修 issue #93)。
416
+ *
417
+ * 背景:接管 user 渲染器后我们把气泡正文基准写死 `--cm-fs-base: 12.5px`(= §1 的面板基准),
418
+ * 而宿主的 `.bubble` 写的是 `var(--dsh-content-font-size,14px)` —— 于是 DSH 的「内容字号」
419
+ * (10–22)**在用户自己的消息上完全失效**:真机内容字号 21 时助手正文字形高 19px、
420
+ * 气泡 11px(同一张截图,比例 0.58)。宿主口径(本机 app.asar 抽出):
421
+ * · `--dsh-content-font-size` 由 ui-layout 的 ThemePresenter 写在 **body** 上;
422
+ * · `--dsh-content-font-delta` = 内容字号 − 14px,由 ui-theme 定义在 body;
423
+ * · `--dsh-chat-content-width` 定义在 ui-conversation 的聊天 body(.body / .embeddedBody)。
424
+ */
425
+ test('气泡字号/行高/限宽跟随宿主内容字号(不得再写死 12.5px 与固定 620px)', () => {
426
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
427
+ // 注释里会引用这些取值(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard.test.ts 同法
428
+ const noComment = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '')
429
+ const scope = noComment.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
430
+ assert.notEqual(scope, '', '必须能定位 .chatEntryScope 规则块(扫描失效时本用例不得静默通过)')
431
+ // ① 正文基准 = 宿主的活值 + 兜底 14px(宿主同一条气泡的兜底;不许兜回 12.5px —— 那是面板档)
432
+ assert.match(scope, /--cm-fs-base:\s*var\(--dsh-content-font-size,\s*14px\)/, '.chatEntryScope 的 --cm-fs-base 必须跟随 --dsh-content-font-size(兜底 14px)')
433
+ assert.doesNotMatch(scope, /--cm-fs-base:\s*12\.5px/, '不得再把气泡正文写死 12.5px')
434
+ // ② 行高 = 宿主同一条公式(只改字号会让 21px 下比正文松 2.5px/行)
435
+ assert.match(scope, /--cm-lh-base:\s*calc\(22px \+ var\(--dsh-content-font-delta,\s*0px\)\)/, '行高必须用宿主同一条公式')
436
+ assert.match(scope, /font-size:\s*var\(--cm-fs-base\)/, '作用域字号必须消费 --cm-fs-base')
437
+ assert.match(scope, /line-height:\s*var\(--cm-lh-base\)/, '作用域行高必须消费 --cm-lh-base')
438
+ // ③ 只改聊天这一处:§1 body 的面板基准仍是 12.5px(动它会全站字号地震)
439
+ const bodyBlock = noComment.match(/body\s*\{[\s\S]*?\n\}/)?.[0] ?? ''
440
+ assert.match(bodyBlock, /--cm-fs-base:\s*12\.5px/, '§1 body 的面板基准必须保持 12.5px')
441
+ // ④ 限宽 = 宿主 .userStack 同一条公式;--cm-fs-meta 保持 11px 档(时间戳/动作标签不跟着放大)
442
+ assert.doesNotMatch(noComment, /min\(620px,\s*82%\)/, '气泡限宽不得再用固定 620px 近似')
443
+ const stack = noComment.match(/\.chatEntryStack\s*\{[^}]*\}/)?.[0] ?? ''
444
+ const notice = noComment.match(/\.chatEntryNotice\s*\{[^}]*\}/)?.[0] ?? ''
445
+ const WIDTH_FORMULA = /max-width:\s*min\(calc\(var\(--dsh-chat-content-width,\s*748px\) \* \.702\),\s*82%\)/
446
+ for (const [name, block] of [['.chatEntryStack', stack], ['.chatEntryNotice', notice]] as [string, string][]) {
447
+ assert.notEqual(block, '', '必须能定位 ' + name + ' 规则块(扫描失效时本用例不得静默通过)')
448
+ assert.match(block, WIDTH_FORMULA, name + ' 限宽必须与宿主同一条公式')
449
+ }
450
+ assert.match(scope, /--cm-fs-meta:\s*11px/, '--cm-fs-meta 保持 11px 档(时间戳/动作标签不跟着放大)')
451
+ })
452
+
453
+ /**
454
+ * 2026-10-17(用户要求「复制 icon 和 icon 动画也使用 DSH 原生的」):动作行图标必须走宿主
455
+ * ui-primitives 的原生图标集,且**必须保留回落**(老宿主没有那份模块表 ⇒ hostIcon() 回 null)。
456
+ * 宿主口径(app.asar 抽出,ui-primitives/lib/index.js):`IconCopyOutlineRegular` / `IconCheckOutlineRegular`
457
+ * / `IconRefreshOutlineRegular`,都是普通 `(props) => svg` 组件(size 缺省 16、Regular 档 1px 描边、
458
+ * 颜色 currentColor);宿主的复制反馈是**直接换图**(`.action svg` 没有任何 transition/animation)。
459
+ */
460
+ test('动作行图标走宿主原生(复制 → 对勾 → 回退),拿不到宿主图标时必须回落本仓库图标', () => {
461
+ const entry = sourceOf('ChatEntryView.tsx')
462
+ for (const name of ['IconCopyOutlineRegular', 'IconCheckOutlineRegular', 'IconRefreshOutlineRegular']) {
463
+ assert.match(entry, new RegExp("hostIcon\\('" + name + "'\\)"), '动作行图标必须按名字取宿主的 ' + name)
464
+ }
465
+ assert.match(entry, /<CopyStateIcon done=\{copied\} size=\{15\} \/>/, '拿不到宿主图标时必须回落 morphicons 的 CopyStateIcon(不能变成空白按钮)')
466
+ assert.match(entry, /<RefreshIcon size=\{15\} \/>/, '拿不到宿主图标时必须回落本仓库的 RefreshIcon')
467
+ assert.match(entry, /copied \? <HostCheckIcon size=\{15\} \/> : <HostCopyIcon size=\{15\} \/>/, '复制反馈必须是宿主的**换图**(不是形变动画)')
468
+ // 闸门只有一处:宿主图标必须经 common/host-primitives.ts 的 hostIcon(不许在组件里裸 require)
469
+ assert.doesNotMatch(entry, /require\(['"]@deepseek-ai/, '组件里不得裸 require 宿主包(唯一出口 = host-primitives.ts)')
470
+ const gate = readFileSync(join(HERE, '..', 'common', 'host-primitives.ts'), 'utf8')
471
+ assert.match(gate, /export function hostIcon\(name: DshIconName\)/, 'hostIcon 必须按闭集名字取图标')
472
+ assert.match(gate, /type DshIconName = 'IconCopyOutlineRegular' \| 'IconCheckOutlineRegular' \| 'IconRefreshOutlineRegular'/, '闭集必须逐字列这三颗,不搬整张图标表')
473
+ })
474
+
475
+ /**
476
+ * 2026-10-17(用户要求「与 DSH 原生气泡完全一致」):§15b 的几何/字号必须与宿主
477
+ * MessageItem + MessageIconActions 的**同一条取值**逐字同式(宿主产物抽出的取值清单见
478
+ * docs/spec/known-gaps.md 的「G-39 增量(八)」)。这张用例是那条口径的源级护栏:
479
+ * 谁把圆角/内边距/动作行/文件卡改回「近似档」,这里立刻红。
480
+ */
481
+ test('气泡与动作行逐值对齐宿主:圆角 xl(20px) / 内边距 10·16 / 动作行 28+delta / 文件卡 240·64', () => {
482
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
483
+ // 注释里会引用这些取值(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard 同法
484
+ const noComment = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '')
485
+ const scope = noComment.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
486
+ assert.notEqual(scope, '', '必须能定位 .chatEntryScope 规则块(扫描失效时本用例不得静默通过)')
487
+ // ① 镜像 token:宿主 radius xl=20(气泡/文件卡)、sm=8(动作按钮)、动作行与图标跟着 delta 长
488
+ assert.match(scope, /--cm-r-chat:\s*var\(--dsw-radius-xl,\s*20px\)/, '气泡/文件卡圆角必须镜像宿主 --dsw-radius-xl(20px)')
489
+ assert.match(scope, /--cm-r-act:\s*var\(--dsw-radius-sm,\s*8px\)/, '动作按钮圆角必须镜像宿主 --dsw-radius-sm(8px)')
490
+ assert.match(scope, /--cm-chat-action:\s*calc\(28px \+ var\(--dsh-content-font-delta,\s*0px\)\)/, '动作行高度/触达区必须与宿主同式(28px + delta)')
491
+ assert.match(scope, /--cm-chat-glyph:\s*calc\(15px \+ var\(--dsh-content-font-delta,\s*0px\)\)/, '动作图标必须与宿主同式(15px + delta)')
492
+
493
+ // ② 气泡:宿主 .bubble 是**块**(尺寸 100% / 内边距 10px 16px / 圆角 xl)
494
+ const bubble = noComment.match(/\.chatBubble\s*\{[^}]*\}/)?.[0] ?? ''
495
+ assert.notEqual(bubble, '', '必须能定位 .chatBubble 规则块')
496
+ assert.match(bubble, /max-width:\s*100%/)
497
+ assert.match(bubble, /padding:\s*var\(--cm-sp-5\) var\(--cm-sp-8\)/, '内边距必须等于宿主的 10px 16px')
498
+ assert.match(bubble, /border-radius:\s*var\(--cm-r-chat\)/, '.chatBubble 必须消费 --cm-r-chat')
499
+ assert.doesNotMatch(bubble, /display:\s*flex/, '宿主 .bubble 是块级:不得再用 flex 列(技能/引用 chip 会被挤成独立一行)')
500
+
501
+ // ③ 动作行:宿主 MessageIconActions(gap 8 / 高 28+delta)+ MessageItem 的 4px / -6px 外边距
502
+ const actions = noComment.match(/\.chatEntryActions\s*\{[^}]*\}/)?.[0] ?? ''
503
+ assert.notEqual(actions, '', '必须能定位 .chatEntryActions 规则块')
504
+ assert.match(actions, /gap:\s*var\(--cm-sp-4\)/, '动作行 gap 必须等于宿主的 8px')
505
+ assert.match(actions, /height:\s*var\(--cm-chat-action\)/, '动作行高度必须跟随宿主的 28px + delta')
506
+ assert.match(actions, /margin-top:\s*var\(--cm-sp-2\)/, '动作行上方必须留宿主的 4px')
507
+ assert.match(actions, /margin-left:\s*calc\(-1 \* var\(--cm-sp-3\)\)/, '动作行必须用宿主同一条负外边距(-6px)对齐气泡右缘')
508
+
509
+ // ④ 时刻与文件卡
510
+ const time = noComment.match(/\.chatEntryTime\s*\{[^}]*\}/)?.[0] ?? ''
511
+ assert.notEqual(time, '', '必须能定位 .chatEntryTime 规则块')
512
+ assert.match(time, /font-size:\s*var\(--cm-fs-second\)/, '时刻必须用宿主的次级字号镜像(13px 档)')
513
+ assert.match(time, /padding-right:\s*var\(--cm-sp-6\)/, '时刻右侧必须留宿主的 12px')
514
+ const card = noComment.match(/\.chatFileCard\s*\{[^}]*\}/)?.[0] ?? ''
515
+ assert.notEqual(card, '', '必须能定位 .chatFileCard 规则块')
516
+ assert.match(card, /width:\s*240px/, '文件卡宽度必须等于宿主的 240px')
517
+ assert.match(card, /min-height:\s*64px/, '文件卡最小高度必须等于宿主的 64px')
518
+ assert.match(card, /gap:\s*var\(--cm-sp-5\)/, '文件卡内图标与文字间距必须等于宿主的 10px')
519
+ assert.match(card, /border-radius:\s*var\(--cm-r-chat\)/, '文件卡圆角必须与气泡同一档(宿主 xl)')
520
+
521
+ // ⑤ 动作行的显现语义:宿主靠 data-chat-flow-kind + :has(~ …) 表达「后面还有用户消息就藏起来」
522
+ assert.match(noComment, /data-chat-flow-kind='user'/, '显现语义必须复用宿主自己的 data-chat-flow-kind 属性')
523
+ assert.match(noComment, /:has\(~ :is\(\[data-chat-flow-kind='user'\]/, '必须与宿主同一条「后面还有用户/steering 消息 ⇒ 动作行默认隐藏」的规则')
524
+ })
525
+
320
526
  /**
321
527
  * 宿主正文投影(2026-10-15):气泡正文优先交给宿主自己的 projectUserText(会话引用/技能 → chip)。
322
528
  * 四条回落判据必须逐条可证 —— 任一条不成立都回落我们自己的纯文本投影(绝不空白、绝不抛)。
@@ -1161,6 +1367,76 @@ test('attachments: one ordered list in content order + compact counts ALL attach
1161
1367
  assert.deepEqual(mixed.images.map((image) => image.attachment), [{ id: 'i1' }, { id: 'i2' }])
1162
1368
  })
1163
1369
 
1370
+ test('issue #92:hostAttachments 保留宿主原始载荷、顺序与 content 一致(我们的 ChatEntryFile 拼不回来)', () => {
1371
+ const image = { id: 'i1', width: 10 }
1372
+ const fileRef = { id: 'f1', name: 'a.txt', bytes: 3, mimeType: 'text/plain' }
1373
+ const view = buildChatEntryBubble([
1374
+ { type: 'file', attachment: fileRef },
1375
+ { type: 'image', attachment: image },
1376
+ { type: 'file', attachment: { name: 'b.txt', bytes: 4 } },
1377
+ ])
1378
+ // 宿主 contentParts 的形状逐字:image 包一层 { attachment },file 是原始 FileAttachmentRef
1379
+ assert.deepEqual(view.hostAttachments, [
1380
+ { type: 'file', file: fileRef },
1381
+ { type: 'image', image: { attachment: image } },
1382
+ { type: 'file', file: { name: 'b.txt', bytes: 4 } },
1383
+ ])
1384
+ assert.equal(view.hostAttachments.length, view.attachments.length, '两份列表同趟走查、同序(不得只算一半)')
1385
+ // 没有 attachment 的块照旧只进 rest:两份列表都不许凭空多一项
1386
+ assert.deepEqual(view.hostAttachments.filter((item) => item.type === 'image').length, 1)
1387
+ const hostile = buildChatEntryBubble([{ type: 'image' }, { type: 'file' }])
1388
+ assert.equal(hostile.hostAttachments.length, 0)
1389
+ assert.equal(hostile.raw.length, 2)
1390
+ // 非数组 / 字符串 content 的安全值里也必须带着这个字段(渲染层会直接读它)
1391
+ assert.deepEqual(buildChatEntryBubble('hi').hostAttachments, [])
1392
+ assert.deepEqual(buildChatEntryBubble(undefined).hostAttachments, [])
1393
+ })
1394
+
1395
+ test('issue #92:content factory 入参 = 宿主 UserStyleBubble 的 prepared/facts(原文 + rest + 字段存在性)', () => {
1396
+ const content = [
1397
+ { type: 'text', text: 'see @[S](dsh-session:1)' },
1398
+ { type: 'image', attachment: { id: 'i1' } },
1399
+ { type: 'mystery', payload: { deep: 1 } },
1400
+ ]
1401
+ const bare = buildChatEntryView({ kind: 'user', location: { kind: 'step', turn: { turn: 1, status: 'closed' } }, data: { content } })
1402
+ // text 必须是**未投影**的原文(chip 由宿主 factory 内部投影),attachments/extraBlocks 逐字带过去
1403
+ assert.equal(bare.hostContent.content.text, 'see @[S](dsh-session:1)')
1404
+ assert.deepEqual(bare.hostContent.content.attachments, [{ type: 'image', image: { attachment: { id: 'i1' } } }])
1405
+ assert.deepEqual(bare.hostContent.content.extraBlocks, [{ type: 'mystery', payload: { deep: 1 } }])
1406
+ // 宿主用「字段是否存在」区分「没带」与「空」⇒ 没带时两个对象里都不许出现这三个键
1407
+ assert.equal('referenceLabels' in bare.hostContent.content, false)
1408
+ assert.equal('sessionLabels' in bare.hostContent.facts, false)
1409
+ assert.equal('skillNames' in bare.hostContent.facts, false)
1410
+
1411
+ const withData = buildChatEntryView({
1412
+ kind: 'user',
1413
+ location: { kind: 'step', turn: { turn: 1, status: 'closed' } },
1414
+ data: { content: [{ type: 'text', text: 'q' }], referenceLabels: ['s-a', 7], skillNames: ['changelog-writing'] },
1415
+ })
1416
+ assert.deepEqual(withData.hostContent.content.referenceLabels, ['s-a'], '非字符串项照旧被剔除')
1417
+ assert.deepEqual(withData.hostContent.facts.sessionLabels, ['s-a'])
1418
+ assert.deepEqual(withData.hostContent.facts.skillNames, ['changelog-writing'])
1419
+ // 空数组是**真的带了空**(与「没带」不同)
1420
+ const emptyLabels = buildChatEntryView({
1421
+ kind: 'user',
1422
+ location: { kind: 'step', turn: { turn: 1, status: 'closed' } },
1423
+ data: { content: [{ type: 'text', text: 'q' }], referenceLabels: [], skillNames: [] },
1424
+ })
1425
+ assert.deepEqual(emptyLabels.hostContent.content.referenceLabels, [])
1426
+ assert.deepEqual(emptyLabels.hostContent.facts.sessionLabels, [])
1427
+ })
1428
+
1429
+ test('issue #92:组件把正文委托给宿主 content factory,并把自绘气泡当 fallback(双栈,不在注册期赌形态)', () => {
1430
+ const src = sourceOf('ChatEntryView.tsx')
1431
+ assert.match(src, /CHAT_USER_CONTENT_FACTORY/, '必须按名字委托宿主的 content factory')
1432
+ assert.match(src, /typeof renderFactorySlot === 'function'/, '委托前必须探测该席位是否存在(老宿主没有)')
1433
+ assert.match(src, /fallback:\s*ownContent/, '自绘气泡必须是那次调用的 fallback(factory 缺失时不许出空白气泡)')
1434
+ assert.match(src, /interactive:\s*true/, '宿主对用户消息传 interactive: true,必须同构')
1435
+ assert.match(src, /loadImage,/, '宿主授权过的图片加载器必须原样转发')
1436
+ // 反向:图片**不许**再由我们自己 renderMessageImages 一途承载(0.2.1 起该 props 已不存在)
1437
+ assert.match(src, /const content = typeof renderFactorySlot/, '正文/附件渲染的唯一出口是这一处三元')
1438
+ })
1439
+
1164
1440
  test('bubble file cards: file name + extension + size; a missing byte count stays unknown (no fake 0 B)', () => {
1165
1441
  const view = buildChatEntryBubble([
1166
1442
  { type: 'file', attachment: { name: 'C:/tmp/report.final.PDF', bytes: 1536 } },