فهرست منبع

Merge branch 'master' into worktree/web-session-titles

Tianyi Cui 1 ماه پیش
والد
کامیت
ea019beae9
30فایلهای تغییر یافته به همراه795 افزوده شده و 106 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
  2. 13 3
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
  3. 13 3
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
  4. 4 1
      packages/client/runtime/src/client/index.ts
  5. 2 6
      packages/client/runtime/src/client/sessions/conversation.ts
  6. 79 0
      packages/client/runtime/src/client/sessions/pending.ts
  7. 31 17
      packages/client/runtime/src/client/sessions/session.ts
  8. 4 0
      packages/client/runtime/src/client/slots.ts
  9. 5 3
      packages/client/runtime/tests/fake-api.ts
  10. 2 2
      packages/client/runtime/tests/manager.spec.ts
  11. 47 1
      packages/client/runtime/tests/session.spec.ts
  12. 6 1
      packages/client/ui-conversation/src/client/apply.ts
  13. 1 1
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  14. 4 4
      packages/client/ui-conversation/src/client/chat/PendingCard.tsx
  15. 24 3
      packages/client/ui-conversation/src/client/contract/slots.ts
  16. 20 12
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  17. 5 3
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  18. 4 2
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  19. 3 2
      packages/client/ui-conversation/tests/coverage-tails.spec.tsx
  20. 5 0
      packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
  21. 35 4
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  22. 3 1
      packages/client/ui-slots/README.md
  23. 85 22
      packages/client/ui-slots/src/index.ts
  24. 28 1
      packages/client/ui-slots/tests/core.spec.ts
  25. 57 0
      packages/client/ui-slots/tests/type-chain.spec.tsx
  26. 4 0
      packages/client/ui-trajectory/tests/views.spec.tsx
  27. 1 1
      packages/client/web-react/README.md
  28. 1 1
      packages/client/web-react/src/index.ts
  29. 98 8
      packages/client/web-react/src/scoped-slots.tsx
  30. 209 2
      packages/client/web-react/tests/scoped-slots.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-22-slot-type-chain-implementation.md: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1
-2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9
+2026-07-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd
+2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617

+ 13 - 3
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md

@@ -34,7 +34,7 @@ ctx.slots.register({
 
 There is no separate slot-definition API. The `children` object both **declares the child slots into existence** and **authorizes this component to render them** — a slot is a hole in the render tree that exists because someone will render it, so its lifecycle is the declaring entry's lifecycle (entry disposed → slots gone, contributions cleared). The values are the runtime spec (`kind`/`scope` drive outlet iteration and binding selection; `SlotMap` is types-only and erased at runtime, which is why an array of keys could not work), statically checked against the `SlotMap` entry so type and value are declared at one point and cross-validated.
 
-Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes.
+Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`.
 
 `SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type").
 
@@ -43,12 +43,20 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
 | Share | Type | Source of truth | Contents |
 |---|---|---|---|
 | runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` |
-| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S |
+| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
 | store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
 | business | `I` | inject return type | plain data + callbacks (hooks banned) |
 
 `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
 
+### The chain kind: entries self-nominate, first match renders
+
+The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect<O, M>`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`).
+
+The decline decision lives in `select`, never in a mounted component probing its own props: a component that mounts only to render null still runs its hooks and effects for nothing, and the resulting mount/unmount churn breaks memoization and React key semantics, whereas a selector is a pure function — unit-testable, zero mount side effects — the same discipline as "presentation methods are pure functions of `args`". Purity is the selector's contract: it reads no external mutable state and produces no side effects, so the routing decision is entirely a function of the owner props and safe to run on every dispatch. Selectors route; they never mint — per-dispatch object construction would churn identity every render, so wrapping a matched value in a richer face happens inside the elected component (`useMemo` keyed on `matched`).
+
+In the type chain, a chain entry's SlotMap shape is `{ kind: 'chain'; scope; owner }` with `owner` as the chain's currency; `M` — the `matched` prop's type — is inferred from the select return (a selector narrowing a union member types `matched` automatically), and the component position stays out of `M` inference, the same NoInfer ruling that pins the inject share (rulings below). On the owner side, `renderSlotChain(key, owner, { fallback })` joins `renderSlot` in the `PropsRenderSlots` share, its key domain statically narrowed to the chain-kind keys of the entry's children declaration (`ChainKeysOf`); the dispatch site is one line and holds no derivation or routing logic of its own.
+
 ### The store seat: framework engine, registrant schema
 
 The framework owns exactly one subscription machine: the snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads):
@@ -93,7 +101,7 @@ Two hardening decisions in the register signature exist because the obvious alte
 
 ## Consequences
 
-Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
+Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit surface stays the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
 
 ## Alternatives considered
 
@@ -107,3 +115,5 @@ Render authority is enforceable rather than conventional: who renders what is a
 | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
 | Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
 | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |
+| Keyed dispatch with owner-side routing for takeover slots | The owner accumulates per-entry contracts and a hardcoded routing table (`find` + `entryKey` per takeover); the chain currency keeps new takeover registrations at zero owner edits |
+| Components declining by rendering null | Declining requires mounting first — hooks and effects run for nothing, and mount/unmount churn breaks memoization and key semantics; a pure selector decides without a component instance |

+ 13 - 3
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md

