diff --git a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml
index 8826b372a2..4834371d87 100644
--- a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml
@@ -9,11 +9,11 @@
en: d16c3bfef49e16be
zh: 6714da1c9c984fe9
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision:
- en: 3a4380324cfc1679
- zh: 44db368e091cbd46
+ en: d23bc3c8c3b20ac9
+ zh: 874c7a4dda283e89
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision/implementation:
- en: 01189e457c91deeb
- zh: 94f7e8865b332a07
+ en: 02ec3c2bfad692af
+ zh: 229a3ae12eac49a6
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision/first-consumer:
en: 1afdf4ceeef7a3b3
zh: be01cf39850db0ca
@@ -21,5 +21,5 @@
en: b4824619c502b422
zh: 610364f2dc8cb2b7
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/consequences:
- en: 7542ee2bc2c70258
- zh: bc0dd1dc6e50de66
+ en: 6d350e8f9df1d33a
+ zh: 5411b39fc4cc14ce
diff --git a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md
index ccfb0d11f9..a79d28ceda 100644
--- a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md
+++ b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md
@@ -20,27 +20,29 @@ The previous approach had the Conversation classify blank clicks on its body and
## Decision
+[The Markdown fence keyed hoc slot note](2026-11-05-markdown-fence-hoc-slot.md) records why hoc layers take an optional dispatch `key`, may omit `children`, share compiled chains per key, and stay registered after a crash.
+
Add a fifth kind: `hoc`.
| Item | Contract |
|---|---|
| Declaration | The parent entry's `children` lists `{ kind: 'hoc', scope }`; `SlotMap` declares `kind: 'hoc'` |
-| Rendering | `renderSlot(key, owner, { children })`; `entryKey` / `only` / `fallback` / `hookContext` are unavailable; omitted `children` renders the layers around nothing |
-| Registration | Same as `list`: required `id`, optional `order` and `priority`; the same `id` at the same `priority` throws, a different `priority` shadows |
+| Rendering | `renderSlot(key, owner, { children, entryKey })`; `only` / `fallback` / `hookContext` are unavailable; omitted `children` renders the layers around nothing; omitted `entryKey` applies only wildcard layers |
+| Registration | Required `id`, optional `key`, `order`, and `priority`; a layer without `key` is a wildcard serving every dispatch, a layer with `key` serves only that `entryKey`; the cell is `(key or wildcard, id)`, and within one `id` an exact-key layer replaces the wildcard layer for that key; the same cell at the same `priority` throws, a different `priority` shadows |
| Nesting order | Ascending `order`, then `priority`, then registration order; lowest outermost |
| Layer props | Every layer receives the same owner props plus the framework-supplied `children` (the next inner layer; the innermost is the owner content); `children` is not an owner prop, `HocShare` types it, and owner props must not use that name |
-| Layer obligation | Render `children` exactly once without creating a box of its own; do not change the props passed inward |
+| Layer obligation | Render `children` at most once; a layer that omits it replaces everything inside it; a layer that wraps `children` creates no box of its own unless its slot documents otherwise; do not change the props passed inward |
| No entry | `children` renders unchanged |
-| Layer crash | Each layer has its own error boundary; a crashed layer renders its `children` and abdicates, so the next render omits it |
+| Layer crash | Each layer has its own error boundary; a crashed layer reports its error and renders its `children` at the occurrence that failed for the rest of that occurrence's mount, even when its owner props change; it does not abdicate, so other occurrences, including those dispatching the same key, keep it, and a remounted occurrence renders it again |
| Content error | An error thrown by the wrapped content passes every layer as its original error and is reported against no layer |
-| Remounting | Adding or removing a layer remounts the content inside it; a `session` or `session-maybe` layer also remounts it when its Session binding changes |
+| Remounting | Adding or removing a layer that applies to a dispatch remounts the content inside it, while a registration for another key leaves it mounted; a `session` or `session-maybe` layer also remounts it when its Session binding changes |
### Implementation
| Package | Realization |
|---|---|
-| `ui-slots` | `SlotKind` includes `'hoc'`; `HocRenderOpts`, `HocKeysOf`, `HocShare`; `RenderSlotFn` has a hoc-key overload taking optional `{ children }`; `KindOptions` has a hoc branch; `SlotCore.register` / `entriesOfSlot` key hoc cells by `id` |
-| `ui-renderer` | `HocOutlet` calls `composeHocLayers` when the slot's ledger version or declaration changes (`useMemo` on the outlet instance) and nests the layers into one render function. Each layer is one Component that fixes its scope-specific Entry (`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`), entry, slot inject, and crash reporting at composition, and receives only `owner` and the next inner layer when rendering. With no layer the outlet returns the owner content directly. `HocContentBoundary` wraps each layer's inner content and rethrows its errors as `HocContentError`, which layer boundaries pass through and `HocOutletBoundary` unwraps to the original error. The Entry chain passes `children` to the registered Component as JSX children; non-hoc entries receive `undefined`. `SlotErrorBoundary` takes a `crashed` replacement node |
+| `ui-slots` | `SlotKind` includes `'hoc'`; `HocRenderOpts` (optional `children` and `entryKey`), `HocKeysOf`, `HocShare`; `RenderSlotFn` has a hoc-key overload taking optional `{ children, entryKey }`; `KindOptions` has a hoc branch with optional `key`; `SlotCore.register` / `entriesOfSlot` key hoc cells by `(key or wildcard, id)` |
+| `ui-renderer` | `HocOutlet` takes its render function from `hocChainFor`, a cache per host, live declaration, and dispatch key that every outlet dispatching the same key shares. Once per ledger version it reselects the key's layers with `hocLayersFor` (wildcard winners plus exact-key winners, an exact-key layer replacing the wildcard layer with its id, sorted by `order`) and calls `composeHocLayers` to nest them into one render function only when that ordered list changes by entry identity. Each layer is one Component that fixes its scope-specific Entry (`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`), entry, slot inject, and crash reporting at composition, and receives only `owner` and the next inner layer when rendering. With no layer the outlet returns the owner content directly. `HocContentBoundary` wraps each layer's inner content and rethrows its errors as `HocContentError`, which layer boundaries pass through and `HocOutletBoundary` unwraps to the original error. The Entry chain passes `children` to the registered Component as JSX children; non-hoc entries receive `undefined`. `SlotErrorBoundary` takes a `crashed` replacement node, which a layer's boundary keeps rendering for the rest of its mount; layer crash reports pass `abdicate: false` |
| `scripts/gen-client-catalog.ts` | Register options, a `children`-rendering example, and the `wraps-owner-content` replace risk for hoc slots |
### First consumer
@@ -68,5 +70,5 @@ Add a fifth kind: `hoc`.
- React 18 recollects the component stack where an error is rethrown, so a content error reaching boundaries above a hoc slot with layers carries a component stack that starts at `HocOutletBoundary`.
- A layer that wraps its `children` in its own error boundary catches the internal `HocContentError` rather than the original content error.
- The frame contract holds only on the home: other phases leave the Conversation root out of the stacking order, so unportaled Conversation popovers still rise above AppFrame chrome that floats over the column, while on the home they stack only within the root.
-- `HocOutlet` keeps its layer Components through `useMemo`, as Factory local Components keep theirs; if React ever discards memoized values, both need an explicit cache, because these Components are the remount boundary of the content inside them.
+- `HocOutlet` keeps its layer Components in a compile cache shared per host, live declaration, and dispatch key, not in outlet-instance state, so they do not depend on React retaining memoized values; these Components are the remount boundary of the content inside them, and a redeclared slot starts with an empty cache.
- [The slot system standard](2026-07-22-slot-type-chain-implementation.md) remains authoritative for the other kinds; this note adds the fifth.
diff --git a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md
index f09a37fede..0f3ff1dc88 100644
--- a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md
+++ b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md
@@ -20,27 +20,29 @@ Status: implemented
## Decision
+[Markdown fence keyed hoc slot 的 Note](2026-11-05-markdown-fence-hoc-slot.zh.md) 记录了 hoc 层为何接受可选的分派 `key`、可以不渲染 `children`、为何按 key 共享编译好的层链,以及为何崩溃后仍保持注册。
+
新增第五种 kind:`hoc`。
| 项 | 约定 |
|---|---|
| 声明 | 父 entry 的 `children` 写 `{ kind: 'hoc', scope }`;`SlotMap` 中 `kind: 'hoc'` |
-| 渲染 | `renderSlot(key, owner, { children })`;`entryKey` / `only` / `fallback` / `hookContext` 不可用;省略 `children` 时各层包裹空内容 |
-| 注册 | 与 `list` 相同:必填 `id`,可选 `order`、`priority`;同 `id` 同 `priority` 抛错,不同 `priority` 遮蔽 |
+| 渲染 | `renderSlot(key, owner, { children, entryKey })`;`only` / `fallback` / `hookContext` 不可用;省略 `children` 时各层包裹空内容;省略 `entryKey` 时只叠加通配层 |
+| 注册 | 必填 `id`,可选 `key`、`order`、`priority`;不带 `key` 的层是通配层,服务所有分派,带 `key` 的层只服务该 `entryKey`;cell 为 `(key 或通配, id)`,同一 `id` 下精确 key 的层替换该 key 的通配层;同 cell 同 `priority` 抛错,不同 `priority` 遮蔽 |
| 嵌套顺序 | 依次按 `order`、`priority`、注册顺序升序,最小者在最外层 |
| 层的 props | 每层收到同一份 owner props,加上框架注入的 `children`(内一层,最内层是宿主内容);`children` 不属于 owner props,类型由 `HocShare` 提供,owner props 不得使用这个名字 |
-| 层的义务 | 恰好渲染一次 `children`,自身不产生盒子;不能修改传给内层的 props |
+| 层的义务 | 至多渲染一次 `children`;不渲染 `children` 的层替换其内部全部内容;包裹 `children` 的层自身不产生盒子(其 slot 另有说明的除外);不能修改传给内层的 props |
| 无 entry | 原样渲染 `children` |
-| 层崩溃 | 每层有独立 error boundary;崩溃时该层渲染自己的 `children` 并 abdicate,下一次渲染不再包含该层 |
+| 层崩溃 | 每层有独立 error boundary;崩溃时错误照常上报,该层在出错的那一处渲染自己的 `children`,直到该处本次挂载结束,owner props 变化也不恢复;该层不 abdicate,因此其他出现处(包括分派同一 key 的出现处)仍保留它,重新挂载的出现处会再次渲染它 |
| 内容出错 | 被包裹内容抛出的错误以原始错误穿过所有层,不记到任何层名下 |
-| 重新挂载 | 增减一层会让其内部内容重新挂载;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载 |
+| 重新挂载 | 增减一个对本次分派生效的层会让其内部内容重新挂载,为其他 key 注册的层不会;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载 |
### 实现
| 包 | 实现 |
|---|---|
-| `ui-slots` | `SlotKind` 包含 `'hoc'`;`HocRenderOpts`、`HocKeysOf`、`HocShare`;`RenderSlotFn` 为 hoc key 提供接受可选 `{ children }` 的重载;`KindOptions` 有 hoc 分支;`SlotCore.register` / `entriesOfSlot` 按 `id` 划分 hoc cell |
-| `ui-renderer` | `HocOutlet` 在插槽 ledger 版本或声明变化时调用 `composeHocLayers`(`useMemo` 缓存在 outlet 实例上),把各层组合成一个嵌套渲染函数。每层是一个组件,在组合时固定按 scope 选定的 Entry(`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`)、entry、slot inject 与崩溃上报,渲染时只接收 `owner` 与内一层。没有层时 outlet 直接返回宿主内容。`HocContentBoundary` 包住每层的内层内容,把其中的错误包装为 `HocContentError` 重新抛出;层的边界放行这种错误,`HocOutletBoundary` 再解开为原始错误。Entry 链以 JSX children 把 `children` 交给注册组件;非 hoc entry 收到 `undefined`。`SlotErrorBoundary` 接受 `crashed` 替换节点 |
+| `ui-slots` | `SlotKind` 包含 `'hoc'`;`HocRenderOpts`(可选 `children` 与 `entryKey`)、`HocKeysOf`、`HocShare`;`RenderSlotFn` 为 hoc key 提供接受可选 `{ children, entryKey }` 的重载;`KindOptions` 的 hoc 分支含可选 `key`;`SlotCore.register` / `entriesOfSlot` 按 `(key 或通配, id)` 划分 hoc cell |
+| `ui-renderer` | `HocOutlet` 从 `hocChainFor` 取渲染函数,该缓存按 host、存活声明与分派 key 划分,分派同一 key 的所有 outlet 共享。每个 ledger 版本它只用 `hocLayersFor` 重新选出一次该 key 的层(通配胜者加精确 key 胜者,精确 key 的层替换同 id 的通配层,按 `order` 排序),仅当这一有序列表按 entry 身份比较发生变化时才调用 `composeHocLayers` 把各层组合成一个嵌套渲染函数。每层是一个组件,在组合时固定按 scope 选定的 Entry(`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`)、entry、slot inject 与崩溃上报,渲染时只接收 `owner` 与内一层。没有层时 outlet 直接返回宿主内容。`HocContentBoundary` 包住每层的内层内容,把其中的错误包装为 `HocContentError` 重新抛出;层的边界放行这种错误,`HocOutletBoundary` 再解开为原始错误。Entry 链以 JSX children 把 `children` 交给注册组件;非 hoc entry 收到 `undefined`。`SlotErrorBoundary` 接受 `crashed` 替换节点,层的边界在本次挂载内一直渲染该节点;层的崩溃上报传 `abdicate: false` |
| `scripts/gen-client-catalog.ts` | hoc 插槽的注册选项、渲染 `children` 的示例,以及 `wraps-owner-content` 替换风险 |
### 首个使用者
@@ -68,5 +70,5 @@ Status: implemented
- React 18 会在错误被重新抛出的位置重新采集组件栈,因此有层的 hoc 插槽之上的边界收到的内容错误,组件栈从 `HocOutletBoundary` 开始。
- 层如果自己在 `children` 外包错误边界,捕获到的是内部的 `HocContentError`,而不是原始的内容错误。
- frame 契约只在首页阶段成立:其他阶段根元素不参与层叠隔离,未经 portal 的 Conversation 浮层仍能盖住悬浮在中栏上方的 AppFrame 元素;首页阶段它们只在根元素内部比较层级。
-- `HocOutlet` 与 Factory 局部组件一样用 `useMemo` 保持层组件身份;如果 React 将来会丢弃 memo 缓存,两处都需要改成显式缓存,因为这些组件是其内部内容的重新挂载边界。
+- `HocOutlet` 把层组件保存在按 host、存活声明与分派 key 共享的编译缓存中,而不是 outlet 实例状态里,因此不依赖 React 保留 memo 缓存;这些组件是其内部内容的重新挂载边界,重新声明的插槽从空缓存开始。
- 其他 kind 仍以 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 为准;本 Note 新增第五种。
diff --git a/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.i18n.yaml
new file mode 100644
index 0000000000..468e7726f7
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.i18n.yaml
@@ -0,0 +1,31 @@
+# Bilingual-pair consistency record for 2026-11-05-markdown-fence-hoc-slot.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.md
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot:
+ en: 08e828c3c043e2c1
+ zh: badc07274d22c3d3
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/problem:
+ en: 47f1840013d9a25a
+ zh: daa30c8a43adb61d
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision:
+ en: a15c7c13f167e5ac
+ zh: ff261e3705c47ff0
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/developer-facing-api:
+ en: 03db9adf39724644
+ zh: 59350008e49737c3
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/hoc-slots-with-dispatch-keys:
+ en: efd632de6cd9612b
+ zh: 996ebdce579fa62a
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/code-blocks:
+ en: 8ca893405e949ced
+ zh: d08790568180ec36
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/spacing:
+ en: 5a1f4e68a5eb8492
+ zh: cc50dc7d70fe6e3b
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/alternatives-considered:
+ en: 7cc525b5fc20a009
+ zh: 00698b2f024a29e8
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/consequences:
+ en: fd3f897b3fd9fc1b
+ zh: 75ab3c49866662e0
diff --git a/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.md b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.md
new file mode 100644
index 0000000000..559fd5d9d1
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.md
@@ -0,0 +1,96 @@
+# Agent Note: Dispatch Markdown fences by language through a keyed hoc slot
+
+Status: implemented
+
+English | [中文](2026-11-05-markdown-fence-hoc-slot.zh.md)
+
+## Problem
+
+A Markdown fence is a fenced code block: an opening line of three or more backticks or tildes followed by an info string, such as `ts title=demo`, then the body and a closing fence. The info string's first word names the fence's language.
+
+The Chat flow renders fences by language, and plugins need to register, replace, or decorate the rendering of one language. Primitives choose fence rendering in fixed branches — `renderCode` in [render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx) sends `math` to KaTeX, [CodeBlock.tsx](../../../../packages/client/ui-primitives/src/markdown/CodeBlock.tsx) colors `diff` / `patch` rows, and other languages go to shiki. [ui-primitives](../../../../packages/client/ui-primitives/README.md) has no Cordis dependency, so its components cannot reach slots, and the other `MarkdownText` callers must keep their rendering unchanged.
+
+## Decision
+
+### Developer-facing API
+
+The API has two parts: a delegate that hands each fence to its owner, and a keyed hoc slot through which plugins register language renderers.
+
+| Part | Contract |
+|---|---|
+| Delegate | `MarkdownDelegate.renderCode?(fence: MarkdownDelegateFenceInfo): ReactNode`. An owner that supplies it on `MarkdownDelegateProvider` renders every fence in that scope that has a language and content |
+| Fence info | `lang` (the leading letters, digits, `_`, and `-` of the info string's language word, lowercased, so `c++` yields `c`), `code` (the body), `streaming` (whether the document is still streaming), and optional `meta` (the info string after the language word, as written) |
+| Keyed hoc slot | The owner dispatches each fence to its hoc slot with `entryKey: fence.lang` and passes the plain code block as `children`; plugins register layers by language `key` |
+| Chat flow | ChatView supplies the delegate and declares `conversation.chat.markdown.fence` (hoc, session scope) with `MarkdownDelegateFenceInfo` as owner props; its scope is every `MarkdownText` inside `conversation.chat.flow` |
+
+A plugin registers a layer for one language:
+
+```text
+ctx.slots.inject('conversation.chat.markdown.fence', () => ctx.slots.register(
+ { name: 'conversation.chat.markdown.fence', key: 'mermaid', id: '@acme/plugin/mermaid' },
+ ({ code, streaming, children }) => (streaming ? children : ),
+))
+```
+
+The API guarantees:
+
+- `lang` and `meta` are final when `renderCode` first runs: the info-string line ends before content appears, and a fence without content takes the empty-fence branch.
+- The plain code block is the innermost `children` of every dispatch. A language without a layer, a layer that renders `children`, and a crashed layer all show it.
+- Each streamed chunk reaches the same layer instance as new owner props; the layer owns streaming. A layer that cannot render incomplete content renders `children` while `streaming`.
+- A layer that omits `children` replaces the code block; a layer that renders `children` decorates it. A layer may parse `meta` itself and keeps its root free of outer margins.
+- A document without `renderCode` keeps the built-in rendering: diff rows follow `lang`, and `math` renders as KaTeX once settled.
+- Chat registers `math` (source while streaming, display TeX once settled) and `diff` / `patch` (unified-diff rows, `children` omitted) under literal package-path ids.
+
+### Hoc slots with dispatch keys
+
+| Item | Contract |
+|---|---|
+| Registration | Optional `key`; a layer without `key` is a wildcard that serves every dispatch |
+| Cell | `(key or wildcard, id)`; the same cell at the same `priority` throws, and a lower `priority` shadows |
+| Dispatch | `renderSlot(name, owner, { entryKey, children })` applies wildcard layers and layers whose `key === entryKey`; without `entryKey`, only wildcard layers apply |
+| Exact key first | Within one `id`, an exact-key layer replaces the wildcard layer |
+| Nesting | Ascending `order`, then `priority`, then registration order; lowest `order` outermost |
+| children | Rendered at most once; omitting it replaces everything inside the layer |
+| Crash | The failed occurrence renders `children` for the rest of its mount and the error is reported; the layer stays registered (`abdicate: false`) and other occurrences keep it; the occurrence retries when it remounts |
+| Compilation | One chain per `(host, slot declaration, entryKey)`, shared by every outlet; each ledger version reselects that key's layers and recompiles only when they change, so a registration for another key leaves mounted content in place, while switching an outlet's `entryKey` switches chains |
+
+These hoc semantics apply to every hoc slot, including `conversation.hero.frame`, whose layers are all wildcards. This note partially supersedes four decisions of [hoc slot kind](2026-11-03-hoc-slot-kind.md): rendering `children` exactly once, rejecting `entryKey`, per-outlet `useMemo` composition, and abdicating a crashed layer.
+
+### Code blocks
+
+| Item | Contract |
+|---|---|
+| `CodeBlock.diff` | Omitted follows `lang` (`diff` / `patch`); `true` or `false` forces unified-diff rows on or off for any `lang` |
+| `FenceCodeBlock` | Renders a fence body through `CodeBlock`, appending the trailing newline that `CodeBlock` trims, and forwards `diff`; ChatView's dispatch content passes `diff={false}` |
+| `FenceMathBlock` | Renders a `math` fence body as display KaTeX |
+| `MarkdownFenceSeat` | The Component in `render.tsx` that reads the delegate, so cached streaming elements follow a Provider change; without `renderCode` it renders the two presets |
+
+### Spacing
+
+Primitives wrap each delegated fence in a block box, `fenceSeat`, the direct child of `.markdown` or `li`. The box takes a code block's spacing among its siblings, so the slot's `display: contents` anchor inside it does not affect the document rhythm:
+
+| Rule | Box value |
+|---|---|
+| Default | `margin: 16px 0` |
+| Followed by a sibling | `margin-bottom: 11px` |
+| compact variant | `margin: 4px 0` |
+| Last child of a list item | Keeps its margins, like a code block |
+| `.md-code-block` and `.katex-display` inside the box | `margin-block: 0` |
+
+## Alternatives considered
+
+- **Keep `diff` and `math` on fixed primitives branches and open a slot only for new languages**: two mechanisms, and built-in renderings cannot be replaced or decorated.
+- **Register the default code block as a wildcard layer**: other layers would need its `order`, and a layer ordered inside it would never mount because the default omits `children`; as hoc content it needs no id or `order`.
+- **Restore spacing with selectors that skip one wrapper level instead of a box**: they assume exactly one wrapper and one root element, and cannot restore the 11px gap.
+- **Abdicate a crashed layer, as single, keyed, and list do**: one content-triggered crash would remove that language's rendering from every fence until re-registration.
+- **Retry a crashed occurrence when owner props change**: needs state and shallow comparison in the error boundary every kind shares, and a faulty layer would report once per streamed chunk.
+- **Compose layers per outlet**: every same-language fence would compose the same chain, and any registration would remount every fence.
+
+## Consequences
+
+- Plugins add, replace, or decorate Chat flow fence rendering by language without changing primitives; `math`, `diff` / `patch`, and third-party languages share one mechanism.
+- `MarkdownText` callers without `renderCode` render the built-in blocks, with no box and no slot.
+- A layer crash affects only the fence that failed; a faulty layer crashes and reports again at each mount while that fence shows the code block.
+- Layers of every hoc slot may omit `children`, so a `conversation.hero.frame` layer can hide the hero content.
+- Each delegated fence adds one block box and one slot outlet (one registry subscription, one locale subscription, and one `display: contents` element); compiled chains are shared per key.
+- In the Chat flow, display formula spacing comes from the box rather than KaTeX's default 1em, fence banners show the lowercase `lang`, and the `labels` of `conversation.chat.reasoning.content` apply only to footnotes and fences without a language.
diff --git a/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.zh.md b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.zh.md
new file mode 100644
index 0000000000..32daa0c05a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.zh.md
@@ -0,0 +1,96 @@
+# Agent Note: Markdown fence 按语言分派到 keyed hoc slot
+
+Status: implemented
+
+[English](2026-11-05-markdown-fence-hoc-slot.md) | 中文
+
+## 问题
+
+Markdown fence 指围栏代码块:开头一行是三个或更多反引号(或波浪号),后接 info string,例如 `ts title=demo`;随后是正文和闭合围栏。info string 的第一个词表示 fence 的语言。
+
+会话流要按语言渲染 fence,插件需要能注册、替换或装饰某一种语言的渲染。primitives 在固定分支里选择 fence 的渲染方式:[render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx) 的 `renderCode` 把 `math` 交给 KaTeX,[CodeBlock.tsx](../../../../packages/client/ui-primitives/src/markdown/CodeBlock.tsx) 给 `diff` / `patch` 画行着色,其余语言走 shiki。[ui-primitives](../../../../packages/client/ui-primitives/README.zh.md) 不依赖 Cordis,组件拿不到 slot;其他 `MarkdownText` 调用方的渲染必须保持不变。
+
+## 决定
+
+### 面向开发者的 API
+
+API 分两部分:把每个 fence 交给 owner 的 delegate,以及插件按语言注册渲染器的 keyed hoc slot。
+
+| 部分 | 约定 |
+|---|---|
+| Delegate | `MarkdownDelegate.renderCode?(fence: MarkdownDelegateFenceInfo): ReactNode`。在 `MarkdownDelegateProvider` 上提供它的 owner,负责渲染该范围内每个带语言且有内容的 fence |
+| Fence 信息 | `lang`(info string 语言词开头的字母、数字、`_`、`-` 部分转小写,`c++` 得 `c`)、`code`(正文)、`streaming`(文档是否仍在流式输出)、可选的 `meta`(语言词之后的原文) |
+| Keyed hoc slot | owner 用 `entryKey: fence.lang` 把每个 fence 分派到自己的 hoc slot,并把普通代码块作为 `children` 传入;插件按语言 `key` 注册层 |
+| 会话流 | ChatView 提供 delegate 并声明 `conversation.chat.markdown.fence`(hoc,session scope),owner props 为 `MarkdownDelegateFenceInfo`;覆盖 `conversation.chat.flow` 内的全部 `MarkdownText` |
+
+插件为一种语言注册一层:
+
+```text
+ctx.slots.inject('conversation.chat.markdown.fence', () => ctx.slots.register(
+ { name: 'conversation.chat.markdown.fence', key: 'mermaid', id: '@acme/plugin/mermaid' },
+ ({ code, streaming, children }) => (streaming ? children : ),
+))
+```
+
+API 保证:
+
+- `renderCode` 第一次运行时 `lang` 与 `meta` 已经定稿:内容出现之前 info string 那一行已经结束,没有内容的 fence 走空 fence 分支。
+- 普通代码块是每次分派最内层的 `children`。没有对应层的语言、渲染 `children` 的层、崩溃的层,显示的都是它。
+- 每个流式 chunk 以新的 owner props 送到同一个层实例,流式由层自己处理。处理不了不完整内容的层,在 `streaming` 期间渲染 `children`。
+- 不渲染 `children` 的层替换代码块,渲染 `children` 的层装饰代码块。层可以自行解析 `meta`,根元素不设外边距。
+- 没有 `renderCode` 的文档保持内建渲染:diff 行按 `lang` 自动选择,`math` 落定后渲染为 KaTeX。
+- Chat 以字面量包路径 id 注册 `math`(流式显示源码,落定后显示 display TeX)与 `diff` / `patch`(统一 diff 行,不渲染 `children`)。
+
+### 带分派 key 的 hoc slot
+
+| 项 | 约定 |
+|---|---|
+| 注册 | 可选 `key`;不带 `key` 的层是通配层,作用于每次分派 |
+| 格子 | `(key 或通配, id)`;同一格子同一 `priority` 重复注册报错,更低的 `priority` 遮蔽 |
+| 分派 | `renderSlot(name, owner, { entryKey, children })` 叠加通配层与 `key === entryKey` 的层;不传 `entryKey` 时只叠加通配层 |
+| 精确 key 优先 | 同一 `id` 下,精确 key 的层替换通配层 |
+| 嵌套 | 依次按 `order`、`priority`、注册顺序升序,`order` 最小的在最外层 |
+| children | 至多渲染一次;不渲染即替换该层内部的全部内容 |
+| 崩溃 | 出错的那一处在本次挂载内渲染 `children`,错误照常上报;层保持注册(`abdicate: false`),其他出现处保留该层;该处重新挂载时重试 |
+| 编译 | 每个 `(host, slot 声明, entryKey)` 一条层链,所有 outlet 共享;每个 ledger 版本重新选取该 key 的层,只有层变化时才重编,因此为其他 key 注册的层不会让已挂载内容重新挂载,outlet 切换 `entryKey` 则换到另一条层链 |
+
+这些 hoc 语义适用于所有 hoc slot,包括层全部为通配层的 `conversation.hero.frame`。本 note 部分取代 [hoc slot kind](2026-11-03-hoc-slot-kind.zh.md) 的四处决定:恰好渲染一次 `children`、不接受 `entryKey`、按 outlet `useMemo` 组合层、崩溃层 abdicate。
+
+### 代码块
+
+| 项 | 约定 |
+|---|---|
+| `CodeBlock.diff` | 不传时按 `lang`(`diff` / `patch`)自动选择;`true` 或 `false` 对任意 `lang` 强制开关统一 diff 行 |
+| `FenceCodeBlock` | 经 `CodeBlock` 渲染 fence 正文,补上 `CodeBlock` 会去掉的结尾换行,并透传 `diff`;ChatView 分派的 content 传 `diff={false}` |
+| `FenceMathBlock` | 把 `math` fence 正文渲染为 display KaTeX |
+| `MarkdownFenceSeat` | `render.tsx` 中读取 delegate 的组件,流式缓存的元素因此跟随 Provider 变化;没有 `renderCode` 时渲染上面两个预设 |
+
+### 间距
+
+primitives 把每个经 delegate 渲染的 fence 包进块级外框 `fenceSeat`,外框是 `.markdown` 或 `li` 的直接子元素。外框承担代码块与相邻块之间的间距,所以外框内 slot 的 `display: contents` 锚点不影响文档节奏:
+
+| 规则 | 外框取值 |
+|---|---|
+| 默认 | `margin: 16px 0` |
+| 后面还有兄弟元素 | `margin-bottom: 11px` |
+| compact 变体 | `margin: 4px 0` |
+| 列表项最后一个子元素 | 与代码块一样保留外边距 |
+| 外框内的 `.md-code-block` 与 `.katex-display` | `margin-block: 0` |
+
+## 考虑过的替代方案
+
+- **`diff` 与 `math` 留在 primitives 的固定分支里,只为新语言开 slot**:两套机制,内建渲染无法被替换或装饰。
+- **默认代码块注册成通配层**:其他层需要知道它的 `order`,排在它里面的层永远不会挂载,因为默认层不渲染 `children`;作为 hoc content 则不需要任何 id 或 `order`。
+- **不加外框,用跳过一层包装的选择器恢复间距**:这些规则默认恰好一层包装、一个根元素,也补不回 11px 间距。
+- **像 single、keyed、list 那样让崩溃的层 abdicate**:一次由内容触发的崩溃会让该语言的所有 fence 失去渲染,直到重新注册。
+- **owner props 变化时重试崩溃的出现处**:需要在所有 kind 共用的错误边界里加状态与浅比较,有问题的层每个流式 chunk 都会报一次错。
+- **按 outlet 组合层**:每个同语言 fence 都要组合同一条层链,任何注册都会让所有 fence 重新挂载。
+
+## 后果
+
+- 插件无需改动 primitives,就能按语言新增、替换或装饰会话流 fence 的渲染;`math`、`diff` / `patch` 与第三方语言共用同一机制。
+- 没有 `renderCode` 的 `MarkdownText` 调用方渲染内建块,没有外框也没有 slot。
+- 层崩溃只影响出错的那个 fence;有问题的层每次挂载都会再崩一次、再报一次错,该 fence 显示代码块。
+- 所有 hoc slot 的层都可以不渲染 `children`,因此 `conversation.hero.frame` 的层可以隐藏 hero 内容。
+- 每个经 delegate 渲染的 fence 多一个块级外框和一个 slot outlet(一次注册表订阅、一次 locale 订阅、一个 `display: contents` 元素);编译结果按 key 共享。
+- 在会话流中,display 公式的间距来自外框而不是 KaTeX 默认的 1em,fence 横幅显示小写 `lang`,`conversation.chat.reasoning.content` 的 `labels` 只作用于脚注与无语言 fence。
diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml
index a101ff98ea..7d02bb4794 100644
--- a/docs/subsystems/slots.i18n.yaml
+++ b/docs/subsystems/slots.i18n.yaml
@@ -9,11 +9,11 @@
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
/web-client-slots/cardinality-and-scope:
- en: 488f2f309af94398
- zh: 7941265e4c545c21
+ en: 8ffb43e3e0d863d5
+ zh: 96f32d0047f784cb
/web-client-slots/cardinality-and-scope/hoc-layers:
- en: e6862954bd8874af
- zh: 0244a2d7bb0befdf
+ en: fa392c3a5c556751
+ zh: f148ce2c56af3779
/web-client-slots/component-inputs:
en: 278b2eee160be413
zh: d6e0fd1e9f4a4b27
diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md
index d13357d0b2..820bd90171 100644
--- a/docs/subsystems/slots.md
+++ b/docs/subsystems/slots.md
@@ -61,7 +61,7 @@ The slot declaration fixes two independent axes.
| cardinality | `list` | Cells are addressed by required `id` and ordered by `order`, then registration order. |
| cardinality | `keyed` | The owner dispatches an `entryKey`; the matching cell renders with any key-specific props. |
| cardinality | `chain` | Each entry supplies a pure `select(owner)` function. The first non-null result in priority order renders and receives that result as `matched`; otherwise the owner fallback renders. |
-| cardinality | `hoc` | Entries nest around the owner's `renderSlot(key, owner, { children })` content, lowest outermost; see [Hoc layers](#hoc-layers). |
+| cardinality | `hoc` | Entries nest around the owner's `renderSlot(key, owner, { children })` content, lowest outermost; an optional `key` selects layers by the owner's `entryKey`. See [Hoc layers](#hoc-layers). |
| scope | `root` | One root-scoped component and store instance. |
| scope | `session-maybe` | Inherits the surrounding Provider binding but stays renderable without one; Session values are optional. |
| scope | `session` | Requires a resolved surrounding Provider binding and receives definite Session values. |
@@ -72,11 +72,13 @@ The slot declaration fixes two independent axes.
### Hoc layers
-A hoc entry is a layer addressed by its required `id`. Layers nest by ascending `order`, then `priority`, then registration order, lowest outermost. Each layer receives the same owner props plus the next inner layer as `children`, the owner's content at the innermost, and renders it exactly once; owner props must not use the name `children`. Omitted `children` renders the layers around nothing, and with no live layer the content renders unchanged.
+A hoc entry is a layer addressed by its required `id` and optional `key`. A layer without `key` is a wildcard that applies to every dispatch; a layer with `key` applies only when the owner passes the same `entryKey` to `renderSlot`, and an owner that omits `entryKey` gets only wildcard layers. Each `(key, id)` pair, with the wildcard as its own key, is one shadowing cell; within one `id`, an exact-key layer replaces the wildcard layer for that key.
-- Remounting: adding or removing a layer remounts the content inside it, including a crashed layer leaving the slot; a `session` or `session-maybe` layer also remounts it when its Session binding changes.
-- Errors: a crashed layer renders its `children` and leaves the slot. An error thrown by the wrapped content passes every layer and is reported against none; a layer that wraps its `children` in its own error boundary catches it as an internal wrapper error rather than the original.
-- Layout: the outlet inserts a `display: contents` `[data-slot]` element between the owner and its content, so owner selectors cannot rely on the content being a direct child. A layer must not create a box of its own — for example, use `display: contents` — or the content leaves its parent's layout.
+Layers nest by ascending `order`, then `priority`, then registration order, lowest outermost. Each layer receives the same owner props plus the next inner layer as `children`, the owner's content at the innermost, and renders it at most once; a layer that omits it replaces everything inside it. Owner props must not use the name `children`. Omitted `children` renders the layers around nothing, and with no applicable layer the content renders unchanged.
+
+- Remounting: adding or removing a layer that applies to a dispatch remounts the content inside it; a `session` or `session-maybe` layer also remounts it when its Session binding changes. Outlets that dispatch the same key share one compiled layer chain, so a registration for another key does not remount their content; an outlet that switches its `entryKey` switches chains and remounts its content even when the same layers apply.
+- Errors: a crashed layer reports its error and renders its `children` at the occurrence that failed, for the rest of that occurrence's mount even when its owner props change. The layer stays registered, so other occurrences, including those dispatching the same key, keep it, and a remounted occurrence renders it again. An error thrown by the wrapped content passes every layer and is reported against none; a layer that wraps its `children` in its own error boundary catches it as an internal wrapper error rather than the original.
+- Layout: the outlet inserts a `display: contents` `[data-slot]` element between the owner and its content, so owner selectors cannot rely on the content being a direct child. A layer that wraps its `children` must not create a box of its own unless its slot documents otherwise — for example, use `display: contents` — or the content leaves its parent's layout.
The [context-menu plugin](../../packages/client/ui-context-menu/README.md) contributes `useContextMenu` through `provideRoot`; every scope receives the same plugin-owned trigger factory. Its prop and retained handlers retire when the provider unloads. Its root-scoped keyed `contextMenu.icon` Slot renders business-owned icons from action metadata.
@@ -154,6 +156,7 @@ Factory: conversation.content [ConversationContent]
│ └─ conversation.view
│ ├─ id=chat [ChatView]
│ │ ├─ conversation.chat.text-interaction [list]
+│ │ ├─ conversation.chat.markdown.fence [hoc, keyed by fence language]
│ │ └─ conversation.chat.flow [ChatFlow]
│ │ └─ conversation.chat.node [ChatNodeSeat dispatch]
│ │ ├─ key=user [UserMessageNodeView]
diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md
index 61014d4418..69456901eb 100644
--- a/docs/subsystems/slots.zh.md
+++ b/docs/subsystems/slots.zh.md
@@ -61,7 +61,7 @@ Slot 声明固定两个相互独立的维度。
| cardinality | `list` | cell 由必填 `id` 定址,先按 `order`、再按注册顺序排列。 |
| cardinality | `keyed` | owner 传入 `entryKey`;匹配 cell 以该 key 对应的 props 渲染。 |
| cardinality | `chain` | 每个 entry 提供纯 `select(owner)` 函数;按 priority 顺序遇到的第一个非 null 结果获选,并以 `matched` 传给组件;全部拒绝时渲染 owner fallback。 |
-| cardinality | `hoc` | entry 层层包裹 owner 经 `renderSlot(key, owner, { children })` 传入的内容,最小者在最外层;见 [Hoc 层](#hoc-layers)。 |
+| cardinality | `hoc` | entry 层层包裹 owner 经 `renderSlot(key, owner, { children })` 传入的内容,最小者在最外层;可选的 `key` 按 owner 的 `entryKey` 选层。见 [Hoc 层](#hoc-layers)。 |
| scope | `root` | 一个 root 作用域组件和 store 实例。 |
| scope | `session-maybe` | 继承外围 Provider binding,但没有 binding 时仍可渲染;Session 值是可选的。 |
| scope | `session` | 要求可解析的外围 Provider binding,并收到确定存在的 Session 值。 |
@@ -72,11 +72,13 @@ Slot 声明固定两个相互独立的维度。
### Hoc 层
-hoc entry 是以必填 `id` 寻址的一层。各层依次按 `order`、`priority`、注册顺序升序嵌套,最小者在最外层。每层收到同一份 owner props,并以 `children` 收到内一层(最内层是 owner 的内容),且只渲染一次;owner props 不得使用 `children` 这个名字。省略 `children` 时各层包裹空内容;没有存活的层时原样渲染内容。
+hoc entry 是以必填 `id` 和可选 `key` 寻址的一层。不带 `key` 的层是通配层,作用于每次分派;带 `key` 的层只在 owner 向 `renderSlot` 传入相同 `entryKey` 时生效,省略 `entryKey` 的 owner 只得到通配层。每个 `(key, id)` 组合(通配视为单独的 key)是一个遮蔽 cell;同一 `id` 下,精确 key 的层替换该 key 的通配层。
-- 重新挂载:增减一层会让其内部内容重新挂载,崩溃的层退出 slot 也是如此;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载。
-- 错误:崩溃的层渲染它的 `children` 并退出 slot。被包裹内容抛出的错误穿过所有层,不记到任何层名下;层如果自己在 `children` 外包错误边界,捕获到的是内部包装错误而不是原始错误。
-- 布局:outlet 会在 owner 与内容之间插入一个 `display: contents` 的 `[data-slot]` 元素,所以 owner 侧的选择器不能假定内容是直接子元素。层自身不得产生盒子——例如应使用 `display: contents`——否则内容会脱离父元素的布局。
+各层依次按 `order`、`priority`、注册顺序升序嵌套,最小者在最外层。每层收到同一份 owner props,并以 `children` 收到内一层(最内层是 owner 的内容),且至多渲染一次;不渲染 `children` 的层替换其内部的全部内容。owner props 不得使用 `children` 这个名字。省略 `children` 时各层包裹空内容;没有适用的层时原样渲染内容。
+
+- 重新挂载:增减一个适用于本次分派的层会让其内部内容重新挂载;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载。分派同一 key 的 outlet 共享一条编译好的层链,因此为其他 key 注册的层不会让它们的内容重新挂载;outlet 切换 `entryKey` 时会换到另一条层链,即使生效的层相同,内容也会重新挂载。
+- 错误:崩溃的层照常上报错误,并在出错的那一处渲染它的 `children`,直到该处本次挂载结束,owner props 变化也不恢复。该层保持注册,因此其他出现处(包括分派同一 key 的出现处)仍保留它,重新挂载的出现处会再次渲染它。被包裹内容抛出的错误穿过所有层,不记到任何层名下;层如果自己在 `children` 外包错误边界,捕获到的是内部包装错误而不是原始错误。
+- 布局:outlet 会在 owner 与内容之间插入一个 `display: contents` 的 `[data-slot]` 元素,所以 owner 侧的选择器不能假定内容是直接子元素。包裹 `children` 的层自身不得产生盒子(其 slot 另有说明的除外)——例如应使用 `display: contents`——否则内容会脱离父元素的布局。
@@ -156,6 +158,7 @@ Factory: conversation.content [ConversationContent]
│ └─ conversation.view
│ ├─ id=chat [ChatView]
│ │ ├─ conversation.chat.text-interaction [list]
+│ │ ├─ conversation.chat.markdown.fence [hoc, keyed by fence language]
│ │ └─ conversation.chat.flow [ChatFlow]
│ │ └─ conversation.chat.node [ChatNodeSeat dispatch]
│ │ ├─ key=user [UserMessageNodeView]
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 3120b6b576..2e047aefad 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 41cd21266011d571
zh: 3172d1a2acbb27b7
/deepseek-ai-dsh-client-ui-chat/reference-previews:
- en: 9386791a9e5efa00
- zh: 7cb9f3bbc42319d6
+ en: c07a50787d77a7cd
+ zh: 70ee4e32d664e88e
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
en: 8ca277dc7fed699f
zh: 167478dba50f3904
@@ -33,8 +33,8 @@
en: 7cc6692b879aa500
zh: e924733c88b526c9
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
- en: b1630d93a52f483e
- zh: 59cb81ca50ecffc4
+ en: e2b02942a55fb669
+ zh: a38bbee0cc69ae4e
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: dd4e69828096c3a0
zh: 2a9bc370f44efcd7
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index d845c15bbd..562ed7ebef 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -39,6 +39,10 @@ Sent generic-file attachments retain the draft's [file-card radius](../../../doc
`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. [Supported Markdown line references](../ui-primitives/README.md#rendering-agent-output) navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
+`ChatView` also passes `renderCode`, so each Markdown fence in the Chat flow that has a language and content renders through the session-scoped `conversation.chat.markdown.fence` hoc slot. Its `entryKey` is the lowercased fence language, and its owner props carry that `lang`, the fence `code`, the `streaming` state, and `meta`, the rest of the info string when present. The slot content is a plain code block without unified-diff rows, so a language without a layer renders as code. Chat registers layers for `math`, which shows the source while the message streams and display TeX after it settles, and for `diff` and `patch`, which render unified-diff rows in place of the content. Other plugins add a language by registering a layer whose `key` is the lowercased language: a layer that omits `children` replaces the code block, a layer that renders `children` decorates it, and either may parse its own parameters from `meta`.
+
+A fence layer's root element sets no margins, because the fence's [block box](../ui-primitives/README.md#component-catalog) spaces it among adjacent blocks. A layer catches its own content-dependent errors. When a layer crashes, the error is reported and only that fence renders the crashed layer's `children`, for the rest of the fence's mount even when its props change; other fences of the same language keep the layer, and a remounted fence uses it again. A layer that cannot render incomplete content therefore renders `children` while `streaming` is true.
+
Native message images use separate user and output positions. The `conversation.chat.user.content` Factory declares `conversation.chat.user.images`, which `UserContent` renders; the Assistant node registration declares `conversation.chat.message.images`, which `AssistantMarkdown` renders. Both receive image sources, the authorized `loadImage`, and layout options through explicit props. Their official `MessageImages` entries call the shared root-scoped `conversation.images.content` Factory. Each position can be replaced independently without duplicating the gallery implementation.
The session-scoped `conversation.chat.user.content` Factory renders only the user content stack: text, extra blocks, image/file attachments in input order, and reference summaries. Its [`UserContentInput`](src/client/contract/slots.ts) carries prepared `content`, transform `facts`, display props, and explicit `loadImage`; node-owned injections provide reference actions. It calls the shared `conversation.user.text` Factory for text and its own image Slot for images. Direct callers need a Session binding and supply their own row wrapper.
@@ -131,7 +135,7 @@ Scroll-edge fades initialize when `ResizeObserver` reports the open group's layo
Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted.
-The session-scoped `conversation.chat.reasoning.body` single Slot offers the original `rawText`, display-ready `text`, and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the untranslated text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows.
+The session-scoped `conversation.chat.reasoning.body` single Slot offers the original `rawText`, display-ready `text`, and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. Inside the Chat flow, fences that name a language render through the fence slot with the Chat view's code-block labels, so these `labels` apply only to footnotes and language-less fences there. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the untranslated text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows.
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 80402eadf2..7696d53951 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -39,6 +39,10 @@ kind: "package-reference"
`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。[支持的 Markdown 行号引用](../ui-primitives/README.zh.md#rendering-agent-output)定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
+`ChatView` 还传入 `renderCode`,因此 Chat 消息流中带语言且有内容的每个 Markdown 围栏都经由 session 作用域的 `conversation.chat.markdown.fence` hoc slot 渲染。其 `entryKey` 为小写的围栏语言,owner props 携带该 `lang`、围栏 `code`、`streaming` 状态,以及存在时为 info string 其余部分的 `meta`。slot 内容是不带统一 diff 行的普通代码块,因此没有对应层的语言按代码渲染。Chat 自带 `math` 层,消息流式输出时显示源码,落定后显示行间 TeX;还自带 `diff` 与 `patch` 层,以统一 diff 行代替内容渲染。其他插件注册 `key` 为小写语言的层即可接入该语言:不渲染 `children` 的层替换代码块,渲染 `children` 的层装饰代码块,两者都可以自行从 `meta` 解析参数。
+
+围栏层的根元素不设外边距,因为围栏所在的[块级外框](../ui-primitives/README.zh.md#component-catalog)负责它与相邻块之间的间距。与内容相关的错误由层自己接住。层崩溃时错误照常上报,只有该围栏改为渲染崩溃层的 `children`,并在该围栏本次挂载内保持如此,props 变化也不恢复;同语言的其他围栏仍保留该层,重新挂载的围栏会再次使用它。因此,处理不了不完整内容的层在 `streaming` 为 true 时渲染 `children`。
+
原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。
Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。
@@ -131,7 +135,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的
每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。
-Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始文本 `rawText`、显示文本 `text` 和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看未翻译文本的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。
+Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始文本 `rawText`、显示文本 `text` 和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。在 Chat 消息流中,带语言的围栏经由围栏 slot 渲染并使用 Chat 视图的代码块文案,因此这些 `labels` 在那里只作用于脚注和无语言围栏。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看未翻译文本的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index d3a6df8b96..084e45e414 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -38,6 +38,7 @@ import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { UserContent } from './chat/UserContent.tsx'
import { ActivityPill, UsagePill } from './chat/StatsPills.tsx'
import { registerConversationNodes } from './conversation-nodes/register.ts'
+import { registerFenceLayers } from './fence/register.ts'
import { QuotaNoticeHost } from './chat/QuotaNoticeHost.tsx'
import { en, ja, es, fr, de, ru, pt, NS, zh } from './locale.ts'
import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
@@ -243,6 +244,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.chat.flow': { kind: 'single', scope: 'session', inject: CHAT_FLOW_INJECT },
'conversation.chat.text-interaction': { kind: 'keyed', scope: 'session' },
+ 'conversation.chat.markdown.fence': { kind: 'hoc', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId): ChatViewInjected => {
@@ -314,6 +316,7 @@ export function apply(ctx: Context): void {
}, ChatView)
return disposeView
})
+ registerFenceLayers(ctx)
// The quota notice host lives in the frame-wide layer so a notice outlives
// the Chat panel that reported it. Its chain child lets a package with a
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index e265ab635f..48e081bfa8 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -8,11 +8,13 @@ import type {
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import {
- Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, type MarkdownMenuTarget,
+ Button, FenceCodeBlock, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
+ type MarkdownDelegateFenceInfo, type MarkdownMenuTarget,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { isAtOrOutside } from '../contract/group-tree.ts'
import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
+import { markdownLabels } from '../markdown-labels.ts'
import { TurnNavigator } from './TurnNavigator.tsx'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useTextInteraction } from './text-interaction.ts'
@@ -106,6 +108,15 @@ export function ChatView({
dialog: t('image.dialog'), close: t('image.close'),
},
}), [cwd, t, fileDrag, sessionId])
+ const codeLabels = useMemo(() => markdownLabels(t).code, [t])
+ // Every fence in the flow dispatches by language; the content is the plain
+ // code block, so a language without a layer renders as code without diff rows.
+ const renderCode = useCallback((fence: MarkdownDelegateFenceInfo) => renderSlot('conversation.chat.markdown.fence', fence, {
+ entryKey: fence.lang,
+ children: (
+
+ ),
+ }), [renderSlot, codeLabels])
const running = useSession(s => s.running)
// Fold transitions run while a Turn is live, while an input is pending, and briefly after settling.
const [motionTail, setMotionTail] = useState(false)
@@ -248,6 +259,7 @@ export function ChatView({
openFile={requestOpenFile}
openExternalLink={openExternalLink}
fileImages={fileImages}
+ renderCode={renderCode}
>
{renderSlot('conversation.chat.flow', {
entries, pendingInputs, lastInputTurn, deferCollapse,
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index a879601747..ed89452f50 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -17,7 +17,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client'
-import type { FileDragActions, MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { FileDragActions, MarkdownDelegateFenceInfo, MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import type { ChatKey } from '../locale.ts'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -387,7 +387,7 @@ export type ChatFlowSlotProps =
/** Full Chat view props. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'>
- & PropsRenderSlots<'conversation.chat.flow' | 'conversation.chat.text-interaction'>
+ & PropsRenderSlots<'conversation.chat.flow' | 'conversation.chat.text-interaction' | 'conversation.chat.markdown.fence'>
& PropsStore
& InjectFace
& PropsLocale<'chat'>
@@ -426,6 +426,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Area-local text gestures and surfaces; each consumer registers its own synchronous admission. */
'conversation.chat.text-interaction': { kind: 'keyed'; scope: 'session'; owner: TextInteractionSurface }
+ /** Language-keyed layers around one Markdown fence in the Chat flow; the content is the plain code block. */
+ 'conversation.chat.markdown.fence': { kind: 'hoc'; scope: 'session'; owner: MarkdownDelegateFenceInfo }
/** Ordered Chat rows and local echoes with viewport-bound visibility hooks. */
'conversation.chat.flow': {
diff --git a/packages/client/ui-chat/src/client/fence/FenceLayers.tsx b/packages/client/ui-chat/src/client/fence/FenceLayers.tsx
new file mode 100644
index 0000000000..031d8358e2
--- /dev/null
+++ b/packages/client/ui-chat/src/client/fence/FenceLayers.tsx
@@ -0,0 +1,33 @@
+/** Chat's language layers for Markdown fences: display math and unified-diff rows. */
+import { useMemo } from 'react'
+import type { ReactNode } from 'react'
+import { FenceCodeBlock, FenceMathBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { HocShare, PropsLocale, PropsRuntime, SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
+import { markdownLabels } from '../markdown-labels.ts'
+
+/** Props of the math layer: the fence and the next inner content. */
+export type MathFenceLayerProps = PropsRuntime<'conversation.chat.markdown.fence'> & HocShare
+
+/** Props of the diff layer: the fence and the Chat locale seat. */
+export type DiffFenceLayerProps = PropsRuntime<'conversation.chat.markdown.fence'> & PropsLocale<'chat'>
+
+/**
+ * Show a `math` fence as display TeX once its document settles; while it
+ * streams, the inner code block shows the source so a partial formula never
+ * renders as an error.
+ * @param props - the fence and the inner content.
+ * @returns the formula, or the inner content while streaming.
+ */
+export function MathFenceLayer({ code, streaming, children }: MathFenceLayerProps): ReactNode {
+ return streaming ? children :
+}
+
+/**
+ * Show a `diff` or `patch` fence as unified-diff rows in place of the inner code block.
+ * @param props - the fence and the Chat locale seat.
+ * @returns the diff code block.
+ */
+export function DiffFenceLayer({ lang, code, streaming, t }: DiffFenceLayerProps): ReactNode {
+ const labels = useMemo(() => markdownLabels(t).code, [t])
+ return
+}
diff --git a/packages/client/ui-chat/src/client/fence/register.ts b/packages/client/ui-chat/src/client/fence/register.ts
new file mode 100644
index 0000000000..9773fe2ea7
--- /dev/null
+++ b/packages/client/ui-chat/src/client/fence/register.ts
@@ -0,0 +1,22 @@
+/** Registration of Chat's Markdown fence layers. */
+import type { Context } from '@deepseek-ai/cordis'
+import { NS } from '../locale.ts'
+import { DiffFenceLayer, MathFenceLayer } from './FenceLayers.tsx'
+
+/**
+ * Register Chat's fence layers into the flow's language-keyed fence Slot;
+ * they follow its declaration and the plugin's lifetime. Ids stay literal so
+ * the Client slot catalog can name each occupied layer.
+ * @param ctx - the Chat plugin context.
+ */
+export function registerFenceLayers(ctx: Context): void {
+ ctx.slots.inject('conversation.chat.markdown.fence', () => ctx.slots.register({
+ name: 'conversation.chat.markdown.fence', key: 'math', id: '@deepseek-ai/dsh-client-ui-chat/fence/math',
+ }, MathFenceLayer))
+ ctx.slots.inject('conversation.chat.markdown.fence', () => ctx.slots.register({
+ name: 'conversation.chat.markdown.fence', key: 'diff', id: '@deepseek-ai/dsh-client-ui-chat/fence/diff', locale: NS,
+ }, DiffFenceLayer))
+ ctx.slots.inject('conversation.chat.markdown.fence', () => ctx.slots.register({
+ name: 'conversation.chat.markdown.fence', key: 'patch', id: '@deepseek-ai/dsh-client-ui-chat/fence/diff', locale: NS,
+ }, DiffFenceLayer))
+}
diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
index 3e61dce8bf..7a6a7c5bcd 100644
--- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
@@ -144,6 +144,13 @@ describe('Chat apply wiring', () => {
.toEqual(['activity', 'usage'])
expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
.toEqual(['transcript-view', 'link-opening', 'composer-enter', 'performance-usage'])
+ expect(b.runtime.slots.spec('conversation.chat.markdown.fence')).toMatchObject({ kind: 'hoc', scope: 'session' })
+ expect(b.runtime.slots.entries('conversation.chat.markdown.fence').map(row => [row.options.key, row.options.id]))
+ .toEqual([
+ ['math', '@deepseek-ai/dsh-client-ui-chat/fence/math'],
+ ['diff', '@deepseek-ai/dsh-client-ui-chat/fence/diff'],
+ ['patch', '@deepseek-ai/dsh-client-ui-chat/fence/diff'],
+ ])
await b.runtime.dispose()
})
@@ -297,6 +304,8 @@ describe('Chat apply wiring', () => {
it('removes only Chat contributions when Chat unloads', async () => {
const b = await bench()
await b.chat.dispose()
+ expect(b.runtime.slots.spec('conversation.chat.markdown.fence')).toBeUndefined()
+ expect(b.runtime.slots.entries('conversation.chat.markdown.fence')).toHaveLength(0)
expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0)
expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined()
expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation'])
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index ffbd344e57..37011ab8bb 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -8,7 +8,7 @@ import type { GlobalStandardProps, PropsRenderFactories, PropsRenderSlots } from
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useEffect, useMemo } from 'react'
-import { useMarkdownDelegate, type MarkdownMenuTarget } from '@deepseek-ai/dsh-client-ui-primitives'
+import { useMarkdownDelegate, type MarkdownDelegateFenceInfo, type MarkdownMenuTarget } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
AssistantMessageNode, ChatFlowHookContext, ChatFlowOwnerProps, ChatFlowSlotProps,
ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, UserBeforeOwnerProps,
@@ -478,9 +478,18 @@ function makeHarness(
if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`)
return renderUserContent(owner as UserContentInput, renderImages, props.t)
}
- const renderSlot = (key: string, owner: ChatFlowOwnerProps, opts: Parameters[2]) => {
+ const fenceCalls: { owner: MarkdownDelegateFenceInfo; entryKey: string | undefined }[] = []
+ const renderSlot = (
+ key: string,
+ owner: ChatFlowOwnerProps | MarkdownDelegateFenceInfo,
+ opts: Parameters[2] & { entryKey?: string; children?: React.ReactNode },
+ ) => {
if (key === 'conversation.chat.text-interaction') return null
- if (key !== 'conversation.chat.flow') throw new Error(`Undeclared ChatView child: ${key}`)
+ if (key === 'conversation.chat.markdown.fence' && 'lang' in owner) {
+ fenceCalls.push({ owner, entryKey: opts.entryKey })
+ return opts.children ?? null
+ }
+ if (key !== 'conversation.chat.flow' || 'lang' in owner) throw new Error(`Undeclared ChatView child: ${key}`)
if ('renderSlot' in owner || 'useStore' in owner || 'actions' in owner) {
throw new Error('Flow owner data must not replace its rendering or store authority')
}
@@ -577,7 +586,7 @@ function makeHarness(
setConnection: (state: ConnectionState) => { connection.set(state) },
openFile, openUserFile, loadOlder, loadThrough, openView,
setOutline: (value: unknown) => { outlineValue = value },
- chatScroll, forkAt, toolOwners,
+ chatScroll, forkAt, toolOwners, fenceCalls,
setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) },
setGrouped: (value: ConversationGroupedView | undefined) => {
grouped = value
@@ -3742,6 +3751,14 @@ describe('ChatView', () => {
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
})
+ it('hands each final-language fence to the Chat fence slot', () => {
+ const markdown = '```TS\nconst a = 1\n```'
+ const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
+ render()
+ expect(h.fenceCalls).toContainEqual({ owner: { lang: 'ts', code: 'const a = 1', streaming: false }, entryKey: 'ts' })
+ expect(document.querySelector('.md-code-block pre')?.textContent).toBe('const a = 1')
+ })
+
it('streaming partial frames update the tail without replacing a sibling Tool row', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
diff --git a/packages/client/ui-chat/tests/fence-layers.client.spec.tsx b/packages/client/ui-chat/tests/fence-layers.client.spec.tsx
new file mode 100644
index 0000000000..0b0c02cb57
--- /dev/null
+++ b/packages/client/ui-chat/tests/fence-layers.client.spec.tsx
@@ -0,0 +1,39 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { zh } from '../src/client/locale.ts'
+import {
+ DiffFenceLayer, MathFenceLayer, type DiffFenceLayerProps, type MathFenceLayerProps,
+} from '../src/client/fence/FenceLayers.tsx'
+
+const t = makeTranslate(zh, commonZh)
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('Chat Markdown fence layers', () => {
+ it('math layer shows the inner content while streaming', () => {
+ const props = { lang: 'math', code: 'x^2', streaming: true } as MathFenceLayerProps
+ const { container } = render()
+ expect(screen.getByTestId('inner')).toBeTruthy()
+ expect(container.querySelector('.katex')).toBeNull()
+ })
+
+ it('math layer typesets the settled fence and drops the inner content', () => {
+ const props = { lang: 'math', code: 'x^2', streaming: false } as MathFenceLayerProps
+ const { container } = render()
+ expect(container.querySelector('.katex')).not.toBeNull()
+ expect(screen.queryByTestId('inner')).toBeNull()
+ })
+
+ it('diff layer renders unified-diff rows', () => {
+ const props = { lang: 'diff', code: '@@ -1 +1 @@\n-old\n+new', streaming: false, t } as DiffFenceLayerProps
+ const { container } = render()
+ expect(container.querySelector('pre')!.textContent).toBe('@@ -1 +1 @@\n-old\n+new')
+ expect(container.querySelectorAll('[class*="_diffAdd_"]').length).toBe(1)
+ expect(container.querySelectorAll('[class*="_diffDel_"]').length).toBe(1)
+ })
+})
diff --git a/packages/client/ui-chat/tests/fence-slot.client.spec.tsx b/packages/client/ui-chat/tests/fence-slot.client.spec.tsx
new file mode 100644
index 0000000000..2d41efec38
--- /dev/null
+++ b/packages/client/ui-chat/tests/fence-slot.client.spec.tsx
@@ -0,0 +1,152 @@
+// @vitest-environment jsdom
+/** Chat's Markdown fence Slot over the assembled Conversation and Chat plugins. */
+import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { cleanup } from '@testing-library/react'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import type { ISession, SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
+import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
+
+usePinnedBrowserLanguages('zh-CN')
+
+const SID = 'fence-session' as SessionId
+
+class ResizeObserverStub {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+
+const fonts = Object.getOwnPropertyDescriptor(document, 'fonts')
+
+// jsdom has neither ResizeObserver nor font-loading events; the composer row observes both.
+beforeEach(() => {
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() })
+})
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+ if (fonts === undefined) Reflect.deleteProperty(document, 'fonts')
+ else Object.defineProperty(document, 'fonts', fonts)
+})
+
+function AppRoot({ renderSlot }: PropsRenderSlots<'main'>) {
+ return <>{renderSlot('main', {}, { entryKey: 'conversation' })}>
+}
+
+const turnStart: SessionEventLikeEntry[] = [
+ { type: 'event', event: { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } } },
+ { type: 'event', event: { type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } } },
+]
+
+/** A settled Turn whose one assistant message carries `text`. */
+function settledReply(text: string): SessionEventLikeEntry[] {
+ return [
+ ...turnStart,
+ { type: 'event', event: { type: 'assistant/message', seq: SessionSeq(3), time: 3, surfaceOp: 'append', data: {
+ turn: 1, step: 1, stream: [], message: {
+ id: MessageId('reply'), role: 'assistant', content: [{ type: 'text', text }],
+ source: { kind: 'model', provider: 'fixture', model: 'fixture' },
+ },
+ } } },
+ { type: 'event', event: { type: 'turn/end', seq: SessionSeq(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } } },
+ ]
+}
+
+/** An open Turn whose assistant text has streamed `text` so far. */
+function streamingReply(text: string): SessionEventLikeEntry[] {
+ return [
+ ...turnStart,
+ { type: 'transient', event: { type: 'assistant/live-chunk', seq: SessionSeq(3), time: 3, data: {
+ attemptId: LlmAttemptId('fence-attempt'), turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text },
+ } } },
+ ]
+}
+
+async function renderChat(events: readonly SessionEventLikeEntry[]) {
+ const runtime = await SlotTestRuntime.create()
+ onTestFinished(() => runtime.dispose())
+ runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined })
+ const chatSettings = stubConfigForm()
+ runtime.ctx.provide('configForms', {
+ developerTools: { enabled: createSnapshotStore(true) },
+ get: (namespace: string) => namespace === CHAT_SETTINGS_NAMESPACE ? chatSettings.scope : stubConfigForm().scope,
+ } as never)
+ runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
+ runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
+ runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never)
+ runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}), get: vi.fn(() => ({})), subscribe: vi.fn(() => () => {}) } as never)
+ runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never)
+ runtime.remote.provideNamespaces({ session: { openWorkspacePath: vi.fn() } })
+ let mainReference: ReturnType | undefined
+ const openSession = (id: SessionId): void => {
+ const next = runtime.sessions.retain(id, { source: 'mainView' })
+ mainReference?.release()
+ mainReference = next
+ }
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ openSession(SID)
+ }),
+ openSession,
+ } as never)
+ const locale = new LocaleRuntime(runtime.ctx)
+ runtime.ctx.provide('locale', locale)
+ runtime.slots.installLocale(locale)
+ runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) })
+ await runtime.mount({ inject: [...injectMenus], apply: applyMenus })
+ await runtime.sessions.add({
+ id: SID,
+ summary: { title: 'F', displayTitle: 'F', cwd: '/proj' },
+ session: { loadOlder: vi.fn() },
+ events,
+ })
+ openSession(SID)
+ await runtime.root.declare({
+ 'main': { kind: 'keyed', scope: 'root' },
+ 'settings.general.item': { kind: 'list', scope: 'root' },
+ }, AppRoot)
+ await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
+ await runtime.mount({ inject: [...injectChat], apply: applyChat })
+ return runtime.renderRoot()
+}
+
+describe('Chat Markdown fence Slot', () => {
+ it('typesets a settled math fence through the math layer', async () => {
+ const view = await renderChat(settledReply('```math\nx^2\n```'))
+ expect(view.container.querySelector('[data-slot="conversation.chat.markdown.fence"] .katex')).not.toBeNull()
+ expect(view.container.querySelector('[data-slot="conversation.chat.markdown.fence"] .md-code-block')).toBeNull()
+ })
+
+ it('keeps a streaming math fence as its code block', async () => {
+ const view = await renderChat(streamingReply('```math\nx^2\n'))
+ const seat = view.container.querySelector('[data-slot="conversation.chat.markdown.fence"]')
+ expect(seat?.querySelector('.md-code-block pre')?.textContent).toBe('x^2')
+ expect(seat?.querySelector('.katex')).toBeNull()
+ })
+
+ it('draws diff rows through the diff layer', async () => {
+ const view = await renderChat(settledReply('```diff\n@@ -1 +1 @@\n-old\n+new\n```'))
+ const seat = view.container.querySelector('[data-slot="conversation.chat.markdown.fence"]')
+ expect(seat?.querySelectorAll('[class*="_diffAdd_"]')).toHaveLength(1)
+ expect(seat?.querySelector('pre')?.textContent).toBe('@@ -1 +1 @@\n-old\n+new')
+ })
+
+ it('renders a fence without a layer as the plain code block', async () => {
+ const view = await renderChat(settledReply('```ts\n+const a = 1\n```'))
+ const seat = view.container.querySelector('[data-slot="conversation.chat.markdown.fence"]')
+ expect(seat?.querySelector('.md-code-block pre')?.textContent).toBe('+const a = 1')
+ expect(seat?.querySelector('[class*="_diffAdd_"]')).toBeNull()
+ })
+})
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 57651951e1..b15c957779 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -18,8 +18,8 @@
en: ead6be55c7c9d7cf
zh: 125ad36db7328551
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
- en: ae85dcbb25303332
- zh: 471224ebad67df91
+ en: 54c5085d03bf4c53
+ zh: 6b5114206ca545f3
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 2fc65b908307b52e
zh: e5c1181ecd2da6e5
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index aa4fa2d978..60151abe0f 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -72,7 +72,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. |
| `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. |
| `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. `diff` and `patch` fences classify rows by their marker column, color added and removed rows like the Sidebar file diff, and tokenize each file's removed and added sides with the language its header names, so source diffs keep syntax colors; the marker column stays in the copied source. |
+| `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock`, `FenceCodeBlock`, `FenceMathBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation and fence rendering, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. `diff` and `patch` fences classify rows by their marker column, color added and removed rows like the Sidebar file diff, and tokenize each file's removed and added sides with the language its header names, so source diffs keep syntax colors; the marker column stays in the copied source. `CodeBlock.diff` forces these rows on (`true`) or off (`false`) for any `lang`. `MarkdownDelegateProvider.renderCode` renders every fence that has a language and content, including `math` and `diff` fences, from its `MarkdownDelegateFenceInfo` `{ lang, code, streaming, meta }`, with `lang` lowercased and `meta` holding the rest of the info string when present; fences without a language or content keep the built-in blocks. With `renderCode`, each such fence renders inside a block box that takes the fence's vertical spacing among adjacent blocks, and the code blocks and display formulas inside that box carry no margins; without `renderCode`, fences render with no added box. `FenceCodeBlock` renders a fence body as a code block with an optional `diff` choice, and `FenceMathBlock` renders a `math` fence body as display TeX; they are the built-in blocks a document uses without `renderCode`. |
| `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. |
| `useClipboardCopy` | Copies the current text with one-second success feedback, prevents overlapping or success-window repeats, and cancels its reset timer on unmount; late write completion cannot update an unmounted control. |
| `UserMessageBubble` | User-message background, radius, content inset, typography, and plain-text wrapping. Accepts native div props; callers own row alignment, width, content, and actions. |
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 1eeebf6dac..83dc9032e7 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -72,7 +72,7 @@ kind: "package-library"
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 |
| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 |
| `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。`diff` 与 `patch` 围栏按标记列划分行,按侧边栏文件 diff 的样式给新增、删除行上色,并按文件头写明的语言分别对该文件的删除侧与新增侧做语法高亮,因此源码 diff 保留语法颜色;标记列仍保留在复制的源码里。 |
+| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock`、`FenceCodeBlock`、`FenceMathBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航与围栏渲染,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。`diff` 与 `patch` 围栏按标记列划分行,按侧边栏文件 diff 的样式给新增、删除行上色,并按文件头写明的语言分别对该文件的删除侧与新增侧做语法高亮,因此源码 diff 保留语法颜色;标记列仍保留在复制的源码里。`CodeBlock.diff` 对任意 `lang` 强制开启(`true`)或关闭(`false`)这些行。`MarkdownDelegateProvider.renderCode` 按 `MarkdownDelegateFenceInfo` `{ lang, code, streaming, meta }`(`lang` 已转小写,`meta` 在存在时为 info string 其余部分)渲染每个带语言且有内容的围栏,包括 `math` 与 `diff` 围栏;无语言或无内容的围栏保留内置块。有 `renderCode` 时,每个这样的围栏渲染在一个块级外框里,由外框承担该围栏与相邻块之间的上下间距,外框内的代码块与展示公式不带外边距;没有 `renderCode` 时,围栏不加外框。`FenceCodeBlock` 把围栏正文渲染为代码块,可选 `diff` 开关;`FenceMathBlock` 把 `math` 围栏正文渲染为展示公式;二者即文档在没有 `renderCode` 时使用的内置块。 |
| `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 |
| `useClipboardCopy` | 复制当前文本并显示一秒成功反馈,阻止写入期间及成功反馈期间的重复操作,卸载时清除重置计时器;迟到的写入结果不会更新已卸载控件。 |
| `UserMessageBubble` | 用户消息的背景、圆角、内容内边距、字体与纯文本换行规则。接收原生 div props;行对齐、宽度、内容和操作由调用方拥有。 |
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 4fb059f603..3d590e865e 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -116,9 +116,11 @@ export type {
} from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
+export { FenceCodeBlock, FenceMathBlock } from './markdown/FenceBlocks.tsx'
+export type { FenceCodeBlockProps } from './markdown/FenceBlocks.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownDelegateProvider, useMarkdownDelegate } from './markdown/MarkdownDelegate.tsx'
-export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
+export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler, MarkdownDelegateFenceInfo } from './markdown/MarkdownDelegate.tsx'
export { DetailDocument, detailDocumentState, useDetailDocumentLabels } from './DetailDocument.tsx'
export type { DetailDocumentCopy, DetailDocumentLabels, DetailDocumentRead, DetailDocumentState } from './DetailDocument.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index 040189b6ea..44fdbbdbe0 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -17,12 +17,15 @@ import css from './CodeBlock.module.css'
export interface CodeBlockProps {
/** The source text, rendered verbatim (trailing newline trimmed for display). */
code: string
- /**
- * Grammar hint (markdown fence info string or a fixed caller id); unknown = plain.
- * A `diff` or `patch` fence renders row colors and tokenizes each file's body
- * with the language its header names.
- */
+ /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
+ /**
+ * Render unified-diff rows: row colors by marker column, and each file's
+ * removed and added sides tokenized with the language its header names.
+ * Omitted, a `diff` or `patch` `lang` selects them; `true` or `false` forces
+ * the choice for any `lang`.
+ */
+ diff?: boolean | undefined
/**
* The code is still growing (a streaming markdown fence): highlight through
* a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
@@ -106,16 +109,16 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
}
export function CodeBlock({
- code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
+ code, lang, diff, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
}: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
- const diff = useMemo(() => (isDiffFenceLanguage(lang) ? parseDiffFence(trimmed) : undefined),
- [trimmed, lang])
+ const diffRows = useMemo(() => ((diff ?? isDiffFenceLanguage(lang)) ? parseDiffFence(trimmed) : undefined),
+ [trimmed, lang, diff])
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
const rootRef = useRef(null)
// Diff rows join the same viewport gate as grammar-highlighted fences, so an
// off-screen fence paints its row colors without tokenizing the body.
- const highlighting = useViewportHighlighting(rootRef, diff === undefined ? lang : 'diff')
+ const highlighting = useViewportHighlighting(rootRef, diffRows === undefined ? lang : 'diff')
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
@@ -124,8 +127,8 @@ export function CodeBlock({
// re-tokenizes per chunk because that grouping has no incremental path, and
// diffs in a fence are short enough to accept it.
const diffTokens = useMemo(
- () => (diff === undefined || !highlighting ? undefined : diffFenceTokens(diff)),
- [diff, highlighting, loaded],
+ () => (diffRows === undefined || !highlighting ? undefined : diffFenceTokens(diffRows)),
+ [diffRows, highlighting, loaded],
)
// Streaming state lives in refs mutated inside the memo (the MarkdownText
// streaming-cache pattern): the session's caches carry across chunks only
@@ -144,7 +147,7 @@ export function CodeBlock({
const settledRef = useRef(false)
const streamedBody = useMemo(() => {
// A diff fence draws its own rows; the session's single-language stream is unused.
- if (!highlighting || diff !== undefined) {
+ if (!highlighting || diffRows !== undefined) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
@@ -195,12 +198,12 @@ export function CodeBlock({
code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
}
return body
- }, [streaming, highlighting, diff, trimmed, lang, loaded])
+ }, [streaming, highlighting, diffRows, trimmed, lang, loaded])
const html = useMemo(
- () => (diff === undefined && highlighting && streaming !== true && streamedBody === undefined
+ () => (diffRows === undefined && highlighting && streaming !== true && streamedBody === undefined
? highlightToHtml(trimmed, lang)
: undefined),
- [diff, streaming, highlighting, streamedBody, trimmed, lang, loaded],
+ [diffRows, streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const [localWrapped, setWrapped] = useState(true)
@@ -221,9 +224,9 @@ export function CodeBlock({
// shiki's HTML output is a static span tree it generated from `code` (no
// user HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
- const body = diff !== undefined
+ const body = diffRows !== undefined
? (
- {diff.rows.map((row, index) => renderDiffRow(row, index, diffTokens?.get(index)))}
+ {diffRows.rows.map((row, index) => renderDiffRow(row, index, diffTokens?.get(index)))}
)
: streamedBody !== undefined
? streamedBody
@@ -238,7 +241,7 @@ export function CodeBlock({
)
return (
-
+ )
+}
+
+/**
+ * Render one `math` fence body as display TeX.
+ * @param props - the fence body without its closing newline.
+ * @returns the typeset formula.
+ */
+export function FenceMathBlock({ code }: { code: string }): ReactNode {
+ // rehype-katex parity: its text extraction saw the code block's trailing newline.
+ return <>{renderTexToReact(`${code}\n`, true)}>
+}
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx
index c070d67c35..df0491c34e 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx
@@ -16,6 +16,21 @@ export type MarkdownMenuTarget =
| { kind: 'url'; url: string }
| { kind: 'image'; url: string; path?: string; name?: string; mediaType?: string }
+/** One Markdown code fence whose language is final, as {@link MarkdownDelegate.renderCode} receives it. */
+export interface MarkdownDelegateFenceInfo {
+ /**
+ * The info string's language word, cut at its first character outside
+ * letters, digits, `_`, and `-`, then lowercased (`c++` yields `c`).
+ */
+ readonly lang: string
+ /** The fence body without its closing newline. */
+ readonly code: string
+ /** The surrounding document is still streaming, so `code` may still change. */
+ readonly streaming: boolean
+ /** The info string after the language word, as the author wrote it; absent when the fence names only a language. */
+ readonly meta?: string | undefined
+}
+
/** Navigation capabilities supplied by the nearest Markdown owner. */
export interface MarkdownDelegate {
/** Open an object menu without executing its normal activation. */
@@ -38,6 +53,14 @@ export interface MarkdownDelegate {
* @param options - First line to reveal when the destination specifies a line or range.
*/
readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined
+ /**
+ * Render one fence whose language is final: it has a language and content.
+ * Absent, the document renders fences with its built-in code and math blocks;
+ * present, the owner renders every such fence, including `diff` and `math`.
+ * @param fence - language, body, and streaming state.
+ * @returns the rendered block.
+ */
+ readonly renderCode?: ((fence: MarkdownDelegateFenceInfo) => ReactNode) | undefined
}
const MarkdownDelegateContext = createContext({})
@@ -59,9 +82,10 @@ export function MarkdownDelegateProvider({
openFile,
fileImages,
getTriggerProps,
+ renderCode,
}: MarkdownDelegateProviderProps): ReactNode {
- const delegate = useMemo(() => ({ openExternalLink, openFile, fileImages, getTriggerProps }),
- [openExternalLink, openFile, fileImages, getTriggerProps])
+ const delegate = useMemo(() => ({ openExternalLink, openFile, fileImages, getTriggerProps, renderCode }),
+ [openExternalLink, openFile, fileImages, getTriggerProps, renderCode])
return (
{children}
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
index fc6a907ad1..a13f0a6a40 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css
@@ -143,7 +143,7 @@
}
/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */
-.markdown li > *:last-child:not(:global(.md-code-block)) {
+.markdown li > *:last-child:not(:global(.md-code-block), .fenceSeat) {
margin-bottom: 0;
}
@@ -490,3 +490,22 @@
font-size: 12px;
line-height: 18px;
}
+
+/* A fence that a delegate renders sits in this box, which takes a code
+ block's spacing among its siblings; the blocks inside it carry none, so
+ wrappers between the box and a block do not change the document rhythm. */
+.fenceSeat {
+ margin: 16px 0;
+}
+
+.fenceSeat:not(:last-child) {
+ margin-bottom: 11px;
+}
+
+.markdown.compact .fenceSeat {
+ margin: 4px 0;
+}
+
+.markdown .fenceSeat :is(:global(.md-code-block), :global(.katex-display)) {
+ margin-block: 0;
+}
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index 3267902a4b..8d24014135 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -24,7 +24,7 @@ import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import type { CodeToolbarLabels } from '../CodeToolbar.tsx'
-import { CodeBlock } from './CodeBlock.tsx'
+import { FenceCodeBlock, FenceMathBlock } from './FenceBlocks.tsx'
import { isExplicitFilePath, parseFileLink } from './file-link.ts'
import { renderTexToReact } from './katex.tsx'
import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx'
@@ -428,32 +428,43 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
// The replaced pipeline recovered the grammar id from the hast class with
// /language-([\w-]+)/, which truncates at the first non-word character.
const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
- if (!context.streaming && lang === 'math') {
- // ```math fences render as display TeX once settled (rehype-katex parity);
- // its text extraction saw the code block's trailing newline.
- return {renderTexToReact(`${node.value}\n`, true)}
+ // Streaming keys are source offsets, stable while the fence grows, so the
+ // block instance (and its incremental highlight session) survives every
+ // chunk. A fence whose info string is still mid-chunk has no content yet and
+ // took the empty-fence arm above, so `lang` here is final.
+ if (lang === undefined) {
+ return
}
return (
-
)
}
+/**
+ * One fence whose language is final: the delegate's renderer when the owner
+ * supplies one, otherwise the built-in math and code blocks. Reading the
+ * delegate in this Component rather than in the render pass lets cached
+ * streaming elements follow a provider change.
+ */
+function MarkdownFenceSeat({ lang, meta, code, streaming, labels }: {
+ readonly lang: string
+ readonly meta: string | undefined
+ readonly code: string
+ readonly streaming: boolean
+ readonly labels: MarkdownCodeLabels
+}): ReactNode {
+ const { renderCode } = useMarkdownDelegate()
+ if (renderCode !== undefined) {
+ // The seat box spaces the fence among its siblings, so the delegate's blocks carry no margins of their own.
+ return {renderCode({ lang: lang.toLowerCase(), code, streaming, meta })}
+ }
+ // ```math fences render as display TeX once settled; while streaming they stay literal.
+ if (!streaming && lang === 'math') return
+ return
+}
+
/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
function listLoose(list: Md.List): boolean {
return (list.spread ?? false) || list.children.some(listItemLoose)
diff --git a/packages/client/ui-primitives/tests/diff-fence.client.spec.tsx b/packages/client/ui-primitives/tests/diff-fence.client.spec.tsx
index c9d3ea5131..54ccdc383c 100644
--- a/packages/client/ui-primitives/tests/diff-fence.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/diff-fence.client.spec.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
-import { cleanup, render } from '@testing-library/react'
+import { cleanup, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
import { diffBlockLabels, markdownLabels } from './labels.client.ts'
@@ -183,6 +183,13 @@ describe('diff fences', () => {
expect(rows(container)).toHaveLength(3)
})
+ it('omits the local wrap action from the toolbar when the owner supplies wrapping', () => {
+ const { container } = render()
+ expect(container.querySelector('[data-code-wrap="true"]')).toBeTruthy()
+ expect(screen.queryByRole('button', { name: diffBlockLabels.wrapLabel })).toBeNull()
+ expect(rows(container)).toHaveLength(3)
+ })
+
it('renders content under a malformed hunk header', () => {
const code = ['@@ not a hunk header @@', '+add', '-del', ' ctx'].join('\n')
@@ -206,4 +213,18 @@ describe('diff fences', () => {
expect(container.querySelector('[data-line-numbers="true"]')).toBeTruthy()
expect(rows(container)).toHaveLength(9)
})
+
+ it('diff={false} renders a diff-language block without diff rows', () => {
+ const { container } = render()
+ expect(container.querySelectorAll('[class*="_diffAdd_"], [class*="_diffDel_"]').length).toBe(0)
+ expect(container.querySelector('pre')!.textContent).toBe('@@ -1 +1 @@\n-old\n+new')
+ })
+
+ it('diff forces rows for any language', () => {
+ const { container } = render()
+ const lines = rows(container)
+ expect(lines).toHaveLength(3)
+ expect(lines[1]!.className).toContain('_diffDel_')
+ expect(lines[2]!.className).toContain('_diffAdd_')
+ })
})
diff --git a/packages/client/ui-primitives/tests/markdown-fence-delegate.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-fence-delegate.client.spec.tsx
new file mode 100644
index 0000000000..9cb85ce48b
--- /dev/null
+++ b/packages/client/ui-primitives/tests/markdown-fence-delegate.client.spec.tsx
@@ -0,0 +1,171 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
+import { MarkdownDelegateProvider } from '../src/markdown/MarkdownDelegate.tsx'
+import { FenceCodeBlock } from '../src/markdown/FenceBlocks.tsx'
+import type { MarkdownDelegateFenceInfo } from '../src/markdown/MarkdownDelegate.tsx'
+import { markdownLabels } from './labels.client.ts'
+
+afterEach(cleanup)
+
+const FENCE_TS = ['```TS', 'const a = 1', '```'].join('\n')
+const FENCE_PLAIN = ['```', 'plain', '```'].join('\n')
+const FENCE_EMPTY_TS = ['```ts', '```'].join('\n')
+const FENCE_MATH = ['```math', 'x', '```'].join('\n')
+const FENCE_DIFF = ['```diff', '-a', '+b', '```'].join('\n')
+
+function renderFenceSpy() {
+ return vi.fn((fence: MarkdownDelegateFenceInfo): ReactNode => )
+}
+
+describe('MarkdownDelegate.renderCode', () => {
+ it('passes a final-language fence to the delegate with its lowercased language', () => {
+ const renderCode = renderFenceSpy()
+ const { container } = render(
+
+
+ ,
+ )
+ expect(renderCode).toHaveBeenCalledWith({ lang: 'ts', code: 'const a = 1', streaming: false })
+ expect(renderCode.mock.calls[0]?.[0].meta).toBeUndefined()
+ expect(screen.getByTestId('fence').textContent).toBe('ts:const a = 1')
+ expect(container.querySelector('.md-code-block')).toBeNull()
+ })
+
+ it('passes the rest of the info string as meta', () => {
+ const renderCode = renderFenceSpy()
+ render(
+
+ x
\n```'} labels={markdownLabels} />
+ ,
+ )
+ expect(renderCode).toHaveBeenCalledWith({ lang: 'html', code: 'x
', streaming: false, meta: 'width=480 height=360' })
+ expect(renderCode.mock.calls[0]?.[0]).toHaveProperty('meta', 'width=480 height=360')
+ })
+
+ it('keeps language-less and empty fences on the built-in blocks', () => {
+ const renderCode = renderFenceSpy()
+ const { container } = render(
+
+
+ ,
+ )
+ expect(renderCode).not.toHaveBeenCalled()
+ expect(container.querySelector('.md-code-block pre')?.textContent).toBe('plain')
+ const empty = container.querySelector('pre > code.language-ts')
+ expect(empty).not.toBeNull()
+ expect(empty!.outerHTML).toBe('')
+ })
+
+ it('hands math and diff fences to the delegate too', () => {
+ const renderCode = renderFenceSpy()
+ const { container } = render(
+
+
+ ,
+ )
+ expect(renderCode).toHaveBeenCalledTimes(2)
+ expect(renderCode.mock.calls.map(([fence]) => fence.lang)).toEqual(['math', 'diff'])
+ expect(container.querySelector('.katex')).toBeNull()
+ })
+
+ it('waits for the info string to finish while streaming', () => {
+ const renderCode = renderFenceSpy()
+ const { rerender } = render(
+
+
+ ,
+ )
+ expect(renderCode).not.toHaveBeenCalled()
+ rerender(
+
+
+ ,
+ )
+ expect(renderCode).toHaveBeenCalledTimes(1)
+ expect(renderCode).toHaveBeenCalledWith({ lang: 'ts', code: 'let a', streaming: true })
+ })
+
+ it('a provider change reaches a frozen streaming fence', () => {
+ const text = ['```ts', 'const a = 1', '```', '', 'alpha', '', 'beta', '', 'gamma'].join('\n')
+ const first = (): ReactNode => first
+ const second = (): ReactNode => second
+ const { rerender } = render(
+
+
+ ,
+ )
+ expect(screen.getByText('first')).toBeTruthy()
+ rerender(
+
+
+ ,
+ )
+ expect(screen.getByText('second')).toBeTruthy()
+ expect(screen.queryByText('first')).toBeNull()
+ })
+
+ it('places the delegate output in a fence seat directly under the document root', () => {
+ const renderCode = renderFenceSpy()
+ const { container } = render(
+
+
+ ,
+ )
+ const seat = screen.getByTestId('fence').parentElement
+ expect(seat?.className).toContain('_fenceSeat_')
+ expect(seat?.parentElement).toBe(container.firstElementChild)
+ expect(seat?.children).toHaveLength(1)
+ })
+
+ it('renders fences without a seat when no delegate is present', () => {
+ const { container } = render()
+ expect(container.querySelector('.md-code-block')).not.toBeNull()
+ expect(container.querySelector('[class*="_fenceSeat_"]')).toBeNull()
+ })
+
+ it('keeps one delegated code block instance while the fence streams and settles', () => {
+ const renderCode = vi.fn((fence: MarkdownDelegateFenceInfo): ReactNode => (
+
+ ))
+ const view = (text: string, streaming: boolean) => (
+
+
+
+ )
+ const { container, rerender } = render(view('```ts\nconst a = 1\n', true))
+ const block = container.querySelector('.md-code-block')
+ const seat = container.querySelector('[class*="_fenceSeat_"]')
+ expect(block).not.toBeNull()
+ expect(seat?.firstElementChild).toBe(block)
+ rerender(view('```ts\nconst a = 1\nconst b = 2\n', true))
+ expect(container.querySelector('.md-code-block')).toBe(block)
+ expect(container.querySelector('[class*="_fenceSeat_"]')).toBe(seat)
+ rerender(view('```ts\nconst a = 1\nconst b = 2\n```', false))
+ expect(container.querySelector('.md-code-block')).toBe(block)
+ expect(container.querySelector('[class*="_fenceSeat_"]')).toBe(seat)
+ expect(renderCode.mock.calls.map(([fence]) => fence.streaming)).toEqual([true, true, false])
+ expect(block?.querySelector('pre')?.textContent).toBe('const a = 1\nconst b = 2')
+ })
+
+ it('passes the info string meta while streaming', () => {
+ const renderCode = renderFenceSpy()
+ render(
+
+
+ ,
+ )
+ expect(renderCode).toHaveBeenCalledWith({ lang: 'ts', code: 'let a', streaming: true, meta: 'title=x' })
+ })
+
+ it('renders the built-in blocks without a delegate', () => {
+ const math = render()
+ expect(math.container.querySelector('.katex')).not.toBeNull()
+ math.unmount()
+ const diff = render()
+ expect(diff.container.querySelector('pre')!.textContent).toBe('-a\n+b')
+ })
+})
diff --git a/packages/client/ui-renderer/src/client/scoped-slots.tsx b/packages/client/ui-renderer/src/client/scoped-slots.tsx
index ee2370be22..99d59ad9c7 100644
--- a/packages/client/ui-renderer/src/client/scoped-slots.tsx
+++ b/packages/client/ui-renderer/src/client/scoped-slots.tsx
@@ -438,7 +438,8 @@ class HocOutletBoundary extends Component<{ children: ReactNode }> {
* re-renders onto the cell's next survivor, and this boundary's crash face
* only shows until that re-render lands (permanently once the cell is dry —
* the outlet then owns the crash face). A hoc layer supplies `crashed`, its
- * inner content, which replaces the crash face so the failed layer is skipped.
+ * inner content, which replaces the crash face for the rest of this mount;
+ * hoc reports do not abdicate.
*/
class SlotErrorBoundary extends Component<
{
@@ -1116,7 +1117,8 @@ const renderHocContent: RenderHocInner = (_owner, children) => children
/**
* Build one hoc layer Component. Scope, entry, and crash reporting are fixed
* here; a render passes only the owner share and the next inner layer. A
- * crashed layer renders that inner content and abdicates.
+ * crashed layer renders that inner content for the rest of that occurrence's
+ * mount and stays registered, so other occurrences keep it.
* @param host - registry host owning the ledger.
* @param slotKey - the hoc slot.
* @param spec - its live declaration.
@@ -1133,8 +1135,9 @@ function hocLayer(
): FC {
const anchor: SlotAnchorTag = spec.inline === true ? 'span' : 'div'
const slotInjected = cachedSlotInject(spec.inject)
+ // A layer's failure belongs to the occurrence it rendered; retiring it would strip every other occurrence too.
const onEntryError = (error: unknown): void => {
- host.reportEntryError(slotKey, entry, error, { abdicate: true })
+ host.reportEntryError(slotKey, entry, error, { abdicate: false })
}
let Layer: FC
switch (spec.scope) {
@@ -1184,35 +1187,125 @@ function hocLayer(
}
/**
- * Compose a hoc slot's live layers, lowest `order` outermost. Winners arrive
- * in ledger order (priority first), so nesting re-sorts by `order`. Each layer
- * adds exactly one Component; with no layer the result renders the owner's
- * content directly.
+ * The layers one dispatch applies: wildcard layers plus layers registered for
+ * `entryKey`, an exact-key layer replacing the wildcard layer with its id.
+ * Winners arrive in ledger order (priority first), so the stable sort by
+ * `order` keeps priority, then registration order, within equal orders.
+ * @param winners - the slot's shadowing winners.
+ * @param entryKey - the dispatch key, or undefined for wildcard-only dispatch.
+ * @returns the layers, lowest `order` (outermost) first.
+ */
+function hocLayersFor(winners: readonly StoredEntry[], entryKey: string | undefined): readonly StoredEntry[] {
+ const exactIds = new Set()
+ if (entryKey !== undefined) {
+ for (const entry of winners) if (entry.options.key === entryKey) exactIds.add(entry.options.id)
+ }
+ return winners
+ .filter(entry => entry.options.key === undefined ? !exactIds.has(entry.options.id) : entry.options.key === entryKey)
+ .sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
+}
+
+/**
+ * Nest the given layers into one render function, first layer outermost.
+ * Each layer adds exactly one Component; with no layer the result renders the
+ * owner's content directly.
* @param host - registry host owning the ledger.
* @param slotKey - the hoc slot.
* @param spec - its live declaration.
+ * @param layers - the dispatch's layers, outermost first.
* @returns the composed render function; its layer Components are the layer set's identity.
*/
-function composeHocLayers(host: SlotRendererHost, slotKey: string, spec: SlotSpec): RenderHocInner {
- const layers = [...host.entriesOfSlot(slotKey)].sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
+function composeHocLayers(
+ host: SlotRendererHost,
+ slotKey: string,
+ spec: SlotSpec,
+ layers: readonly StoredEntry[],
+): RenderHocInner {
return layers.reduceRight((renderInner, entry) => {
const Layer = hocLayer(host, slotKey, spec, entry, renderInner)
return (owner, children) => {children}
}, renderHocContent)
}
-/** Hoc dispatch: recomposes only when the slot's ledger version or declaration changes. */
-function HocOutlet({ slotKey, spec, ownerProps, children }: {
+/** One compiled layer chain for one dispatch key of one hoc declaration. */
+interface CompiledHoc {
+ /** Ledger version this chain was last validated against. */
+ version: number
+ /** The ordered layers the chain was composed from, compared by identity. */
+ readonly layers: readonly StoredEntry[]
+ /** The composed render function. */
+ readonly render: RenderHocInner
+}
+
+/**
+ * Compiled hoc chains per host, live declaration, and dispatch key. Every
+ * outlet dispatching the same key shares one chain, so its layer Components,
+ * the remount boundary of the content inside them, are created once; a
+ * redeclared slot gets a new spec object and therefore an empty cache.
+ */
+const hocChains = new WeakMap, Map>>()
+
+/**
+ * Whether two layer lists hold the same entries in the same order.
+ * @param a - one list.
+ * @param b - the other list.
+ * @returns whether they are element-wise identical.
+ */
+function sameLayers(a: readonly StoredEntry[], b: readonly StoredEntry[]): boolean {
+ return a.length === b.length && a.every((entry, index) => entry === b[index])
+}
+
+/**
+ * The shared layer chain for one dispatch key: revalidated once per ledger
+ * version and recomposed only when this key's ordered layers change, so a
+ * registration for another key never remounts this key's content.
+ * @param host - registry host owning the ledger.
+ * @param slotKey - the hoc slot.
+ * @param spec - its live declaration.
+ * @param entryKey - the dispatch key, or undefined for wildcard-only dispatch.
+ * @returns the chain's render function.
+ */
+function hocChainFor(
+ host: SlotRendererHost,
+ slotKey: string,
+ spec: SlotSpec,
+ entryKey: string | undefined,
+): RenderHocInner {
+ let bySpec = hocChains.get(host)
+ if (bySpec === undefined) {
+ bySpec = new WeakMap()
+ hocChains.set(host, bySpec)
+ }
+ let chains = bySpec.get(spec)
+ if (chains === undefined) {
+ chains = new Map()
+ bySpec.set(spec, chains)
+ }
+ // Key dispatches carry a prefix so none shares the wildcard dispatch's entry.
+ const cacheKey = entryKey === undefined ? '' : `=${entryKey}`
+ const version = host.getVersion(slotKey)
+ const cached = chains.get(cacheKey)
+ if (cached !== undefined && cached.version === version) return cached.render
+ const layers = hocLayersFor(host.entriesOfSlot(slotKey), entryKey)
+ if (cached !== undefined && sameLayers(cached.layers, layers)) {
+ cached.version = version
+ return cached.render
+ }
+ const render = layers.length === 0 ? renderHocContent : composeHocLayers(host, slotKey, spec, layers)
+ chains.set(cacheKey, { version, layers, render })
+ return render
+}
+
+/** Hoc dispatch over the shared per-key chain; the enclosing outlet re-renders on every ledger version. */
+function HocOutlet({ slotKey, spec, ownerProps, entryKey, children }: {
slotKey: string
spec: SlotSpec
ownerProps: object
+ entryKey: string | undefined
children: ReactNode
}) {
const host = useHost()
- const version = host.getVersion(slotKey)
- // `version` keys the ledger read inside composeHocLayers; a new layer set yields new layer Components and remounts
- // the content.
- const renderLayers = useMemo(() => composeHocLayers(host, slotKey, spec), [host, slotKey, spec, version])
+ const renderLayers = hocChainFor(host, slotKey, spec, entryKey)
if (renderLayers === renderHocContent) return children
return {renderLayers(ownerProps, children)}
}
@@ -1275,7 +1368,11 @@ function renderOutletContent(
throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
}
if (spec.kind === 'hoc') {
- return {opts?.children ?? null}
+ return (
+
+ {opts?.children ?? null}
+
+ )
}
const entries = host.entriesOf(slotKey)
const slotInjected = cachedSlotInject(spec.inject)
diff --git a/packages/client/ui-renderer/tests/hoc-slots.client.spec.tsx b/packages/client/ui-renderer/tests/hoc-slots.client.spec.tsx
index c00ee22480..b8fa704e24 100644
--- a/packages/client/ui-renderer/tests/hoc-slots.client.spec.tsx
+++ b/packages/client/ui-renderer/tests/hoc-slots.client.spec.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* Hoc slots over the real ui-slots SlotCore: nesting order, owner and
- * children delivery, remount semantics, crash skipping, and every scope.
+ * children delivery, remount semantics, crash containment, and every scope.
*/
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -18,10 +18,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'spec.hoc': { kind: 'hoc'; scope: 'root'; owner: { tone: string } }
'spec.hoc.maybe': { kind: 'hoc'; scope: 'session-maybe' }
'spec.hoc.session': { kind: 'hoc'; scope: 'session' }
+ 'spec.hoc.keyed': { kind: 'hoc'; scope: 'root'; owner: { tone: string } }
}
}
-type Frame = PropsRenderSlots<'spec.hoc' | 'spec.hoc.maybe' | 'spec.hoc.session'>
+type Frame = PropsRenderSlots<'spec.hoc' | 'spec.hoc.maybe' | 'spec.hoc.session' | 'spec.hoc.keyed'>
afterEach(() => {
cleanup()
@@ -87,6 +88,7 @@ function mount(core: SlotCore, body?: (renderSlot: Frame['renderSlot']) => React
'spec.hoc': { kind: 'hoc', scope: 'root' },
'spec.hoc.maybe': { kind: 'hoc', scope: 'session-maybe' },
'spec.hoc.session': { kind: 'hoc', scope: 'session' },
+ 'spec.hoc.keyed': { kind: 'hoc', scope: 'root' },
},
}, function Root(props: Frame) {
const [tone, set] = useState('calm')
@@ -136,20 +138,71 @@ describe('hoc slots', () => {
expect(view.getByTestId('content')).toBeTruthy()
})
- it('skips a crashed layer: its content still renders and the layer leaves the slot', async () => {
+ it('contains a crashed layer to its occurrence: content renders there and the layer stays for other occurrences', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const core = new SlotCore()
- const view = mount(core)
+ const reported = vi.fn()
+ core.onEntryError(reported)
+ const view = mount(core, renderSlot => (
+ <>
+ {renderSlot('spec.hoc', { tone: 'calm' }, { children: })}
+ {renderSlot('spec.hoc', { tone: 'loud' }, { children: })}
+ >
+ ))
core.register({ name: 'spec.hoc', id: 'outer' }, layer('outer') as never)
- core.register({ name: 'spec.hoc', id: 'broken', order: 10 }, (() => { throw new Error('layer failed') }) as never)
+ core.register({ name: 'spec.hoc', id: 'fragile', order: 10 }, (({ tone, children }: { tone: string; children: ReactNode }) => {
+ if (tone === 'loud') throw new Error('layer failed')
+ return
+ }) as never)
await flush()
await flush()
- expect(view.container.querySelector('section[data-layer="outer"] p')?.textContent).toBe('content')
+ const loud = view.getByTestId('loud-outlet')
+ expect(loud.querySelector('section[data-layer="outer"] > p')?.textContent).toBe('loud-content')
+ expect(loud.querySelector('section[data-layer="fragile"]')).toBeNull()
+ const calm = view.getByTestId('calm-outlet')
+ expect(calm.querySelector('section[data-layer="outer"] > section[data-layer="fragile"] > p')?.textContent).toBe('calm-content')
expect(view.container.querySelector('[data-slot-error]')).toBeNull()
- expect(core.entriesOfSlot('spec.hoc').map(entry => entry.options.id)).toEqual(['outer'])
+ expect(core.entriesOfSlot('spec.hoc').map(entry => entry.options.id)).toEqual(['outer', 'fragile'])
+ expect(reported).toHaveBeenCalledWith('spec.hoc', expect.anything(), expect.objectContaining({ message: 'layer failed' }), { abdicated: false })
expect(error).toHaveBeenCalled()
})
+ it('a crashed occurrence does not retry while it stays mounted', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const core = new SlotCore()
+ let ownerTone = 'loud'
+ let shown = true
+ const view = mount(core, renderSlot => (shown
+ ? renderSlot('spec.hoc', { tone: ownerTone }, { children: })
+ : false))
+ const calls = vi.fn()
+ core.register({ name: 'spec.hoc', id: 'fragile' }, (({ tone, children }: { tone: string; children: ReactNode }) => {
+ calls(tone)
+ if (tone === 'loud') throw new Error('layer failed')
+ return
+ }) as never)
+ await flush()
+ expect(calls).toHaveBeenCalledWith('loud')
+ expect(view.getByTestId('retry-content')).toBeTruthy()
+ calls.mockClear()
+
+ act(() => { setTone('same-owner') })
+ expect(calls).not.toHaveBeenCalled()
+ ownerTone = 'calm'
+ act(() => { setTone('changed-owner') })
+ expect(calls).not.toHaveBeenCalled()
+ expect(view.getByTestId('retry-content')).toBeTruthy()
+ expect(view.container.querySelector('section[data-layer="fragile"]')).toBeNull()
+
+ shown = false
+ act(() => { setTone('unmounted') })
+ expect(view.queryByTestId('retry-content')).toBeNull()
+ shown = true
+ act(() => { setTone('remounted') })
+ expect(calls).toHaveBeenCalledWith('calm')
+ expect(view.container.querySelector('section[data-layer="fragile"] > p')?.textContent).toBe('retry-content')
+ })
+
it('passes content errors upward without blaming or removing any layer', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const core = new SlotCore()
@@ -203,3 +256,207 @@ describe('hoc slots', () => {
expect(view.container.querySelector('section[data-layer="session"] p')?.textContent).toBe('session-content')
})
})
+
+function keyedOutlet(renderSlot: Frame['renderSlot'], entryKey: string, label: string) {
+ return (
+
+ {renderSlot('spec.hoc.keyed', { tone: 'x' }, { entryKey, children: })}
+
+ )
+}
+
+describe('keyed hoc slots', () => {
+ it('applies wildcard layers to every key and exact-key layers only to their key', async () => {
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => (
+ <>
+ {keyedOutlet(renderSlot, 'ts', 'ts-content')}
+ {keyedOutlet(renderSlot, 'md', 'md-content')}
+ >
+ ))
+ core.register({ name: 'spec.hoc.keyed', id: 'w' }, layer('w') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'ts', key: 'ts', order: 1 }, layer('ts') as never)
+ await flush()
+ const ts = view.getByTestId('ts-content-outlet')
+ expect(ts.querySelector('section[data-layer="w"] > section[data-layer="ts"] > p')?.textContent).toBe('ts-content')
+ const md = view.getByTestId('md-content-outlet')
+ expect(md.querySelector('section[data-layer="w"] > p')?.textContent).toBe('md-content')
+ expect(md.querySelector('section[data-layer="ts"]')).toBeNull()
+ })
+
+ it('omitted entryKey applies only wildcard layers', async () => {
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => renderSlot('spec.hoc.keyed', { tone: 'x' }, { children: }))
+ core.register({ name: 'spec.hoc.keyed', id: 'w' }, layer('w') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'ts', key: 'ts', order: 1 }, layer('ts') as never)
+ await flush()
+ expect(view.container.querySelector('section[data-layer="w"] > p')?.textContent).toBe('wild-content')
+ expect(view.container.querySelector('section[data-layer="ts"]')).toBeNull()
+ })
+
+ it('an exact-key layer replaces the wildcard layer with the same id', async () => {
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => (
+ <>
+ {keyedOutlet(renderSlot, 'ts', 'ts-content')}
+ {keyedOutlet(renderSlot, 'md', 'md-content')}
+ >
+ ))
+ core.register({ name: 'spec.hoc.keyed', id: 'x' }, layer('wild') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'x', key: 'ts' }, layer('exact') as never)
+ await flush()
+ const ts = view.getByTestId('ts-content-outlet')
+ expect(ts.querySelector('section[data-layer="exact"] > p')?.textContent).toBe('ts-content')
+ expect(ts.querySelector('section[data-layer="wild"]')).toBeNull()
+ const md = view.getByTestId('md-content-outlet')
+ expect(md.querySelector('section[data-layer="wild"] > p')?.textContent).toBe('md-content')
+ expect(md.querySelector('section[data-layer="exact"]')).toBeNull()
+ })
+
+ it('a layer may skip its children, so the content never mounts', async () => {
+ const core = new SlotCore()
+ mounts.mockClear()
+ let outletShown = false
+ const view = mount(core, renderSlot => (outletShown ? keyedOutlet(renderSlot, 'ts', 'skipped-content') : null))
+ core.register({ name: 'spec.hoc.keyed', id: 'replace', key: 'ts' }, (() => ) as never)
+ outletShown = true
+ act(() => { setTone('loud') })
+ await flush()
+ expect(view.getByTestId('replaced')).toBeTruthy()
+ expect(view.queryByTestId('skipped-content')).toBeNull()
+ expect(mounts).not.toHaveBeenCalledWith('skipped-content')
+ })
+
+ it('a crashed keyed layer falls back to its children', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => (
+ <>
+ {keyedOutlet(renderSlot, 'ts', 'ts-content')}
+
+ {renderSlot('spec.hoc.keyed', { tone: 'calm' }, { entryKey: 'ts', children: })}
+
+ >
+ ))
+ core.register({ name: 'spec.hoc.keyed', id: 'fragile', key: 'ts' }, (({ tone, children }: { tone: string; children: ReactNode }) => {
+ if (tone === 'x') throw new Error('layer failed')
+ return
+ }) as never)
+ await flush()
+ await flush()
+ expect(view.getByTestId('ts-content').textContent).toBe('ts-content')
+ expect(view.getByTestId('ts-content-outlet').querySelector('section[data-layer="fragile"]')).toBeNull()
+ expect(view.getByTestId('calm-outlet').querySelector('section[data-layer="fragile"] > p')?.textContent).toBe('calm-content')
+ expect(core.entriesOfSlot('spec.hoc.keyed').map(entry => [entry.options.key, entry.options.id])).toEqual([['ts', 'fragile']])
+ })
+
+ it('interleaves wildcard and exact-key layers by order', async () => {
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => (
+ <>
+ {keyedOutlet(renderSlot, 'ts', 'ts-content')}
+ {keyedOutlet(renderSlot, 'md', 'md-content')}
+ >
+ ))
+ core.register({ name: 'spec.hoc.keyed', id: 'w2', order: 2 }, layer('w2') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'e1', key: 'ts', order: 1 }, layer('e1') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'w0', order: 0 }, layer('w0') as never)
+ await flush()
+ const ts = view.getByTestId('ts-content-outlet')
+ expect(ts.querySelector('section[data-layer="w0"] > section[data-layer="e1"] > section[data-layer="w2"] > p')?.textContent)
+ .toBe('ts-content')
+ const md = view.getByTestId('md-content-outlet')
+ expect(md.querySelector('section[data-layer="w0"] > section[data-layer="w2"] > p')?.textContent).toBe('md-content')
+ expect(md.querySelector('section[data-layer="e1"]')).toBeNull()
+ })
+
+ it('a lower-priority registration at the same key and id shadows the layer until disposed', async () => {
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => keyedOutlet(renderSlot, 'ts', 'ts-content'))
+ core.register({ name: 'spec.hoc.keyed', id: 'w0', order: 0 }, layer('w0') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'e1', key: 'ts', order: 1 }, layer('e1') as never)
+ await flush()
+ const dispose = core.register({ name: 'spec.hoc.keyed', id: 'e1', key: 'ts', order: 1, priority: -1 }, layer('shadow') as never)
+ await flush()
+ const ts = view.getByTestId('ts-content-outlet')
+ expect(ts.querySelector('section[data-layer="w0"] > section[data-layer="shadow"] > p')?.textContent).toBe('ts-content')
+ expect(ts.querySelector('section[data-layer="e1"]')).toBeNull()
+ dispose()
+ await flush()
+ expect(ts.querySelector('section[data-layer="w0"] > section[data-layer="e1"] > p')?.textContent).toBe('ts-content')
+ expect(ts.querySelector('section[data-layer="shadow"]')).toBeNull()
+ })
+
+ it('an outer wildcard layer still wraps an exact-key layer that skips its children', async () => {
+ const core = new SlotCore()
+ const view = mount(core, renderSlot => keyedOutlet(renderSlot, 'ts', 'ts-content'))
+ mounts.mockClear()
+ core.register({ name: 'spec.hoc.keyed', id: 'w0', order: 0 }, layer('w0') as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'replace', key: 'ts', order: 1 }, (() => ) as never)
+ core.register({ name: 'spec.hoc.keyed', id: 'w2', order: 2 }, layer('w2') as never)
+ await flush()
+ const ts = view.getByTestId('ts-content-outlet')
+ expect(ts.querySelector('section[data-layer="w0"] > i[data-testid="replaced"]')).not.toBeNull()
+ expect(ts.querySelector('section[data-layer="w2"]')).toBeNull()
+ expect(view.queryByTestId('ts-content')).toBeNull()
+ expect(mounts).not.toHaveBeenCalledWith('ts-content')
+ })
+
+ it('registering a layer for another key keeps this key\'s content mounted', async () => {
+ const core = new SlotCore()
+ mounts.mockClear()
+ const view = mount(core, renderSlot => keyedOutlet(renderSlot, 'ts', 'ts-content'))
+ core.register({ name: 'spec.hoc.keyed', id: 'w' }, layer('w') as never)
+ await flush()
+ const before = mounts.mock.calls.length
+ act(() => { core.register({ name: 'spec.hoc.keyed', id: 'md', key: 'md' }, layer('md') as never) })
+ await flush()
+ expect(mounts).toHaveBeenCalledTimes(before)
+ act(() => { core.register({ name: 'spec.hoc.keyed', id: 'ts2', key: 'ts' }, layer('ts2') as never) })
+ await flush()
+ expect(mounts).toHaveBeenCalledTimes(before + 1)
+ expect(view.container.querySelector('section[data-layer="ts2"] > p')?.textContent).toBe('ts-content')
+ })
+
+ it('keeps every outlet of a key mounted when another key registers', async () => {
+ const core = new SlotCore()
+ mounts.mockClear()
+ const view = mount(core, renderSlot => (
+ <>
+ {keyedOutlet(renderSlot, 'ts', 'first-content')}
+ {keyedOutlet(renderSlot, 'ts', 'second-content')}
+ >
+ ))
+ core.register({ name: 'spec.hoc.keyed', id: 'w' }, layer('w') as never)
+ await flush()
+ const before = mounts.mock.calls.length
+ act(() => { core.register({ name: 'spec.hoc.keyed', id: 'md', key: 'md' }, layer('md') as never) })
+ await flush()
+ expect(mounts).toHaveBeenCalledTimes(before)
+ expect(view.getByTestId('first-content-outlet').querySelector('section[data-layer="w"] > p')?.textContent).toBe('first-content')
+ expect(view.getByTestId('second-content-outlet').querySelector('section[data-layer="w"] > p')?.textContent).toBe('second-content')
+ })
+
+ it('recompiles a key\'s chain for a redeclared slot', async () => {
+ const core = new SlotCore()
+ const declare = () => core.register({
+ name: 'root',
+ children: { 'spec.hoc.keyed': { kind: 'hoc', scope: 'root' } },
+ }, function Root(props: Frame) {
+ return <>{keyedOutlet(props.renderSlot, 'ts', 'ts-content')}>
+ } as never)
+ const undeclare = declare()
+ const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}>)
+ core.register({ name: 'spec.hoc.keyed', id: 'first', key: 'ts' }, layer('first') as never)
+ await flush()
+ expect(view.container.querySelector('section[data-layer="first"] > p')?.textContent).toBe('ts-content')
+ act(() => {
+ undeclare()
+ declare()
+ core.register({ name: 'spec.hoc.keyed', id: 'second', key: 'ts' }, layer('second') as never)
+ })
+ await flush()
+ expect(view.container.querySelector('section[data-layer="second"] > p')?.textContent).toBe('ts-content')
+ expect(view.container.querySelector('section[data-layer="first"]')).toBeNull()
+ })
+})
diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml
index c305f377d5..30e91cb399 100644
--- a/packages/client/ui-slots/README.i18n.yaml
+++ b/packages/client/ui-slots/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-slots/use-this-package:
- en: eb72211e6b97afbc
- zh: d959b329c2eee0c6
+ en: 293a68931770fd5e
+ zh: 5f73b171f37bf08f
/deepseek-ai-dsh-client-ui-slots/use-this-package/reusable-component-factories:
en: 71cbe7aa26f3ff80
zh: 0e35265d4d95cc36
diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md
index e64f58be6c..376c351788 100644
--- a/packages/client/ui-slots/README.md
+++ b/packages/client/ui-slots/README.md
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
## Use this package
-Compose UI through this package whenever you write a client plugin: register a component into a slot your parent declared, or declare child slots your component renders. The five kinds cover the composition shapes — `single` (one occupant), `list` (ordered entries), `keyed` (dispatch by a key), `chain` (entries elect themselves), and `hoc` (entries nest around the owner's `children`).
+Compose UI through this package whenever you write a client plugin: register a component into a slot your parent declared, or declare child slots your component renders. The five kinds cover the composition shapes — `single` (one occupant), `list` (ordered entries), `keyed` (dispatch by a key), `chain` (entries elect themselves), and `hoc` (layers nest around the owner's `children`, each rendering it at most once; an optional `key` limits a layer to the owner's matching `entryKey`).
### Reusable Component Factories
diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md
index ee59e97605..12a708b447 100644
--- a/packages/client/ui-slots/README.zh.md
+++ b/packages/client/ui-slots/README.zh.md
@@ -25,7 +25,7 @@ kind: "package-library"
## 使用本包
-编写客户端插件时都通过本包组合 UI:把组件注册进父级已声明的 slot,或声明组件将要渲染的子 slot。五种 kind 覆盖组合形态——`single`(单个占位者)、`list`(有序条目)、`keyed`(按键分派)、`chain`(条目自行提名)与 `hoc`(条目层层包裹 owner 的 `children`)。
+编写客户端插件时都通过本包组合 UI:把组件注册进父级已声明的 slot,或声明组件将要渲染的子 slot。五种 kind 覆盖组合形态——`single`(单个占位者)、`list`(有序条目)、`keyed`(按键分派)、`chain`(条目自行提名)与 `hoc`(各层包裹 owner 的 `children`,每层至多渲染它一次;可选的 `key` 让该层只作用于 owner 传入的相同 `entryKey`)。
### 可复用 Component Factory
diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts
index 947f507dd5..a6f829e884 100644
--- a/packages/client/ui-slots/src/index.ts
+++ b/packages/client/ui-slots/src/index.ts
@@ -288,12 +288,18 @@ export interface ChainRenderOpts {
/**
* renderSlot options for a hoc slot. `children` is the owner's content; every
- * live entry wraps it, and the slot renders it unchanged when no entry is live.
- * Omitted content renders the layers around nothing.
+ * live layer for the dispatch wraps it, and the slot renders it unchanged when
+ * no layer applies. Omitted content renders the layers around nothing.
*/
-export interface HocRenderOpts {
- /** Innermost content wrapped by the slot's entries. */
+export interface HocRenderOpts {
+ /** Innermost content wrapped by the slot's layers. */
children?: ReactNode
+ /**
+ * Dispatch key: layers registered with exactly this `key` join the wildcard
+ * layers (registered without `key`); within one `id` the exact-key layer
+ * replaces the wildcard one. Omitted, only wildcard layers apply.
+ */
+ entryKey?: EntryKey
}
/**
@@ -331,7 +337,7 @@ type OrdinaryKeysOf = Exclude =
([HocKeysOf] extends [never] ? object : {
- >(key: K, owner: OwnerOf, opts?: HocRenderOpts): ReactNode
+ >(key: K, owner: OwnerOf, opts?: HocRenderOpts>): ReactNode
}) &
([Exclude, HocKeysOf>] extends [never] ? object : {
<
@@ -365,7 +371,8 @@ export type MatchedShare =
/**
* Hoc children share: a hoc-slot component receives the next inner layer, or
* the owner's content at the innermost layer, as the framework-injected
- * `children` prop and renders it exactly once.
+ * `children` prop and renders it at most once; a layer that omits it replaces
+ * everything inside it.
*/
export type HocShare =
E['kind'] extends 'hoc' ? { children: ReactNode } : object
@@ -789,7 +796,7 @@ export type SlotLabel = string | (() => string)
/**
* Kind shape fields carried in register options (keyed dispatch key; list
- * id/order/label; hoc id/order; chain select/priority; for every other kind
+ * id/order/label; hoc id/key/order; chain select/priority; for every other kind
* priority is the cell shadowing rank, and hoc nesting follows order).
*/
export type KindOptions<
@@ -817,9 +824,17 @@ export type KindOptions<
}
: SlotMap[K]['kind'] extends 'hoc' ? {
id: string
+ /**
+ * Dispatch key this layer serves; omitted, the layer serves every
+ * dispatch (wildcard). Within one `id`, an exact-key layer replaces
+ * the wildcard layer for that key.
+ */
+ key?: EntryKey
/** Nesting position: ascending, default 0; the lowest order is the outermost layer. */
order?: number
- /** Cell shadowing rank (ascending, default 0, lowest renders; same id + same priority throws — see {@link SlotCore.register}). */
+ /**
+ * Cell shadowing rank (ascending, default 0, lowest renders; same key + id + priority throws — see {@link SlotCore.register}).
+ */
priority?: number
}
: {
@@ -1006,6 +1021,16 @@ export interface LiveFactoryNode {
/** One root in the live Slot/Factory composition topology. */
export type LiveCompositionNode = LiveSlotNode | LiveFactoryNode
+/**
+ * A hoc layer's shadowing cell: its dispatch key and id, encoded so no key/id
+ * pair collides with another; a wildcard layer's absent key encodes as `null`.
+ * @param entry - one hoc registration.
+ * @returns the cell identity.
+ */
+function hocCell(entry: StoredEntry): string {
+ return JSON.stringify([entry.options.key, entry.options.id])
+}
+
/**
* Pure slot registry (no cordis; event emission and the renderer installation contract
* live in the runtime Service wrapper).
@@ -1170,7 +1195,7 @@ export class SlotCore {
* throws (the selector is the entry's routing seat, see {@link ChainSelect}).
*
* Shadowing (single/keyed/list/hoc): entries sharing one cell (single — the
- * slot itself; keyed — same `key`; list and hoc — same `id`) coexist at distinct
+ * slot itself; keyed — same `key`; list — same id; hoc — same key (or wildcard) and id) coexist at distinct
* priorities, sorted ascending with ties keeping registration order; the
* cell's lowest live entry renders ({@link SlotCore.entriesOfSlot}). A
* second registration at an occupied cell's exact priority (default 0)
@@ -1264,12 +1289,21 @@ export class SlotCore {
}
break
}
- case 'list':
- case 'hoc': {
- if (options.id === undefined) throw new Error(`${spec.kind} slot "${options.name}" requires options.id`)
+ case 'list': {
+ if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
const occupant = rec.entries.find(e => e.options.id === options.id && (e.options.priority ?? 0) === priority)
if (occupant) {
- throw new Error(`${spec.kind} slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`)
+ throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`)
+ }
+ break
+ }
+ case 'hoc': {
+ if (options.id === undefined) throw new Error(`hoc slot "${options.name}" requires options.id`)
+ const occupant = rec.entries.find(e => e.options.id === options.id && e.options.key === options.key
+ && (e.options.priority ?? 0) === priority)
+ if (occupant) {
+ const cell = options.key === undefined ? `id "${options.id}"` : `id "${options.id}" for key "${options.key}"`
+ throw new Error(`hoc slot "${options.name}" already has an entry with ${cell} ${occupantHint(occupant)}`)
}
break
}
@@ -1380,7 +1414,7 @@ export class SlotCore {
/**
* Project a key's entries to its shadowing winners: the first live
* (non-abdicated) entry of each cell in priority order — single: the slot
- * is one cell; keyed: one cell per `key`; list and hoc: one cell per `id`
+ * is one cell; keyed: one cell per `key`; list: one cell per id; hoc: one cell per (key or wildcard, id)
* (winners keep ledger sequence; renderers still refine list display and hoc
* nesting by `order`).
* Chain keys return the raw entries unchanged: election consumes every
@@ -1400,7 +1434,10 @@ export class SlotCore {
for (const entry of rec.entries) {
if (this.abdicated.has(entry)) continue
// Single-kind entries all share the one undefined cell.
- const cell = kind === 'keyed' ? entry.options.key : kind === 'list' || kind === 'hoc' ? entry.options.id : undefined
+ const cell = kind === 'keyed' ? entry.options.key
+ : kind === 'list' ? entry.options.id
+ : kind === 'hoc' ? hocCell(entry)
+ : undefined
if (seenCells.has(cell)) continue
seenCells.add(cell)
heads.push(entry)
@@ -1566,11 +1603,12 @@ export class SlotCore {
/**
* Renderer crash report from an entry boundary. Always notifies
* {@link SlotCore.onEntryError} listeners; with `info.abdicate` set (the
- * shadowing kinds — single/keyed/list/hoc) it first retires the entry from its
+ * shadowing kinds — single/keyed/list) it first retires the entry from its
* cell, one-shot: the record's version bumps through the ordinary mutation
* channel so outlets re-project onto the cell's next survivor, and a
- * repeat abdicating report no-ops entirely. Chain crashes report with
- * `abdicate: false` — election alternatives resolve at select time, so the
+ * repeat abdicating report no-ops entirely. Chain and hoc crashes report
+ * with `abdicate: false` — chain alternatives resolve at select time, and a
+ * hoc layer's failure stays with the occurrence that rendered it — so the
* entry keeps its cell and only the notification fires. The registration
* itself stays on the ledger either way — raw {@link SlotCore.entries}
* still lists the entry and its disposer keeps working.
diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts
index e6ed27a9ef..caae9de6a9 100644
--- a/packages/client/ui-slots/src/renderer.ts
+++ b/packages/client/ui-slots/src/renderer.ts
@@ -163,9 +163,9 @@ export interface SlotRendererHost {
*/
entriesOfSlot(key: string): readonly StoredEntry[]
/**
- * Report an entry boundary crash. With `info.abdicate` (shadowing kinds)
- * the entry retires from its cell, one-shot, so the next survivor renders;
- * chain crashes report without abdicating. The registration stays on the
+ * Report an entry boundary crash. With `info.abdicate` (single, keyed, and
+ * list) the entry retires from its cell, one-shot, so the next survivor
+ * renders; chain and hoc crashes report without abdicating. The registration stays on the
* ledger either way.
* @param key - slot key the entry rendered under.
* @param entry - the crashed entry.
diff --git a/packages/client/ui-slots/tests/core.client.spec.ts b/packages/client/ui-slots/tests/core.client.spec.ts
index 604b6f3804..6eca0434dd 100644
--- a/packages/client/ui-slots/tests/core.client.spec.ts
+++ b/packages/client/ui-slots/tests/core.client.spec.ts
@@ -12,6 +12,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'test.keyed': { kind: 'keyed'; scope: 'session' }
'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } }
'test.hoc': { kind: 'hoc'; scope: 'root' }
+ 'test.hoc.keyed': { kind: 'hoc'; scope: 'root' }
'test.grandchild': { kind: 'single'; scope: 'root' }
}
}
@@ -38,6 +39,7 @@ function mountFrame(core: SlotCore) {
'test.keyed': { kind: 'keyed', scope: 'session' },
'test.chain': { kind: 'chain', scope: 'session' },
'test.hoc': { kind: 'hoc', scope: 'root' },
+ 'test.hoc.keyed': { kind: 'hoc', scope: 'root' },
},
// Type-level renderSlot presence is proven by the type-chain spec; erasing
// here keeps runtime fixtures terse.
@@ -158,6 +160,19 @@ describe('kind semantics', () => {
expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
})
+ it('hoc: a cell is key plus id; same cell and priority throws; wildcard and keyed layers coexist', () => {
+ const core = new SlotCore()
+ mountFrame(core)
+ core.register({ name: 'test.hoc.keyed', id: 'a' }, Comp)
+ core.register({ name: 'test.hoc.keyed', id: 'a', key: 'ts' }, Comp)
+ core.register({ name: 'test.hoc.keyed', id: 'a', key: 'md' }, Comp)
+ expect(() => core.register({ name: 'test.hoc.keyed', id: 'a' }, Comp)).toThrow('with id "a"')
+ expect(() => core.register({ name: 'test.hoc.keyed', id: 'a', key: 'ts' }, Comp)).toThrow('id "a" for key "ts"')
+ expect(() => core.register({ name: 'test.hoc.keyed', id: 'a', key: 'ts', priority: -1 }, Comp)).not.toThrow()
+ expect(core.entriesOfSlot('test.hoc.keyed').map(e => [e.options.key, e.options.id, e.options.priority]))
+ .toEqual([['ts', 'a', -1], [undefined, 'a', undefined], ['md', 'a', undefined]])
+ })
+
it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
const core = new SlotCore()
mountFrame(core)
@@ -408,7 +423,7 @@ describe('subscription API', () => {
const off = core.onMutate(key => keys.push(key))
mountFrame(core)
// Contribution first, then each declared child key.
- expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain', 'test.hoc'])
+ expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain', 'test.hoc', 'test.hoc.keyed'])
keys.length = 0
core.register({ name: 'test.list', id: 'a' }, Comp)
expect(keys).toEqual(['test.list'])
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 0dfc293fa9..6796e589a2 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -58,7 +58,8 @@ export interface ClientSlotEntry {
occupants: readonly string[]
/**
* `shadows-shipped-ui` when registering here replaces shipped UI; `wraps-owner-content` when your
- * component wraps the owner content and must render `children`; `none` when additive.
+ * component wraps the owner content and renders `children` at most once, omitting it to replace
+ * that content; `none` when additive.
*/
replaceRisk: string
/** A minimal browser half that registers into this slot. */
@@ -277,7 +278,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:493',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:495',
},
{
key: 'conversation.chat.commandview',
@@ -331,7 +332,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:475',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:477',
},
{
key: 'conversation.chat.flow',
@@ -378,7 +379,73 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.flow\', () => ctx.slots.register(\n { name: \'conversation.chat.flow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:431',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:433',
+ },
+ {
+ key: 'conversation.chat.markdown.fence',
+ kind: 'hoc',
+ scope: 'session',
+ summary: 'Language-keyed layers around one Markdown fence in the Chat flow; the content is the plain code block.',
+ doc: 'Language-keyed layers around one Markdown fence in the Chat flow; the content is the plain code block.',
+ registerOptions: [
+ {
+ name: 'id',
+ requirement: 'required',
+ type: 'string',
+ doc: 'Your layer key. A fresh id adds a layer; reusing a shipped id replaces that layer. Your component may omit `children`, which replaces everything inside the layer.',
+ },
+ {
+ name: 'key',
+ requirement: 'optional',
+ type: 'string',
+ doc: 'Dispatch key this layer serves; omitted, it serves every dispatch. Within one id, an exact-key layer replaces the wildcard layer for that key.',
+ },
+ {
+ name: 'order',
+ requirement: 'optional',
+ type: 'number',
+ doc: 'Nesting position, ascending (default 0); the lowest order is the outermost layer. Render the `children` prop at most once.',
+ },
+ ],
+ ownerProps: [
+ '/** One Markdown code fence whose language is final, as {@link MarkdownDelegate.renderCode} receives it. */\nexport interface MarkdownDelegateFenceInfo {\n /**\n * The info string\'s language word, cut at its first character outside\n * letters, digits, `_`, and `-`, then lowercased (`c++` yields `c`).\n */\n readonly lang: string\n /** The fence body without its closing newline. */\n readonly code: string\n /** The surrounding document is still streaming, so `code` may still change. */\n readonly streaming: boolean\n /** The info string after the language word, as the author wrote it; absent when the fence names only a language. */\n readonly meta?: string | undefined\n}',
+ ],
+ ownerPropsReferences: [
+ 'MarkdownDelegate',
+ ],
+ standardProps: [
+ 'useRemoteAccess: (action: RemoteEndpoint) => boolean',
+ 'useResource: UseResource',
+ 'useContextMenu: UseContextMenu',
+ 'useWorkspaces: SnapshotSelectorHook',
+ 'usePanelInfo: UsePanelInfo',
+ 'useSessions: UseSessions',
+ 'useSessionStatus: UseSessionStatus',
+ 'useSessionRetainInfo: UseSessionRetainInfo',
+ 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean',
+ 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean',
+ 'useChat: UseChat',
+ 'useConversation: UseConversation',
+ 'useInput: SnapshotSelectorHook',
+ 'inputActions: InputActions',
+ 'useSession: SessionSnapshotSelector',
+ 'sessionId: SessionId',
+ 'useProjection: UseProjection',
+ 'useAccess: (action: RemoteEndpoint) => boolean',
+ 'useTrajectory: UseTrajectory',
+ ],
+ keyDomain: 'open: any entryKey the owner dispatches; a layer without key serves every dispatch, already taken: diff, math, patch',
+ hookContext: '',
+ slotInject: '',
+ declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
+ occupants: [
+ 'client-ui-chat MathFenceLayer id \'@deepseek-ai/dsh-client-ui-chat/fence/math\' key \'math\'',
+ 'client-ui-chat DiffFenceLayer id \'@deepseek-ai/dsh-client-ui-chat/fence/diff\' key \'diff\'',
+ 'client-ui-chat DiffFenceLayer id \'@deepseek-ai/dsh-client-ui-chat/fence/diff\' key \'patch\'',
+ ],
+ replaceRisk: 'wraps-owner-content',
+ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.markdown.fence\', () => ctx.slots.register(\n { name: \'conversation.chat.markdown.fence\', id: \'my-layer\', order: 100 },\n ({ children }) => React.createElement(\'div\', null, children),\n ))\n },\n}',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:430',
},
{
key: 'conversation.chat.message.images',
@@ -425,7 +492,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.message.images\', () => ctx.slots.register(\n { name: \'conversation.chat.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:467',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:469',
},
{
key: 'conversation.chat.node',
@@ -497,7 +564,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:443',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:445',
},
{
key: 'conversation.chat.reasoning.body',
@@ -541,7 +608,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.reasoning.body\', () => ctx.slots.register(\n { name: \'conversation.chat.reasoning.body\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:481',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:483',
},
{
key: 'conversation.chat.text-interaction',
@@ -660,7 +727,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:487',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:489',
},
{
key: 'conversation.chat.user.before',
@@ -710,7 +777,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.before\', () => ctx.slots.register(\n { name: \'conversation.chat.user.before\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:457',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:459',
},
{
key: 'conversation.chat.user.images',
@@ -757,7 +824,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.images\', () => ctx.slots.register(\n { name: \'conversation.chat.user.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:469',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:471',
},
{
key: 'conversation.composer',
@@ -1075,13 +1142,19 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
name: 'id',
requirement: 'required',
type: 'string',
- doc: 'Your layer key. A fresh id adds a layer; reusing a shipped id replaces that layer. Your component must render its `children` prop: a layer that omits it hides the owner content, such as the whole conversation and its composer.',
+ doc: 'Your layer key. A fresh id adds a layer; reusing a shipped id replaces that layer. Your component may omit `children`, which replaces everything inside the layer.',
+ },
+ {
+ name: 'key',
+ requirement: 'optional',
+ type: 'string',
+ doc: 'Dispatch key this layer serves; omitted, it serves every dispatch. Within one id, an exact-key layer replaces the wildcard layer for that key.',
},
{
name: 'order',
requirement: 'optional',
type: 'number',
- doc: 'Nesting position, ascending (default 0); the lowest order is the outermost layer. Render the `children` prop exactly once.',
+ doc: 'Nesting position, ascending (default 0); the lowest order is the outermost layer. Render the `children` prop at most once.',
},
],
ownerProps: [
@@ -4006,7 +4079,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:501',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:503',
},
{
key: 'sidebar',
diff --git a/scripts/gen-client-catalog.ts b/scripts/gen-client-catalog.ts
index 534609bb04..6e992a3eda 100644
--- a/scripts/gen-client-catalog.ts
+++ b/scripts/gen-client-catalog.ts
@@ -83,8 +83,9 @@ const REGISTER_OPTIONS: Readonly unknown | null', doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.' },
],
hoc: [
- { name: 'id', requirement: 'required', type: 'string', doc: 'Your layer key. A fresh id adds a layer; reusing a shipped id replaces that layer. Your component must render its `children` prop: a layer that omits it hides the owner content, such as the whole conversation and its composer.' },
- { name: 'order', requirement: 'optional', type: 'number', doc: 'Nesting position, ascending (default 0); the lowest order is the outermost layer. Render the `children` prop exactly once.' },
+ { name: 'id', requirement: 'required', type: 'string', doc: 'Your layer key. A fresh id adds a layer; reusing a shipped id replaces that layer. Your component may omit `children`, which replaces everything inside the layer.' },
+ { name: 'key', requirement: 'optional', type: 'string', doc: 'Dispatch key this layer serves; omitted, it serves every dispatch. Within one id, an exact-key layer replaces the wildcard layer for that key.' },
+ { name: 'order', requirement: 'optional', type: 'number', doc: 'Nesting position, ascending (default 0); the lowest order is the outermost layer. Render the `children` prop at most once.' },
],
}
@@ -350,10 +351,13 @@ function ownerCompositionNames(type: ts.TypeNode): string[] {
return []
}
-/** How a keyed slot's key domain is constrained, '' for the other kinds. */
+/** How a keyed slot's key domain, or a hoc slot's layer dispatch keys, is constrained; '' for the other kinds. */
function keyDomainOf(declaration: SlotDeclaration, occupants: readonly SlotRegistration[]): string {
- if (declaration.kind !== 'keyed') return ''
const taken = [...new Set(occupants.flatMap(occupant => occupant.entryKey === undefined ? [] : [occupant.entryKey]))].sort()
+ if (declaration.kind === 'hoc') {
+ return taken.length === 0 ? '' : `open: any entryKey the owner dispatches; a layer without key serves every dispatch, already taken: ${taken.join(', ')}`
+ }
+ if (declaration.kind !== 'keyed') return ''
const shipped = taken.length === 0 ? 'none are taken yet' : `already taken: ${taken.join(', ')}`
return declaration.keyProps === undefined
? `open: any string the owner dispatches (no compile-time key set), ${shipped}`
@@ -513,7 +517,8 @@ export function renderClientCatalog(entries: readonly SlotEntry[]): string {
' occupants: readonly string[]',
' /**',
' * `shadows-shipped-ui` when registering here replaces shipped UI; `wraps-owner-content` when your',
- ' * component wraps the owner content and must render `children`; `none` when additive.',
+ ' * component wraps the owner content and renders `children` at most once, omitting it to replace',
+ ' * that content; `none` when additive.',
' */',
' replaceRisk: string',
' /** A minimal browser half that registers into this slot. */',