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
This commit is contained in:
imccyu committed 2026-11-05 12:58:57 +08:00
1 parent beee3dbe42
commit a1e4f45fdb
42 files changed
+1492 -164

No files matched your search

@@ -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
@@ -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.
@@ -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 新增第五种。
@@ -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
@@ -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: <FenceCodeBlock diff={false} …/> })`.
- 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`.
@@ -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: <FenceCodeBlock diff={false} …/> })`。
- 覆盖范围就是这个 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。
+4 -4
View File
@@ -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
+8 -5
View File
@@ -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]
+8 -5
View File
@@ -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`——否则内容会脱离父元素的布局。
<a id="component-inputs"></a>
@@ -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]
+4 -4
View File
@@ -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
+5 -1
View File
@@ -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.
+5 -1
View File
@@ -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,也不改变钩子引用。展示模式切换保留展开状态。
@@ -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
@@ -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: (
<FenceCodeBlock lang={fence.lang} code={fence.code} streaming={fence.streaming} labels={codeLabels} diff={false} />
),
}), [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,
@@ -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<ChatStore>
& InjectFace<ChatViewInjected>
& 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': {
@@ -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<SlotMap['conversation.chat.markdown.fence']>
/** 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 : <FenceMathBlock code={code} />
}
/**
* 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 <FenceCodeBlock lang={lang} code={code} streaming={streaming} labels={labels} diff />
}
@@ -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))
}
@@ -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'])
@@ -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<FlowRenderer>[2]) => {
const fenceCalls: { owner: MarkdownDelegateFenceInfo; entryKey: string | undefined }[] = []
const renderSlot = (
key: string,
owner: ChatFlowOwnerProps | MarkdownDelegateFenceInfo,
opts: Parameters<FlowRenderer>[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<ProcessGroupData> | 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(<h.ChatView {...h.props} />)
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')],
@@ -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(<MathFenceLayer {...props}><p data-testid="inner" /></MathFenceLayer>)
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(<MathFenceLayer {...props}><p data-testid="inner" /></MathFenceLayer>)
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(<DiffFenceLayer {...props} />)
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)
})
})
@@ -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<ChatSettings>()
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<typeof runtime.sessions.retain> | 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<ISession['loadOlder']>() },
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()
})
})
@@ -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
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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;行对齐、宽度、内容和操作由调用方拥有。 |
+3 -1
View File
@@ -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'
@@ -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<HTMLDivElement>(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
? (
<pre className={css.plain}><code>{diff.rows.map((row, index) => renderDiffRow(row, index, diffTokens?.get(index)))}</code></pre>
<pre className={css.plain}><code>{diffRows.rows.map((row, index) => renderDiffRow(row, index, diffTokens?.get(index)))}</code></pre>
)
: streamedBody !== undefined
? streamedBody
@@ -238,7 +241,7 @@ export function CodeBlock({
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, diff !== undefined && !lineNumbers && css.diffFence, toolbarLabels !== undefined && css.card, className)}
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, diffRows !== undefined && !lineNumbers && css.diffFence, toolbarLabels !== undefined && css.card, className)}
data-line-numbers={lineNumbers || undefined}
data-code-wrap={toolbarLabels === undefined ? undefined : wrapped}
style={sourceLines === undefined ? undefined : {
@@ -0,0 +1,51 @@
/** Fence presets: the code and math blocks a Markdown document renders for one fence. */
import type { ReactNode } from 'react'
import { CodeBlock } from './CodeBlock.tsx'
import { renderTexToReact } from './katex.tsx'
import type { MarkdownCodeLabels } from './render.tsx'
/** Inputs of {@link FenceCodeBlock}. */
export interface FenceCodeBlockProps {
/** Grammar hint and banner text; omitted renders plain code. */
lang?: string | undefined
/** The fence body without its closing newline. */
code: string
/** The surrounding document is still streaming; the block keeps its instance as `code` grows. */
streaming: boolean
/** Localized code-block chrome. */
labels: MarkdownCodeLabels
/** Unified-diff rows; omitted follows `lang` as {@link CodeBlock} does. */
diff?: boolean | undefined
}
/**
* Render one fence body as a code block.
* @param props - fence language, body, streaming state, chrome labels, and diff choice.
* @returns the code block.
*/
export function FenceCodeBlock({ lang, code, streaming, labels, diff }: FenceCodeBlockProps): ReactNode {
return (
<CodeBlock
// The replaced hast pipeline appended one synthetic newline that
// CodeBlock's display trim removes; feeding the bare value would make
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${code}\n`}
lang={lang}
streaming={streaming}
diff={diff}
copyLabel={labels.copyLabel}
copiedLabel={labels.copiedLabel}
toolbarLabels={labels.toolbarLabels}
/>
)
}
/**
* 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)}</>
}
@@ -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<MarkdownDelegate>({})
@@ -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 (
<MarkdownDelegateContext.Provider value={delegate}>
{children}
@@ -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;
}
@@ -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 <Fragment key={key}>{renderTexToReact(`${node.value}\n`, true)}</Fragment>
// 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 <FenceCodeBlock key={key} code={node.value} streaming={context.streaming} labels={context.labels.code} />
}
return (
<CodeBlock
key={key}
// The replaced hast pipeline appended one synthetic newline that
// CodeBlock's display trim removes; feeding the bare value would make
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${node.value}\n`}
lang={lang}
// Streaming keys are source offsets, stable while the fence grows, so
// the CodeBlock 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: it can never re-resolve to a different grammar.
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
toolbarLabels={context.labels.code.toolbarLabels}
<MarkdownFenceSeat
key={key} lang={lang} meta={node.meta ?? undefined} code={node.value} streaming={context.streaming} labels={context.labels.code}
/>
)
}
/**
* 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 <div className={css.fenceSeat}>{renderCode({ lang: lang.toLowerCase(), code, streaming, meta })}</div>
}
// ```math fences render as display TeX once settled; while streaming they stay literal.
if (!streaming && lang === 'math') return <FenceMathBlock code={code} />
return <FenceCodeBlock lang={lang} code={code} streaming={streaming} labels={labels} />
}
/** 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)
@@ -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(<CodeBlock code={'@@ -1 +1 @@\n-old\n+new'} lang="diff" toolbarLabels={diffBlockLabels} wrap />)
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(<CodeBlock code={'@@ -1 +1 @@\n-old\n+new'} lang="diff" diff={false} showHeader={false} />)
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(<CodeBlock code={'@@ -1 +1 @@\n-old\n+new'} lang="text" diff showHeader={false} />)
const lines = rows(container)
expect(lines).toHaveLength(3)
expect(lines[1]!.className).toContain('_diffDel_')
expect(lines[2]!.className).toContain('_diffAdd_')
})
})
@@ -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 => <output data-testid="fence">{fence.lang}:{fence.code}</output>)
}
describe('MarkdownDelegate.renderCode', () => {
it('passes a final-language fence to the delegate with its lowercased language', () => {
const renderCode = renderFenceSpy()
const { container } = render(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={FENCE_TS} labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={'```HTML width=480 height=360\n<p>x</p>\n```'} labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
expect(renderCode).toHaveBeenCalledWith({ lang: 'html', code: '<p>x</p>', 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(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={`${FENCE_PLAIN}\n\n${FENCE_EMPTY_TS}`} labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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('<code class="language-ts"></code>')
})
it('hands math and diff fences to the delegate too', () => {
const renderCode = renderFenceSpy()
const { container } = render(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={`${FENCE_MATH}\n\n${FENCE_DIFF}`} labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text="```t" streaming labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
expect(renderCode).not.toHaveBeenCalled()
rerender(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={'```ts\nlet a'} streaming labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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 => <i>first</i>
const second = (): ReactNode => <i>second</i>
const { rerender } = render(
<MarkdownDelegateProvider renderCode={first}>
<MarkdownText text={text} streaming labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
expect(screen.getByText('first')).toBeTruthy()
rerender(
<MarkdownDelegateProvider renderCode={second}>
<MarkdownText text={text} streaming labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={`before\n\n${FENCE_TS}\n\nafter`} labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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(<MarkdownText text={`${FENCE_TS}\n\n${FENCE_MATH}`} labels={markdownLabels} />)
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 => (
<FenceCodeBlock lang={fence.lang} code={fence.code} streaming={fence.streaming} labels={markdownLabels.code} />
))
const view = (text: string, streaming: boolean) => (
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={text} streaming={streaming} labels={markdownLabels} />
</MarkdownDelegateProvider>
)
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(
<MarkdownDelegateProvider renderCode={renderCode}>
<MarkdownText text={'```ts title=x\nlet a'} streaming labels={markdownLabels} />
</MarkdownDelegateProvider>,
)
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(<MarkdownText text={FENCE_MATH} labels={markdownLabels} />)
expect(math.container.querySelector('.katex')).not.toBeNull()
math.unmount()
const diff = render(<MarkdownText text={FENCE_DIFF} labels={markdownLabels} />)
expect(diff.container.querySelector('pre')!.textContent).toBe('-a\n+b')
})
})
@@ -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<HocLayerProps> {
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<HocLayerProps>
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<string | undefined>()
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<SlotEntryDef>): 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<SlotEntryDef>,
layers: readonly StoredEntry[],
): RenderHocInner {
return layers.reduceRight<RenderHocInner>((renderInner, entry) => {
const Layer = hocLayer(host, slotKey, spec, entry, renderInner)
return (owner, children) => <Layer owner={owner}>{children}</Layer>
}, 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<SlotRendererHost, WeakMap<SlotSpec<SlotEntryDef>, Map<string, CompiledHoc>>>()
/**
* 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<SlotEntryDef>,
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<SlotEntryDef>
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 <HocOutletBoundary>{renderLayers(ownerProps, children)}</HocOutletBoundary>
}
@@ -1275,7 +1368,11 @@ function renderOutletContent(
throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
}
if (spec.kind === 'hoc') {
return <HocOutlet slotKey={slotKey} spec={spec} ownerProps={ownerProps}>{opts?.children ?? null}</HocOutlet>
return (
<HocOutlet slotKey={slotKey} spec={spec} ownerProps={ownerProps} entryKey={opts?.entryKey}>
{opts?.children ?? null}
</HocOutlet>
)
}
const entries = host.entriesOf(slotKey)
const slotInjected = cachedSlotInject(spec.inject)
@@ -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 => (
<>
<div data-testid="calm-outlet">{renderSlot('spec.hoc', { tone: 'calm' }, { children: <Content label="calm-content" /> })}</div>
<div data-testid="loud-outlet">{renderSlot('spec.hoc', { tone: 'loud' }, { children: <Content label="loud-content" /> })}</div>
</>
))
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 <section data-layer="fragile">{children}</section>
}) 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: <Content label="retry-content" /> })
: 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 <section data-layer="fragile">{children}</section>
}) 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 (
<div data-testid={`${label}-outlet`}>
{renderSlot('spec.hoc.keyed', { tone: 'x' }, { entryKey, children: <Content label={label} /> })}
</div>
)
}
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: <Content label="wild-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()
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' }, (() => <i data-testid="replaced" />) 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')}
<div data-testid="calm-outlet">
{renderSlot('spec.hoc.keyed', { tone: 'calm' }, { entryKey: 'ts', children: <Content label="calm-content" /> })}
</div>
</>
))
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 <section data-layer="fragile">{children}</section>
}) 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 }, (() => <i data-testid="replaced" />) 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()
})
})
+2 -2
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## 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
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-library"
<a id="use-this-package"></a>
## 使用本包
编写客户端插件时都通过本包组合 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
+56 -18
View File
@@ -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<EntryKey extends string = string> {
/** 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<S extends keyof SlotMap & string> = Exclude<S, ContextualKey
*/
type RenderSlotFn<S extends keyof SlotMap & string> =
([HocKeysOf<S>] extends [never] ? object : {
<K extends HocKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: HocRenderOpts): ReactNode
<K extends HocKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: HocRenderOpts<EntryKeyOf<K>>): ReactNode
}) &
([Exclude<ContextualKeysOf<S>, HocKeysOf<S>>] extends [never] ? object : {
<
@@ -365,7 +371,8 @@ export type MatchedShare<E extends SlotEntryDef, M> =
/**
* 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 extends SlotEntryDef> =
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.
+3 -3
View File
@@ -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.
@@ -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'])
@@ -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: \'<one key the owner dispatches>\' },\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<WorkspaceSnapshot>',
'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<InputState>',
'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: \'<one key the owner dispatches>\' },\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',
+10 -5
View File
@@ -83,8 +83,9 @@ const REGISTER_OPTIONS: Readonly<Record<(typeof KINDS)[number], readonly OptionD
{ name: 'select', requirement: 'required', type: '(owner) => 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. */',