@@ -34,7 +34,7 @@ ctx.slots.register({
 
 不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。
 
-对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。
+对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`
 
 `SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。
 
@@ -43,12 +43,20 @@ ctx.slots.register({
 | 份额 | 类型 | 真源 | 内容 |
 |---|---|---|---|
 | 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
-| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S |
+| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
 | store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
 | 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
 
 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
 
+### chain kind:entry 自荐,首中即渲
+
+第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占坑者,chain 则由 entry 自荐——owner 只分派一份通用货币形态的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect<O, M>`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。
+
+「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其 hook 与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的契约:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由、绝不铸对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。
+
+类型链上,chain entry 的 SlotMap 形状是 `{ kind: 'chain'; scope; owner }`,`owner` 即链的货币;`M`——`matched` prop 的类型——从 select 返回值推导(选择器收窄 union 成员时,`matched` 类型自动随之收窄),且组件位不参与 `M` 的推断,与钉住 inject 份额的 NoInfer 裁定同源(见下文裁定)。owner 侧,`renderSlotChain(key, owner, { fallback })` 与 `renderSlot` 同住 `PropsRenderSlots` 份额,其键域静态收窄到本 entry children 声明中 chain kind 的键(`ChainKeysOf`);分派现场只有一行,不含任何自有的派生或路由逻辑。
+
 ### store 席位:引擎归框架,schema 归注册方
 
 框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
@@ -93,7 +101,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
 
 ## Consequences
 
-渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。
+渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用;对 chain 坑,「谁来渲染」额外多出一层渲染期事实,但做决定的选择器全是 register 现场的声明,审计面仍是 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。
 
 ## Alternatives considered
 
@@ -107,3 +115,5 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
 | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
 | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
 | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
+| 接管坑用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 契约与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 |
+| 组件靠渲染 null 表示不接 | 不接也得先挂载——hook 与 effect 白跑,挂载/卸载抖动破坏 memo 化与 key 语义;纯选择器无需组件实例即可裁决 |

+ 4 - 1
packages/client/runtime/src/client/index.ts

@@ -34,9 +34,12 @@ export type {
 } from './contract/store.ts'
 export type {
   AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
-  PendingInteraction, RunningToolCall, SteeringMessageNode,
+  RunningToolCall, SteeringMessageNode,
   ToolResultNode, UnknownSurfaceNode, UserMessageNode,
 } from './sessions/conversation.ts'
+// PendingWait is a value export: tests construct fixture waits directly.
+export { PendingWait } from './sessions/pending.ts'
+export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
 export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
 
 // ---- Narrowed aliases (the single narrowing point of the slot type chain:

+ 2 - 6
packages/client/runtime/src/client/sessions/conversation.ts

@@ -4,7 +4,8 @@
 // string here (narrow to real brands when convenient).
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import type { PendingInteraction } from './pending.ts'
 
 /** Assistant content blocks sorted by what the UI cares about
  *  (text body / collapsible reasoning / tool-call card head / other fallback). */
@@ -121,11 +122,6 @@ export interface RunningToolCall {
   callView: ToolCallView | null
 }
 
-/** Approval/question placeholder cards (visible, not answerable;
- *  rpcId = the requested frame's envelope id, the future respond backfill key). */
-export type PendingInteraction =
-  | { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
-  | { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
 
 /** In-progress assistant output (chunk accumulator product). */
 export interface PartialAssistant {

+ 79 - 0
packages/client/runtime/src/client/sessions/pending.ts

@@ -0,0 +1,79 @@
+// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only
+// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to
+// the interaction's consumer package.
+
+import type {
+  ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
+} from '@deepseek-ai/dsh-client-connection/client'
+
+/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
+export interface PendingPayloads {
+  approval: Omit<Extract<MuxFrame, { type: 'approval/requested' }>, 'type' | 'sessionId'>
+  question: Omit<Extract<MuxFrame, { type: 'question/requested' }>, 'type' | 'sessionId'>
+}
+
+/** Pending-interaction discriminant (the keys of PendingPayloads). */
+export type PendingKind = keyof PendingPayloads
+
+/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
+export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]
+
+/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */
+const KEY_PREFIX: Record<PendingKind, string> = { approval: 'a', question: 'q' }
+
+/**
+ * One pending host-owned interaction wait: an immutable render face
+ * (kind/key/sessionId/payload) plus the response carrier. respond() backfills
+ * the requested frame's rpcId into a client-response envelope — no consumer
+ * ever sees the raw rpcId. Settlement is expressed only by pending-list
+ * membership (the settled flag is a fail-loud guard, not a render input).
+ */
+export class PendingWait<K extends PendingKind = PendingKind> {
+  /** Interaction kind (union discriminant). */
+  readonly kind: K
+  /** Opaque render identity, `<prefix>:<rpcId>` — stable across baseline replay, usable as a React key. */
+  readonly key: string
+  /** Owning session. */
+  readonly sessionId: SessionId
+  /** The requested frame's domain fields, verbatim. */
+  readonly payload: PendingPayloads[K]
+  #settled = false
+  readonly #rpcId: RpcId
+  readonly #respond: (message: ClientResponse) => Promise<RpcReceipt>
+
+  /**
+   * Minted by Session on a requested frame (public construction is the test-fixture path).
+   * @param kind - interaction kind.
+   * @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it).
+   * @param sessionId - owning session.
+   * @param payload - the requested frame's domain fields.
+   * @param respond - the client-response carrier (api.respond).
+   */
+  constructor(
+    kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K],
+    respond: (message: ClientResponse) => Promise<RpcReceipt>,
+  ) {
+    this.kind = kind
+    this.key = `${KEY_PREFIX[kind]}:${rpcId}`
+    this.sessionId = sessionId
+    this.payload = payload
+    this.#rpcId = rpcId
+    this.#respond = respond
+  }
+
+  /**
+   * Send a result for this wait: wraps it into the client-response envelope
+   * with the rpcId backfilled. Throws synchronously once settled.
+   * @param result - the result shell (ok value / error envelope), domain-encoded by the caller.
+   * @returns the carrier receipt.
+   */
+  respond(result: ClientResponse['result']): Promise<RpcReceipt> {
+    if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`)
+    return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result })
+  }
+
+  /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */
+  markSettled(): void {
+    this.#settled = true
+  }
+}

+ 31 - 17
packages/client/runtime/src/client/sessions/session.ts

@@ -5,12 +5,17 @@
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
+import type {
+  HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
+  SessionId, ToolEventView,
+} from '@deepseek-ai/dsh-client-connection/client'
 import { transportError } from '@deepseek-ai/dsh-client-connection/client'
 import type { ObservableSnapshot } from '../contract/store.ts'
 import type {
-  ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
+  ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
 } from './conversation.ts'
+import type { PendingInteraction } from './pending.ts'
+import { PendingWait } from './pending.ts'
 import { FoldAdapter } from './fold-adapter.ts'
 import { Notifier } from './notifier.ts'
 import { PartialAccumulator } from './partial.ts'
@@ -183,7 +188,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     this.events = []
     this.views = []
     this.baseSeq = 0
-    this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
+    // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
+    // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
+    this.pending.clear()
     this.pendingRev++
     this.subscribedLastSeq = null
     this.liveBuffer = []
@@ -229,33 +236,27 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         return // pure baseline bookkeeping, no visible change
       }
       case 'approval/requested': {
-        this.pending.set(`a:${rpcId}`, {
-          kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
-          ...(frame.callId !== undefined ? { callId: frame.callId } : {}),
-          ...(frame.reason !== undefined ? { reason: frame.reason } : {}),
-        })
-        this.pendingRev++
+        const { type: _type, sessionId: _sid, ...payload } = frame
+        this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m)))
         this.notifier.markDirty()
         return
       }
       case 'approval/resolved': {
-        for (const [key, item] of this.pending) {
-          if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
-            this.pending.delete(key)
-            this.pendingRev++
-          }
+        for (const item of this.pending.values()) {
+          if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item)
         }
         this.notifier.markDirty()
         return
       }
       case 'question/requested': {
-        this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
-        this.pendingRev++
+        const { type: _type, sessionId: _sid, ...payload } = frame
+        this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m)))
         this.notifier.markDirty()
         return
       }
       case 'question/resolved': {
-        if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
+        const item = this.pending.get(`q:${frame.questionRpcId}`)
+        if (item !== undefined) this.settle(item)
         this.notifier.markDirty()
         return
       }
@@ -295,6 +296,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
 
   // ---- 私有 ----
 
+  /** Requested-frame arrival: the wait enters the pending map under its own key. */
+  private mint(wait: PendingInteraction): void {
+    this.pending.set(wait.key, wait)
+    this.pendingRev++
+  }
+
+  /** Authoritative resolved-frame settlement: mark, then drop from the pending map. */
+  private settle(wait: PendingInteraction): void {
+    wait.markSettled()
+    this.pending.delete(wait.key)
+    this.pendingRev++
+  }
+
   /** @param generation - openGeneration at launch; every await re-checks it and a stale pass
    *  drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
   private async doOpen(generation: number): Promise<void> {

+ 4 - 0
packages/client/runtime/src/client/slots.ts

@@ -66,6 +66,10 @@ interface ErasedRegisterOptions {
   id?: string
   order?: number
   label?: string
+  /** Chain-slot routing selector (pure; the core validates presence for chain targets). */
+  select?: (owner: never) => unknown
+  /** Chain-slot explicit ordering override (ascending; registration order otherwise). */
+  priority?: number
   registrant?: string
 }
 

+ 5 - 3
packages/client/runtime/tests/fake-api.ts

