dsh-markdown-bubble 0.1.3 → 0.1.5

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.
package/README.md CHANGED
@@ -33,9 +33,9 @@ DSH 的用户消息气泡按原样显示你输入的文字(`white-space: pre-w
33
33
 
34
34
  - **官方座位替换** —— 经 `ctx.slots` 在 `conversation.chat.node` 的 `user` / `steering` 两个 keyed 座位上以 `priority: -1` 遮蔽宿主渲染器;停用/卸载即恢复宿主自己的气泡,不改源码、不动日志
35
35
  - **宿主同款渲染** —— 不自己造 Markdown 管线,直接调用 `@deepseek-ai/dsh-client-ui-primitives` 的 `MarkdownText`:与助手回复同一套解析、高亮、表格、公式与代码卡
36
- - **引用 chip 保留** —— 宿主投影函数 `projectUserText` 照常调用,返回的片段按 chip / 纯文本拆分:chip 仍是可点击的文件 / 技能 chip,纯文本段逐个进 Markdown
36
+ - **引用 chip 保留** —— 调用宿主自己的文本工厂槽 `conversation.user.text`(0.2.1 起投影就住在那里),返回的片段按 chip / 纯文本拆分:chip 仍是可点击的文件 / 技能 chip,纯文本段逐个进 Markdown
37
37
  - **操作条与三方插件兼容** —— 时间 + 复制按钮用官方 primitives 重建,操作条保留 `_actions` / `_action` 类名后缀与 `data-clock` 锚点;dsh-edit-turn、dsh-delete-turn、dsh-rerun-turn 的按钮注入照常工作
38
- - **附件与补充块** —— 图片走官方 `conversation.message.images` 座位(`renderMessageImages`),文件卡、引用摘要、`JsonBlock` 补充块与宿主同款
38
+ - **附件与补充块** —— 图片走官方的图片画廊工厂槽 `conversation.images.content`,文件卡、引用摘要、`JsonBlock` 补充块与宿主同款
39
39
  - **软换行保留** —— 仅对围栏代码之外的续行补 GFM 两空格硬换行;代码块一个字节不动,缩进代码与表格行不误伤
40
40
  - **出错兜底** —— 单条消息的渲染错误被错误边界接住,回退为宿主的原始投影,不会连累整条座位被槽位机制摘除
41
41
  - **中英双语** 随系统语言切换(代码卡文案复用宿主 `chat` 字典)
@@ -89,12 +89,31 @@ const a = 1
89
89
 
90
90
  ### 兼容性
91
91
 
92
- - 实测 DSH `0.2.0-rc.1`(web profile,Chromium)。依赖:keyed 槽位 `conversation.chat.node`、行锚点 `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo`、primitives 导出的 `MarkdownText` / `projectUserText` / `writeClipboard`。
92
+ - 实测 DSH `0.2.1-rc.2`(web profile,Chromium)。依赖:keyed 槽位 `conversation.chat.node`(`user` / `steering`)、座位上的 `renderFactorySlot` 绑定、行锚点 `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo`、primitives 导出的 `MarkdownText` / `writeClipboard`。
93
+ - **0.2.1 把扩展点往里挪了**,本插件 0.1.5 已按新契约移植(旧契约的三样东西都不在了):`primitives.projectUserText` 被删除 → 改调文本工厂槽 `conversation.user.text`;座位 props 从 `{ node, renderMessageImages, openFile, openSkill, t }` 变成 `{ node, renderFactorySlot, loadImage, t }` → 图片改调图片画廊工厂槽 `conversation.images.content`。
94
+ - **为什么不能去换内容工厂槽**:`conversation.chat.user.content` 由宿主注册,插件再注册同名工厂会让**宿主自己的聊天插件启动失败**(实测 `web boot: 1 entry did not activate @deepseek-ai/dsh-client-ui-chat: failed`),所以「只换气泡内部」这条路走不通;同理,`conversation.chat.user.images` 这个子槽也不能由插件声明(实测 `slot "conversation.chat.user.images" is already declared (by factory "conversation.chat.user.content")`)。座位替换 + 两个工厂槽调用是当前版本唯一可行且不伤宿主的组合。
93
95
  - 与 `dsh-edit-turn`、`dsh-delete-turn`、`dsh-rerun-turn` 协同:三者的按钮注入基于 `[class*="_actions"]` / `[class*="_action"]`,本插件保留该契约;被 dsh-edit-turn 改写(rollback)的消息显示其自己的改写气泡,与本插件无关。
94
96
  - 宿主半区为惰性入口(无运行时行为),全部逻辑在 `./client` 浏览器半区;不修改官方源码,不写私有事件类型。
95
97
 
98
+ ### 为什么现在不能换成「替换内容工厂」(B 方案)
99
+
100
+ 理想做法是替换宿主的内容工厂槽 `conversation.chat.user.content`:气泡内部(附件 + 气泡 + 引用摘要)全部交回宿主,本插件只换掉正文那一段。这样能删掉自建的行外壳、时间/复制操作条与相关样式约 200 行,也不再依赖座位 props 的形状。**现在做不了,卡在下面三条**(都在 DSH `0.2.1-rc.2` 上实测过;等哪天宿主放开了任意一条,就可以改成 B):
101
+
102
+ | 缺什么 | 实测结果 | 放开之后 |
103
+ |---|---|---|
104
+ | 工厂槽能被插件**遮蔽/替换** | 注册同名工厂不是遮蔽而是硬错误,且会让**宿主自己的聊天插件启动失败**:`web boot: 1 entry did not activate @deepseek-ai/dsh-client-ui-chat: failed` | 直接换成注册 `conversation.chat.user.content`,删掉自建行外壳与操作条 |
105
+ | 子槽能由**多个**声明方共享 | 想让座位条目自己声明 `conversation.chat.user.images` 以拿到 `renderSlot`,被拒:`slot "conversation.chat.user.images" is already declared (by factory "conversation.chat.user.content")`,且整条注册一起失败 | 图片可以用官方的座位/子槽渲染,不必绕工厂槽 |
106
+ | 有一个**公开的**「渲染一条用户消息」入口 | 0.2.1 删掉了 `primitives.projectUserText`,投影只存在于工厂槽内部(现在只能调 `conversation.user.text` / `conversation.images.content` 这两个工厂槽) | 插件插入的气泡(例如 dsh-edit-turn 改写后的提示词)也能走同一条管线,见 [deepseek-harness#10704](https://github.com/deepseek-ai/deepseek-harness/discussions/10704) |
107
+
108
+ 也就是说:**座位替换(A)+ 调两个工厂槽**是当前版本唯一不伤宿主的组合;上面三条任意一条放开,都可以迁到 B。
109
+
96
110
  ### 修复记录
97
111
 
112
+ - **0.1.5** —— 按 DSH `0.2.1-rc.2` 的新契约移植,并把 `engines.dsh` 从开口的 `>=0.2.0-rc.1` 收成 `>=0.2.1-rc.2`(旧区间会让它在 0.2.1 上照样安装、只静默降级)。
113
+ - **引用 chip 回来了**:`primitives.projectUserText` 在 0.2.1 被删除(真机报 `TypeError: primitives.projectUserText is not a function`),投影改成调用宿主的文本工厂槽 `conversation.user.text`(`facts.sessionLabels` / `facts.skillNames` + `interactive`),chip 拆分逻辑不变。真机复验:控制台里那条降级告警不再出现(0 条),25/25 条用户气泡仍由本插件渲染。
114
+ - **图片附件回来了**:座位 props 里的 `renderMessageImages` 已不存在,改调图片画廊工厂槽 `conversation.images.content`(附件插件自己注册的那个;它的 `MessageImages` 也只是这一步调用)。三条看起来更顺的路都被堵着,README 的「兼容性」一节记了实测报错原文。
115
+ - **性能**:降级投影从「每帧算两次」改成只算一次(`MarkdownBoundary` 的 fallback 改成惰性 thunk,只有真失败时才构造);`toMarkdown()` 加 64 条 LRU 缓存(流式期间同一条消息会被反复重排,而准备源码是 split/join + 逐行正则);传给 `MarkdownText` 的 labels 对象按 `t` 记忆化(宿主自己的 `ReasoningContent` 正是这么做的,每次新建对象会让下游 memo 失效)。
116
+ - **0.1.4** —— 按评审意见把降级路径**合成一个函数**:此前「合成失败」「宿主投影抛错」「最后兜底」各自写了一份降级逻辑(一份调宿主投影、一份内联拼裸文本),三处要人工保持同步。现在只有 `degradedBody()` 一处:先试宿主自己的投影(保住引用 chip 等宿主渲染),投影也抛才退到裸文本;三条降级路径全部走它。内容优先这一点写进函数契约:裸文本是最后一步,只有宿主自己拒绝投影时才到。新增回归:断言 `projectUserText` 全仓只有一次调用、裸文本 span 只有一处构造、降级调用方都走同一函数(再造一份降级该测试转红)。
98
117
  - **0.1.3** —— 把「座位 render 绝不抛」这条不变式补完:0.1.2 守住了合成与文案查询,但仍有形状会在**座位自己的渲染里**抛错(`node` 缺失 → `node.data`、`props` 缺失 → 解构本身、`new Date(time)` 遇到 Symbol / BigInt / 抛错的 valueOf、宿主图片渲染器抛错)。这些同样冒泡到槽位机制、退掉整条条目,症状与 0.1.2 修的完全一样。现在三层兜底:座位本体一层 try 并把 `node` / `data` 的取用改成防御式;时钟与附件卡在各自调用点兜底;最后一层 `renderPlainRow` 保证最坏情况只是这一行退回纯文本,而操作条与兄弟插件锚点仍在。新增回归:21 种恶意宿主形状逐一断言不抛、且行与操作条都在。
99
118
  - **0.1.2** — 座位组件不再可能因渲染期异常被槽位机制摘除:`composeUserMarkdown` / `buildMarkdownLabels` 在座位渲染过程中运行,抛错不会被错误边界接住(错误边界只能接住其后代,接不住创建它的那次渲染),会冒泡到槽位机制并把该条目整条退掉——屏幕上就是气泡交还给宿主、Markdown 变回源码。现在合成与所有文案查询都在座位内部兜底:任何宿主形状不符(节点结构异常、投影返回不可识别、locale 席位不可调用)都退回宿主原始投影,气泡外壳、操作条与兄弟插件锚点全部保留。`data.content` 非数组、`t` 不可调用等情形一并加进回归测试。
100
119
  - **0.1.1** — 修复两个引用 chip 之间只有空白时该空白被当作空 Markdown 块丢弃的问题:相邻 chip 不再粘成 `@a@b`,chip 之间的换行也按宿主 `pre-wrap` 气泡的样子保留为换行(空白段渲染为 `data-dshmb-gap` 的预换行节点,仍不产生空 Markdown 块);自建的操作条 / 复制按钮补上 `data-dshmb-actions` / `data-dshmb-action` 命名空间标记;座位优先级 `-1` 的宿主依据(宿主默认 0、最低者渲染、同优先级抛错)与三个兄弟插件依赖的锚点结构一并加进回归测试。
@@ -179,6 +198,7 @@ Queued-message previews, the composer itself and the goal panel stay plain text
179
198
 
180
199
  ### Fixes
181
200
 
201
+ - **0.1.4** — synthesizes the degradation paths into ONE function, as the reviewer asked: composition failure, a throwing host projection and the last-resort net each used to carry their own copy (one calling the host projection, one building the raw-text span inline), all kept in step by hand. There is now a single `degradedBody()`: it tries the host's own projection first — so reference chips and the host's own inline pieces survive — and only a projection that itself throws degrades to the raw text. Content first is now the function's contract: raw text is the last step, reached only when the host refuses to project. New regression: exactly one `projectUserText` call in the whole file, one raw-text span construction, and every degradation caller sharing the function (re-introducing a second copy turns it red).
182
202
  - **0.1.3** — completes the "the seat render never throws" invariant: 0.1.2 guarded composition and the copy lookups, but shapes still threw in the seat's own render (an absent `node` -> `node.data`, absent props -> the destructuring itself, a `time` the Date constructor refuses — Symbol, BigInt, a `valueOf` that throws — and a host image renderer that throws). Each escaped to the slot machinery and retired the entry, the same symptom 0.1.2 fixed. Three layers now: one try around the seat body with defensive `node` / `data` access, per-call guards for the clock and the attachment card, and a last-resort `renderPlainRow` so the worst case is one row falling back to plain text with its action strip and the sibling anchors intact. Regression: 21 hostile host shapes, each asserted not to throw and to keep the row and its strip.
183
203
  - **0.1.2** — the seat component can no longer be retired by a render-time exception: `composeUserMarkdown` / `buildMarkdownLabels` run while the seat renders, and a throw there is not caught by the error boundary (a boundary catches its descendants, not the render that creates it) — it escapes to the slot machinery, which drops the whole entry and hands the row back to the host, so the bubble reverts to raw source on screen. Composition and every copy lookup now degrade inside the seat: any host-shape mismatch (an unexpected node, an unrecognised projection result, a locale seat that is not callable) falls back to the host's plain projection while the bubble shell, the action strip and the sibling-plugin anchors stay intact. `data.content` that is not an array and an uncallable `t` are covered by regression tests.
184
204
  - **0.1.1** — fixed whitespace-only plain runs between two reference chips being dropped as an empty Markdown block: adjacent chips no longer glue into `@a@b`, and a newline between two chips stays a line break the way the host's `pre-wrap` bubble shows it (the run renders as a `data-dshmb-gap` pre-wrap node, still never an empty Markdown block); the self-created action strip and copy button now carry the `data-dshmb-actions` / `data-dshmb-action` namespace markers; and the host evidence behind `priority: -1` (host default 0, lowest renders, same priority throws) plus the anchor structure the three sibling plugins rely on are covered by regression tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-markdown-bubble",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Markdown rendering for DeepSeek Harness sent user messages: every message bubble renders lists, code, tables, quotes and inline formatting through the host's own Markdown pipeline, while queues, previews and the composer keep their plain projection.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -48,7 +48,7 @@
48
48
  ]
49
49
  },
50
50
  "engines": {
51
- "dsh": ">=0.2.0-rc.1"
51
+ "dsh": ">=0.2.1-rc.2"
52
52
  }
53
53
  }
54
54
  }
package/src/client.js CHANGED
@@ -49,7 +49,7 @@ window.__ModuleLoader__.load({
49
49
  const NS = 'dsh-markdown-bubble'
50
50
 
51
51
  /** Keep in sync with package.json and src/index.js. */
52
- const PLUGIN_VERSION = '0.1.3'
52
+ const PLUGIN_VERSION = '0.1.5'
53
53
 
54
54
  /** Presence marker the live verifier reads. */
55
55
  const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
@@ -189,7 +189,27 @@ window.__ModuleLoader__.load({
189
189
  * @param text - raw logged message text.
190
190
  * @returns markdown source for the renderer.
191
191
  */
192
+ // Prepared markdown source, keyed by the raw text. A bubble repaints on
193
+ // every transcript update (a streaming turn re-renders constantly) and the
194
+ // preparation is a split/join plus a regex per line, so the same message was
195
+ // re-walked dozens of times while an unrelated row streamed. Cleared whole
196
+ // when it grows past the cap: the entries are strings, and a long transcript
197
+ // has far fewer distinct message texts than it has repaints.
198
+ const markdownSourceCache = new Map()
199
+ const MARKDOWN_CACHE_LIMIT = 64
200
+
192
201
  function toMarkdown(text) {
202
+ const key = typeof text === 'string' ? text : ''
203
+ const cached = markdownSourceCache.get(key)
204
+ if (cached !== undefined) return cached
205
+ const source = prepareMarkdown(key)
206
+ if (markdownSourceCache.size >= MARKDOWN_CACHE_LIMIT) markdownSourceCache.clear()
207
+ markdownSourceCache.set(key, source)
208
+ return source
209
+ }
210
+
211
+ /** The uncached preparation: CRLF, session wire links, soft breaks. */
212
+ function prepareMarkdown(text) {
193
213
  const normalized = text.replace(/\r\n?/gu, '\n')
194
214
  return walkOutsideFences(normalized, (line, next) => hardBreakLine(line.replace(SESSION_WIRE_RE, '$1'), next))
195
215
  }
@@ -249,12 +269,44 @@ window.__ModuleLoader__.load({
249
269
  }
250
270
  }
251
271
 
252
- /** The host projection, called in a guard: it is the fallback of last resort. */
253
- function plainProjection(text, referenceLabels, skillNames, references) {
272
+ /**
273
+ * The ONE degraded projection: what this plugin shows whenever the markdown
274
+ * pipeline cannot be used.
275
+ *
276
+ * The host's own projection is tried first — it is the rendering the host
277
+ * would have used, so reference chips and inline pieces survive it — and a
278
+ * projection that throws degrades once more, to the raw text. Composition
279
+ * failure (see {@link safeMarkdownBody}), a boundary catch and the
280
+ * last-resort net (see {@link renderPlainRow}) ALL answer here, so the ways
281
+ * this plugin degrades cannot drift apart: there is no second copy of the
282
+ * fallback for someone to keep in step.
283
+ *
284
+ * Content comes first: the raw-text step is the last step, reached only when
285
+ * the host itself refuses to project.
286
+ *
287
+ * @param text - the message text.
288
+ * @param referenceLabels - session recall labels.
289
+ * @param skillNames - skill names.
290
+ * @param renderFactorySlot - the seat's Factory binding (chips + plain runs).
291
+ * @returns the degraded body, never null and never a throw.
292
+ */
293
+ function degradedBody(text, referenceLabels, skillNames, renderFactorySlot) {
254
294
  try {
255
- return primitives.projectUserText(text, referenceLabels, skillNames, 'skill', references)
295
+ if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
296
+ // The projection moved into the host's own Factory in 0.2.1-rc.2
297
+ // (`conversation.user.text`, registered by dsh-client-ui-conversation;
298
+ // it owns the keyed child slot that renders a reference chip). The old
299
+ // public primitive `primitives.projectUserText` was REMOVED in the same
300
+ // release, which is why this call used to throw and chips degraded to
301
+ // plain text.
302
+ return renderFactorySlot('conversation.user.text', {
303
+ text,
304
+ facts: { sessionLabels: referenceLabels, skillNames },
305
+ interactive: true,
306
+ ellipsis: false,
307
+ })
256
308
  } catch (error) {
257
- console.warn('[' + NS + '] the host user-text projection failed; showing raw text', error)
309
+ warnOnce('the host user-text projection failed; showing raw text', error)
258
310
  return h('span', { className: 'dshmb-plain' }, text)
259
311
  }
260
312
  }
@@ -269,22 +321,21 @@ window.__ModuleLoader__.load({
269
321
  * the row back to the host's raw renderer; on screen that is
270
322
  * indistinguishable from "the plugin stopped working". A host-shape
271
323
  * mismatch (an unexpected node shape, an unrecognised projection result)
272
- * therefore falls back to the plain projection right here, inside our own
324
+ * therefore falls back to {@link degradedBody} right here, inside our own
273
325
  * render, so the bubble keeps its shell, its actions and the sibling
274
326
  * plugins' injection anchors.
275
327
  * @param text - the message text.
276
328
  * @param t - the injected chat translator.
277
329
  * @param referenceLabels - session recall labels.
278
330
  * @param skillNames - skill names.
279
- * @param references - file/skill open callbacks.
331
+ * @param renderFactorySlot - the seat's Factory binding.
280
332
  * @returns the composed body, or null when markdown cannot be produced.
281
333
  */
282
- function safeMarkdownBody(text, t, referenceLabels, skillNames, references) {
334
+ function safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels) {
283
335
  try {
284
- const labels = buildMarkdownLabels(t)
285
- return composeUserMarkdown(text, labels, referenceLabels, skillNames, references)
336
+ return composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot)
286
337
  } catch (error) {
287
- console.warn('[' + NS + '] markdown composition failed; showing the plain projection', error)
338
+ warnOnce('markdown composition failed; showing the degraded projection', error)
288
339
  return null
289
340
  }
290
341
  }
@@ -321,12 +372,14 @@ window.__ModuleLoader__.load({
321
372
  * @param labels - markdown chrome labels.
322
373
  * @param referenceLabels - session recall labels the host associates with the message.
323
374
  * @param skillNames - skill names the host loaded for the message.
324
- * @param references - file/skill open callbacks for clickable chips.
375
+ * @param renderFactorySlot - the seat's Factory binding.
325
376
  * @returns the composed body, or null for empty text.
326
377
  */
327
- function composeUserMarkdown(text, labels, referenceLabels, skillNames, references) {
378
+ function composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot) {
328
379
  if (typeof text !== 'string' || text === '') return null
329
- const projected = plainProjection(text, referenceLabels, skillNames, references)
380
+ // The same single degraded projection, so a projection that throws inside
381
+ // composition degrades exactly as one that throws on the healthy path.
382
+ const projected = degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
330
383
  const pieces =
331
384
  react.isValidElement(projected) && projected.type === Fragment && Array.isArray(projected.props.children)
332
385
  ? projected.props.children
@@ -503,7 +556,12 @@ window.__ModuleLoader__.load({
503
556
  }
504
557
 
505
558
  render() {
506
- return this.state.failed ? this.props.fallback : this.props.children
559
+ if (!this.state.failed) return this.props.children
560
+ const { fallback } = this.props
561
+ // A thunk is called only when it is actually needed: building the
562
+ // degraded projection on every healthy render is wasted work (and, on a
563
+ // host whose projection is gone, a caught throw every time).
564
+ return typeof fallback === 'function' ? fallback() : fallback
507
565
  }
508
566
  }
509
567
 
@@ -593,9 +651,24 @@ window.__ModuleLoader__.load({
593
651
  }
594
652
  }
595
653
 
596
- function renderAttachmentEntry({ attachment, renderMessageImages, compactImages }) {
654
+ function renderAttachmentEntry({ attachment, renderFactorySlot, loadImage, compactImages }) {
597
655
  if (attachment.type === 'image') {
598
- return h(Fragment, null, renderMessageImages({ images: [attachment.image], align: 'end', compact: compactImages }))
656
+ // The host's image gallery, through the SAME Factory seam as the text
657
+ // projection. The three routes that look obvious are all closed on
658
+ // 0.2.1-rc.2: the `renderMessageImages` seat prop is gone, the child
659
+ // slot `conversation.chat.user.images` cannot be declared by a plugin
660
+ // ("already declared (by factory conversation.chat.user.content)",
661
+ // verified live), and `@deepseek-ai/dsh-client-ui-attachment` exports
662
+ // only apply/inject. The gallery Factory is the one that works: the
663
+ // attachment plugin registers `conversation.images.content` at root
664
+ // scope and its own `MessageImages` is nothing but a call to it.
665
+ if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
666
+ return h(Fragment, null, renderFactorySlot('conversation.images.content', {
667
+ images: [attachment.image],
668
+ loadImage,
669
+ align: 'end',
670
+ compact: compactImages,
671
+ }))
599
672
  }
600
673
  const file = attachment.file ?? {}
601
674
  const name = typeof file.name === 'string' ? file.name : ''
@@ -627,7 +700,7 @@ window.__ModuleLoader__.load({
627
700
  return renderMarkdownBubble(props)
628
701
  } catch (error) {
629
702
  // Last-resort net. Everything that can throw is guarded where it is
630
- // called (see safeMarkdownBody, plainProjection, formatMessageClock,
703
+ // called (see safeMarkdownBody, degradedBody, formatMessageClock,
631
704
  // AttachmentEntry); this exists so an UNKNOWN host shape degrades one
632
705
  // row to plain text instead of retiring the renderer. The fallback still
633
706
  // renders the action strip, because the three sibling plugins anchor
@@ -646,21 +719,31 @@ window.__ModuleLoader__.load({
646
719
  function renderMarkdownBubble(props) {
647
720
  const safeProps = props !== null && typeof props === 'object' ? props : {}
648
721
  const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
649
- const renderMessageImages = safeProps.renderMessageImages
650
- const openFile = safeProps.openFile
651
- const openSkill = safeProps.openSkill
722
+ // The seat's props on 0.2.1-rc.2: the two renderer bindings and the image
723
+ // loader. The old `renderMessageImages` / `openFile` / `openSkill` pair is
724
+ // gone; images now go through the declared child slot and reference chips
725
+ // through the `conversation.user.text` Factory.
726
+ const renderFactorySlot = safeProps.renderFactorySlot
727
+ const renderSlot = safeProps.renderSlot
728
+ const loadImage = safeProps.loadImage
652
729
  const t = safeProps.t
653
730
  const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
654
731
  const { text, attachments, rest } = contentParts(Array.isArray(data.content) ? data.content : [])
655
732
  const referenceLabels = Array.isArray(data.referenceLabels) ? data.referenceLabels : []
656
733
  const skillNames = Array.isArray(data.skillNames) ? data.skillNames : []
657
- const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
658
734
  const compactImages = attachments.length > 1
659
735
  const showBubble = text !== '' || rest.length > 0
736
+ // The host memoizes its own label object before handing it to MarkdownText
737
+ // (`ReasoningContent` does exactly this); a fresh object every render
738
+ // defeats that memoization and re-renders the whole markdown tree.
739
+ const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
660
740
 
661
741
  // Never let composition throw out of this render — see safeMarkdownBody.
662
- const body = safeMarkdownBody(text, t, referenceLabels, skillNames, references)
663
- const fallback = plainProjection(text, referenceLabels, skillNames, references)
742
+ // ONE degraded body per render: it is the composition guard's projection
743
+ // and the boundary's fallback, so building it eagerly (as this used to)
744
+ // ran the projection twice per render and, on 0.2.1-rc.2, threw twice.
745
+ const body = safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels)
746
+ const degraded = () => degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
664
747
 
665
748
  // The action strip leads the row in DOM order and is pushed below the
666
749
  // bubble by flex `order`. Sibling plugins pick their bar with
@@ -684,7 +767,8 @@ window.__ModuleLoader__.load({
684
767
  h(AttachmentEntry, {
685
768
  key: `${attachment.type}:${index}`,
686
769
  attachment,
687
- renderMessageImages,
770
+ renderFactorySlot,
771
+ loadImage,
688
772
  compactImages,
689
773
  }),
690
774
  ),
@@ -693,7 +777,7 @@ window.__ModuleLoader__.load({
693
777
  h(
694
778
  'div',
695
779
  { className: 'dshmb-bubble' },
696
- h(MarkdownBoundary, { key: text, fallback }, body === null ? fallback : body),
780
+ h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded() : body),
697
781
  rest.map((block, index) =>
698
782
  h(primitives.JsonBlock, {
699
783
  key: 'rest' + index,
@@ -725,19 +809,28 @@ window.__ModuleLoader__.load({
725
809
  const safeProps = props !== null && typeof props === 'object' ? props : {}
726
810
  const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
727
811
  const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
728
- let text = ''
812
+ let parts
729
813
  try {
730
- text = contentParts(Array.isArray(data.content) ? data.content : []).text
814
+ parts = contentParts(Array.isArray(data.content) ? data.content : [])
731
815
  } catch {
732
- text = ''
816
+ parts = { text: '', attachments: [], rest: [] }
733
817
  }
818
+ const text = parts.text
819
+ // The same degraded projection the healthy path uses, so a row that
820
+ // degraded in one place looks exactly like a row that degraded in another.
821
+ const degraded = degradedBody(
822
+ text,
823
+ Array.isArray(data.referenceLabels) ? data.referenceLabels : [],
824
+ Array.isArray(data.skillNames) ? data.skillNames : [],
825
+ safeProps.renderFactorySlot,
826
+ )
734
827
  return h(
735
828
  'div',
736
829
  { className: 'dshmb-row', 'data-dshmb-row': '1', 'data-dshmb-degraded': '1' },
737
830
  h(MessageActions, { text, time: data.time, t: safeProps.t }),
738
831
  text === ''
739
832
  ? null
740
- : h('div', { className: 'dshmb-stack' }, h('div', { className: 'dshmb-bubble' }, h('span', { className: 'dshmb-plain' }, text))),
833
+ : h('div', { className: 'dshmb-stack' }, h('div', { className: 'dshmb-bubble' }, degraded)),
741
834
  )
742
835
  }
743
836
 
package/src/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  export const PLUGIN_ID = 'dsh-markdown-bubble'
16
16
 
17
17
  /** Keep in sync with package.json and src/client.js. */
18
- export const PLUGIN_VERSION = '0.1.3'
18
+ export const PLUGIN_VERSION = '0.1.5'
19
19
 
20
20
  /** Cordis plugin name; the bundle patch resolves the package by this row. */
21
21
  export const name = PLUGIN_ID