From a1e4f45fdbe034aefccc02ddf55c173213e76f45 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:41:30 +0800
Subject: [PATCH] feat(ui-chat): dispatch Markdown fences by language through a
keyed hoc slot
MarkdownDelegate gains renderCode for fences whose language is final; ChatView
declares conversation.chat.markdown.fence and registers math and diff/patch
layers. Hoc slots accept an optional dispatch key, render children at most
once, share compiled chains per key, and contain layer crashes to the failing
occurrence.
Refs #7926
---
.../2026-11-03-hoc-slot-kind.i18n.yaml | 12 +-
.../architecture/2026-11-03-hoc-slot-kind.md | 18 +-
.../2026-11-03-hoc-slot-kind.zh.md | 18 +-
...26-11-05-markdown-fence-hoc-slot.i18n.yaml | 34 +++
.../2026-11-05-markdown-fence-hoc-slot.md | 100 +++++++
.../2026-11-05-markdown-fence-hoc-slot.zh.md | 100 +++++++
docs/subsystems/slots.i18n.yaml | 8 +-
docs/subsystems/slots.md | 13 +-
docs/subsystems/slots.zh.md | 13 +-
packages/client/ui-chat/README.i18n.yaml | 8 +-
packages/client/ui-chat/README.md | 6 +-
packages/client/ui-chat/README.zh.md | 6 +-
packages/client/ui-chat/src/client/apply.ts | 3 +
.../ui-chat/src/client/chat/ChatView.tsx | 14 +-
.../ui-chat/src/client/contract/slots.ts | 6 +-
.../ui-chat/src/client/fence/FenceLayers.tsx | 33 +++
.../ui-chat/src/client/fence/register.ts | 22 ++
.../ui-chat/tests/chat-apply.client.spec.tsx | 9 +
.../ui-chat/tests/chat-view.client.spec.tsx | 25 +-
.../tests/fence-layers.client.spec.tsx | 39 +++
.../ui-chat/tests/fence-slot.client.spec.tsx | 152 ++++++++++
.../client/ui-primitives/README.i18n.yaml | 4 +-
packages/client/ui-primitives/README.md | 2 +-
packages/client/ui-primitives/README.zh.md | 2 +-
packages/client/ui-primitives/src/index.ts | 4 +-
.../ui-primitives/src/markdown/CodeBlock.tsx | 39 +--
.../src/markdown/FenceBlocks.tsx | 51 ++++
.../src/markdown/MarkdownDelegate.tsx | 28 +-
.../src/markdown/MarkdownText.module.css | 21 +-
.../ui-primitives/src/markdown/render.tsx | 53 ++--
.../tests/diff-fence.client.spec.tsx | 23 +-
.../markdown-fence-delegate.client.spec.tsx | 171 +++++++++++
.../ui-renderer/src/client/scoped-slots.tsx | 129 +++++++--
.../tests/hoc-slots.client.spec.tsx | 271 +++++++++++++++++-
packages/client/ui-slots/README.i18n.yaml | 4 +-
packages/client/ui-slots/README.md | 2 +-
packages/client/ui-slots/README.zh.md | 2 +-
packages/client/ui-slots/src/index.ts | 74 +++--
packages/client/ui-slots/src/renderer.ts | 6 +-
.../client/ui-slots/tests/core.client.spec.ts | 17 +-
.../src/client/slot-catalog.ts | 99 ++++++-
scripts/gen-client-catalog.ts | 15 +-
42 files changed, 1492 insertions(+), 164 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.md
create mode 100644 .agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.zh.md
create mode 100644 packages/client/ui-chat/src/client/fence/FenceLayers.tsx
create mode 100644 packages/client/ui-chat/src/client/fence/register.ts
create mode 100644 packages/client/ui-chat/tests/fence-layers.client.spec.tsx
create mode 100644 packages/client/ui-chat/tests/fence-slot.client.spec.tsx
create mode 100644 packages/client/ui-primitives/src/markdown/FenceBlocks.tsx
create mode 100644 packages/client/ui-primitives/tests/markdown-fence-delegate.client.spec.tsx
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..499df0fa2a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.i18n.yaml
@@ -0,0 +1,34 @@
+# 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: 54a89cba05169f09
+ zh: 5258e00d91b20a4b
+/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/dispatch-entry:
+ en: a4afcb1e86bd8131
+ zh: 4aa2f07b36a5cf85
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/primitives-presets:
+ en: b15ed1d19b74aa35
+ zh: 2ed221485c5f67a3
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/hoc-dispatch-ui-slots-ui-renderer:
+ en: 41fdaf7d6f451834
+ zh: d363f3424b28c151
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/chat-flow-ui-chat:
+ en: a50b7c38db7f8a58
+ zh: b9fffb45c834917b
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/decision/styling:
+ en: 6b9af0b5564d0b51
+ zh: f3ee17fce2140523
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/alternatives-considered:
+ en: 52ad660eb563f4d2
+ zh: 1f23ea4d4e2063a2
+/agent-note-dispatch-markdown-fences-by-language-through-a-keyed-hoc-slot/consequences:
+ en: 5261c339a4a10e83
+ zh: d4e207e63ca30f21
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..bf226adafb
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.md
@@ -0,0 +1,100 @@
+# 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
+
+The conversation flow must render Markdown code fences by language and let plugins register, replace, or decorate the rendering of one language. With fence branches fixed inside primitives — `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 everything else goes to shiki — a new language such as mermaid means another branch in those two files, and a plugin can neither register a renderer for a language nor replace or decorate an existing rendering at one location.
+
+[ui-primitives](../../../../packages/client/ui-primitives/README.md) has no Cordis dependency, so its components cannot reach slots. `MarkdownText` has more than ten callers, most of which need no extension, and their rendering must stay exactly as it is.
+
+## Decision
+
+### Dispatch entry
+
+- `MarkdownDelegate` has an optional `renderCode(fence: MarkdownDelegateFenceInfo): ReactNode`. `MarkdownDelegateFenceInfo` has four fields: `lang` (the leading letters, digits, `_`, and `-` of the info string's language word, lowercased, so `c++` yields `c`), `code` (the fence body), `streaming` (whether the surrounding document is still streaming), and the optional `meta` (the info string after the language word, as written; it shares the first line with `lang` and is final once content appears).
+- `renderCode` runs only once `lang` is final: the fence has a language and non-empty content. While streaming, the info-string line has ended by the time content appears, so `lang` cannot change and dispatch never reverts; a fence whose info string is still incomplete has no content and takes the empty-fence branch.
+- Where no `renderCode` is supplied, primitives use the built-in default: diff rows follow `lang`, and `math` renders as KaTeX after the document settles.
+- Where `renderCode` is supplied, the owner handles every language: it declares its own slot and registers the layers it needs. Primitives do not fall back to diff or math for it.
+- Primitives wrap the output of `renderCode` in a block box, `fenceSeat`, which takes the fence's vertical spacing among adjacent blocks; code blocks and display formulas inside it carry no margins, and layer root elements set none either. Without `renderCode`, no box is added.
+
+### Primitives presets
+
+| Item | Content |
+|---|---|
+| `CodeBlock` `diff` switch | Omitted follows `lang`; `true` / `false` forces diff rows on or off for any `lang` |
+| `FenceCodeBlock` | Fence preset: hands the fence to `CodeBlock`, appends the trailing newline the Markdown renderer expects, and forwards `diff` |
+| `FenceMathBlock` | Fence preset: display KaTeX, with the trailing newline appended |
+| Built-in default | `MarkdownFenceSeat` in `render.tsx` reads the delegate inside a Component, so cached streaming elements follow a Provider change; without `renderCode` it renders the two presets above |
+
+### Hoc dispatch (ui-slots / ui-renderer)
+
+| Item | Content |
+|---|---|
+| Registration | A hoc registration has an optional `key`; omitted, the layer is a wildcard that matches every `entryKey` |
+| Cell | `(key or wildcard, id)`; the same cell at the same `priority` throws, a different `priority` shadows |
+| Dispatch | `renderSlot(name, owner, { entryKey, children })`; applies only wildcard layers and layers whose `key === entryKey`; an omitted `entryKey` applies only wildcard layers |
+| Exact key first | Within one `id`, an exact-key layer replaces the wildcard layer |
+| Nesting order | `order`, then `priority`, then registration order; lowest `order` outermost |
+| children | A layer renders `children` at most once and may omit it to replace everything inside; applies to every hoc |
+| Crash | A crashed layer reports its error without abdicating (`abdicate: false`): only the occurrence that failed renders `children` (the inner content), the layer stays registered, and other occurrences of the same key are unaffected; the failed occurrence does not retry within its mount, even when props change, and recovers when it remounts; applies to every hoc |
+| Compilation | Compiled once per `(host, slot declaration, entryKey)` and shared by every outlet; each ledger version reselects the key's ordered layers and compares them entry by entry, reusing the chain when unchanged and recompiling only on change; a registration for another key does not remount mounted content, an outlet that switches `entryKey` switches chains and remounts its content, and a redeclared slot starts with an empty cache |
+
+The layers of `conversation.hero.frame` have no key and are all wildcards, and ui-conversation passes no `entryKey`; those layers may also omit the hero content.
+
+This note partially supersedes four decisions of [hoc slot kind](2026-11-03-hoc-slot-kind.md): the obligation to render `children` exactly once, hoc rejecting `entryKey`, per-outlet `useMemo` layer composition, and abdication of a crashed layer. That note's other decisions remain in force, and the hoc facts it records agree with this note.
+
+### Chat flow (ui-chat)
+
+- ChatView declares `'conversation.chat.markdown.fence': { kind: 'hoc', scope: 'session' }` in its `conversation.view` id=chat registration, with `MarkdownDelegateFenceInfo` as owner props, and supplies `renderCode` on its existing `MarkdownDelegateProvider`: `renderSlot('conversation.chat.markdown.fence', fence, { entryKey: fence.lang, children: })`.
+- Coverage equals that Provider's scope: every `MarkdownText` inside `conversation.chat.flow`, including replies, reasoning, compaction summaries, and tool details. Within that scope, the `labels` of `conversation.chat.reasoning.content` apply only to footnotes and fences without a language.
+- `src/client/fence/` registers two kinds of layers, with literal package-path ids so the slot catalog can list each layer id:
+ - `key: 'math'`: renders `children` while streaming and only `FenceMathBlock` after the document settles.
+ - `key: 'diff'` and `key: 'patch'` (one id): render `FenceCodeBlock diff` and omit `children`.
+- Third parties register layers by language; to replace a language's default rendering in the flow, register a layer for that key that omits `children`; to decorate it, render `children`. A layer may read `meta` to parse info-string parameters itself.
+- Layer contract: the root element sets no margins (`fenceSeat` owns spacing); a layer that cannot handle incomplete content renders `children` while `streaming`, because a mid-stream crash otherwise leaves that occurrence on the code block for the rest of its mount; a layer catches its own content-dependent errors.
+
+### Styling
+
+The outlet's `[data-slot]` (`display:contents`) wrapper sits inside the box, so the box is the direct child of `.markdown` and `li`; first- and last-child margin resets, the list-item first-child reset, and the 11px `:not(:last-child)` rule all apply to the box directly:
+
+| Rule | Box value |
+|---|---|
+| Default | `margin: 16px 0` |
+| Followed by a sibling | `margin-bottom: 11px` (matching `CodeBlock`'s `.block:not(:last-child)`) |
+| compact variant | `margin: 4px 0` |
+| Last child of a list item | Keeps its margins like a code block (`li > *:last-child:not(.md-code-block, .fenceSeat)`) |
+| `.md-code-block` and `.katex-display` inside the box | `margin-block: 0` |
+
+The slot anchor keeps the general hoc behavior.
+
+## Alternatives considered
+
+- **Keep `diff` / `math` on fixed primitives branches and open a slot only for new languages**: two mechanisms for old and new languages, and built-in renderings cannot be replaced or decorated.
+- **A global Provider installed by AppFrame covering every CodeBlock**: adds application-wide global state and conflicts with the rule that a document without `renderCode` keeps the built-in rendering.
+- **A shared Factory carrying the base diff / math registrations, with each slot wrapping the Factory**: workable but structurally heavier; "an owner that defines `renderCode` handles every language" is simpler.
+- **Two hoc levels (a fence layer plus a CodeBlock body layer)**: diff needs only an existing `CodeBlock` switch, not a second level.
+- **An "anchorless" hoc declaration to avoid the wrapper element**: changes a general slot property; the styling problem is solved in the styles.
+- **No box, with "one level removed" structural rules restoring edge and list spacing**: the rules assume exactly one wrapper and a single root element, break when a layer emits several roots or one more wrapper, and cannot restore the 11px gap.
+- **Abdicate the whole crashed layer (the other kinds' semantics)**: one content-triggered crash would send that language back to code blocks in every Session until re-registration.
+- **Retry after a crash when owner props change**: requires state and shallow comparison in the error boundary every kind shares, and a faulty layer would report once per streamed chunk. No retry only needs hoc layers to report with `abdicate: false`.
+- **An html preview layer**: kept as a mechanism demonstration and security investigation only, outside this scope; browsers cannot block script-initiated self-navigation of an iframe, which needs its own design.
+- **Register the default rendering as a wildcard layer**: other plugins would need its `order`, and a layer with a larger `order` would nest inside a default layer that omits `children` and never mount; passing it as hoc content requires no id or `order` knowledge.
+- **`MarkdownDelegateFenceInfo` carrying `labels` and the language name in its original case**: each layer chooses its own chrome copy; a lowercase `lang` in the banner is acceptable.
+- **Per-outlet `useMemo` layer composition**: many same-language fences in a Session each compose the same chain, and any registration change remounts every fence; sharing compiled chains per `(slot declaration, entryKey)` applies only to hoc, unlike the "precompile every kind per ledger version" option that [hoc slot kind](2026-11-03-hoc-slot-kind.md) rejected.
+
+## Consequences
+
+- Plugins add, replace, or decorate fence rendering in the flow by language without touching 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 content-triggered layer crash affects only the occurrence that failed; other fences of the same language keep the layer.
+- Same-language fences share one compiled layer chain; registering a layer for another language does not remount mounted fences.
+- Layers of every hoc may omit `children`: a `conversation.hero.frame` layer can hide the hero content.
+- Hoc layer crashes do not abdicate: a faulty layer crashes and reports again at every mount, and that occurrence keeps showing its inner content.
+- In the flow, display formula spacing comes from the box (16px / 11px), not KaTeX's default 1em.
+- On the delegate path, each fence with a language adds one block box element.
+- In the flow, each fence with a language adds one slot outlet (one registry subscription, one locale subscription, and one `display:contents` element); compiled chains are shared per key.
+- In the flow, custom labels that ReasoningContent callers pass do not apply to fence chrome; fence chrome copy comes from ChatView's locale.
+- In the flow, fence banners show the lowercase `lang`.
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..e44eed2eac
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-11-05-markdown-fence-hoc-slot.zh.md
@@ -0,0 +1,100 @@
+# Agent Note: Markdown fence 按语言分派到 keyed hoc slot
+
+Status: implemented
+
+[English](2026-11-05-markdown-fence-hoc-slot.md) | 中文
+
+## 问题
+
+会话流需要按语言渲染 Markdown 代码块(fence),并让插件按语言注册、替换或装饰渲染。fence 分支写死在 primitives 内部时——[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 高亮——新增一种语言(例如 mermaid)只能继续往这两个文件里加分支,插件既无法按语言注册渲染器,也无法在某一处替换或装饰已有的渲染。
+
+[ui-primitives](../../../../packages/client/ui-primitives/README.zh.md) 零 cordis,组件拿不到 slot;`MarkdownText` 有十几个调用方,大多数不需要扩展,它们的渲染结果必须保持原样。
+
+## 决定
+
+### 分派入口
+
+- `MarkdownDelegate` 有可选的 `renderCode(fence: MarkdownDelegateFenceInfo): ReactNode`。`MarkdownDelegateFenceInfo` 有四个字段:`lang`(info string 语言词开头的字母、数字、`_`、`-` 部分转小写,如 `c++` 得 `c`)、`code`(fence 内容)、`streaming`(所在文档是否仍在流式输出)、可选的 `meta`(info string 语言词之后的原文,与 lang 同在第一行,内容出现时已经定稿)。
+- `renderCode` 只在 lang 已定时调用:fence 有 lang 且内容非空。流式输出中,内容出现时 info string 那一行已经结束,lang 不会再变,触发后不会回退;info string 未写完的 fence 内容为空,走空 fence 分支。
+- 没有提供 `renderCode` 的地方走 primitives 内建默认:diff 按 lang 自动渲染,`math` 在 settle 后渲染为 KaTeX。
+- 提供了 `renderCode` 的地方自己负责全部语言规则:自己声明 slot、自己注册需要的层。primitives 不替它兜 diff 或 math。
+- primitives 把 `renderCode` 的输出包进一个块级外框 `fenceSeat`,由外框承担该 fence 与相邻块之间的上下间距;外框内的代码块与 display 公式不带外边距,层的根元素也不设外边距。没有 `renderCode` 时不包外框。
+
+### primitives 预设
+
+| 项 | 内容 |
+|---|---|
+| `CodeBlock` 的 `diff` 开关 | 不传时按 lang 自动;`true` / `false` 对任意 lang 显式开关 |
+| `FenceCodeBlock` | fence 预设:把 fence 交给 `CodeBlock`,内部补 Markdown 渲染器需要的结尾换行,透传 `diff` |
+| `FenceMathBlock` | fence 预设:display KaTeX,内部补结尾换行 |
+| 内建默认 | `render.tsx` 的 `MarkdownFenceSeat` 在组件内读取 delegate,缓存的流式元素因此也跟随 Provider 变化;没有 `renderCode` 时用上面两个预设渲染 |
+
+### hoc 分派(ui-slots / ui-renderer)
+
+| 项 | 内容 |
+|---|---|
+| 注册 | hoc 注册有可选 `key`;不写为通配,匹配所有 entryKey |
+| 格子 | `(key 或通配, id)`;同格子同 priority 重复注册报错,不同 priority 遮蔽 |
+| 分派 | `renderSlot(name, owner, { entryKey, children })`;只叠加 `key === entryKey` 的层与通配层;不传 entryKey 只叠加通配层 |
+| 精确优先 | 同一 id 下,精确 key 的层替换通配层 |
+| 嵌套顺序 | order → priority → 注册顺序,order 小的在外 |
+| children | 层至多渲染一次 children,可以不渲染(替换内层);适用于所有 hoc |
+| 崩溃 | 层崩溃时错误照常上报,但不 abdicate(`abdicate: false`):只有出错的那一处渲染 children(内层内容),层保持注册,同 key 的其他出现处不受影响;出错处在本次挂载内不重试(props 变化也不重试),重新挂载时恢复;适用于所有 hoc |
+| 编译 | 按 `(host, slot 声明, entryKey)` 编译一次,所有 outlet 共享;每个 ledger 版本重新选出该 key 生效的有序层列表,逐项比较 entry 身份,不变复用,变化才重编;注册其他 key 不让已挂载的内容重新挂载,同一 outlet 换 entryKey 会换链并重新挂载内容;重新声明的 slot 从空缓存开始 |
+
+`conversation.hero.frame` 的层没有 key,全部是通配层,ui-conversation 不传 entryKey;它的层也可以不渲染 hero 内容。
+
+本 note 部分取代 [hoc slot kind](2026-11-03-hoc-slot-kind.zh.md) 的四处决定:层"恰好渲染一次 children"的义务、hoc 不接受 `entryKey`、按 outlet 实例 `useMemo` 组合层、崩溃层 abdicate。该 note 的其余决定仍然有效,它记录的 hoc 事实与本 note 一致。
+
+### 会话流(ui-chat)
+
+- ChatView 在 `conversation.view` id=chat 的注册里声明 `'conversation.chat.markdown.fence': { kind: 'hoc', scope: 'session' }`,owner props 为 `MarkdownDelegateFenceInfo`;它在已有的 `MarkdownDelegateProvider` 上提供 `renderCode`:`renderSlot('conversation.chat.markdown.fence', fence, { entryKey: fence.lang, children: })`。
+- 覆盖范围就是这个 Provider 的范围:`conversation.chat.flow` 以内的全部 `MarkdownText`(正文、reasoning、compaction 摘要、工具详情等)。在这个范围内,`conversation.chat.reasoning.content` 的 `labels` 只作用于脚注与无语言 fence。
+- `src/client/fence/` 注册两类层,id 用包名路径并写成字面量,slot 目录据此列出各层 id:
+ - `key: 'math'`:流式时渲染 children,settle 后只渲染 `FenceMathBlock`。
+ - `key: 'diff'` 与 `key: 'patch'`(同一 id):渲染 `FenceCodeBlock diff`,不渲染 children。
+- 第三方按语言注册层;要在会话流替换某语言的默认渲染,注册该 key 的层并不渲染 children;要装饰,渲染 children。层可以读取 `meta` 自行解析 info string 中的参数。
+- 层的契约:根元素不设外边距(间距由 `fenceSeat` 承担);处理不了不完整内容的层,在 `streaming` 期间渲染 children,否则流式中途的崩溃会让该处在本次挂载内停在代码块;内容相关的错误由层自己接住。
+
+### 样式
+
+outlet 的 `[data-slot]`(`display:contents`)包装位于外框之内,外框才是 `.markdown` 与 `li` 的直接子元素,所以首尾外边距清零、列表项首个子元素清零与 `:not(:last-child)` 的 11px 规则都直接作用于外框:
+
+| 规则 | 外框取值 |
+|---|---|
+| 默认 | `margin: 16px 0` |
+| 后面还有兄弟元素 | `margin-bottom: 11px`(与 `CodeBlock` 的 `.block:not(:last-child)` 一致) |
+| compact 变体 | `margin: 4px 0` |
+| 列表项最后一个子元素 | 与代码块一样保留外边距(`li > *:last-child:not(.md-code-block, .fenceSeat)`) |
+| 外框内的 `.md-code-block`、`.katex-display` | `margin-block: 0` |
+
+slot 锚点保持 hoc 的通用行为。
+
+## 考虑过的替代方案
+
+- **把 `diff` / `math` 留在 primitives 的写死分支里,只为新语言开 slot**:新旧语言两套机制,内建语言的渲染无法被替换或装饰。
+- **全局 Provider(AppFrame 安装)覆盖所有 CodeBlock**:引入应用级全局状态;也与"没定义 `renderCode` 就保持内建渲染"的原则冲突。
+- **共享 Factory 携带基础 diff / math 注册,各处 slot 包在 Factory 外**:可行但结构更复杂;"定义了 `renderCode` 就自己负责全部语言"更简单。
+- **双层 hoc(fence 层 + CodeBlock body 层)**:diff 只需要一个现成的 `CodeBlock` 开关,不需要第二层。
+- **给 hoc 增加"无锚点"声明以避免包装元素**:改变 slot 的通用性质;样式问题直接改样式解决。
+- **不加外框,用"隔一层"的结构规则补首尾与列表间距**:规则默认外面恰好包一层、层只输出一个根元素,层输出多个根或多包一层即失效,也补不回 11px。
+- **崩溃层整体 abdicate(其他 kind 的语义)**:一条内容导致的崩溃会让该语言在所有会话退回代码块,直到重新注册。
+- **崩溃后 owner props 变化时重试**:要给所有 kind 共用的错误边界加状态与浅比较;流式期间有问题的层会每个 chunk 报一次错。不重试只需 hoc 层以 `abdicate: false` 上报。
+- **html 预览层**:只作为机制演示与安全调研,不在本次范围;浏览器无法禁止脚本发起的 iframe 自导航,需要单独设计。
+- **默认实现注册成通配层**:外部必须知道它的 order,order 更大的层会被嵌进不渲染 children 的默认层而永远不挂载;作为 hoc content 传入则外部无需知道任何 id 或 order。
+- **`MarkdownDelegateFenceInfo` 携带 `labels` 与原样大小写的语言名**:各层自行决定外观文案;横幅显示小写 lang 可接受。
+- **每个 outlet 各自 `useMemo` 组合层**:会话里大量同语言 fence 各自组合同一条层链,且任何一次注册变化都让所有 fence 重新挂载;按 `(slot 声明, entryKey)` 共享编译结果只针对 hoc,不同于 [hoc slot kind](2026-11-03-hoc-slot-kind.zh.md) 否决的"所有 kind 按 ledger 版本预编译"。
+
+## 后果
+
+- 插件按语言新增、替换或装饰会话流中的 fence 渲染,无需改动 primitives;`math`、`diff` / `patch` 与第三方语言走同一套机制。
+- 没有 `renderCode` 的 `MarkdownText` 调用方渲染内建块,不加外框,也不经过 slot。
+- 一条内容导致的层崩溃只影响出错的那一处,同语言的其他 fence 保留该层。
+- 同语言的 fence 共享一条编译好的层链;注册其他语言的层不会让已挂载的 fence 重新挂载。
+- 所有 hoc 的层都可以不渲染 children:`conversation.hero.frame` 的层可以隐藏 hero 内容。
+- hoc 层崩溃不 abdicate:有 bug 的层每次挂载都会再崩一次、再报一次错,该处始终显示内层内容。
+- 会话流内 display 公式的上下间距由外框决定(16px / 11px),不是 KaTeX 默认的 1em。
+- delegate 路径每个带 lang 的 fence 多一个块级外框元素。
+- 会话流内每个带 lang 的 fence 多一个 slot outlet(一次注册表订阅、一次 locale 订阅、一个 `display:contents` 元素);编译结果按 key 共享。
+- 会话流内 ReasoningContent 调用方传入的自定义 labels 不作用于 fence 外壳;fence 外壳文案统一来自 ChatView 的 locale。
+- 会话流内 fence 横幅显示小写 lang。
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 d3c3aefc0f..e31b4ddf31 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: [
@@ -4005,7 +4078,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. */',