@@ -2,7 +2,7 @@
 // data source on a real clock; behavior tests need per-case responses and
 // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
 import type {
-  HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
+  ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
 } from '@deepseek-ai/dsh-client-connection/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 
@@ -93,8 +93,10 @@ export class FakeApiClient implements IApiClient {
     host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
   }
 
-  respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
-    return Promise.resolve({ accepted: false, reason: 'not-pending' })
+  onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
+
+  respond(message: ClientResponse): Promise<RpcReceipt> {
+    return this.record('respond', message, this.onRespond(message))
   }
 
   /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

+ 2 - 2
packages/client/runtime/tests/manager.spec.ts

@@ -34,7 +34,7 @@ describe('instances', () => {
     manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
     manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
     const session = manager.get(S1)
-    expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
+    expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
     // Buffer cleared: a second instantiation of another id gets nothing.
     expect(manager.get(S2).getSnapshot().pending).toEqual([])
   })
@@ -48,7 +48,7 @@ describe('instances', () => {
     }
     const pending = manager.get(S1).getSnapshot().pending
     expect(pending).toHaveLength(32)
-    expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
+    expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
     // Removed session: buffered frames must not replay on a future instantiation.
     manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
     manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })

+ 47 - 1
packages/client/runtime/tests/session.spec.ts

@@ -251,6 +251,36 @@ describe('pending interactions', () => {
     session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
     expect(session.getSnapshot().pending).toEqual([])
   })
+
+  it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
+    const { api, session } = makeSession()
+    session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    const wait = session.getSnapshot().pending[0]!
+    expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
+    const receipt = await wait.respond({
+      ok: true,
+      value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
+    })
+    expect(receipt).toEqual({ accepted: true })
+    expect(api.callsOf('respond')).toEqual([{
+      type: 'client-response', rpcId: 'rq-answer',
+      result: {
+        ok: true,
+        value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
+      },
+    }])
+  })
+
+  it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
+    const { api, session } = makeSession()
+    session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    const wait = session.getSnapshot().pending[0]!
+    session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
+    expect(session.getSnapshot().pending).toEqual([])
+    expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
+      .toThrow('already settled')
+    expect(api.callsOf('respond')).toEqual([])
+  })
 })
 
 describe('remaining branches', () => {
@@ -355,7 +385,7 @@ describe('remaining branches', () => {
     session.handleMuxEnvelope('ra' as never, {
       type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
     })
-    expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
+    expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } })
     session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
     session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
     session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
@@ -568,6 +598,22 @@ describe('resync', () => {
     expect(cold.api.calls).toEqual([]) // never opened: no traffic
   })
 
+  it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    const before = session.getSnapshot().pending[0]!
+    await session.resync()
+    session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    const after = session.getSnapshot().pending[0]!
+    expect(after).not.toBe(before)
+    expect(after.key).toBe(before.key)
+    // Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
+    await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
+    expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
+  })
+
   it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
     const { api, session } = makeSession()
     const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()

+ 6 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -69,7 +69,12 @@ export function apply(ctx: Context): void {
   // ConversationRoot is the only component authorized to render the ring.
   slots.register({
     name: 'conversation',
-    children: { 'conversation.view': { kind: 'list', scope: 'session' } },
+    // The composer chain rides the same declaration table: takeover plugins
+    // register selector-routed replacements of the InputBar.
+    children: {
+      'conversation.view': { kind: 'list', scope: 'session' },
+      'conversation.composer': { kind: 'chain', scope: 'session' },
+    },
     store: chatStore,
     inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
       // History pull is NOT triggered here: the runtime sessions service opens

+ 1 - 1
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -254,7 +254,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
             ))}
           </div>
         )}
-        {pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
+        {pending.map((item) => <PendingCard key={item.key} item={item} />)}
         </div>
       </div>
       <StatsLine useSession={useSession} />

+ 4 - 4
packages/client/ui-conversation/src/client/chat/PendingCard.tsx

@@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
     <div className={css.card}>
       {item.kind === 'approval' ? (
         <>
-          <div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
-          {item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
+          <div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
+          {item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
         </>
       ) : (
         <>
-          <div className={css.title}>等待回答({item.questions.length} 题)</div>
-          <JsonBlock label="问题内容" payload={item.questions} />
+          <div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
+          <JsonBlock label="问题内容" payload={item.payload.questions} />
         </>
       )}
       <div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>

+ 24 - 3
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -11,7 +11,7 @@
  * here.
  */
 import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
+import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
 import type { createChatStore } from '../stores.ts'
 import type { CallId, SelectionTarget, ViewTab } from './views.ts'
 
@@ -33,6 +33,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * `fallback` for unregistered tools.
      */
     'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
+    /**
+     * The composer takeover chain: entries are selector-routed replacements
+     * of the default InputBar. Declared by this package's 'conversation'
+     * entry; the owner dispatches the {@link ComposerChainProps} currency and
+     * routing lives in entry selectors — new takeover kinds register with
+     * zero owner changes.
+     */
+    'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
   }
 }
 
@@ -107,9 +115,22 @@ export interface ConversationInjected {
   open(id: SessionId): void
 }
 
-/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
+/**
+ * Composer chain currency: what ConversationRoot dispatches at its
+ * renderSlotChain site. The owner declares the currency only — never a
+ * per-entry contract; takeover packages narrow it in their own selectors
+ * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register
+ * with zero owner changes.
+ */
+export interface ComposerChainProps {
+  /** The session's live pending waits, in arrival order (snapshot reference). */
+  interactions: readonly PendingInteraction[]
+}
+
+/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
 export type ConversationSlotProps =
-  PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
+  PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
+  & PropsStore<ChatStore> & ConversationInjected
 
 /**
  * Injected share of the chat view entry: the two callbacks whose targets live

+ 20 - 12
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -3,7 +3,8 @@
 // props: the framework standard kit (useSession/sessionId/useSessions), the
 // declared chat store's useStore/actions, the injected business face, and the
 // renderSlot share for the declared 'conversation.view' child slot (views are
-// slot entries; the active one renders via the list `only` filter).
+// slot entries; the active one renders via the list `only` filter) plus the
+// renderSlotChain share for the 'conversation.composer' takeover chain.
 // Breadcrumbs derive from useSessions with a pure parentId walk; the active
 // view id lives in the chat store's `view` field (per-session by store scope).
 
@@ -36,7 +37,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
 }
 
 export function ConversationRoot({
-  sessionId, useSession, useSessions, useStore, actions, renderSlot,
+  sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
   views, send, stop, open,
 }: ConversationRootProps) {
   useSyncExternalStore(views.subscribe, views.version)
@@ -52,11 +53,27 @@ export function ConversationRoot({
   const removed = useSession(s => s.removed)
   const promptError = useSession(s => s.promptError)
   const turns = useSession(s => countTurns(s))
+  const pending = useSession(s => s.pending)
 
   const error: InputBarError | null = promptError === null
     ? null
     : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
 
+  // The default composer doubles as the chain's all-decline fallback: a
+  // pending wait with no registered takeover must still leave the input usable.
+  const composerBar = (
+    <InputBar
+      draft={draft}
+      running={running}
+      disabled={removed}
+      error={error}
+      variant="composer"
+      onDraftChange={actions.setDraft}
+      onSend={(mode) => { send(draft, mode) }}
+      onStop={stop}
+    />
+  )
+
   return (
     <div className={css.root}>
       <header className={css.header}>
@@ -106,16 +123,7 @@ export function ConversationRoot({
         {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
       </div>
 
-      <InputBar
-        draft={draft}
-        running={running}
-        disabled={removed}
-        error={error}
-        variant="composer"
-        onDraftChange={actions.setDraft}
-        onSend={(mode) => { send(draft, mode) }}
-        onStop={stop}
-      />
+      {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
     </div>
   )
 }

+ 5 - 3
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -4,9 +4,11 @@
 // single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
 // machinery specs since the tool ring dissolved into renderSlot.)
 
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { MessageItem } from '../src/client/chat/MessageItem.tsx'
 import { PendingCard } from '../src/client/chat/PendingCard.tsx'
@@ -44,7 +46,7 @@ describe('MessageItem arms', () => {
 describe('small branch tails', () => {
   it('PendingCard approval reason renders when present', () => {
     const view = render(
-      <PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
+      <PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
     )
     expect(view.getByText('careful')).toBeTruthy()
   })

+ 4 - 2
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -10,7 +10,8 @@ import type {
   AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { createChatStore } from '../src/client/stores.ts'
 import { ChatView } from '../src/client/chat/ChatView.tsx'
@@ -342,7 +343,8 @@ describe('ChatView', () => {
 
   it('pending interactions render placeholder cards', () => {
     const h = makeHarness({
-      pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
+      pending: [new PendingWait('approval', RpcId('r1'), SID,
+        { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
     })
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByText(/等待审批/)).toBeTruthy()

+ 3 - 2
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -8,7 +8,8 @@ import { cleanup, render } from '@testing-library/react'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
-import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply as nodeApply } from '../src/index.ts'
 import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -34,7 +35,7 @@ describe('tails', () => {
 
   it('PendingCard renders the question arm with its count', () => {
     const view = render(
-      <PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
+      <PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
     )
     expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
   })

+ 5 - 0
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx

@@ -21,6 +21,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
+/** Fallback-only chain stub (no takeover registered in these benches). */
+const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
+  (_key, _owner, opts) => opts?.fallback ?? null
 
 function snapshotBase(): ConversationSnapshot {
   return {
@@ -73,6 +76,7 @@ describe('ConversationRoot branches', () => {
         useStore={hookOf(chat)}
         actions={chat.actions}
         renderSlot={stubRenderSlot}
+        renderSlotChain={fallbackRenderSlotChain}
         SessionProvider={SessionProviderStub}
         views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
         send={vi.fn()}
@@ -131,6 +135,7 @@ describe('ConversationRoot branches', () => {
         useStore={hookOf(chat)}
         actions={chat.actions}
         renderSlot={stubRenderSlot}
+        renderSlotChain={fallbackRenderSlotChain}
         SessionProvider={SessionProviderStub}
         views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
         send={vi.fn()}

+ 35 - 4
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
-import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
+import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
 // Export discipline: packages/client/AGENTS.md.
@@ -36,11 +38,12 @@ interface FakeSnapshot {
   running: boolean
   removed: boolean
   promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
+  pending: readonly PendingInteraction[]
 }
 
 function fakeSession(init: Partial<FakeSnapshot> = {}) {
   const store = createSnapshotStore<FakeSnapshot>({
-    nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
+    nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
   })
   return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
 }
@@ -99,8 +102,11 @@ describe('EmptyState', () => {
 })
 
 describe('ConversationRoot', () => {
-  function bench(tabs: ViewTab[], activeView?: string) {
-    const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
+  function bench(
+    tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
+    renderSlotChain?: ConversationRootProps['renderSlotChain'],
+  ) {
+    const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
     const { useSessions } = fakeSessions([
       { id: 'root', title: 'proj' },
       { id: 's1', title: 'child', parentId: 'root' },
@@ -124,6 +130,7 @@ describe('ConversationRoot', () => {
         useStore={bindSnapshotSelector(chat)}
         actions={chat.actions}
         renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
+        renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
         SessionProvider={SessionProviderStub}
         views={{
           list: () => tabs,
@@ -179,6 +186,30 @@ describe('ConversationRoot', () => {
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(send).toHaveBeenCalledWith('hi', 'queue')
   })
+
+  it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
+    const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
+      { questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
+    // A matching entry takes the composer over.
+    const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
+    bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
+    expect(screen.getByText('question takeover')).toBeTruthy()
+    expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
+    // The owner dispatches the raw pending list (chain currency); routing
+    // lives in entry selectors, not here.
+    expect(renderSlotChain).toHaveBeenCalledWith(
+      'conversation.composer',
+      expect.objectContaining({
+        interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
+      }),
+      expect.objectContaining({ fallback: expect.anything() }),
+    )
+    cleanup()
+    // Zero registered entries (default all-decline stub): the fallback IS the
+    // default InputBar — behavior equals the pre-chain composer.
+    bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
+    expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
+  })
 })
 
 describe('DetailsPanel', () => {

+ 3 - 1
packages/client/ui-slots/README.md

@@ -11,11 +11,13 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co
 | store | `PropsStore<H>` | the declared handle: `useStore` selector hook + draft-stripped `actions` |
 | business | `I` | inferred from the `inject` factory's return |
 
+Chain-kind slots invert keyed routing — entries self-nominate instead of the dispatch site picking an `entryKey`: each registration carries a pure `ChainSelect` selector (plus optional ascending `priority`, ties in registration order), the first non-null return elects its entry and becomes the component's `matched` prop, and all-null falls to the owner's `renderSlotChain` fallback (`ChainRenderOpts`).
+
 The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
 
 The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
 
-`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
+`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
 
 ## Model Experience
 

+ 85 - 22
packages/client/ui-slots/src/index.ts

@@ -22,8 +22,8 @@ export * from './renderer.ts'
 /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
 export interface SlotMap {}
 
-/** Slot cardinality: single occupant, ordered list, or key-dispatched. */
-export type SlotKind = 'single' | 'list' | 'keyed'
+/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
+export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
 
 /** Slot data context: root (no session) or session-bound. */
 export type SlotScope = 'root' | 'session'
@@ -98,6 +98,34 @@ export type PropsRuntime<K extends keyof SlotMap & string> =
 /** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
 export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
 
+/** renderSlotChain dispatch options: the owner's fallback body, rendered when every entry's selector declines. */
+export interface ChainRenderOpts { fallback?: ReactNode }
+
+/**
+ * Chain-entry selector: the routing decision of one chain contribution.
+ * Runs at render time in chain order (ascending `priority`, default 0, lower
+ * tries first; ties keep registration = assembly order); the first non-null
+ * return elects its entry
+ * and becomes the component's `matched` prop; `null` passes to the next
+ * entry; all-null falls to the owner's {@link ChainRenderOpts} fallback.
+ * MUST be pure — a function of the owner props only, no external mutable
+ * reads, no side effects (the decline decision lives here, never in a
+ * mounted component probing its own props).
+ */
+export type ChainSelect<O extends object, M> = (owner: O) => M | null
+
+/** Keys of a slot-key union whose SlotMap entry is chain-kind (renderSlotChain's dispatch domain). */
+export type ChainKeysOf<S extends keyof SlotMap & string> =
+  S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never
+
+/**
+ * Chain matched share: a chain-slot component receives its selector's
+ * non-null result as the framework-injected `matched` prop; other kinds add
+ * nothing to the composed constraint.
+ */
+export type MatchedShare<E extends SlotEntryDef, M> =
+  E['kind'] extends 'chain' ? { matched: M } : object
+
 /**
  * Conversation-session selector hook alias for props contracts. Wide by
  * default at this dependency-inverted layer; the runtime narrows at its
@@ -135,15 +163,27 @@ export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
  */
 export type PropsRenderSlots<S extends keyof SlotMap & string> = {
   /**
-   * Render a declared child slot.
+   * Render a declared non-chain child slot (chain keys dispatch through
+   * `renderSlotChain` — their routing lives in entry selectors).
    * @param key - declared child key.
    * @param owner - owner props share for that key (decided at the render site).
    * @param opts - kind dispatch options.
    * @returns rendered node(s).
    */
-  renderSlot: <K extends S>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
+  renderSlot: <K extends Exclude<S, ChainKeysOf<S>>>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
   readonly __renders?: ((key: S) => void) | undefined
-} & ('session' extends ScopeOf<S>
+} & ([ChainKeysOf<S>] extends [never] ? object : {
+  /**
+   * Render a declared chain child slot: entry selectors run in chain order
+   * over `owner`; the first non-null match renders its component with the
+   * selector result injected as `matched`; all-null renders `opts.fallback`.
+   * @param key - declared chain child key.
+   * @param owner - owner props share (the selectors' routing input).
+   * @param opts - fallback body for the all-null case.
+   * @returns rendered node(s).
+   */
+  renderSlotChain: <K extends ChainKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: ChainRenderOpts) => ReactNode
+}) & ('session' extends ScopeOf<S>
   // The SessionProvider seat rides the same source as renderSlot: declaring
   // a session-scope child is what makes a session area exist, so the seat
   // derives from the children key set's scopes (renderer injects the value).
@@ -168,7 +208,8 @@ export type ComposedProps<
   S extends keyof SlotMap & string,
   H,
   I extends object,
-> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I
+  M = never,
+> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
 
 /**
  * Inject factory parameter list, derived from the registration's declaration:
@@ -182,27 +223,35 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
     ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
     : ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
 
-/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label). */
-export type KindOptions<E extends SlotEntryDef> =
+/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */
+export type KindOptions<E extends SlotEntryDef, M = never> =
   E['kind'] extends 'keyed' ? { key: string }
     : E['kind'] extends 'list' ? { id: string; order?: number; label?: string }
-      : object
+      : E['kind'] extends 'chain' ? {
+        /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */
+        select: ChainSelect<E extends { owner: infer O extends object } ? O : object, M>
+        /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
+        priority?: number
+      }
+        : object
 
 /**
  * Compile-time presence check: an entry declaring children MUST consume
- * `renderSlot` (declaring is claiming — an entry that does not render its
- * children should not declare them). Evaluates to an unsatisfiable
- * intersection member naming the declared keys when violated.
+ * `renderSlot` (or `renderSlotChain` when its only children are chain slots)
+ * — declaring is claiming; an entry that does not render its children should
+ * not declare them. Evaluates to an unsatisfiable intersection member naming
+ * the declared keys when violated.
  */
 type RendersCheck<C, D> =
   [keyof D & keyof SlotMap & string] extends [never] ? unknown
     : C extends (props: infer P) => ReactNode
       ? ('renderSlot' extends keyof P ? unknown
-        : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string })
+        : 'renderSlotChain' extends keyof P ? unknown
+          : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string })
       : unknown
 
 /** Common register options share (see {@link SlotCore.register} for semantics). */
-type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
+type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
   /** Target slot key (the entry contributes INTO this slot). */
   name: K
   /** Child-slot declaration + render authorization + runtime spec, in one table. */
@@ -211,7 +260,7 @@ type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> =
   store?: H
   /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
   registrant?: string
-} & KindOptions<SlotMap[K]>
+} & KindOptions<SlotMap[K], M>
 
 /**
  * One stored registration, as recorded by the core and read by the render
@@ -220,7 +269,9 @@ type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> =
  */
 export interface StoredEntry {
   component: unknown
-  options: { key?: string; id?: string; order?: number; label?: string }
+  options: { key?: string; id?: string; order?: number; label?: string; priority?: number }
+  /** Chain routing selector (type-erased like `inject`; present exactly on chain-slot entries). */
+  select?: ((owner: never) => unknown) | undefined
   /** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */
   inject?: ((...args: never[]) => Record<string, unknown>) | undefined
   /** Child-slot declaration table (declaration + authorization + runtime spec in one). */
@@ -243,6 +294,8 @@ interface ErasedOptions {
   id?: string | undefined
   order?: number | undefined
   label?: string | undefined
+  select?: ((owner: never) => unknown) | undefined
+  priority?: number | undefined
   children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
   store?: StoreDecl | undefined
   /* eslint-disable-next-line @typescript-eslint/no-explicit-any --
@@ -308,7 +361,8 @@ export class SlotCore {
    * names the first declarer); mounting one shared store handle under slots
    * of different scopes throws. Kind constraints: single — duplicate
    * registration throws; keyed — missing/duplicate `key` throws; list —
-   * missing/duplicate `id` throws.
+   * missing/duplicate `id` throws; chain — missing `select` throws (the
+   * selector is the entry's routing seat, see {@link ChainSelect}).
    *
    * Lifecycle: the disposer removes the contribution AND collapses every
    * declared child slot (child entries clear recursively; their stale
@@ -326,11 +380,12 @@ export class SlotCore {
     K extends keyof SlotMap & string,
     const D extends ChildrenDecl = Record<never, never>,
     H extends StoreDecl | undefined = undefined,
+    M = never,
     C extends SlotComponent<never> = SlotComponent<never>,
   >(
-    options: BaseOptions<K, D, H> & { inject?: undefined },
+    options: BaseOptions<K, D, H, M> & { inject?: undefined },
     component: C
-      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
+      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
       & RendersCheck<C, D>,
   ): () => void
   /**
@@ -348,11 +403,12 @@ export class SlotCore {
     I extends object,
     const D extends ChildrenDecl = Record<never, never>,
     H extends StoreDecl | undefined = undefined,
+    M = never,
     C extends SlotComponent<never> = SlotComponent<never>,
   >(
-    options: BaseOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
+    options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
     component: C
-      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
+      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
       & RendersCheck<C, D>,
   ): () => void
   register(options: ErasedOptions, component: unknown): () => void {
@@ -379,6 +435,9 @@ export class SlotCore {
           throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`)
         }
         break
+      case 'chain':
+        if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`)
+        break
     }
     if (options.children) {
       for (const childKey of Object.keys(options.children)) {
@@ -407,15 +466,19 @@ export class SlotCore {
         ...(options.id !== undefined ? { id: options.id } : {}),
         ...(options.order !== undefined ? { order: options.order } : {}),
         ...(options.label !== undefined ? { label: options.label } : {}),
+        ...(options.priority !== undefined ? { priority: options.priority } : {}),
       },
+      ...(options.select !== undefined ? { select: options.select } : {}),
       ...(options.inject !== undefined ? { inject: options.inject } : {}),
       ...(options.children !== undefined ? { children: options.children } : {}),
       ...(options.store !== undefined ? { store: options.store } : {}),
       ...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
     }
     const next = [...rec.entries, entry]
-    // Stable sort: order ascending, ties keep registration sequence.
+    // Stable sorts: ascending, ties keep registration sequence (list rides
+    // `order`, chain rides `priority` — lower priority tries first).
     if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
+    if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
     rec.entries = next
     this.markDirty(options.name, rec)
     if (options.children) {

+ 28 - 1
packages/client/ui-slots/tests/core.spec.ts

@@ -13,6 +13,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     'test.session': { kind: 'single'; scope: 'session' }
     'test.list': { kind: 'list'; scope: 'root' }
     'test.keyed': { kind: 'keyed'; scope: 'session' }
+    'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } }
     'test.grandchild': { kind: 'single'; scope: 'root' }
   }
 }
@@ -39,6 +40,7 @@ function mountFrame(core: SlotCore) {
       'test.session': { kind: 'single', scope: 'session' },
       'test.list': { kind: 'list', scope: 'root' },
       'test.keyed': { kind: 'keyed', scope: 'session' },
+      'test.chain': { kind: 'chain', scope: 'session' },
     },
   // Type-level renderSlot presence is proven by the type-chain spec; erasing
   // here keeps runtime fixtures terse.
@@ -148,6 +150,31 @@ describe('kind semantics', () => {
     expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
   })
 
+  it('chain: missing select throws; select and priority land on the stored entry', () => {
+    const core = new SlotCore()
+    mountFrame(core)
+    // Statically rejected (KindOptions); runtime guard stays for dynamic callers.
+    // @ts-expect-error chain registration requires options.select
+    expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select')
+    const select = ({ tags }: { tags: string[] }) => tags[0] ?? null
+    core.register({ name: 'test.chain', select, priority: 5 }, Comp as never)
+    const entry = core.entries('test.chain')[0]!
+    expect(entry.select).toBe(select)
+    expect(entry.options.priority).toBe(5)
+  })
+
+  it('chain: entries sort by priority ascending, ties keep registration order', () => {
+    const core = new SlotCore()
+    mountFrame(core)
+    const sel = () => null
+    core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never)
+    core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never)
+    core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never)
+    core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never)
+    expect(core.entries('test.chain').map(e => e.registrant))
+      .toEqual(['first', 'default-a', 'default-b', 'late'])
+  })
+
   it('single: second registration throws, disposer frees the seat', () => {
     const core = new SlotCore()
     mountFrame(core)
@@ -294,7 +321,7 @@ describe('subscription surface', () => {
     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'])
+    expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain'])
     keys.length = 0
     core.register({ name: 'test.list', id: 'a' }, Comp)
     expect(keys).toEqual(['test.list'])

+ 57 - 0
packages/client/ui-slots/tests/type-chain.spec.tsx

@@ -20,9 +20,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
     'chain.conv': { kind: 'single'; scope: 'session' }
     'chain.tools': { kind: 'keyed'; scope: 'session' }
+    'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } }
   }
 }
 
+/** Chain-currency fixture: the owner share carries a union the selectors narrow. */
+interface Item { kind: 'q' | 'a'; id: string }
+
 declare const defineStore: DefineStore
 
 /** Factory form (exclusive seat): module-level export, never a handle. */
@@ -68,6 +72,9 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c
 declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
 declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
 declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
+declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode
+declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode
+declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode
 
 describe('terminal-design type chain', () => {
   it('holds the positive chain and the compile-time negatives', () => {
@@ -115,6 +122,28 @@ describe('terminal-design type chain', () => {
       // Keyed registration carries key.
       core.register({ name: 'chain.tools', key: 'bash' }, Tool)
 
+      // Chain registration: select is mandatory, M infers from its return,
+      // matched joins the component constraint; priority is the explicit
+      // chain position.
+      core.register({
+        name: 'chain.takeover',
+        select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
+        priority: 1,
+      }, Takeover)
+
+      // A component accepting a wider matched than the selector supplies
+      // checks through parameter contravariance.
+      core.register({
+        name: 'chain.takeover',
+        select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
+      }, WideTakeover)
+
+      // renderSlotChain share: chain keys dispatch with the fallback bag;
+      // non-chain keys stay on renderSlot.
+      const chainSlots: PropsRenderSlots<'chain.takeover' | 'chain.conv'> = null as never
+      chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null })
+      chainSlots.renderSlot('chain.conv', {})
+
       // ── negatives ──────────────────────────────────────────────────
       // children spec must match the SlotMap entry.
       core.register({
@@ -156,6 +185,34 @@ describe('terminal-design type chain', () => {
       // @ts-expect-error keyed registration requires options.key
       core.register({ name: 'chain.tools' }, Tool)
 
+      // chain registration without select.
+      // @ts-expect-error chain registration requires options.select
+      core.register({ name: 'chain.takeover' }, Takeover)
+
+      // Drifted chain component: demands a matched shape the selector cannot
+      // supply (NoInfer pins M to the select return — the component position
+      // must not widen it).
+      // @ts-expect-error component matched prop drifts from the select return
+      core.register({
+        name: 'chain.takeover',
+        select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null,
+      }, NarrowTakeover)
+
+      // select must return M | null, not undefined (find() must be coalesced).
+      // @ts-expect-error select may not return undefined
+      core.register({
+        name: 'chain.takeover',
+        select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'),
+      }, Takeover)
+
+      // Chain keys are not renderSlot-dispatchable (and vice versa).
+      // @ts-expect-error chain keys dispatch through renderSlotChain only
+      chainSlots.renderSlot('chain.takeover', { items: [] })
+      // @ts-expect-error non-chain keys have no renderSlotChain dispatch
+      chainSlots.renderSlotChain('chain.conv', {})
+      // @ts-expect-error a children set without chain keys provides no renderSlotChain
+      fp.renderSlotChain
+
       // renderSlot owner share typed at the call site.
       // @ts-expect-error owner shape mismatch (width missing)
       fp.renderSlot('chain.side', { collapsed: false })

+ 4 - 0
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -28,6 +28,9 @@ import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
 
 const SID = 's1' as SessionId
+/** Fallback-only chain stub (no composer takeover in these benches). */
+const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
+  (_key, _owner, opts) => opts?.fallback ?? null
 
 afterEach(cleanup)
 // The chat store persists under its declared key; clear so one case's active
@@ -120,6 +123,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
       useStore={bindSnapshotSelector(chat)}
       actions={chat.actions}
       renderSlot={renderSlot}
+      renderSlotChain={fallbackRenderSlotChain}
       SessionProvider={SessionProviderStub}
       views={{
         list: () => tabsOf(slots),

+ 1 - 1
packages/client/web-react/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-web-react
 
-Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
+Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. Chain-slot outlets run the registered selectors in chain order at render time and mount only the elected entry, its select return joining the props as `matched`; the `renderSlotChain` binding is per-entry cached like `renderSlot`. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
 
 ## Model Experience
 

+ 1 - 1
packages/client/web-react/src/index.ts

@@ -22,7 +22,7 @@ export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap
 
 // -- renderer: the install-seam implementation; contract lives in ui-slots --
 export type {
-  HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
+  ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
   SlotRenderer, SlotRendererHost, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
 export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'

+ 98 - 8
packages/client/web-react/src/scoped-slots.tsx

@@ -5,9 +5,12 @@
  * renderSlot binding synthesized from the entry's children declaration.
  * Standard-kit synthesis per entry: the global useSessions hook, the session
  * pair (useSession + sessionId) under SessionProvider, the store pair
- * (useStore + actions) for store-declaring entries, and the renderSlot
- * binding (entry-identity bound, stale-checked) for children-declaring
- * entries. Inject factories run inside the entry component bodies ON PURPOSE
+ * (useStore + actions) for store-declaring entries, the renderSlot binding
+ * (entry-identity bound, stale-checked) for children-declaring entries, and
+ * the renderSlotChain binding for entries declaring a chain-kind child
+ * (selector-routed: first non-null select elects and its value joins the
+ * props as `matched`; all-null falls to the owner fallback).
+ * Inject factories run inside the entry component bodies ON PURPOSE
  * — the per-entry error boundary contains a throwing factory to its own
  * entry; parameters follow the declaration (sessionId for session slots,
  * baked actions when a store is declared).
@@ -15,8 +18,8 @@
 import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import {
   SlotOwnershipError, StaleAuthorizationError,
-  type RenderOpts, type SessionCell, type SlotRenderer, type SlotRendererHost,
-  type StoredEntry,
+  type ChainRenderOpts, type RenderOpts, type SessionCell, type SlotRenderer,
+  type SlotRendererHost, type StoredEntry,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
@@ -27,6 +30,9 @@ type InjectedProps = Record<string, unknown>
 /** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
 type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
 
+/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */
+type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
+
 /**
  * Per-entry renderSlot bindings. The binding is identity-stable per entry
  * (memoized components must not resubscribe on unrelated re-renders) and dies
@@ -43,9 +49,13 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot
         throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`)
       }
       // Plain-JS backstop; typed callers are narrowed to the declared keys.
-      if (entry.children?.[key] === undefined) {
+      const declared = entry.children?.[key]
+      if (declared === undefined) {
         throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
       }
+      if (declared.kind === 'chain') {
+        throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`)
+      }
       return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
     }
     renderSlotCache.set(entry, binding)
@@ -53,6 +63,35 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot
   return binding
 }
 
+/**
+ * Per-entry renderSlotChain bindings: identity-stable per entry (same cache
+ * axis as renderSlot — a per-frame dispatch must not rebuild the binding) and
+ * dead with the entry. The chain-kind check is the plain-JS backstop twin of
+ * the declaration check; typed callers are narrowed to chain keys.
+ */
+const renderSlotChainCache = new WeakMap<StoredEntry, RenderSlotChainBinding>()
+
+function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): RenderSlotChainBinding {
+  let binding = renderSlotChainCache.get(entry)
+  if (!binding) {
+    binding = (key, owner, opts) => {
+      if (!host.isLive(entry)) {
+        throw new StaleAuthorizationError(`renderSlotChain('${key}') from a disposed registration`)
+      }
+      const declared = entry.children?.[key]
+      if (declared === undefined) {
+        throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
+      }
+      if (declared.kind !== 'chain') {
+        throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`)
+      }
+      return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
+    }
+    renderSlotChainCache.set(entry, binding)
+  }
+  return binding
+}
+
 /**
  * Inject results cache: root entries per entry, session entries per
  * (entry x session cell). WeakMap keys are entry/cell objects (both
@@ -96,6 +135,26 @@ function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: obj
   return props
 }
 
+/**
+ * Entry-identity React keys for chain boundaries. A chain outlet renders ONE
+ * elected entry through an error boundary; without a key, a boundary that
+ * failed on entry A would survive a re-election and keep a healthy entry B
+ * blacked out. Keying by entry identity remounts the boundary fresh whenever
+ * the election changes (entries are identity-stable per registration, so the
+ * key is stable while the same entry stays elected).
+ */
+let nextEntryKey = 0
+const entryKeys = new WeakMap<StoredEntry, number>()
+
+function entryKeyOf(entry: StoredEntry): number {
+  let key = entryKeys.get(entry)
+  if (key === undefined) {
+    key = nextEntryKey++
+    entryKeys.set(entry, key)
+  }
+  return key
+}
+
 /**
  * Per-entry isolation: one registrant crashing (component render or inject
  * factory) must not take down siblings. Assembly errors (missing providers)
@@ -144,6 +203,11 @@ function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCe
   }
   if (entry.children !== undefined) {
     kit['renderSlot'] = boundRenderSlot(host, entry)
+    // renderSlotChain rides the same declaration source: only entries whose
+    // children include a chain-kind slot receive the chain dispatch seat.
+    if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) {
+      kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
+    }
     // SessionProvider standard seat: entries declaring a session-scope child
     // render the session area, so the framework hands them the self-wired
     // provider (module-level component = stable reference; no value import).
@@ -198,9 +262,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
   // The boundary must wrap the Entry ELEMENT, not live inside it: inject
   // factories and kit synthesis run in the Entry body and must land in the
   // per-entry fallback rather than escaping to the tree above.
-  const guarded = (entry: StoredEntry, key?: string | number) => (
+  const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => (
     <SlotErrorBoundary slotKey={slotKey} key={key}>
-      <Entry entry={entry} ownerProps={ownerProps} />
+      <Entry entry={entry} ownerProps={owner} />
     </SlotErrorBoundary>
   )
 
@@ -214,6 +278,32 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
     if (!entry) return <>{opts?.fallback ?? null}</>
     return guarded(entry)
   }
+  if (spec.kind === 'chain') {
+    // Entries arrive priority-sorted from the ledger (the core orders at
+    // register, ties keep registration sequence). Selectors are pure
+    // functions of the owner props (register-face contract), so the routing
+    // pass runs per render with zero mount side effects: the first non-null
+    // election renders, decliners never mount.
+    for (const entry of entries) {
+      let matched: unknown
+      try {
+        // Chain entries always carry select (SlotCore register validation).
+        matched = (entry.select as (owner: object) => unknown)(ownerProps)
+      } catch (error) {
+        // A throwing selector is a registrant contract breach (select MUST be
+        // pure and total), but it runs before the entry's SlotErrorBoundary
+        // exists — uncontained it would black out the whole owner region. So
+        // it degrades to a decline: the chain and the fallback stay intact,
+        // and the breach is reported like a crashed entry.
+        console.error(
+          `chain selector crashed in '${slotKey}' (${entry.registrant ?? 'unknown registrant'}), treating as declined:`,
+          error)
+        continue
+      }
+      if (matched !== null) return guarded(entry, entryKeyOf(entry), { ...ownerProps, matched })
+    }
+    return <>{opts?.fallback ?? null}</>
+  }
   // list: registration order refined by explicit order, optional id filter.
   const withListOptions = entries.map((entry) => ({
     entry,

+ 209 - 2
packages/client/web-react/tests/scoped-slots.spec.tsx

@@ -13,13 +13,14 @@ import { act, render } from '@testing-library/react'
 import type { ReactNode } from 'react'
 import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  createSlotRenderer, SessionProvider, SlotOwnershipError,
+  createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
   type RenderOpts, type SessionCell,
   type SlotRendererHost, type StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-web-react'
 
 type AnyProps = Record<string, unknown>
 type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
+type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode }) => ReactNode
 type DeclaredSpec = SlotSpec<SlotEntryDef>
 /** Entry literal helper: fake entries default the mandatory options bag. */
 const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
@@ -129,7 +130,13 @@ function makeHost() {
     declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
     add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
       const entry = entryOf(partial)
-      entries.set(key, [...(entries.get(key) ?? []), entry])
+      const next = [...(entries.get(key) ?? []), entry]
+      // Mirror the ledger contract: chain entries arrive priority-sorted
+      // (stable, ascending) — outlets iterate entries() order as-is.
+      if (specs.get(key)?.kind === 'chain') {
+        next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
+      }
+      entries.set(key, next)
       live.add(entry)
       bump(key)
       return () => {
@@ -165,6 +172,29 @@ function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (rende
 
 const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' }
 const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' }
+const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' }
+
+/** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */
+const chainEntryOf = (partial: {
+  component: unknown
+  select: (owner: object) => unknown
+  priority?: number
+}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
+  component: partial.component,
+  select: partial.select as StoredEntry['select'],
+  ...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
+})
+
+/** Mount a root entry whose component renders `body` with its kit renderSlotChain. */
+function mountChainRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) {
+  const dispose = h.add('root', {
+    component: (props: { renderSlotChain: RenderSlotChainFn }) => <>{body(props.renderSlotChain)}</>,
+    children,
+  })
+  const renderer = createSlotRenderer()
+  const view = render(<>{renderer.renderRoot(h.host, {})}</>)
+  return { view, dispose }
+}
 
 describe('root outlet', () => {
   it('renders the root registration and fails loud when root is unregistered (boot order)', () => {
@@ -262,6 +292,183 @@ describe('child outlets and the renderSlot binding', () => {
   })
 })
 
+describe('chain outlets and the renderSlotChain binding', () => {
+  it('elects the first non-null selector in order, injects matched, and skips decliners without mounting them', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    const declinerBody = vi.fn(() => <span>never</span>)
+    h.add('k.chain', chainEntryOf({
+      component: declinerBody,
+      select: () => null,
+    }))
+    h.add('k.chain', chainEntryOf({
+      component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
+      select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }),
+    }))
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
+      (renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' }))
+    // The declining entry never mounts: the routing decision is select-layer only.
+    expect(view.container.textContent).toBe('hit:T')
+    expect(declinerBody).not.toHaveBeenCalled()
+  })
+
+  it('contains a throwing selector to its entry: reported, treated as declined, chain and fallback intact', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    h.add('k.chain', chainEntryOf({
+      component: () => <span>never</span>,
+      select: () => { throw new Error('selector boom') },
+    }))
+    h.add('k.chain', chainEntryOf({
+      component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
+      select: (owner) => (owner as { pick?: string }).pick ?? null,
+    }))
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
+      <main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
+      <aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
+    </>)
+    // The breach never escapes to the owner region: later entries still get
+    // tried, and an all-throw/all-null pass still lands on the fallback.
+    expect(view.container.querySelector('main')!.textContent).toBe('OK')
+    expect(view.container.querySelector('aside')!.textContent).toBe('fb')
+    expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
+    spy.mockRestore()
+  })
+
+  it('remounts the boundary on re-election: a failed entry does not black out its replacement', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    h.add('k.chain', chainEntryOf({
+      component: () => { throw new Error('entry A boom') },
+      select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null,
+    }))
+    h.add('k.chain', chainEntryOf({
+      component: () => <b>B-ok</b>,
+      select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null,
+    }))
+    let pick = 'A'
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
+      (renderSlotChain) => renderSlotChain('k.chain', { pick }))
+    spy.mockRestore()
+    expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
+    // Re-elect entry B: the entry-keyed boundary remounts fresh instead of
+    // holding A's failed state over the healthy replacement.
+    pick = 'B'
+    act(() => { h.add('root', { component: () => null }) })   // root bump re-renders the dispatch site
+    expect(view.container.textContent).toBe('B-ok')
+    expect(view.container.querySelector('[data-slot-error]')).toBeNull()
+  })
+
+  it('falls to the owner fallback when every selector declines, and re-routes live', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    h.add('k.chain', chainEntryOf({
+      component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
+      select: (owner) => (owner as { pick?: string }).pick ?? null,
+    }))
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
+      <main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
+      <aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
+    </>)
+    // Same chain, two dispatch sites: all-null owner props fall back, matching ones elect.
+    expect(view.container.querySelector('main')!.textContent).toBe('bar')
+    expect(view.container.querySelector('aside')!.textContent).toBe('P')
+  })
+
+  it('renders the fallback for an empty chain and elects live once an entry registers', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
+      (renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
+    expect(view.container.textContent).toBe('none')
+    let dispose = () => {}
+    act(() => {
+      dispose = h.add('k.chain', chainEntryOf({
+        component: () => <b>IN</b>,
+        select: () => ({}),
+      }))
+    })
+    expect(view.container.textContent).toBe('IN')
+    act(() => { dispose() })
+    expect(view.container.textContent).toBe('none')
+  })
+
+  it('orders the chain by ascending priority with registration sequence breaking ties', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    // Registered first but priority 2: must yield to the later priority-1 entry.
+    h.add('k.chain', chainEntryOf({
+      component: () => <b>late</b>,
+      select: () => ({}),
+      priority: 2,
+    }))
+    h.add('k.chain', chainEntryOf({
+      component: () => <b>early</b>,
+      select: () => ({}),
+      priority: 1,
+    }))
+    // Tie pair at priority 1: registration order decides (early wins over tie).
+    h.add('k.chain', chainEntryOf({
+      component: () => <b>tie</b>,
+      select: () => ({}),
+      priority: 1,
+    }))
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
+      (renderSlotChain) => renderSlotChain('k.chain', {}))
+    expect(view.container.textContent).toBe('early')
+  })
+
+  it('keeps the renderSlotChain binding identity-stable across re-renders', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    const seen: RenderSlotChainFn[] = []
+    mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => {
+      seen.push(renderSlotChain)
+      return renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })
+    })
+    act(() => { h.add('root', { component: () => null }) })   // root bump re-renders the entry
+    expect(seen.length).toBeGreaterThan(1)
+    expect(seen.at(-1)).toBe(seen[0])
+  })
+
+  it('backstops off-declaration keys, kind mismatches both ways, and disposed registrations', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_ROOT)
+    h.declare('k.single', SINGLE_ROOT)
+    let chainFn: RenderSlotChainFn | undefined
+    let slotFn: RenderSlotFn | undefined
+    const dispose = h.add('root', {
+      component: (props: { renderSlot: RenderSlotFn; renderSlotChain: RenderSlotChainFn }) => {
+        slotFn = props.renderSlot
+        chainFn = props.renderSlotChain
+        return null
+      },
+      children: { 'k.chain': CHAIN_ROOT, 'k.single': SINGLE_ROOT },
+    })
+    const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    expect(() => chainFn!('k.undeclared', {})).toThrow(SlotOwnershipError)
+    expect(() => chainFn!('k.single', {})).toThrow(SlotOwnershipError)   // non-chain key via chain face
+    expect(() => slotFn!('k.chain', {})).toThrow(SlotOwnershipError)     // chain key via plain face
+    view.unmount()
+    dispose()
+    expect(() => chainFn!('k.chain', {})).toThrow(StaleAuthorizationError)
+  })
+
+  it('withholds the renderSlotChain seat from entries declaring no chain child', () => {
+    const h = makeHost()
+    h.declare('k.single', SINGLE_ROOT)
+    const seen: AnyProps[] = []
+    h.add('root', {
+      component: (props: AnyProps) => { seen.push(props); return null },
+      children: { 'k.single': SINGLE_ROOT },
+    })
+    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    expect(seen.at(-1)!['renderSlotChain']).toBeUndefined()
+  })
+})
+
 describe('standard-kit synthesis', () => {
   it('delivers a live useSessions hook to every slot component', () => {
     const h = makeHost()