Просмотр исходного кода

feat(client): add reusable component factories

imccyu 4 дней назад
Родитель
Сommit
c094b663fb
66 измененных файлов с 2857 добавлено и 281 удалено
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml
  2. 105 0
      .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md
  3. 105 0
      .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md
  4. 2 2
      docs/tool-catalog.i18n.yaml
  5. 1 1
      docs/tool-catalog.md
  6. 1 1
      docs/tool-catalog.zh.md
  7. 11 10
      packages/client/AGENTS.md
  8. 2 2
      packages/client/README.i18n.yaml
  9. 1 1
      packages/client/README.md
  10. 1 1
      packages/client/README.zh.md
  11. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  12. 2 1
      packages/client/ui-conversation/README.md
  13. 2 1
      packages/client/ui-conversation/README.zh.md
  14. 12 2
      packages/client/ui-conversation/src/client/apply.ts
  15. 42 11
      packages/client/ui-conversation/src/client/contract/slots.ts
  16. 2 1
      packages/client/ui-conversation/src/client/index.ts
  17. 11 15
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  18. 9 12
      packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
  19. 3 3
      packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
  20. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  21. 4 2
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  22. 5 1
      packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
  23. 55 16
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  24. 1 2
      packages/client/ui-layout/src/client/index.ts
  25. 2 2
      packages/client/ui-renderer/README.i18n.yaml
  26. 4 3
      packages/client/ui-renderer/README.md
  27. 4 3
      packages/client/ui-renderer/README.zh.md
  28. 1 3
      packages/client/ui-renderer/src/client/bindings.tsx
  29. 4 0
      packages/client/ui-renderer/src/client/errors.ts
  30. 2 1
      packages/client/ui-renderer/src/client/index.ts
  31. 170 17
      packages/client/ui-renderer/src/client/registry.ts
  32. 367 28
      packages/client/ui-renderer/src/client/scoped-slots.tsx
  33. 693 0
      packages/client/ui-renderer/tests/factory-slots.client.spec.tsx
  34. 145 1
      packages/client/ui-renderer/tests/registry.client.spec.ts
  35. 7 0
      packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx
  36. 7 0
      packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx
  37. 7 0
      packages/client/ui-renderer/tests/session-provider.client.spec.tsx
  38. 7 0
      packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx
  39. 7 0
      packages/client/ui-renderer/tests/use-projection.client.spec.tsx
  40. 2 2
      packages/client/ui-slots/README.i18n.yaml
  41. 14 7
      packages/client/ui-slots/README.md
  42. 14 7
      packages/client/ui-slots/README.zh.md
  43. 1 1
      packages/client/ui-slots/package.json
  44. 467 34
      packages/client/ui-slots/src/index.ts
  45. 53 1
      packages/client/ui-slots/src/renderer.ts
  46. 23 0
      packages/client/ui-slots/tests/core.client.spec.ts
  47. 117 2
      packages/client/ui-slots/tests/type-chain.client.spec.tsx
  48. 67 6
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  49. 14 8
      packages/extensions/cordis-client-runner/src/client/guard.ts
  50. 25 7
      packages/extensions/cordis-client-runner/src/client/providers.ts
  51. 5 2
      packages/extensions/cordis-client-runner/src/client/runtime.ts
  52. 69 31
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  53. 16 0
      packages/extensions/cordis-client-runner/tests/guard.client.spec.ts
  54. 50 0
      packages/extensions/cordis-client-runner/tests/providers.client.spec.ts
  55. 24 3
      packages/extensions/cordis-client-runner/tests/runner.client.spec.ts
  56. 3 2
      packages/extensions/tool-cordis/src/index.ts
  57. 14 2
      packages/test-support/client-runtime/src/index.ts
  58. 6 3
      packages/typert/generator/src/cordis-catalog.ts
  59. 24 0
      packages/typert/generator/tests/cordis-catalog.spec.ts
  60. 10 0
      scripts/gen-client-catalog.spec.ts
  61. 6 3
      scripts/gen-client-catalog.ts
  62. 5 2
      scripts/gen-cordis-inspect-catalog.ts
  63. 12 9
      scripts/slot-walk.ts
  64. 1 1
      snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md
  65. 1 1
      snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json
  66. 1 1
      snapshots/web/cordis-tool-round/tool-schemas.expected.json

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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 .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md
+2026-09-10-component-factories-and-local-slots.md: 801e77644d9d2c80c4a9f3cad263420d1deda163
+2026-09-10-component-factories-and-local-slots.zh.md: 3ccd5f01633a1aa4ae830e1ee41243f5d1b65cc5

+ 105 - 0
.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md

@@ -0,0 +1,105 @@
+# Agent Note: Reusable component factories with local slots
+
+Status: implemented
+
+English | [中文](2026-09-10-component-factories-and-local-slots.zh.md)
+
+## Problem
+
+The browser Slot system starts with a parent-owned extension position. A parent entry declares a child through `children`, and unrelated plugins may then register implementations into that position. The declaration fixes the child Slot's kind, scope, render authority, and lifetime.
+
+A reusable component assembly has the opposite ownership direction. One package defines the assembly, unrelated parents render independent occurrences, and each parent may choose a different Component for a named internal region. An ordinary Slot cannot represent this relationship because its definition belongs to one parent position in the global Slot tree.
+
+The defining and consuming packages compile independently. TypeScript cannot infer a selected Component's props from a runtime registration in another package, and a separately maintained flattened props type would duplicate the store, injection, locale, child-render, and scope declarations.
+
+## Decision
+
+`ui-slots` and `ui-renderer` provide named Component Factories beside the [ordinary Slot system](2026-07-22-slot-type-chain-implementation.md). `registerFactory()` installs one reusable definition, `renderFactorySlot()` renders an occurrence, and the definition reads caller-selected local Components through `useFactorySlot()`.
+
+### Opposite registration directions
+
+Ordinary Slots and Component Factories retain distinct ownership models.
+
+| Property | Ordinary Slot | Component Factory |
+|---|---|---|
+| First declaration | Parent declares a child Slot | Definition owner declares the Factory |
+| Later operation | Child registers into the parent position | Parent renders an occurrence |
+| Static authority | `SlotMap` describes the position | `SlotFactoryMap` describes the complete definition |
+| Live definitions | Multiple entries may occupy cells | One definition owns one Factory name |
+| Parent input | `renderSlot()` owner and keyed props | `renderFactorySlot()` occurrence props |
+| Parent-selected Component | Registry routing selects entries | Caller selects one Component per local slot |
+| Descendant extension points | Entry-owned ordinary `children` | Definition-owned ordinary `children` |
+
+`SlotFactoryMap` declaration-merges the complete static definition:
+
+```text
+interface SlotFactoryDef {
+  scope: SlotScope
+  props?: object
+  children?: ChildrenDecl
+  store?: StoreDecl
+  inject?: object
+  locale?: keyof LocaleNamespaceMap & string
+  slots?: Record<string, { scope: SlotScope; props?: object }>
+}
+```
+
+The map is the only type authority. `registerFactory()` checks the runtime definition and main Component against its map entry. Store declarations normalize through `HandleOf`, so registration accepts one shared handle or one factory that produces that handle, never a nested factory. `FactoryComponentPropsOf<F>` and `FactoryLocalComponentPropsOf<F, N>` derive the complete Component props from the same entry; definition owners and consumers do not restate a flattened shared type.
+
+### Definition and occurrence lifecycle
+
+A Factory name has one live definition in a registry ledger separate from ordinary Slot cells. Registration follows the caller's Cordis effect. Disposal removes the definition, collapses its ordinary child declarations, notifies mounted outlets, and invalidates retained child-render or local-Component authority.
+
+Each `renderFactorySlot()` call creates an occurrence whose identity follows its React position and `key`. Definition replacement remounts occurrences. Definition Components and their fallback local Components report failures against the definition, while caller-selected local Components report against the caller registration; nested Factory renders preserve the same owner. All failures remain contained to that occurrence without retiring the shared definition. Assembly errors propagate, while ownership and stale-authorization failures report like ordinary component failures. The outer error boundary resets with the Factory scope incarnation, and each local boundary resets with its local slot's scope incarnation.
+
+Factory scope follows the scope binding at the occurrence's React position. `renderFactorySlot()` accepts no Session id or scope target. A strict `session` Factory requires a current binding and remounts when its identity changes; a `session-maybe` Factory preserves its first empty-to-Session adoption and remounts on later identity changes, matching ordinary Slot behavior.
+
+A shared store handle keeps the ordinary handle-by-scope behavior, including requiring a Session binding for either non-root scope. An exclusive store factory creates one handle when an occurrence first materializes and rejects that handle if it declares `spec.persist`, because registration must remain lazy and independently live occurrences cannot share one persistence key safely. Render-time records stay weak until an idempotent effect setup retains the committed occurrence; effect cleanup removes the strong mounted reference, while the occurrence-keyed WeakMap preserves identity during React effect replay and permits collection after unmount.
+
+### Local slots and ordinary children
+
+The caller may select one Component for each name in the Factory's `slots` declaration. The Factory calls `useFactorySlot(name, fallback)` and receives an identity-stable bound Component that accepts only that local slot's occurrence props. The renderer supplies the Factory's store, injection, locale, child renderers, and the local slot's own standard scope props when the bound Component renders.
+
+Local slots have no list, keyed, or chain routing and no independent registration lifetime. Multi-contributor extension points remain ordinary child Slots declared by the Factory. Those child declarations are global to the definition, while every occurrence renders their registered entries under its inherited scope.
+
+Live inspection represents each definition as a `type: 'factory'` node and nests its ordinary child Slots beneath it. Ordinary nodes retain `type: 'slot'` and their existing `kind`; callers select a Factory root with `factory:<name>`.
+
+Every renderer-created Component receives `renderFactorySlot`, so a Factory occurrence needs no parent-side use declaration. Local selection remains per occurrence and does not introduce a runtime value import from the defining package.
+
+### First shipped use
+
+`ui-conversation` registers the optional-Session `conversation.content` Factory around the shared body and Composer. Its strict-Session `views` local position defaults to an adapter that renders the existing `conversation.session` Slot; another occurrence can select a different view Component without mounting the main Conversation Header.
+
+The Factory does not own the Conversation store. The ordinary `conversation.session` body and `conversation.session.header` retain one shared strict-Session handle, preserving draft and View-selection identity without mounting that handle under both `session` and `session-maybe` scopes.
+
+### Type and runtime enforcement
+
+The type chain rejects unknown Factory names, missing or extra occurrence props, child specs that disagree with `SlotMap`, definition fields that disagree with `SlotFactoryMap`, nested store factories, unknown local names, incompatible selected Components, and overlapping ownership among input, registration, injection, and scope props.
+
+Runtime checks cover dynamically assembled and plain-JavaScript callers: duplicate definitions, child-declaration conflicts, undeclared local names, recursive rendering, prop collisions, stale authority, strict-scope absence, and component failure isolation. Type and runtime tests also pin independent stores per occurrence, shared handles by scope, fallback behavior before registration, definition replacement, local scope projection, and ordinary child rendering.
+
+## Alternatives considered
+
+**Reuse an ordinary Slot as a portable definition.** An ordinary Slot belongs to one parent declaration and one position in the global tree. Reusing it elsewhere would borrow the wrong ownership and lifetime.
+
+**Move the main Conversation Header into the Factory.** Only the main host renders that Header. Keeping it outside the Factory lets embedded occurrences omit it without another local selection and preserves its existing strict-Session Slot lifecycle.
+
+**Move the shared Conversation store onto the optional-Session Factory.** The Header and Session body share one strict-Session handle. Mounting that handle on both the `session-maybe` Factory and the `session` Header would violate the one-handle-one-scope rule.
+
+**Register the same assembly under every parent.** Separate registrations would duplicate the definition and its child declarations. Global contributors would need parallel child names or conflicting declarations.
+
+**Maintain a flattened shared props type.** This repeats facts already present in `children`, `store`, `inject`, `locale`, and scope fields, allowing the declaration and Component props to drift.
+
+**Pass React nodes or render callbacks as business props.** Those values bypass renderer-supplied scope props, store and injection assembly, stale-authority checks, and local Component type checking.
+
+**Give local slots ordinary Slot routing.** Ordinary Slots already own multi-contributor routing. A local slot represents one caller choice for one occurrence.
+
+**Pass Session identity to `renderFactorySlot()`.** The occurrence inherits its render-position scope like an ordinary Slot. A second identity argument would create two authorities that can disagree; independently addressed Session providers are a separate capability.
+
+## Consequences
+
+Feature packages can publish one reusable UI assembly without runtime-importing its Component from consumers. Each occurrence gets independently selected local Components and exclusive state while preserving global ordinary child contributions and the existing scope, locale, injection, and store rules.
+
+The additional registry ledger and occurrence bookkeeping increase renderer complexity. Factory definitions must be globally unique, local slots intentionally support only one selected Component, and the API does not itself create an independently addressed Session scope.
+
+The Factory type and runtime tests are the executable compatibility record. The Slots subsystem reference and the `ui-slots` and `ui-renderer` package references document the consumer API.

+ 105 - 0
.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md

@@ -0,0 +1,105 @@
+# Agent Note: 带局部 slot 的可复用组件 Factory
+
+Status: implemented
+
+[English](2026-09-10-component-factories-and-local-slots.md) | 中文
+
+## 问题
+
+浏览器 Slot 系统从 parent-owned 扩展位置开始。parent entry 通过 `children` 声明 child,之后互不相关的插件可以向该位置注册实现。声明固定 child Slot 的 kind、scope、渲染权限与生命周期。
+
+可复用组件装配采用相反的所有权方向。一个包定义装配,互不相关的 parents 渲染独立 occurrence,并且每个 parent 可以为具名内部区域选择不同 Component。普通 Slot 无法表示这种关系,因为它的 definition 属于全局 Slot 树中的一个 parent 位置。
+
+定义包与消费包独立编译。TypeScript 无法从另一个包中的运行时 registration 推导所选 Component 的 props,另行维护的扁平 props 类型则会重复 store、injection、locale、child-render 与 scope 声明。
+
+## 决策
+
+`ui-slots` 与 `ui-renderer` 在[普通 Slot 体系](2026-07-22-slot-type-chain-implementation.zh.md)之外提供具名 Component Factory。`registerFactory()` 安装一个可复用 definition,`renderFactorySlot()` 渲染一个 occurrence,definition 通过 `useFactorySlot()` 读取调用方选择的局部 Component。
+
+### 相反的注册方向
+
+普通 Slot 与 Component Factory 保留不同的所有权模型。
+
+| 属性 | 普通 Slot | Component Factory |
+|---|---|---|
+| 首个声明 | Parent 声明 child Slot | Definition owner 声明 Factory |
+| 后续操作 | Child 注册进 parent 位置 | Parent 渲染 occurrence |
+| 静态权威 | `SlotMap` 描述位置 | `SlotFactoryMap` 描述完整 definition |
+| 有效 definitions | 多个 entries 可以占据 cells | 一个 definition 独占一个 Factory 名 |
+| Parent 输入 | `renderSlot()` owner 与 keyed props | `renderFactorySlot()` occurrence props |
+| Parent 选择的 Component | Registry routing 选择 entries | 调用方为每个局部 slot 选择一个 Component |
+| 后代扩展点 | Entry-owned 普通 `children` | Definition-owned 普通 `children` |
+
+`SlotFactoryMap` 通过声明合并给出完整静态 definition:
+
+```text
+interface SlotFactoryDef {
+  scope: SlotScope
+  props?: object
+  children?: ChildrenDecl
+  store?: StoreDecl
+  inject?: object
+  locale?: keyof LocaleNamespaceMap & string
+  slots?: Record<string, { scope: SlotScope; props?: object }>
+}
+```
+
+该 map 是唯一类型权威。`registerFactory()` 根据对应 map 条目检查运行时 definition 与主 Component。Store 声明通过 `HandleOf` 规范化,因此 registration 只接受一个共享 handle 或一个生成该 handle 的 factory,绝不接受嵌套 factory。`FactoryComponentPropsOf<F>` 和 `FactoryLocalComponentPropsOf<F, N>` 从同一条目推导完整 Component props;definition owner 与消费方无需重述扁平共享类型。
+
+### Definition 与 occurrence 生命周期
+
+每个 Factory 名在独立于普通 Slot cells 的 registry ledger 中只有一个有效 definition。registration 遵循调用方的 Cordis effect。dispose 会移除 definition、折叠其普通 child 声明、通知已挂载 outlets,并令保留的 child-render 或局部 Component 权限失效。
+
+每次 `renderFactorySlot()` 调用都会创建 occurrence,其 identity 由 React 位置和 `key` 决定。替换 definition 会重新挂载 occurrence。Definition Component 及其 fallback 局部 Component 的失败归属 definition,调用方所选局部 Component 的失败归属调用方 registration;嵌套 Factory 渲染保留同一 owner。所有失败都限制在当前 occurrence 内,不会移除共享 definition。装配错误继续向外传播,所有权与陈旧授权错误则像普通组件失败一样上报。外层错误边界随 Factory scope incarnation 重置,每个局部边界随自身局部 slot 的 scope incarnation 重置。
+
+Factory scope 跟随 occurrence 所在 React 位置的 scope binding。`renderFactorySlot()` 不接受 Session id 或 scope target。严格 `session` Factory 要求当前 binding 存在,并在 identity 变化时重新挂载;`session-maybe` Factory 保留首次从空状态采纳 Session 的过程,并在后续 identity 变化时重新挂载,与普通 Slot 行为一致。
+
+共享 store handle 保留普通模式的 handle-by-scope 行为,包括两个非 root scope 均要求 Session binding。独占 store factory 在 occurrence 首次物化时创建一个 handle;若该 handle 声明 `spec.persist`,renderer 会拒绝它,因为 registration 必须保持 lazy,且多个同时存活的独立 occurrence 无法安全共享一个 persistence key。渲染期记录在幂等 effect setup 保留已 commit occurrence 前只持有弱引用;effect cleanup 会移除 mounted 强引用,而 occurrence-keyed WeakMap 在 React effect replay 期间保留 identity,并允许实例在卸载后被回收。
+
+### 局部 slots 与普通 children
+
+调用方可以为 Factory `slots` 声明中的每个名称选择一个 Component。Factory 调用 `useFactorySlot(name, fallback)`,取得 identity 稳定的绑定 Component;该 Component 只接受对应局部 slot 的 occurrence props。绑定 Component 渲染时,renderer 提供 Factory 的 store、injection、locale、child renderers 与该局部 slot 自身的标准 scope props。
+
+局部 slots 没有 list、keyed 或 chain routing,也没有独立 registration 生命周期。多贡献方扩展点仍使用 Factory 声明的普通 child Slots。这些 child 声明在 definition 范围内全局共享,而每个 occurrence 都在继承的 scope 下渲染其 registered entries。
+
+实时检查将每个 definition 表示为 `type: 'factory'` 节点,并把其普通 child Slots 嵌套在该节点下。普通节点保留 `type: 'slot'` 与现有 `kind`;调用方通过 `factory:<name>` 选择 Factory 根节点。
+
+每个由 renderer 创建的 Component 都会收到 `renderFactorySlot`,因此 Factory occurrence 不需要 parent-side use declaration。局部选择仍然属于单个 occurrence,并且不会引入对定义包的运行时 value import。
+
+### 首个交付用途
+
+`ui-conversation` 在共享正文与 Composer 外注册 optional-Session `conversation.content` Factory。其 strict-Session `views` 局部位置默认使用一个渲染现有 `conversation.session` Slot 的 adapter;其他 occurrence 可以选择不同的 View Component,且不会挂载主 Conversation Header。
+
+Factory 不拥有 Conversation store。普通 `conversation.session` body 与 `conversation.session.header` 保留同一个 strict-Session handle,在保持草稿与 View 选择 identity 的同时,避免将该 handle 同时挂到 `session` 和 `session-maybe` scope。
+
+### 类型与运行时强制规则
+
+类型链拒绝未知 Factory 名、缺失或多余的 occurrence props、与 `SlotMap` 不一致的 child spec、与 `SlotFactoryMap` 不一致的 definition 字段、嵌套 store factory、未知局部名称、不兼容的选中 Component,以及 input、registration、injection 与 scope props 之间的所有权重叠。
+
+运行时检查覆盖动态装配与纯 JavaScript 调用方:重复 definitions、child 声明冲突、未声明的局部名称、递归渲染、prop 冲突、陈旧权限、严格 scope 缺失与组件失败隔离。类型和运行时测试还固定了各 occurrence 的独占 store、按 scope 共享的 handles、注册前 fallback 行为、definition 替换、局部 scope 投影与普通 child 渲染。
+
+## 考虑过的替代方案
+
+**把普通 Slot 复用为可移植 definition。** 普通 Slot 属于一个 parent 声明以及全局树中的一个位置。在其他位置复用它会借用错误的所有权与生命周期。
+
+**把主 Conversation Header 移进 Factory。** 只有主 host 渲染该 Header。将其留在 Factory 外,使嵌入式 occurrence 无需另一个局部选择即可省略 Header,并保留其现有 strict-Session Slot 生命周期。
+
+**把共享 Conversation store 移到 optional-Session Factory。** Header 与 Session body 共享一个 strict-Session handle。将该 handle 同时挂到 `session-maybe` Factory 和 `session` Header 会违反 one-handle-one-scope 规则。
+
+**在每个 parent 下分别注册同一装配。** 独立 registrations 会重复 definition 及其 child 声明。全局贡献方需要使用平行 child 名称,或造成声明冲突。
+
+**维护扁平共享 props 类型。** 这会重复 `children`、`store`、`inject`、`locale` 与 scope 字段中已有的事实,使声明与 Component props 可以发生漂移。
+
+**把 React node 或 render callback 作为业务 props 传递。** 这些值绕过 renderer 提供的 scope props、store 与 injection 装配、陈旧权限检查和局部 Component 类型检查。
+
+**让局部 slots 具备普通 Slot routing。** 普通 Slots 已经负责多贡献方 routing。局部 slot 表示一次 occurrence 的一个调用方选择。
+
+**向 `renderFactorySlot()` 传递 Session identity。** occurrence 像普通 Slot 一样继承渲染位置的 scope。第二个 identity 参数会产生两个可能不一致的权威;独立定址的 Session provider 属于另一项能力。
+
+## 影响
+
+功能包可以发布一份可复用 UI 装配,而消费方无需运行时导入其 Component。每个 occurrence 可以获得独立选择的局部 Components 与独占状态,同时保留全局普通 child 贡献以及现有 scope、locale、injection 和 store 规则。
+
+新增的 registry ledger 与 occurrence 记录增加了 renderer 复杂度。Factory definitions 必须全局唯一,局部 slots 有意只支持一个选中 Component,并且该 API 本身不会创建可独立定址的 Session scope。
+
+Factory 类型与运行时测试是可执行的兼容性记录。Slots 子系统参考以及 `ui-slots` 和 `ui-renderer` 包参考记录消费方 API。

+ 2 - 2
docs/tool-catalog.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 docs/tool-catalog.md
-tool-catalog.md: eae53047f91a2a86fd9d5e68ff2f84d8cf62faa6
-tool-catalog.zh.md: 15dda3b508e78dc3eeaddabed05b60f318886064
+tool-catalog.md: cd7805d3104120a04e21363cb75c6dcb8456f5e0
+tool-catalog.zh.md: 52752836f49c8fb745a69f10c4f8cd1ef79a871d

+ 1 - 1
docs/tool-catalog.md

@@ -727,7 +727,7 @@ Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/
 
 ### `cordis_inspect_query`
 
-Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.
+Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant.
 
 ```json
 {

+ 1 - 1
docs/tool-catalog.zh.md

@@ -731,7 +731,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
 
 ### `cordis_inspect_query`
 
-执行 Inspect Provider 明确声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。对于 Service.listService 和 Event.listEvents,不传 input 可浏览精简签名目录,再查询准确服务或事件以获得完整约定及引用类型。对于 Slots.listSubTree,不传 root 可浏览精简树,再查询准确 root 以获得完整注册约定和 props
+执行 Inspect Provider 明确声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。对于 Service.listService 和 Event.listEvents,不传 input 可浏览精简签名目录,再查询准确服务或事件以获得完整约定及引用类型。对于 Slots.listSubTree,不传 root 可浏览精简树;查询准确的 Slot root 可获得完整注册约定和 props,而查询准确的 Factory root 只返回 identity、scope 与 registrant
 
 ```json
 {

+ 11 - 10
packages/client/AGENTS.md

@@ -8,13 +8,14 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<nam
 
 The [Slots reference](../../docs/subsystems/slots.md) owns the current design; these are the rules you must not violate when writing or reviewing client code:
 
-1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
-2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'tool.call.toolview'`).
-3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
-4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
+1. **Two declaration forms**: use `ctx.slots.register({ name, children?, store?, inject? }, Component)` for a parent-owned extension position. Use `ctx.slots.registerFactory()` only when one reusable assembly needs independent render occurrences under unrelated parents. The shell alone renders `'root'`.
+2. **children = declaration + authorization**: the slots your component renders are exactly the keys of its registration's `children` object (spec values: `kind`/`scope`). Factory children obey the same `SlotMap` checks and global ownership rule. Rendering an undeclared slot, or declaring one someone else declared, fails at load. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (for example, `'tool.call.toolview'`).
+3. **Component props are the five shares, all derived**: `PropsRuntime<K>` (owner data plus scope/global seats), `PropsRenderSlots<S>` (children), `PropsRenderFactories` (`renderFactorySlot`), `PropsStore<H>` (store), and the inject face. Never hand-write or locally re-type a derived member. Factory props follow the corresponding `Factory*PropsOf` aliases.
+4. **Hooks are framework-made only**: standing seats include `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot`, and `renderFactorySlot`; Factory definitions additionally receive `useFactorySlot`. The renderer also binds `use<Name>` hooks from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
-6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation API. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
+6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation API. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to registrations inside `apply`. A Factory may instead receive the factory itself to create one handle per committed render position. Production code never calls the factory or `.create()` outside registration; tests do (that is the sanctioned zero-machinery path).
 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for.
+8. **Factory local Components keep stable identities**: pass module-level function Components through `renderFactorySlot(..., { slots })`. Creating a Component function during render intentionally remounts that local subtree and resets its local state; the caller owns that behavior.
 
 ## Reactive read and contract-currency discipline
 
@@ -37,7 +38,7 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a
 
 ## ctx discipline (components never see ctx)
 
-`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
+`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks through the derived props shares; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
 
 ## Layering red lines
 
@@ -45,7 +46,7 @@ The stack has one-way knowledge, documented in the [Web Client architecture](../
 
 1. **Data object layer** (React-free): `client/connection` owns transport generations, `api/session-controller/client` owns `ClientSessions` → `SessionManager` → `Session`, `api/workspace-controller/client` owns Workspace state, and `client/store` owns the snapshot-store engine (`defineStore`, `createSnapshotStore`, `shallowEqual`). Store products are bare observable sources with no hook members.
 2. **Render machinery** (`ui-renderer`, dynamic plugin): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; production business code carries no ui-renderer value dependency.
-3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
+3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the derived props shares.
 
 Non-negotiables across the layers:
 
@@ -105,7 +106,7 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent
 
 ## Directory regime (plugin packages)
 
-One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
+One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` or `slots.registerFactory` in `apply` — never module-level side effects.
 
 ## Styling and localization
 
@@ -147,8 +148,8 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com
 
 1. **Check the [ui-primitives catalog](ui-primitives/README.md#component-catalog) before writing a control.** A plugin cannot import another plugin's component, so `ui-primitives` is the only place a control can be shared; the catalog states when to reuse, when to promote, and when your own package is the right home.
 
-2. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component see the [Slots reference](../../docs/subsystems/slots.md). No other composition route exists.
-3. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
+2. Compose through `register`: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component. Use a Component Factory only for a reusable assembly rendered independently under unrelated parents; see the [Slots reference](../../docs/subsystems/slots.md).
+3. Type ordinary props from the five shares (`PropsRuntime` & `PropsRenderSlots` & `PropsRenderFactories` & `PropsStore` & inject face), and use the derived `Factory*PropsOf` aliases for Factory Components. Shared or remount-surviving state goes in a declared store; component-private state stays local.
 4. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
 5. Tokens only in CSS; product copy follows the localization rule above; English comments.
 6. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.

+ 2 - 2
packages/client/README.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 packages/client/README.md
-README.md: d3aee40a79e5d57029d8af42c333abcd5a9112a9
-README.zh.md: a3776a2c1034cb747fb191c206b52d570ec47aaf
+README.md: f8146ceafa179508fcc77718c1ecc91bd459d3d0
+README.zh.md: f8db7644a2164c892c4fa2d92aef836ecae9e7e5

+ 1 - 1
packages/client/README.md

@@ -35,7 +35,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` |
 | [`test-runtime/`](../test-support/client-runtime/README.md) | Shared repository test support for client feature packages | — |
 | [`ui-renderer/`](ui-renderer/README.md) | Binds slot data to React and mounts the assembled application | `ctx.uiRenderer` |
-| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots | — |
+| [`ui-slots/`](ui-slots/README.md) | Defines typed extension Slots and reusable Component Factories | — |
 | [`ui-session/`](ui-session/README.md) | Adapts Session Controller state into standard Slot sources and hooks | — |
 | [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme | — |
 | [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers | — |

+ 1 - 1
packages/client/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-group"
 | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` |
 | [`test-runtime/`](../test-support/client-runtime/README.zh.md) | 为客户端功能包提供共享的仓库测试支持 | — |
 | [`ui-renderer/`](ui-renderer/README.zh.md) | 将 slot 数据绑定到 React,并挂载组装完成的应用 | `ctx.uiRenderer` |
-| [`ui-slots/`](ui-slots/README.zh.md) | 定义 UI 功能注册与组合扩展 slot 的方式 | — |
+| [`ui-slots/`](ui-slots/README.zh.md) | 定义类型化扩展 Slots 与可复用 Component Factory | — |
 | [`ui-session/`](ui-session/README.zh.md) | 把会话控制器状态适配为标准 Slot source 与钩子 | — |
 | [`ui-theme/`](ui-theme/README.zh.md) | 应用所选颜色主题 | — |
 | [`ui-primitives/`](ui-primitives/README.zh.md) | 提供共享 React 控件、图标与内容渲染器 | — |

+ 2 - 2
packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md
-README.md: 749c2d0d99ce7e5afe2f9a03b1451d62a17206e7
-README.zh.md: 98d7d9672922ca68ea4df5499f8789e8c2fde162
+README.md: 3853c0bd1c7a69563cf25383449f1949c5d3e66a
+README.zh.md: 362a4aff3371e9690f070edd545825eb6ccb61bd

+ 2 - 1
packages/client/ui-conversation/README.md

@@ -44,7 +44,7 @@ Claimed commands retain their identity and highlight when only their arguments a
 
 Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
 
-The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
+The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes one strict-Session local position, `views`; its default adapter renders the existing `conversation.session` entry, so that body and the Header retain one shared strict-Session store. Another occurrence can replace `views` without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
 
 A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites.
 
@@ -128,6 +128,7 @@ None; Conversation assembly and browser input state do not alter provider-side p
 <a id="known-limitations-and-deferred-work"></a>
 
 - **Only registered targets can render** — the shell deliberately has no implicit fallback target beyond the registered `chat` preference.
+- **Factory occurrences inherit their render-position Session** — `conversation.content` does not accept an independently addressed Session; that requires a separate Session-provider capability.
 
 
 <a id="dev-note"></a>

+ 2 - 1
packages/client/ui-conversation/README.zh.md

@@ -44,7 +44,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 
 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
 
-本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
+本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开一个 strict-Session 局部位置 `views`;其默认 adapter 渲染现有 `conversation.session` entry,因此该 body 与 Header 继续共享一个 strict-Session store。其他 occurrence 可以替换 `views`,且不会渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
 
 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。
 
@@ -128,6 +128,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
 <a id="known-limitations-and-deferred-work"></a>
 
 - **只有已注册 target 可以渲染**——除已注册的 `chat` 偏好外,shell 刻意不提供隐式 fallback target。
+- **Factory occurrence 继承渲染位置的 Session**——`conversation.content` 不接受独立寻址的 Session;该能力需要单独的 Session provider。
 
 
 <a id="dev-note"></a>

+ 12 - 2
packages/client/ui-conversation/src/client/apply.ts

@@ -29,6 +29,7 @@ import { queueDockEntry } from './queue/QueueDock.tsx'
 import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
 import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
 import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
+import { ConversationContent } from './skeleton/ConversationContent.tsx'
 import { ConversationPanel } from './skeleton/ConversationPanel.tsx'
 import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
 import { InputBar } from './skeleton/InputBar.tsx'
@@ -236,10 +237,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
 
   const registerConversationRoot = () => slots.register({
     name: 'main.conversation',
+    children: {
+      'conversation.session.header': { kind: 'single', scope: 'session' },
+    },
+  }, ConversationRoot)
+
+  const registerConversationContent = () => slots.registerFactory({
+    name: 'conversation.content',
+    scope: 'session-maybe',
     locale: NS,
     children: {
       'conversation.session': { kind: 'single', scope: 'session' },
-      'conversation.session.header': { kind: 'single', scope: 'session' },
       'conversation.composer': { kind: 'chain', scope: 'session' },
       'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
       'conversation.input.dock': { kind: 'list', scope: 'session' },
@@ -247,6 +255,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       'conversation.hero.workspace': { kind: 'single', scope: 'root' },
       'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
     },
+    slots: { views: { scope: 'session' } },
     inject: (sessionId: SessionId | undefined): ConversationInjected => ({
       hooks: {
         composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
@@ -273,7 +282,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
         }
       }),
     }),
-  }, ConversationRoot)
+  }, ConversationContent)
 
   const registerConversationSession = () => slots.register({
     name: 'conversation.session',
@@ -405,6 +414,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       children: { 'main.conversation': { kind: 'single', scope: 'session-maybe' } },
     }, ConversationPanel)
     yield registerConversationRoot()
+    yield registerConversationContent()
     yield registerConversationSession()
     yield registerConversationHeader()
     yield registerComposerBar()

+ 42 - 11
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -8,7 +8,8 @@ import type {
   MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-store'
 import type {
-  InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
+  FactoryComponentPropsOf, FactoryLocalComponentPropsOf,
+  InjectFace, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime, PropsStore,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionPendingInteraction } from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -199,6 +200,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
   }
 
+  interface SlotFactoryMap {
+    /** Reusable Conversation content instantiated by presentation hosts. */
+    'conversation.content': {
+      scope: 'session-maybe'
+      props: ConversationContentInputProps
+      children: {
+        'conversation.session': { kind: 'single'; scope: 'session' }
+        'conversation.composer': { kind: 'chain'; scope: 'session' }
+        'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe' }
+        'conversation.input.dock': { kind: 'list'; scope: 'session' }
+        'conversation.hero.brand.mark': { kind: 'single'; scope: 'root' }
+        'conversation.hero.workspace': { kind: 'single'; scope: 'root' }
+        'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe' }
+      }
+      inject: ConversationInjected
+      locale: 'conversation'
+      slots: {
+        views: { scope: 'session' }
+      }
+    }
+  }
+
   interface GlobalStandardProps {
     /** Workspace selector supplied by the independently loaded Workspace UI. */
     useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>
@@ -385,20 +408,28 @@ export interface HeroBrandMarkOwnerProps {
 /** Full props of the resident optional-Session Conversation shell. */
 export type ConversationSlotProps =
   PropsRuntime<'main.conversation'>
-  & PropsRenderSlots<
-    | 'conversation.session' | 'conversation.session.header'
-    | 'conversation.composer' | 'conversation.composer.bar'
-    | 'conversation.input.dock'
-    | 'conversation.hero.brand.mark'
-    | 'conversation.hero.workspace'
-    | 'conversation.hero.agentPreset'
-  >
-  & InjectFace<ConversationInjected>
-  & PropsLocale<'conversation'>
+  & PropsRenderSlots<'conversation.session.header'>
+  & PropsRenderFactories
+
+/** Main-host inputs for one reusable Conversation content occurrence. */
+export interface ConversationContentInputProps {
+  phase: 'settling' | 'hero' | 'active'
+  hero: boolean
+  onHandleStart: () => number
+  onHandleDrag: (width: number) => void
+  onHandleCommit: (width: number) => void
+  onHandleEnd: () => void
+}
+
+/** Full props of the reusable Conversation Factory definition. */
+export type ConversationContentProps = FactoryComponentPropsOf<'conversation.content'>
 
 /** Shared target-neutral Conversation store handle. */
 export type ConversationStore = ReturnType<typeof createConversationStore>
 
+/** Full props of the Factory's caller-selectable Conversation View position. */
+export type ConversationViewsProps = FactoryLocalComponentPropsOf<'conversation.content', 'views'>
+
 /** Full props of the strict Session body. */
 export type ConversationSessionSlotProps =
   PropsRuntime<'conversation.session'>

+ 2 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -54,8 +54,9 @@ export type {
   ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, DraftFileUploads,
   ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps,
   ConversationHeaderActionOwnerProps, ConversationHeaderCornerOwnerProps, ConversationHeaderLineageOwnerProps,
+  ConversationContentInputProps, ConversationContentProps,
   ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps,
-  ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
+  ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationViewsProps,
   ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
   HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
   MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation,

+ 11 - 15
packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx

@@ -1,25 +1,18 @@
 import { useCallback, useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
-import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
-import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
+import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts'
 import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
 import css from './ConversationRoot.module.css'
 
-type ConversationContentProps = Omit<ConversationSlotProps, 'useSession' | 'useConversation'> & {
-  session: SessionSnapshot | undefined
-  phase: 'settling' | 'hero' | 'active'
-  hero: boolean
-  onHandleStart: () => number
-  onHandleDrag: (width: number) => void
-  onHandleCommit: (width: number) => void
-  onHandleEnd: () => void
-}
-
 const WHEEL_DELTA_LINE = 1
 const WHEEL_DELTA_PAGE = 2
 const FALLBACK_WHEEL_LINE_PX = 16
 
+function ConversationSessionView({ renderSlot }: ConversationViewsProps) {
+  return renderSlot('conversation.session', {})
+}
+
 /** Convert a wheel event's vertical delta to scrollport pixels. */
 function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number {
   if (event.deltaMode === WHEEL_DELTA_LINE) {
@@ -133,10 +126,13 @@ function WidthHandle(props: {
  * @returns the unchanged Conversation body subtree.
  */
 export function ConversationContent({
-  sessionId, session, phase, hero, useSessions, useSessionStatus,
+  sessionId, phase, hero, useSession, useSessions, useSessionStatus,
   useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
-  selectWorkspace, t, onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd,
+  selectWorkspace, t, useFactorySlot,
+  onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd,
 }: ConversationContentProps) {
+  const session = useSession(snapshot => snapshot)
+  const Views = useFactorySlot('views', ConversationSessionView)
   const pendingInteraction = useSessionStatus(snapshot =>
     sessionId === undefined ? undefined : snapshot.get(sessionId)?.pendingInteraction)
   const inputState = useInput(s => s)
@@ -291,7 +287,7 @@ export function ConversationContent({
   return (
     <div className={css.body}>
       <div className={css.scrollBody} data-conversation-scroll="">
-        {sessionId === undefined ? null : renderSlot('conversation.session', {})}
+        {sessionId === undefined ? null : <Views />}
         {composerSeat}
       </div>
       {/* Width handles only while a transcript is on screen; the hero has no

+ 9 - 12
packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx

@@ -1,7 +1,6 @@
 import { useCallback, useRef } from 'react'
 import type { ConversationSlotProps } from '../contract/slots.ts'
 import { conversationPhase } from '../contract/snapshot.ts'
-import { ConversationContent } from './ConversationContent.tsx'
 import css from './ConversationRoot.module.css'
 
 /** localStorage key for the dragged transcript width preference (px). */
@@ -40,7 +39,7 @@ function resolveContentWidth(columnWidth: number, preference: number | null): nu
  * @returns the unchanged root, Header, content, and width-control subtree.
  */
 export function ConversationMainPanel(props: ConversationSlotProps) {
-  const { sessionId, useSession, useSessions, useConversation, renderSlot } = props
+  const { sessionId, useSession, useSessions, useConversation, renderSlot, renderFactorySlot } = props
   const session = useSession(s => s)
   const conversation = useConversation(s => s)
   const shellPhase = session === undefined || conversation === undefined
@@ -131,16 +130,14 @@ export function ConversationMainPanel(props: ConversationSlotProps) {
   return (
     <div ref={rootResizeRef} className={css.root} data-phase={phase}>
       {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
-      <ConversationContent
-        {...props}
-        session={session}
-        phase={phase}
-        hero={hero}
-        onHandleStart={onHandleStart}
-        onHandleDrag={onHandleDrag}
-        onHandleCommit={onHandleCommit}
-        onHandleEnd={onHandleEnd}
-      />
+      {renderFactorySlot('conversation.content', {
+        phase,
+        hero,
+        onHandleStart,
+        onHandleDrag,
+        onHandleCommit,
+        onHandleEnd,
+      })}
     </div>
   )
 }

+ 3 - 3
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx

@@ -7,11 +7,11 @@ import {
   FISH_LOGO_PATH, FISH_LOGO_VIEWBOX, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
-import type { ConversationSlotProps } from '../contract/slots.ts'
+import type { ConversationContentProps } from '../contract/slots.ts'
 import css from './HeroShell.module.css'
 
 /** The owner's locale seat type, passed to hero chrome as a plain prop. */
-type HeroTranslate = ConversationSlotProps['t']
+type HeroTranslate = ConversationContentProps['t']
 
 /**
  * Basename label for the workspace chip (the shared derivation);
@@ -66,7 +66,7 @@ export interface HeroShellProps {
   /** The owner's locale seat, passed down as a plain prop. */
   t: HeroTranslate
   /** Authorized renderer for the hero brand-mark slot. */
-  renderSlot: ConversationSlotProps['renderSlot']
+  renderSlot: ConversationContentProps['renderSlot']
   /** Overlay content after the stack (modals). */
   children?: ReactNode
 }

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -171,7 +171,7 @@ export const InputBar = memo(function InputBar({
     focusDraftEditor(editor, revealSelection)
   }, [locked, sessionId, editor])
 
-  // A persisted draft arrives AFTER the unlock effect: ConversationSession
+  // A persisted draft arrives AFTER the unlock effect: DefaultConversationViews
   // adopts it in its own mount effect, and a parent's mount effect runs after
   // its children's. Reveal when the draft becomes non-empty so a restored long
   // draft does not stay at its head with the caret at its end. This effect does

+ 4 - 2
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -101,8 +101,10 @@ async function bench() {
     return { instance, injected }
   }
   const residentApi = (id: SessionId | undefined) => {
-    const entry = entryOf('main.conversation')
-    return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
+    const definition = runtime.factoryOf('conversation.content')
+    return (definition.inject as unknown as (
+      sessionId: SessionId | undefined,
+    ) => ConversationInjected)(id)
   }
   const headerApi = (id: SessionId) => {
     const entry = entryOf('conversation.session.header')

+ 5 - 1
packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx

@@ -31,6 +31,7 @@ async function bench(options: { declareConversation?: boolean } = {}) {
     }, (_props: { renderSlot?: unknown }) => null)
   }
   const feature = await runtime.mount({ inject: [...inject], apply })
+  if (options.declareConversation !== false) runtime.renderRoot()
   return { runtime, feature }
 }
 
@@ -51,11 +52,13 @@ describe('target-neutral Conversation apply wiring', () => {
       'main': { kind: 'keyed', scope: 'root' },
       'settings.general.item': { kind: 'list', scope: 'root' },
     }, (_props: { renderSlot?: unknown }) => null)
-
+    b.runtime.renderRoot()
     expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation'])
     expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1)
     expect(b.runtime.slots.spec('main.conversation'))
       .toEqual({ kind: 'single', scope: 'session-maybe' })
+    expect(b.runtime.factoryOf('conversation.content').slots)
+      .toMatchObject({ views: { scope: 'session' } })
     expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1)
     expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1)
     expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1)
@@ -75,6 +78,7 @@ describe('target-neutral Conversation apply wiring', () => {
     const session = entry(b.runtime, 'conversation.session')
     const header = entry(b.runtime, 'conversation.session.header')
     expect(entry(b.runtime, 'main.conversation')?.store).toBeUndefined()
+    expect(b.runtime.factoryOf('conversation.content').store).toBeUndefined()
     expect(session?.store).toBeDefined()
     expect(header?.store).toBe(session?.store)
     expect(b.runtime.slots.spec('conversation.composer'))

+ 55 - 16
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { ComponentProps, ReactNode } from 'react'
+import { createContext, useContext, type ReactNode } from 'react'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type { Context } from '@deepseek-ai/cordis'
 import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -13,7 +13,6 @@ import {
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
-import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { EMPTY_CONVERSATION_SNAPSHOT } from '../src/client/contract/snapshot.ts'
@@ -21,7 +20,8 @@ import type { ConversationSnapshot } from '../src/client/contract/snapshot.ts'
 import { createConversationStore } from '../src/client/stores.ts'
 import { SessionInputShell } from '../src/client/input/facade.ts'
 import { en, zh } from '../src/client/locales.ts'
-import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
+import { ConversationContent } from '../src/client/skeleton/ConversationContent.tsx'
+import { ConversationMainPanel } from '../src/client/skeleton/ConversationMainPanel.tsx'
 import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
 import { conversationPhase } from '../src/client/contract/snapshot.ts'
 import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
@@ -29,13 +29,23 @@ import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx'
 import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import type {
-  ComposerBarOwnerProps, ConversationHeaderLineageOwnerProps,
+  ComposerBarOwnerProps, ConversationContentInputProps, ConversationContentProps,
+  ConversationHeaderLineageOwnerProps, ConversationSessionSlotProps, ConversationSlotProps,
+  ConversationViewsProps,
 } from '../src/client/contract/slots.ts'
 import type { ViewTab } from '../src/client/contract/views.ts'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
+const FactoryViewsTestContext = createContext<ConversationViewsProps | undefined>(undefined)
+
+function StableConversationViews() {
+  const props = useContext(FactoryViewsTestContext)
+  if (props === undefined) throw new Error('Factory views test context is missing')
+  return <>{props.renderSlot('conversation.session', {})}</>
+}
+
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
@@ -81,13 +91,13 @@ beforeEach(() => {
   vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
-const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh)
+const t: ConversationContentProps['t'] = makeTranslate(zh, commonZh)
 
 const sid = (id: string) => id as SessionId
 const wid = (id: string) => id as WorkspaceId
 const SID = sid('s1')
 
-type SessionSlotProps = ComponentProps<typeof ConversationSession>
+type SessionSlotProps = ConversationSessionSlotProps
 
 const useChat: SessionSlotProps['useChat'] = () => { throw new Error('unused') }
 const useTrajectory: SessionSlotProps['useTrajectory'] = () => { throw new Error('unused') }
@@ -285,7 +295,7 @@ function mount(
       )
     }
     return <div data-testid={`view-${opts?.only ?? key}`} />
-  }) as ConversationRootProps['renderSlot']
+  }) as ConversationContentProps['renderSlot']
   const renderSlotChain = ((_key, _owner, opts) => (
     options.overlayTakeover === true
       ? (
@@ -297,11 +307,43 @@ function mount(
         </>
       )
       : (opts?.fallback ?? null)
-  )) as ConversationRootProps['renderSlotChain']
-  const props: ConversationRootProps = {
+  )) as ConversationContentProps['renderSlotChain']
+  const SessionProvider: ConversationContentProps['SessionProvider'] = ({ children }) => children
+  const renderFactorySlot = ((_name: string, input: ConversationContentInputProps) => {
+    const common: ConversationViewsProps = {
+      sessionId: SID,
+      SessionProvider,
+      useSession,
+      useConversation,
+      useChat,
+      useTrajectory,
+      useSessions: bindSnapshotSelector(sessions),
+      usePanelInfo: selector => selector({ activePanelId: null }),
+      useResource,
+      useSessionStatus,
+      useSessionRetainInfo: () => undefined,
+      useWorkspaces: bindSnapshotSelector(workspaces),
+      useProjection: (() => undefined),
+      useComposerBlock: select => select(options.composerBlock),
+      useInput,
+      inputActions,
+      renderSlot,
+      renderSlotChain,
+      renderFactorySlot,
+      selectWorkspace: retargetWorkspace,
+      t,
+    }
+    const useFactorySlot = (() => StableConversationViews) as ConversationContentProps['useFactorySlot']
+    return (
+      <FactoryViewsTestContext.Provider value={common}>
+        <ConversationContent {...({ ...common, ...input, useFactorySlot })} />
+      </FactoryViewsTestContext.Provider>
+    )
+  }) as ConversationSlotProps['renderFactorySlot']
+  const props: ConversationSlotProps = {
     usePanelInfo: selector => selector({ activePanelId: null }),
     sessionId: SID,
-    SessionProvider: ({ children }) => children,
+    SessionProvider,
     useSession,
     useConversation,
     useSessions: bindSnapshotSelector(sessions),
@@ -310,19 +352,16 @@ function mount(
     useResource,
     useWorkspaces: bindSnapshotSelector(workspaces),
     useProjection: (() => undefined),
-    useComposerBlock: select => select(options.composerBlock),
     useInput,
     inputActions,
     renderSlot,
-    renderSlotChain,
-    selectWorkspace: retargetWorkspace,
-    t,
+    renderFactorySlot,
   }
-  const view = render(<ConversationRoot {...props} />)
+  const view = render(<ConversationMainPanel {...props} />)
   return {
     view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open,
     pickerOwner: () => pickerOwner,
-    rerender: () => { view.rerender(<ConversationRoot {...props} />) },
+    rerender: () => { view.rerender(<ConversationMainPanel {...props} />) },
   }
 }
 

+ 1 - 2
packages/client/ui-layout/src/client/index.ts

@@ -94,8 +94,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 
 // OwnerShare contracts — the render-side share the slot owner supplies at
 // renderSlot. Registrants IMPORT these and compose their full component props
-// through the four-share intersection (PropsRuntime & PropsRenderSlots &
-// PropsStore & I). Conversation business state and actions arrive through
+// from the framework-derived shares. Conversation business state and actions arrive through
 // framework-standard hooks and each registrant's inject face, not owner props.
 
 /** Sidebar owner share: live column state from the frame's concession solve. */

+ 2 - 2
packages/client/ui-renderer/README.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 packages/client/ui-renderer/README.md
-README.md: 1069a86d27e2bed71078e29c890bec150e32e82a
-README.zh.md: b2f75b215dcde20246619709a231f4dd943b2ea3
+README.md: a354d2e6d502d90beaad544babeed2f314e73246
+README.zh.md: 97e408bf1ab89f4823f8b9577cf15eb69d25d4b9

+ 4 - 3
packages/client/ui-renderer/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root for the dsh web client."
+description: "Browser UI renderer: React bindings for ordinary Slots and reusable Component Factories, ctx.uiRenderer, and the assembled dsh web application root."
 kind: "package-reference"
 ---
 
@@ -33,7 +33,7 @@ This package is infrastructure: the web shell and the boot kernel are its only d
 
 ### For business plugins
 
-A business plugin registers a component through the slot system; the renderer binds the runtime's session and workspace observable sources into selector hooks at the outlet. The plugin receives the standard session props (session id, conversation snapshot hooks) through its composed props — it never imports the renderer or touches React internals.
+A business plugin registers an ordinary Slot entry or a reusable Component Factory; the renderer binds the runtime's session and workspace observable sources into selector hooks at the render position. The plugin receives standard scope props through its derived Component props — it never imports the renderer or touches React internals. Every renderer-created Component can render a Factory occurrence, and a Factory can expose caller-selected local Components without sharing implementation values between packages.
 
 -----
 
@@ -51,7 +51,7 @@ The plugin activates after `slots`, `sessions`, and `layout`; it installs `creat
 
 ### Slot bindings
 
-`createSlotRenderer` connects the slot registry to React: entry lists become reactive sources, and each outlet renders through the installed renderer. Business plugins pass bare observable sources through typed slot `hooks`; the renderer binds them at the outlet via the uSES adapter.
+`createSlotRenderer` connects the slot registry to React: ordinary entry lists and Factory definitions become reactive sources, and each outlet or occurrence renders through the installed renderer. Business plugins pass bare observable sources through typed `hooks`; the renderer binds them at the render position via the uSES adapter. A Factory Store factory stays lazy until an occurrence first materializes; its exclusive handle rejects persistence, render-time records stay weak, and an idempotent effect strongly retains only mounted occurrences while preserving identity across effect replay. Factory errors use the ordinary supervision channel without abdicating the shared definition: the definition and its fallback local Components report against the definition, caller-selected local Components report against the caller registration, and each boundary resets with its own scope incarnation.
 
 ### Identity
 
@@ -71,6 +71,7 @@ These pages cover the surrounding machinery and the composition model.
 - [ui-session](../ui-session/README.md) — the adapter that supplies the standard Session sources and hooks this renderer binds.
 - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — the loading chain, object layer, and layering red lines.
 - [Slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) — the definitive composition model.
+- [Component Factories](../../../.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md) — reusable definitions, local Component selection, and occurrence lifetimes.
 
 -----
 

+ 4 - 3
packages/client/ui-renderer/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "浏览器 UI 渲染器:React slot 绑定、ctx.uiRenderer 与 dsh Web 客户端组装后的应用根。"
+description: "浏览器 UI 渲染器:普通 Slot 与可复用 Component Factory 的 React 绑定、ctx.uiRenderer 与组装后的 dsh Web 应用根。"
 kind: "package-reference"
 ---
 
@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 对业务插件
 
-业务插件通过 slot 系统注册组件;渲染器在 outlet 处把运行时的会话与 Workspace observable source 绑定为 selector 钩子。插件通过其组合 props 收到标准会话 props(session id、对话快照钩子)——它绝不导入渲染器,也不触碰 React 内部机制。
+业务插件注册普通 Slot entry 或可复用 Component Factory;渲染器在渲染位置把运行时的会话与 Workspace observable source 绑定为 selector 钩子。插件通过推导出的 Component props 收到标准 scope props——它绝不导入渲染器,也不触碰 React 内部机制。每个由 renderer 创建的 Component 都能渲染 Factory occurrence,Factory 也能暴露由调用方选择的局部 Component,而无需在包之间共享实现值。
 
 -----
 
@@ -51,7 +51,7 @@ kind: "package-reference"
 
 ### Slot 绑定
 
-`createSlotRenderer` 把 slot 注册表连接到 React:条目列表成为响应式 source,每个 outlet 经已安装的渲染器渲染。业务插件通过带类型的 slot `hooks` 传递裸 observable source;渲染器经 uSES 适配器在 outlet 处完成绑定
+`createSlotRenderer` 把 slot 注册表连接到 React:普通 entry list 与 Factory definition 成为响应式 source,每个 outlet 或 occurrence 经已安装的渲染器渲染。业务插件通过带类型的 `hooks` 传递裸 observable source;渲染器经 uSES 适配器在渲染位置完成绑定。Factory Store factory 保持 lazy,直到 occurrence 首次物化时才创建 handle;其 exclusive handle 拒绝持久化,渲染期记录保持弱引用,幂等 effect 仅强引用 mounted occurrences,同时在 effect replay 期间保留 identity。Factory 错误使用普通监督通道且不会 abdicate 共享 definition:definition 及其 fallback 局部 Component 的失败归属 definition,调用方所选局部 Component 的失败归属调用方 registration,每个边界随自身 scope incarnation 重置
 
 ### 身份
 
@@ -71,6 +71,7 @@ React、React DOM、Cordis、ui-slots 与 ui-primitives 通过 Web 外壳的静
 - [ui-session](../ui-session/README.zh.md)——提供本渲染器所绑定标准会话 source 与钩子的适配器。
 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——加载链、对象层与分层红线。
 - [slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)——权威组合模型。
+- [Component Factory](../../../.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md)——可复用 definitions、局部 Component 选择与 occurrence 生命周期。
 
 -----
 

+ 1 - 3
packages/client/ui-renderer/src/client/bindings.tsx

@@ -9,9 +9,7 @@ import type {
   StandardSourceBinding,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import { bindSnapshotSelector } from './bind.ts'
-
-/** Missing renderer assembly dependency. */
-export class SlotAssemblyError extends Error {}
+import { SlotAssemblyError } from './errors.ts'
 
 /** In-package renderer host context. */
 export const HostContext = createContext<SlotRendererHost | null>(null)

+ 4 - 0
packages/client/ui-renderer/src/client/errors.ts

@@ -0,0 +1,4 @@
+/** Shared renderer failure categories. @module */
+
+/** Renderer assembly failures that must escape component error boundaries. */
+export class SlotAssemblyError extends Error {}

+ 2 - 1
packages/client/ui-renderer/src/client/index.ts

@@ -33,7 +33,8 @@ export interface UiRendererService {
 declare module '@deepseek-ai/cordis' {
   interface Events {
     /**
-     * A slot declaration or registration set changed.
+     * An ordinary Slot declaration or entry registration set changed. Factory
+     * definitions publish through `subscribeFactory()` instead.
      * @mode emit
      * @param key - mutated SlotMap key.
      */

+ 170 - 17
packages/client/ui-renderer/src/client/registry.ts

@@ -16,13 +16,14 @@
  * redundancy. */
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
-import { SlotCore, standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotCore, StaleAuthorizationError, standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
-  HostObservable, LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
+  HostObservable, LiveCompositionNode, LocaleFace, OwnerOf, RegisterFactory, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
   RootStandardSourceContribution, ScopedStandardSourceBinding, SlotScope, SlotScopeAdapter, SlotSpec,
-  StandardSourceBinding,
+  StandardSourceBinding, StoredFactory,
   StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotAssemblyError } from './errors.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
@@ -66,6 +67,19 @@ interface StoreAxisRecord {
   instances: Map<string, EngineStoreInstance>
 }
 
+interface FactoryStoreOccurrence {
+  readonly handle: EngineStoreHandle
+  readonly instances: Map<string, EngineStoreInstance>
+  retainers: number
+}
+
+interface FactoryStoreAxis {
+  /** Render-created records stay weak until their occurrence commits. */
+  readonly occurrences: WeakMap<object, FactoryStoreOccurrence>
+  /** Committed occurrences are enumerable for Session-generation cleanup. */
+  readonly mounted: Map<object, FactoryStoreOccurrence>
+}
+
 /** Type-erased options view the implementation works with (the typed overloads proved the shares). */
 interface ErasedRegisterOptions {
   name: string
@@ -85,8 +99,19 @@ interface ErasedRegisterOptions {
   registrant?: string
 }
 
+interface ErasedFactoryOptions {
+  name: string
+  scope: SlotScope
+  children?: Record<string, SlotSpec<SlotEntryDef>>
+  store?: StoreDecl
+  inject?: (...args: never[]) => Record<string, unknown>
+  locale?: string
+  slots?: Record<string, { scope: SlotScope }>
+}
+
 /** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
 interface ErasedCore { register(options: object, component: unknown): () => void }
+interface ErasedFactoryCore { registerFactory(options: object, component: unknown): () => void }
 
 /** One synchronous effect installed while an injected slot declaration is live. */
 type SlotInjectionEffect = (() => void) | Iterable<() => void, void, void>
@@ -96,6 +121,7 @@ export class SlotRegistry extends Service {
   private readonly _core = new SlotCore()
   /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
   private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
+  private readonly _factoryStores = new Map<StoredFactory, FactoryStoreAxis>()
   /** Latest live Context generation for each scoped store key. */
   private readonly _storeScopeOwners = new Map<string, Context>()
   private _renderer: SlotRenderer | undefined
@@ -136,7 +162,7 @@ export class SlotRegistry extends Service {
   }
 
   /**
-   * The single registration API. The typed face IS the core's register
+   * The ordinary Slot registration API. The typed face IS the core's register
    * (both overloads reused verbatim — one authority, no structural copy;
    * see SlotCore.register for children declaration, store seat, inject
    * face, load-time validation, and the unload cascade). This layer adds:
@@ -154,6 +180,17 @@ export class SlotRegistry extends Service {
    */
   declare readonly register: SlotCore['register']
 
+  /**
+   * Register one reusable Component Factory under the caller's effect lifetime.
+   * A Store factory mints one handle per rendered occurrence rather than per
+   * definition. Like {@link SlotRegistry.register}, this remains a prototype
+   * method so the Cordis proxy binds `this.ctx` to the caller's Context.
+   * @param options - runtime definition checked against `SlotFactoryMap`.
+   * @param component - reusable Factory Component.
+   * @returns the idempotent definition disposer.
+   */
+  declare readonly registerFactory: RegisterFactory
+
   /**
    * Install an effect for each declaration lifetime of a slot. The callback
    * runs synchronously when the declaration already exists; otherwise it runs
@@ -380,26 +417,29 @@ export class SlotRegistry extends Service {
   }
 
   /**
-   * Export the current JSON-safe Slot declaration tree for read-only inspection.
-   * @param root - exact live Slot root; omitted returns all roots.
-   * @returns selected Slot trees.
+   * Export the current JSON-safe Slot and Factory declaration trees for read-only inspection.
+   * @param root - exact live Slot key or `factory:<name>`; omitted returns all roots.
+   * @returns selected composition trees.
    */
-  snapshot(root?: string): LiveSlotNode[] {
+  snapshot(root?: string): LiveCompositionNode[] {
     return this._core.snapshot(root)
   }
 
   /**
-   * Observe entry boundary crashes (every render-time entry failure the
-   * boundaries contain, abdicating or not) — the supervision seam for
-   * plugins mirroring contribution health. Fires synchronously per report,
-   * after the registry mutated for abdicating crashes. Callers own the
-   * disposer (wire it through ctx.effect for fiber-lifetime cleanup, as with
-   * {@link SlotRegistry.subscribe}).
-   * @param fn - called with the slot key, the crashed entry, the crash
-   * cause, and `abdicated`: whether the crash retired the entry from its cell.
+   * Observe ordinary entry and Factory occurrence crashes through one
+   * supervision channel. Fires synchronously after any ordinary-entry
+   * abdication mutation. Callers own the disposer (wire it through ctx.effect
+   * for fiber-lifetime cleanup, as with {@link SlotRegistry.subscribe}).
+   * @param fn - called with the Slot or `factory:<name>` key, crashed
+   * registration, cause, and whether an ordinary entry was retired.
    * @returns unsubscribe.
    */
-  onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
+  onEntryError(fn: (
+    key: string,
+    registration: StoredEntry | StoredFactory,
+    error: unknown,
+    info: { abdicated: boolean },
+  ) => void): () => void {
     return this._core.onEntryError(fn)
   }
 
@@ -460,6 +500,35 @@ export class SlotRegistry extends Service {
     }
   }
 
+  private _registerFactory(options: ErasedFactoryOptions, component: unknown): () => void {
+    const registrant = (this.ctx.fiber as { name?: string } | undefined)?.name
+    const erased = {
+      ...options,
+      ...(registrant === undefined ? {} : { registrant }),
+    }
+    const dispose = (this._core as unknown as ErasedFactoryCore).registerFactory(erased, component)
+    const definition = this._core.factory(options.name)
+    if (definition === undefined) throw new Error(`slot factory "${options.name}" disappeared during registration`)
+    if (definition.store !== undefined && typeof definition.store !== 'function') {
+      this._acquire(definition.store, definition.scope)
+    } else if (typeof definition.store === 'function') {
+      this._factoryStores.set(definition, {
+        occurrences: new WeakMap(),
+        mounted: new Map(),
+      })
+    }
+    let disposed = false
+    return () => {
+      if (disposed) return
+      disposed = true
+      dispose()
+      this._factoryStores.delete(definition)
+      if (definition.store !== undefined && typeof definition.store !== 'function') {
+        this._release(definition.store)
+      }
+    }
+  }
+
   /** Build the domain-neutral host face once; installed adapters remain live through getters. */
   private hostFace(): SlotRendererHost {
     if (this._host !== undefined) return this._host
@@ -475,12 +544,21 @@ export class SlotRegistry extends Service {
       entriesOf: key => this._core.entries(key),
       entriesOfSlot: key => this._core.entriesOfSlot(key),
       reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) },
+      reportFactoryError: (name, registration, error) => { this._core.reportFactoryError(name, registration, error) },
       specOf: key => this._core.specDynamic(key),
       isLive: entry => this._core.isLive(entry),
       storeOf: (entry, scopeBinding) =>
         entry.store === undefined
           ? undefined
           : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeBinding),
+      factoryStoreOf: (definition, scopeBinding, occurrence) =>
+        this.resolveFactoryStore(definition, scopeBinding, occurrence),
+      retainFactoryOccurrence: (definition, occurrence) =>
+        this.retainFactoryOccurrence(definition, occurrence),
+      subscribeFactory: (name, fn) => this._core.subscribeFactory(name, fn),
+      getFactoryVersion: name => this._core.factoryVersion(name),
+      factoryOf: name => this._core.factory(name),
+      isFactoryLive: definition => this._core.isFactoryLive(definition),
       root: this._rootSource,
       scopeRevision: this._scopeRevisionSource,
       scope: scope => service._scopes.get(scope === 'session-maybe' ? 'session' : scope),
@@ -547,12 +625,70 @@ export class SlotRegistry extends Service {
     return instance
   }
 
+  private resolveFactoryStore(
+    definition: StoredFactory,
+    scopeBinding: ScopedStandardSourceBinding | undefined,
+    occurrence: object,
+  ): StoreInstanceLike | undefined {
+    if (!this._core.isFactoryLive(definition)) {
+      throw new StaleAuthorizationError(`slot factory "${definition.name}" is not registered`)
+    }
+    const declaration = definition.store
+    if (declaration === undefined) return undefined
+    if (typeof declaration !== 'function') {
+      return this.resolveStore(declaration, scopeBinding)
+    }
+    const axis = this._factoryStores.get(definition) as FactoryStoreAxis
+    const scopeKey = definition.scope === 'root'
+      ? ROOT_INSTANCE_KEY
+      : requireScopeKey(definition, scopeBinding)
+    if (scopeBinding !== undefined && definition.scope !== 'root') this.bindStoreScope(scopeBinding)
+    let record = axis.occurrences.get(occurrence)
+    if (record === undefined) {
+      const handle = declaration()
+      if (handle.spec.persist !== undefined) {
+        throw new SlotAssemblyError(
+          `exclusive store for factory "${definition.name}" cannot declare persistence`,
+        )
+      }
+      record = { handle, instances: new Map(), retainers: 0 }
+      axis.occurrences.set(occurrence, record)
+    }
+    const existing = record.instances.get(scopeKey)
+    if (existing !== undefined) return existing
+    const instance = definition.scope === 'root' || scopeBinding === undefined
+      ? record.handle.create()
+      : record.handle.create(scopeBinding.key)
+    record.instances.set(scopeKey, instance)
+    return instance
+  }
+
+  private retainFactoryOccurrence(definition: StoredFactory, occurrence: object): () => void {
+    if (!this._core.isFactoryLive(definition)) return () => {}
+    if (typeof definition.store !== 'function') return () => {}
+    const axis = this._factoryStores.get(definition) as FactoryStoreAxis
+    const record = axis.occurrences.get(occurrence) as FactoryStoreOccurrence
+    record.retainers += 1
+    axis.mounted.set(occurrence, record)
+    let released = false
+    return () => {
+      if (released) return
+      released = true
+      record.retainers -= 1
+      if (record.retainers !== 0) return
+      axis.mounted.delete(occurrence)
+    }
+  }
+
   /** Drop every materialized non-root Store instance for one ended Context generation. */
   private releaseStoreScope(key: string): void {
     for (const record of this._stores.values()) {
       if (record.scope === 'root') continue
       record.instances.delete(key)
     }
+    for (const axis of this._factoryStores.values()) {
+      for (const record of axis.mounted.values()) record.instances.delete(key)
+    }
   }
 
   /** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
@@ -608,3 +744,20 @@ function copyUnique<T>(
     // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
     return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
   }
+
+;(SlotRegistry.prototype as { registerFactory: (options: object, component: unknown) => () => void }).registerFactory
+  = function registerFactory(this: SlotRegistry, rawOptions: object, component: unknown): () => void {
+    const options = rawOptions as ErasedFactoryOptions
+    // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
+    return this.ctx.effect(() => this['_registerFactory'](options, component), 'slots.registerFactory()')
+  }
+
+function requireScopeKey(
+  definition: StoredFactory,
+  binding: ScopedStandardSourceBinding | undefined,
+): string {
+  if (binding === undefined) {
+    throw new Error(`${definition.scope} factory store resolution requires a session id`)
+  }
+  return binding.key
+}

+ 367 - 28
packages/client/ui-renderer/src/client/scoped-slots.tsx

@@ -2,19 +2,23 @@
  * React renderer for declarative slots. Per-entry bindings enforce child
  * authorization, and entry boundaries contain registrant failures.
  */
-import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
+import {
+  Component, createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore,
+  type FC, type ReactNode,
+} from 'react'
 import {
   SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
-  type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderOpts,
+  type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderFactorySlot, type RenderOpts,
   type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
   type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
-  type StoredEntry, type Translate,
+  type StoredEntry, type StoredFactory, type Translate,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  HostContext, RootStandardProvider, ScopeBindingProvider, ScopeProvider, SlotAssemblyError,
+  HostContext, RootStandardProvider, ScopeBindingProvider, ScopeProvider,
   keyedObservableHook, maybeObservableHook, observableHook, useHost, useRootBinding,
   useScopeBinding,
 } from './bindings.tsx'
+import { SlotAssemblyError } from './errors.ts'
 
 type InjectedProps = Record<string, unknown>
 
@@ -30,6 +34,28 @@ type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => Reac
 
 type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
 
+type FactoryRenderOwner = StoredEntry | StoredFactory
+const factoryRenderCache = new WeakMap<FactoryRenderOwner, RenderFactorySlot>()
+
+function boundRenderFactorySlot(caller: FactoryRenderOwner): RenderFactorySlot {
+  let render = factoryRenderCache.get(caller)
+  if (render !== undefined) return render
+  render = ((name: string, props: object, options?: {
+    slots?: Readonly<Record<string, FC<InjectedProps>>>
+    fallback?: ReactNode
+  }) => (
+    <FactoryOutlet
+      name={name}
+      inputProps={props}
+      slots={options?.slots}
+      fallback={options?.fallback}
+      caller={caller}
+    />
+  )) as RenderFactorySlot
+  factoryRenderCache.set(caller, render)
+  return render
+}
+
 /**
  * Per-entry renderSlot bindings. The binding is identity-stable per entry
  * (memoized components must not resubscribe on unrelated re-renders) and dies
@@ -89,6 +115,43 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende
   return binding
 }
 
+const factoryRenderSlotCache = new WeakMap<StoredFactory, RenderSlotBinding>()
+const factoryRenderSlotChainCache = new WeakMap<StoredFactory, RenderSlotChainBinding>()
+
+function boundFactoryRenderSlot(host: SlotRendererHost, definition: StoredFactory): RenderSlotBinding {
+  let binding = factoryRenderSlotCache.get(definition)
+  if (binding !== undefined) return binding
+  binding = (key, owner, opts) => {
+    if (!host.isFactoryLive(definition)) {
+      throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed Factory`)
+    }
+    const declared = definition.children?.[key]
+    if (declared === undefined) throw new SlotOwnershipError(`slot '${key}' is not declared by this Factory`)
+    if (declared.kind === 'chain') throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`)
+    return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
+  }
+  factoryRenderSlotCache.set(definition, binding)
+  return binding
+}
+
+function boundFactoryRenderSlotChain(host: SlotRendererHost, definition: StoredFactory): RenderSlotChainBinding {
+  let binding = factoryRenderSlotChainCache.get(definition)
+  if (binding !== undefined) return binding
+  binding = (key, owner, opts) => {
+    if (!host.isFactoryLive(definition)) {
+      throw new StaleAuthorizationError(`renderSlotChain('${key}') from a disposed Factory`)
+    }
+    const declared = definition.children?.[key]
+    if (declared === undefined) throw new SlotOwnershipError(`slot '${key}' is not declared by this Factory`)
+    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} />
+  }
+  factoryRenderSlotChainCache.set(definition, binding)
+  return binding
+}
+
 /**
  * Inject results cache: root entries per entry, session entries per
  * (entry x scope binding). WeakMap keys are entry/binding objects (both
@@ -101,7 +164,11 @@ const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceB
 
 const EMPTY_INJECTED_PROPS: InjectedProps = {}
 
-function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefined, actions: object | undefined): InjectedProps {
+function runInject(
+  entry: Pick<StoredEntry, 'inject'>,
+  binding: StandardSourceBinding | undefined,
+  actions: object | undefined,
+): InjectedProps {
   const inject = entry.inject
   if (!inject) return EMPTY_INJECTED_PROPS
   // Declaration-derived positional arguments: sessionId for session scope,
@@ -352,6 +419,27 @@ class SlotErrorBoundary extends Component<
   }
 }
 
+/** Contain one Factory occurrence without retiring the shared definition. */
+/* jscpd:ignore-start */
+class FactoryErrorBoundary extends Component<
+  { name: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
+> {
+  override state = { failed: false }
+  static getDerivedStateFromError(error: unknown): { failed: boolean } {
+    if (error instanceof SlotAssemblyError) throw error
+    return { failed: true }
+  }
+  override componentDidCatch(error: unknown): void {
+    console.error(`slot factory occurrence crashed in '${this.props.name}':`, error)
+    this.props.onEntryError(error)
+  }
+  override render(): ReactNode {
+    if (this.state.failed) return <div data-factory-error={this.props.name} />
+    return this.props.children
+  }
+}
+/* jscpd:ignore-end */
+
 const rootStandardCache = new WeakMap<StandardSourceBinding, InjectedProps>()
 const sessionStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
 const sessionMaybeStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
@@ -463,7 +551,7 @@ function standardKit(
   actions: object | undefined
 } {
   const standard = standardProps(scope, rootBinding, scopeBinding)
-  const kit: InjectedProps = { ...standard }
+  const kit: InjectedProps = { ...standard, renderFactorySlot: boundRenderFactorySlot(entry) }
   if (entry.locale !== undefined) {
     const face = host.locale
     // Loud assembly failure: locale is immediately-tier infrastructure; a
@@ -619,6 +707,32 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
   hasHookContext: boolean
 }) {
   const binding = useScopeBinding()
+  const epoch = useMaybeIncarnation(binding)
+  return (
+    <SessionMaybeEntryBody
+      key={epoch}
+      entry={entry}
+      ownerProps={ownerProps}
+      binding={binding}
+      slotKey={slotKey}
+      slotInjected={slotInjected}
+      hookContext={hookContext}
+      hasHookContext={hasHookContext}
+    />
+  )
+}
+
+/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
+interface MaybeIncarnation {
+  /** Session generation this incarnation adopted; undefined while born blank and unadopted. */
+  readonly adopted: object | undefined
+  /** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
+  readonly epoch: number
+}
+
+const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }
+
+function useMaybeIncarnation(binding: StandardSourceBinding): number {
   const identity = binding.key === undefined
     ? undefined
     : (binding as ScopedStandardSourceBinding).ctx
@@ -645,30 +759,9 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
     epoch += 1
     setState({ adopted, epoch })
   }
-  return (
-    <SessionMaybeEntryBody
-      key={epoch}
-      entry={entry}
-      ownerProps={ownerProps}
-      binding={binding}
-      slotKey={slotKey}
-      slotInjected={slotInjected}
-      hookContext={hookContext}
-      hasHookContext={hasHookContext}
-    />
-  )
-}
-
-/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
-interface MaybeIncarnation {
-  /** Session this incarnation adopted; undefined while born blank and unadopted. */
-  readonly adopted: object | undefined
-  /** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
-  readonly epoch: number
+  return epoch
 }
 
-const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }
-
 function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: {
   entry: StoredEntry
   ownerProps: object
@@ -685,6 +778,252 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
   return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
 }
 
+interface FactoryOccurrenceValue {
+  readonly host: SlotRendererHost
+  readonly definition: StoredFactory
+  readonly selected: Readonly<Record<string, FC<InjectedProps>>>
+  readonly registrationKit: InjectedProps
+  readonly rootBinding: StandardSourceBinding
+  readonly caller: FactoryRenderOwner
+}
+
+const FactoryOccurrenceContext = createContext<FactoryOccurrenceValue | null>(null)
+const FactoryAncestryContext = createContext<ReadonlySet<string>>(new Set())
+const EMPTY_FACTORY_SELECTION: Readonly<Record<string, FC<InjectedProps>>> = {}
+
+function useFactorySlotRuntime(name: string, fallback: FC<InjectedProps>): FC<InjectedProps> {
+  const occurrence = useContext(FactoryOccurrenceContext)
+  if (occurrence === null) throw new SlotAssemblyError('useFactorySlot() called outside a Factory occurrence')
+  if (!occurrence.host.isFactoryLive(occurrence.definition)) {
+    throw new StaleAuthorizationError(`useFactorySlot('${name}') from a disposed Factory`)
+  }
+  const declared = occurrence.definition.slots?.[name]
+  if (declared === undefined) {
+    throw new SlotOwnershipError(`local slot '${name}' is not declared by factory '${occurrence.definition.name}'`)
+  }
+  const selected = occurrence.selected[name]
+  const Selected = selected ?? fallback
+  const usesFallback = selected === undefined
+  const { definition, host } = occurrence
+  return useMemo(function bindFactoryLocalComponent() {
+    return function BoundFactoryLocalComponent(localProps: InjectedProps): ReactNode {
+      const current = useContext(FactoryOccurrenceContext)
+      const localScopeBinding = useScopeBinding()
+      const localMaybeEpoch = useMaybeIncarnation(localScopeBinding)
+      if (!host.isFactoryLive(definition)) {
+        throw new StaleAuthorizationError(`local slot '${name}' from a disposed Factory`)
+      }
+      if (current === null || current.definition !== definition) {
+        throw new SlotOwnershipError(`local slot '${name}' rendered outside factory '${definition.name}'`)
+      }
+      if (declared.scope === 'session' && localScopeBinding.key === undefined) {
+        throw new SlotAssemblyError(
+          `strict session local slot '${name}' from factory '${definition.name}' rendered without a scope binding`)
+      }
+      const localOwner = usesFallback ? definition : current.caller
+      const localScopeIdentity = declared.scope === 'root'
+        ? 'root'
+        : declared.scope === 'session'
+          ? `session:${sessionGenerationKeyOf(localScopeBinding as ScopedStandardSourceBinding)}`
+          : `session-maybe:${localMaybeEpoch}`
+      const localStandard = standardProps(declared.scope, current.rootBinding, localScopeBinding)
+      const localRegistrationKit = {
+        ...current.registrationKit,
+        renderFactorySlot: boundRenderFactorySlot(localOwner),
+      }
+      assertNoPropOverlap(`factory '${definition.name}' local slot '${name}'`, localRegistrationKit, localStandard)
+      const provided = { ...localRegistrationKit, ...localStandard }
+      assertNoPropOverlap(`factory '${definition.name}' local slot '${name}'`, provided, localProps)
+      return (
+        <FactoryErrorBoundary
+          key={`${definition.name}:${name}:${localScopeIdentity}`}
+          name={`${definition.name}:${name}`}
+          onEntryError={(error) => { host.reportFactoryError(definition.name, localOwner, error) }}
+        >
+          <Selected {...provided} {...localProps} />
+        </FactoryErrorBoundary>
+      )
+    }
+  }, [Selected, declared.scope, definition, host, name, usesFallback])
+}
+
+function assertNoPropOverlap(owner: string, provided: InjectedProps, received: object): void {
+  for (const name of Object.keys(received)) {
+    if (Object.hasOwn(provided, name)) {
+      throw new SlotAssemblyError(`${owner} received duplicate prop '${name}'`)
+    }
+  }
+}
+
+function factoryKit(
+  host: SlotRendererHost,
+  definition: StoredFactory,
+  rootBinding: StandardSourceBinding,
+  scopeBinding: StandardSourceBinding | undefined,
+  occurrence: object,
+): { kit: InjectedProps; registrationKit: InjectedProps; actions: object | undefined } {
+  const standard = standardProps(definition.scope, rootBinding, scopeBinding)
+  const registrationKit: InjectedProps = { renderFactorySlot: boundRenderFactorySlot(definition) }
+  if (definition.locale !== undefined) {
+    const face = host.locale
+    if (face === undefined) {
+      throw new SlotAssemblyError(
+        `factory declares locale namespace '${definition.locale}' but no locale face is installed`)
+    }
+    registrationKit['t'] = localeSeat(face, definition.locale)
+  }
+  const scoped = scopeBinding?.key === undefined ? undefined : scopeBinding as ScopedStandardSourceBinding
+  const store = host.factoryStoreOf(definition, scoped, occurrence)
+  if (store !== undefined) {
+    registrationKit['useStore'] = observableHook(store)
+    registrationKit['actions'] = store.actions
+  }
+  if (definition.children !== undefined) {
+    registrationKit['renderSlot'] = boundFactoryRenderSlot(host, definition)
+    if (Object.values(definition.children).some(spec => spec.kind === 'chain')) {
+      registrationKit['renderSlotChain'] = boundFactoryRenderSlotChain(host, definition)
+    }
+    if (Object.values(definition.children).some(spec => spec.scope !== 'root')) {
+      const sessionAdapter = host.scope('session')
+      if (sessionAdapter === undefined) {
+        throw new SlotAssemblyError("factory declares a session child without an installed 'session' scope adapter")
+      }
+      registrationKit['SessionProvider'] = scopeAreaProvider(sessionAdapter)
+    }
+  }
+  return { kit: { ...standard, ...registrationKit }, registrationKit, actions: store?.actions }
+}
+
+interface FactoryOccurrenceProps {
+  definition: StoredFactory
+  inputProps: object
+  selected: Readonly<Record<string, FC<InjectedProps>>>
+  caller: FactoryRenderOwner
+}
+
+function FactoryOccurrence({ definition, inputProps, selected, caller, binding, maybeEpoch }: FactoryOccurrenceProps & {
+  binding: StandardSourceBinding
+  maybeEpoch: number
+}) {
+  if (definition.scope === 'root') {
+    return <FactoryOccurrenceBody definition={definition} inputProps={inputProps} selected={selected} caller={caller} />
+  }
+  if (definition.scope === 'session') {
+    if (binding.key === undefined) {
+      throw new SlotAssemblyError(`strict session factory '${definition.name}' rendered without a scope binding`)
+    }
+    return (
+      <FactoryOccurrenceBody
+        key={sessionGenerationKeyOf(binding as ScopedStandardSourceBinding)}
+        definition={definition}
+        inputProps={inputProps}
+        selected={selected}
+        caller={caller}
+        scopeBinding={binding}
+      />
+    )
+  }
+  return (
+    <FactoryOccurrenceBody
+      key={maybeEpoch}
+      definition={definition}
+      inputProps={inputProps}
+      selected={selected}
+      caller={caller}
+      scopeBinding={binding}
+    />
+  )
+}
+
+function FactoryOccurrenceBody({
+  definition, inputProps, selected, caller, scopeBinding,
+}: FactoryOccurrenceProps & { scopeBinding?: StandardSourceBinding | undefined }) {
+  const host = useHost()
+  const rootBinding = useRootBinding()
+  const occurrence = useRef<object>({}).current
+  const localeRevision = useLocaleRevision(host.locale)
+  useEffect(
+    () => host.retainFactoryOccurrence(definition, occurrence),
+    [definition, host, occurrence],
+  )
+  const { kit, registrationKit, actions } = useMemo(
+    () => factoryKit(host, definition, rootBinding, scopeBinding, occurrence),
+    [definition, host, localeRevision, occurrence, rootBinding, scopeBinding],
+  )
+  const injected = useMemo(
+    () => runInject(definition, scopeBinding, actions),
+    [actions, definition, scopeBinding],
+  )
+  assertNoPropOverlap(`factory '${definition.name}' inject`, kit, injected)
+  const provided = { ...kit, ...injected, useFactorySlot: useFactorySlotRuntime }
+  assertNoPropOverlap(`factory '${definition.name}' occurrence`, provided, inputProps)
+  const context = useMemo<FactoryOccurrenceValue>(() => ({
+    host,
+    definition,
+    selected,
+    registrationKit: { ...registrationKit, ...injected },
+    rootBinding,
+    caller,
+  }), [caller, definition, host, injected, registrationKit, rootBinding, selected])
+  const Comp = definition.component as FC<InjectedProps>
+  return (
+    <FactoryOccurrenceContext.Provider value={context}>
+      <Comp {...provided} {...inputProps} />
+    </FactoryOccurrenceContext.Provider>
+  )
+}
+
+function FactoryOutlet({ name, inputProps, slots: selected = EMPTY_FACTORY_SELECTION, fallback, caller }: {
+  name: string
+  inputProps: object
+  slots?: Readonly<Record<string, FC<InjectedProps>>> | undefined
+  fallback?: ReactNode
+  caller: FactoryRenderOwner
+}) {
+  const host = useHost()
+  const ancestors = useContext(FactoryAncestryContext)
+  const binding = useScopeBinding()
+  const maybeEpoch = useMaybeIncarnation(binding)
+  const version = useSyncExternalStore(
+    listener => host.subscribeFactory(name, listener),
+    () => host.getFactoryVersion(name),
+  )
+  const definition = host.factoryOf(name)
+  if (definition === undefined) return <>{fallback ?? null}</>
+  if (ancestors.has(name)) throw new SlotOwnershipError(`recursive render of factory '${name}'`)
+  for (const localName of Object.keys(selected)) {
+    if (definition.slots?.[localName] === undefined) {
+      throw new SlotOwnershipError(`local slot '${localName}' is not declared by factory '${name}'`)
+    }
+  }
+  const nextAncestors = new Set(ancestors).add(name)
+  const scopeIdentity = definition.scope === 'root'
+    ? 'root'
+    : definition.scope === 'session'
+      ? binding.key === undefined
+        ? 'session:absent'
+        : `session:${sessionGenerationKeyOf(binding as ScopedStandardSourceBinding)}`
+      : `session-maybe:${maybeEpoch}`
+  return (
+    <FactoryErrorBoundary
+      key={`${name}:${version}:${scopeIdentity}`}
+      name={name}
+      onEntryError={(error) => { host.reportFactoryError(name, definition, error) }}
+    >
+      <FactoryAncestryContext.Provider value={nextAncestors}>
+        <FactoryOccurrence
+          definition={definition}
+          inputProps={inputProps}
+          selected={selected}
+          caller={caller}
+          binding={binding}
+          maybeEpoch={maybeEpoch}
+        />
+      </FactoryAncestryContext.Provider>
+    </FactoryErrorBoundary>
+  )
+}
+
 function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: {
   slotKey: string
   entry: StoredEntry

+ 693 - 0
packages/client/ui-renderer/tests/factory-slots.client.spec.tsx

@@ -0,0 +1,693 @@
+// @vitest-environment jsdom
+import { StrictMode, useEffect, useState, type ReactNode } from 'react'
+import { act, fireEvent } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {
+  ActionsDecl, FactoryComponentPropsOf, FactoryLocalComponentPropsOf, HostObservable,
+  PropsRenderFactories, PropsRuntime, StoreHandle, StoreInstanceLike,
+} from '@deepseek-ai/dsh-client-ui-slots'
+import { StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
+
+interface CounterState { count: number }
+interface CounterActions extends ActionsDecl<CounterState> {
+  increment(draft: CounterState): void
+}
+type CounterStore = StoreHandle<CounterState, CounterActions>
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    'renderer.factory.child': { kind: 'list'; scope: 'root' }
+    'renderer.factory.chain': { kind: 'chain'; scope: 'root'; owner: { enabled: boolean } }
+    'renderer.factory.session-area': { kind: 'single'; scope: 'session' }
+  }
+
+  interface LocaleNamespaceMap {
+    'renderer-factory': 'word'
+  }
+
+  interface SlotFactoryMap {
+    'renderer.factory': {
+      scope: 'root'
+      props: { label: string; crash?: boolean }
+      children: { 'renderer.factory.child': { kind: 'list'; scope: 'root' } }
+      store: CounterStore
+      inject: {
+        hooks: { suffix: HostObservable<string> }
+        tag: string
+      }
+      locale: 'renderer-factory'
+      slots: {
+        view: { scope: 'root'; props: { text: string } }
+      }
+    }
+    'renderer.recursive-factory': { scope: 'root' }
+    'renderer.session-store-factory': {
+      scope: 'session'
+      store: CounterStore
+    }
+    'renderer.scoped-local-factory': {
+      scope: 'root'
+      slots: { view: { scope: 'session' } }
+    }
+    'renderer.nested-factory': {
+      scope: 'root'
+      slots: { view: { scope: 'root' } }
+    }
+    'renderer.session-factory-root-local': {
+      scope: 'session'
+      props: { report: (hasSessionId: boolean) => void }
+      slots: { view: { scope: 'root'; props: { report: (hasSessionId: boolean) => void } } }
+    }
+    'renderer.session-error-factory': {
+      scope: 'session'
+      props: { crashIn: string }
+    }
+    'renderer.error-factory': { scope: 'root' }
+    'renderer.healthy-factory': { scope: 'root' }
+    'renderer.chain-factory': {
+      scope: 'root'
+      children: {
+        'renderer.factory.child': { kind: 'list'; scope: 'root' }
+        'renderer.factory.chain': { kind: 'chain'; scope: 'root' }
+      }
+    }
+  }
+}
+
+const SESSION_AREA = {
+  'renderer.factory.session-area': { kind: 'single', scope: 'session' },
+} as const
+
+const sid = (value: string): SessionId => value as SessionId
+
+function observable<T>(initial: T) {
+  let value = initial
+  const listeners = new Set<() => void>()
+  return {
+    getSnapshot: () => value,
+    subscribe: (listener: () => void) => {
+      listeners.add(listener)
+      return () => { listeners.delete(listener) }
+    },
+    set: (next: T) => {
+      value = next
+      for (const listener of [...listeners]) listener()
+    },
+  }
+}
+
+function counterHandle(clearPersisted = vi.fn()): CounterStore {
+  return {
+    spec: {
+      init: () => ({ count: 0 }),
+      actions: { increment: (draft) => { draft.count += 1 } },
+    },
+    create: () => {
+      const source = observable<CounterState>({ count: 0 })
+      return {
+        ...source,
+        actions: {
+          increment: () => { source.set({ count: source.getSnapshot().count + 1 }) },
+        },
+        clearPersisted,
+      } as StoreInstanceLike as ReturnType<CounterStore['create']>
+    },
+  }
+}
+
+type FactoryProps = FactoryComponentPropsOf<'renderer.factory'>
+type LocalProps = FactoryLocalComponentPropsOf<'renderer.factory', 'view'>
+type ChainFactoryProps = FactoryComponentPropsOf<'renderer.chain-factory'>
+type NestedFactoryProps = FactoryComponentPropsOf<'renderer.nested-factory'>
+type ScopedLocalProps = FactoryLocalComponentPropsOf<'renderer.scoped-local-factory', 'view'>
+
+let defaultMounts = 0
+let customMounts = 0
+
+function DefaultView({ text, tag, t, useSuffix }: LocalProps) {
+  useEffect(() => { defaultMounts += 1 }, [])
+  return <i data-view="default">{text}:{tag}:{useSuffix(value => value)}:{t('word')}</i>
+}
+
+function CustomView({ text, tag, t, useSuffix }: LocalProps) {
+  useEffect(() => { customMounts += 1 }, [])
+  return <i data-view="custom">{text}:{tag}:{useSuffix(value => value)}:{t('word')}</i>
+}
+
+function CrashingView(): never {
+  throw new Error('selected view crashed')
+}
+
+function SessionCrashingView({ sessionId }: ScopedLocalProps) {
+  if (sessionId === 'broken') throw new Error('broken session view')
+  return <span>{sessionId}</span>
+}
+
+function NestedFactoryBody({ useFactorySlot }: NestedFactoryProps) {
+  const View = useFactorySlot('view', () => null)
+  return <View />
+}
+
+function NestedFactoryView({ renderFactorySlot }: LocalProps) {
+  return renderFactorySlot('renderer.nested-factory', {}, { slots: { view: CrashingView } })
+}
+
+function FactoryBody({
+  label, crash, useStore, actions, useFactorySlot, renderSlot,
+}: FactoryProps) {
+  if (crash === true) throw new Error(`crash:${label}`)
+  const View = useFactorySlot('view', DefaultView)
+  return (
+    <section data-factory={label}>
+      <span data-count>{useStore(state => state.count)}</span>
+      <button type="button" onClick={actions.increment}>increment {label}</button>
+      <View text={label} />
+      {renderSlot('renderer.factory.child', {})}
+    </section>
+  )
+}
+
+function installLocale(runtime: SlotTestRuntime) {
+  const revision = observable({ revision: 0 })
+  runtime.slots.installLocale({
+    ...revision,
+    bind: namespace => key => `${namespace}:${key}`,
+  })
+  return revision
+}
+
+function registerFactory(
+  runtime: SlotTestRuntime,
+  storeFactory: () => CounterStore,
+  component: (props: FactoryProps) => ReactNode = FactoryBody,
+): () => void {
+  const suffix = observable('suffix')
+  return runtime.slots.registerFactory({
+    name: 'renderer.factory',
+    scope: 'root',
+    children: { 'renderer.factory.child': { kind: 'list', scope: 'root' } },
+    store: storeFactory,
+    inject: () => ({ hooks: { suffix }, tag: 'injected' }),
+    locale: 'renderer-factory',
+    slots: { view: { scope: 'root' } },
+  }, component)
+}
+
+describe('Factory rendering', () => {
+  it('reports direct test-runtime lookups before rendering and without a definition', async () => {
+    const runtime = await SlotTestRuntime.create()
+    expect(() => runtime.factoryOf('renderer.factory')).toThrow('before renderRoot')
+    await runtime.root.declare({}, () => null)
+    runtime.renderRoot()
+    expect(() => runtime.factoryOf('renderer.factory')).toThrow('no definition')
+    await runtime.dispose()
+  })
+
+  it('assembles inject, locale, children, local selection, and exclusive stores per occurrence', async () => {
+    defaultMounts = 0
+    customMounts = 0
+    const runtime = await SlotTestRuntime.create()
+    installLocale(runtime)
+    const handles: CounterStore[] = []
+    registerFactory(runtime, () => {
+      const handle = counterHandle()
+      handles.push(handle)
+      return handle
+    })
+    runtime.slots.register({ name: 'renderer.factory.child', id: 'child' }, () => <small>child</small>)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => {
+      const [revision, setRevision] = useState(0)
+      return (
+        <>
+          <button type="button" onClick={() => { setRevision(value => value + 1) }}>rerender {revision}</button>
+          {renderFactorySlot('renderer.factory', { label: 'A' }, { slots: { view: CustomView } })}
+          {renderFactorySlot('renderer.factory', { label: 'B' })}
+        </>
+      )
+    })
+    const view = runtime.renderRoot()
+
+    expect(handles).toHaveLength(2)
+    expect(view.getByText('A:injected:suffix:renderer-factory:word').getAttribute('data-view')).toBe('custom')
+    expect(view.getByText('B:injected:suffix:renderer-factory:word').getAttribute('data-view')).toBe('default')
+    expect(view.getAllByText('child')).toHaveLength(2)
+    fireEvent.click(view.getByRole('button', { name: 'increment A' }))
+    expect(view.container.querySelector('[data-factory="A"] [data-count]')?.textContent).toBe('1')
+    expect(view.container.querySelector('[data-factory="B"] [data-count]')?.textContent).toBe('0')
+
+    fireEvent.click(view.getByRole('button', { name: /rerender/u }))
+    expect(customMounts).toBe(1)
+    expect(defaultMounts).toBe(1)
+    await runtime.dispose()
+  })
+
+  it('keeps the occurrence Store across StrictMode effect replay and renderer updates', async () => {
+    const runtime = await SlotTestRuntime.create()
+    const locale = installLocale(runtime)
+    registerFactory(runtime, counterHandle)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <StrictMode>{renderFactorySlot('renderer.factory', { label: 'strict' })}</StrictMode>
+    ))
+    const view = runtime.renderRoot()
+
+    fireEvent.click(view.getByRole('button', { name: 'increment strict' }))
+    expect(view.container.querySelector('[data-count]')?.textContent).toBe('1')
+    await act(async () => { locale.set({ revision: 1 }) })
+    expect(view.container.querySelector('[data-count]')?.textContent).toBe('1')
+    await runtime.dispose()
+  })
+
+  it('mints one exclusive handle per rendered Session incarnation', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 's1' })
+    const first = runtime.sessions.retainFor(runtime.ctx, sid('s1'))
+    const handles: CounterStore[] = []
+    runtime.slots.registerFactory({
+      name: 'renderer.session-store-factory',
+      scope: 'session',
+      store: () => {
+        const handle = counterHandle()
+        handles.push(handle)
+        return handle
+      },
+    }, ({ useStore, sessionId }) => <span>{sessionId}:{useStore(state => state.count)}</span>)
+    let selectReference: ((reference: SessionReference) => void) | undefined
+    await runtime.root.declare(SESSION_AREA, ({ renderFactorySlot, SessionProvider }) => {
+      const [reference, select] = useState(first)
+      selectReference = select
+      return (
+        <SessionProvider session={reference}>
+          {renderFactorySlot('renderer.session-store-factory', {})}
+        </SessionProvider>
+      )
+    })
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toBe('s1:0')
+    expect(handles).toHaveLength(1)
+
+    await runtime.sessions.add({ id: 's2' })
+    const second = runtime.sessions.retainFor(runtime.ctx, sid('s2'))
+    await act(async () => { selectReference?.(second) })
+    expect(view.container.textContent).toBe('s2:0')
+    expect(handles).toHaveLength(2)
+    await runtime.dispose()
+  })
+
+  it('binds a local Component to the scope at its render position', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 'nested' })
+    const reference = runtime.sessions.retainFor(runtime.ctx, sid('nested'))
+    runtime.slots.registerFactory({
+      name: 'renderer.scoped-local-factory',
+      scope: 'root',
+      slots: { view: { scope: 'session' } },
+    }, ({ useFactorySlot }) => {
+      const View = useFactorySlot('view', ({ sessionId: bound }) => <span>{bound}</span>)
+      return <View />
+    })
+    await runtime.root.declare(SESSION_AREA, ({ renderFactorySlot, SessionProvider }) => (
+      <SessionProvider session={reference}>
+        {renderFactorySlot('renderer.scoped-local-factory', {})}
+      </SessionProvider>
+    ))
+
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toBe('nested')
+    await runtime.dispose()
+  })
+
+  it('binds chain children with ordinary routing and stale-authority checks', async () => {
+    const runtime = await SlotTestRuntime.create()
+    let retainedRender: ChainFactoryProps['renderSlot'] | undefined
+    let retainedChain: ChainFactoryProps['renderSlotChain'] | undefined
+    const dispose = runtime.slots.registerFactory({
+      name: 'renderer.chain-factory',
+      scope: 'root',
+      children: {
+        'renderer.factory.child': { kind: 'list', scope: 'root' },
+        'renderer.factory.chain': { kind: 'chain', scope: 'root' },
+      },
+    }, ({ renderSlot, renderSlotChain }) => {
+      retainedRender = renderSlot
+      retainedChain = renderSlotChain
+      return renderSlotChain('renderer.factory.chain', { enabled: true }, { fallback: <i>none</i> })
+    })
+    runtime.slots.register({
+      name: 'renderer.factory.chain',
+      select: owner => owner.enabled ? 'selected' : null,
+    }, ({ matched }) => <span>{matched}</span>)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.chain-factory', {})}</>
+    ))
+
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toBe('selected')
+    if (retainedRender === undefined || retainedChain === undefined) throw new Error('Factory child bindings are missing')
+    const render = retainedRender as unknown as (key: string, owner: object) => ReactNode
+    const renderChain = retainedChain as unknown as (key: string, owner: object) => ReactNode
+    expect(() => render('renderer.factory.chain', {})).toThrow(/use renderSlotChain/)
+    expect(() => renderChain('renderer.factory.child', {})).toThrow(/not 'chain'/)
+    dispose()
+    expect(() => renderChain('renderer.factory.chain', { enabled: true })).toThrow(/disposed Factory/)
+    await runtime.dispose()
+  })
+
+  it('gives a local Component only its own scope standard props', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 'outer-session' })
+    const first = runtime.sessions.retainFor(runtime.ctx, sid('outer-session'))
+    const report = vi.fn()
+    let mounts = 0
+    runtime.slots.registerFactory({
+      name: 'renderer.session-factory-root-local',
+      scope: 'session',
+      slots: { view: { scope: 'root' } },
+    }, ({ report, sessionId: outerSessionId, useFactorySlot }) => {
+      useEffect(() => { mounts += 1 }, [])
+      const View = useFactorySlot('view', (props) => {
+        props.report(Object.hasOwn(props, 'sessionId'))
+        return <span>{outerSessionId}</span>
+      })
+      return <View report={report} />
+    })
+    let selectReference: ((reference: SessionReference) => void) | undefined
+    await runtime.root.declare(SESSION_AREA, ({ renderFactorySlot, SessionProvider }) => {
+      const [reference, select] = useState(first)
+      selectReference = select
+      return (
+        <SessionProvider session={reference}>
+          {renderFactorySlot('renderer.session-factory-root-local', { report })}
+        </SessionProvider>
+      )
+    })
+
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toBe('outer-session')
+    expect(report).toHaveBeenCalledWith(false)
+    expect(mounts).toBe(1)
+    await runtime.sessions.add({ id: 'second-session' })
+    const second = runtime.sessions.retainFor(runtime.ctx, sid('second-session'))
+    await act(async () => { selectReference?.(second) })
+    expect(view.container.textContent).toBe('second-session')
+    expect(mounts).toBe(2)
+    await runtime.dispose()
+  })
+
+  it('fails loud when a strict local Component has no current scope binding', async () => {
+    const runtime = await SlotTestRuntime.create()
+    runtime.slots.registerFactory({
+      name: 'renderer.scoped-local-factory',
+      scope: 'root',
+      slots: { view: { scope: 'session' } },
+    }, ({ useFactorySlot }) => {
+      const View = useFactorySlot('view', () => null)
+      return <View />
+    })
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.scoped-local-factory', {})}</>
+    ))
+
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    expect(() => runtime.renderRoot()).toThrow(/strict session local slot 'view'/)
+    spy.mockRestore()
+    await runtime.dispose()
+  })
+
+  it('renders a fallback until a definition appears and restores it after disposal', async () => {
+    const runtime = await SlotTestRuntime.create()
+    installLocale(runtime)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'A' }, { fallback: <b>missing</b> })}</>
+    ))
+    const view = runtime.renderRoot()
+    expect(view.container.textContent).toBe('missing')
+
+    let dispose = () => {}
+    await act(async () => { dispose = registerFactory(runtime, counterHandle) })
+    expect(view.container.querySelector('[data-factory="A"]')).not.toBeNull()
+    await act(async () => { dispose() })
+    expect(view.container.textContent).toBe('missing')
+
+    await act(async () => {
+      registerFactory(runtime, counterHandle, props => <strong>replacement:{props.label}</strong>)
+    })
+    expect(view.container.textContent).toBe('replacement:A')
+    await runtime.dispose()
+  })
+
+  it('contains a component crash to its occurrence', async () => {
+    const runtime = await SlotTestRuntime.create()
+    installLocale(runtime)
+    registerFactory(runtime, counterHandle)
+    const reported = vi.fn()
+    runtime.slots.onEntryError(reported)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>
+        {renderFactorySlot('renderer.factory', { label: 'bad', crash: true })}
+        {renderFactorySlot('renderer.factory', { label: 'good' })}
+      </>
+    ))
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const view = runtime.renderRoot()
+    const definition = runtime.factoryOf('renderer.factory')
+    spy.mockRestore()
+
+    expect(view.container.querySelector('[data-factory-error="renderer.factory"]')).not.toBeNull()
+    expect(view.container.querySelector('[data-factory="good"]')).not.toBeNull()
+    expect(reported).toHaveBeenCalledWith(
+      'factory:renderer.factory', definition, expect.any(Error), { abdicated: false },
+    )
+    await runtime.dispose()
+  })
+
+  it('attributes a caller-selected local Component crash to its caller', async () => {
+    const runtime = await SlotTestRuntime.create()
+    installLocale(runtime)
+    registerFactory(runtime, counterHandle)
+    const reported = vi.fn()
+    runtime.slots.onEntryError(reported)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'local' }, { slots: { view: CrashingView } })}</>
+    ))
+    const caller = runtime.slots.entries('root')[0]
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const view = runtime.renderRoot()
+    spy.mockRestore()
+
+    expect(view.container.querySelector('[data-factory-error="renderer.factory:view"]')).not.toBeNull()
+    expect(reported).toHaveBeenCalledWith(
+      'factory:renderer.factory', caller, expect.any(Error), { abdicated: false },
+    )
+    await runtime.dispose()
+  })
+
+  it('attributes a fallback local Component crash to its Factory definition and resets for its Session', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 'broken' })
+    const broken = runtime.sessions.retainFor(runtime.ctx, sid('broken'))
+    runtime.slots.registerFactory({
+      name: 'renderer.scoped-local-factory',
+      scope: 'root',
+      slots: { view: { scope: 'session' } },
+    }, ({ useFactorySlot }) => {
+      const View = useFactorySlot('view', SessionCrashingView)
+      return <View />
+    })
+    const reported = vi.fn()
+    runtime.slots.onEntryError(reported)
+    let selectReference: ((reference: SessionReference) => void) | undefined
+    await runtime.root.declare(SESSION_AREA, ({ renderFactorySlot, SessionProvider }) => {
+      const [reference, select] = useState(broken)
+      selectReference = select
+      return (
+        <SessionProvider session={reference}>
+          {renderFactorySlot('renderer.scoped-local-factory', {})}
+        </SessionProvider>
+      )
+    })
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const view = runtime.renderRoot()
+    const definition = runtime.factoryOf('renderer.scoped-local-factory')
+
+    expect(view.container.querySelector('[data-factory-error="renderer.scoped-local-factory:view"]')).not.toBeNull()
+    expect(reported).toHaveBeenCalledWith(
+      'factory:renderer.scoped-local-factory', definition, expect.any(Error),
+      { abdicated: false },
+    )
+    await runtime.sessions.add({ id: 'healthy' })
+    const healthy = runtime.sessions.retainFor(runtime.ctx, sid('healthy'))
+    await act(async () => { selectReference?.(healthy) })
+    expect(view.container.textContent).toBe('healthy')
+    spy.mockRestore()
+    await runtime.dispose()
+  })
+
+  it('preserves caller ownership when a selected local Component renders a nested Factory', async () => {
+    const runtime = await SlotTestRuntime.create()
+    installLocale(runtime)
+    registerFactory(runtime, counterHandle)
+    runtime.slots.registerFactory({
+      name: 'renderer.nested-factory', scope: 'root', slots: { view: { scope: 'root' } },
+    }, NestedFactoryBody)
+    const reported = vi.fn()
+    runtime.slots.onEntryError(reported)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'nested' }, { slots: { view: NestedFactoryView } })}</>
+    ))
+    const caller = runtime.slots.entries('root')[0]
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    runtime.renderRoot()
+    spy.mockRestore()
+
+    expect(reported).toHaveBeenCalledWith(
+      'factory:renderer.nested-factory', caller, expect.any(Error),
+      { abdicated: false },
+    )
+    await runtime.dispose()
+  })
+
+  it('reports stale authorization failures while their Factory definition remains live', async () => {
+    const runtime = await SlotTestRuntime.create()
+    installLocale(runtime)
+    registerFactory(runtime, counterHandle, () => {
+      throw new StaleAuthorizationError('definition unloaded during render')
+    })
+    const reported = vi.fn()
+    runtime.slots.onEntryError(reported)
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'stale' })}</>
+    ))
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const view = runtime.renderRoot()
+    spy.mockRestore()
+
+    expect(view.container.querySelector('[data-factory-error="renderer.factory"]')).not.toBeNull()
+    expect(reported).toHaveBeenCalledWith(
+      'factory:renderer.factory', runtime.factoryOf('renderer.factory'), expect.any(StaleAuthorizationError),
+      { abdicated: false },
+    )
+    await runtime.dispose()
+  })
+
+  it('retries a failed session Factory after the Session incarnation changes', async () => {
+    const runtime = await SlotTestRuntime.create()
+    await runtime.sessions.add({ id: 'broken' })
+    const broken = runtime.sessions.retainFor(runtime.ctx, sid('broken'))
+    runtime.slots.registerFactory({
+      name: 'renderer.session-error-factory', scope: 'session',
+    }, ({ crashIn, sessionId }) => {
+      if (sessionId === crashIn) throw new Error(`crash:${sessionId}`)
+      return <span>{sessionId}</span>
+    })
+    let selectReference: ((reference: SessionReference) => void) | undefined
+    await runtime.root.declare(SESSION_AREA, ({ renderFactorySlot, SessionProvider }) => {
+      const [reference, select] = useState(broken)
+      selectReference = select
+      return (
+        <SessionProvider session={reference}>
+          {renderFactorySlot('renderer.session-error-factory', { crashIn: 'broken' })}
+        </SessionProvider>
+      )
+    })
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const view = runtime.renderRoot()
+    expect(view.container.querySelector('[data-factory-error="renderer.session-error-factory"]')).not.toBeNull()
+
+    await runtime.sessions.add({ id: 'healthy' })
+    const healthy = runtime.sessions.retainFor(runtime.ctx, sid('healthy'))
+    await act(async () => { selectReference?.(healthy) })
+    expect(view.container.textContent).toBe('healthy')
+    spy.mockRestore()
+    await runtime.dispose()
+  })
+
+  it('resets a failed boundary when the render position selects another Factory', async () => {
+    const runtime = await SlotTestRuntime.create()
+    runtime.slots.registerFactory({ name: 'renderer.error-factory', scope: 'root' }, () => {
+      throw new Error('broken factory')
+    })
+    runtime.slots.registerFactory(
+      { name: 'renderer.healthy-factory', scope: 'root' },
+      () => <span>healthy factory</span>,
+    )
+    await runtime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => {
+      const [name, setName] = useState<'renderer.error-factory' | 'renderer.healthy-factory'>('renderer.error-factory')
+      return (
+        <>
+          <button type="button" onClick={() => { setName('renderer.healthy-factory') }}>switch factory</button>
+          {renderFactorySlot(name, {})}
+        </>
+      )
+    })
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const view = runtime.renderRoot()
+    expect(view.container.querySelector('[data-factory-error="renderer.error-factory"]')).not.toBeNull()
+
+    fireEvent.click(view.getByRole('button', { name: 'switch factory' }))
+    expect(view.container.textContent).toContain('healthy factory')
+    spy.mockRestore()
+    await runtime.dispose()
+  })
+
+  it('rejects undeclared local slots, recursive rendering, duplicate props, and stale child authority', async () => {
+    const unknownRuntime = await SlotTestRuntime.create()
+    installLocale(unknownRuntime)
+    registerFactory(unknownRuntime, counterHandle)
+    await unknownRuntime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'A' }, {
+        slots: { unknown: DefaultView } as never,
+      })}</>
+    ))
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const unknownView = unknownRuntime.renderRoot()
+    expect(unknownView.container.querySelector('[data-slot-error="root"]')).not.toBeNull()
+    expect(spy.mock.calls.flat().some(value => String(value).includes("local slot 'unknown' is not declared"))).toBe(true)
+    spy.mockRestore()
+    await unknownRuntime.dispose()
+
+    const recursiveRuntime = await SlotTestRuntime.create()
+    recursiveRuntime.slots.registerFactory({
+      name: 'renderer.recursive-factory', scope: 'root',
+    }, ({ renderFactorySlot }: FactoryComponentPropsOf<'renderer.recursive-factory'>) => (
+      <>{renderFactorySlot('renderer.recursive-factory', {})}</>
+    ))
+    await recursiveRuntime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.recursive-factory', {})}</>
+    ))
+    const recursiveSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const recursiveView = recursiveRuntime.renderRoot()
+    expect(recursiveView.container.querySelector('[data-factory-error="renderer.recursive-factory"]')).not.toBeNull()
+    expect(recursiveSpy.mock.calls.flat().some(value => String(value).includes('recursive render of factory'))).toBe(true)
+    recursiveSpy.mockRestore()
+    await recursiveRuntime.dispose()
+
+    const staleRuntime = await SlotTestRuntime.create()
+    installLocale(staleRuntime)
+    let retainedRender: FactoryProps['renderSlot'] | undefined
+    const dispose = registerFactory(staleRuntime, counterHandle, (props) => {
+      retainedRender = props.renderSlot
+      return null
+    })
+    await staleRuntime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'A' })}</>
+    ))
+    staleRuntime.renderRoot()
+    dispose()
+    expect(() => retainedRender?.('renderer.factory.child', {})).toThrow(/disposed Factory/)
+    await staleRuntime.dispose()
+
+    const collisionRuntime = await SlotTestRuntime.create()
+    installLocale(collisionRuntime)
+    registerFactory(collisionRuntime, counterHandle)
+    await collisionRuntime.root.declare({}, ({ renderFactorySlot }: PropsRuntime<'root'> & PropsRenderFactories) => (
+      <>{renderFactorySlot('renderer.factory', { label: 'A', actions: 'caller' } as never)}</>
+    ))
+    const collisionSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    expect(() => collisionRuntime.renderRoot()).toThrow(/duplicate prop 'actions'/)
+    collisionSpy.mockRestore()
+    await collisionRuntime.dispose()
+  })
+})

+ 145 - 1
packages/client/ui-renderer/tests/registry.client.spec.ts

@@ -8,6 +8,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import type { FC } from 'react'
+import { StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ScopedStandardSourceBinding, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotRegistry } from '../src/client/registry.ts'
 
@@ -30,6 +31,7 @@ const C: FC<object> = () => null
  */
 interface ErasedService {
   register(options: object, component: unknown): () => void
+  registerFactory(options: object, component: unknown): () => void
   inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
   install(renderer: object): void
   renderSlot(key: string, owner: object): unknown
@@ -60,9 +62,14 @@ interface FakeInstance {
 }
 
 /** Fake store handle factory (create-count and clearPersisted observable). */
-function fakeHandle() {
+function fakeHandle(persist?: string) {
   const created: FakeInstance[] = []
   const handle = {
+    spec: {
+      init: () => undefined,
+      actions: {},
+      ...(persist === undefined ? {} : { persist }),
+    },
     create: vi.fn((_scopeKey?: string): FakeInstance => {
       const instance: FakeInstance = {
         getSnapshot: () => undefined, subscribe: () => () => undefined,
@@ -103,6 +110,141 @@ function scopedBinding(_ctx: Context, key: string) {
   return { binding, fiber: ctx.fiber }
 }
 
+describe('Factory definition ledger', () => {
+  it('publishes one definition, rejects duplicates, and collapses its children on disposal', async () => {
+    const bench = await boot()
+    const host = captureHost(bench)
+    const changed = vi.fn()
+    host.subscribeFactory('test.factory', changed)
+    const dispose = bench.erased.registerFactory({
+      name: 'test.factory',
+      scope: 'root',
+      children: { 't.host': { kind: 'single', scope: 'root' } },
+    }, C)
+    const definition = host.factoryOf('test.factory')
+    expect(definition).toMatchObject({ name: 'test.factory', scope: 'root' })
+    expect(bench.svc.spec('t.host')).toEqual({ kind: 'single', scope: 'root' })
+    const disposeChild = bench.erased.register({ name: 't.host' }, C)
+    const child = host.entriesOf('t.host')[0]
+    expect(bench.svc.snapshot('factory:test.factory')).toMatchObject([{
+      type: 'factory',
+      name: 'test.factory',
+      scope: 'root',
+      children: [{ type: 'slot', name: 't.host' }],
+    }])
+    expect(bench.svc.snapshot().some(node => node.type === 'slot' && node.name === 't.host')).toBe(false)
+    expect(bench.svc.snapshot('factory:missing')).toEqual([])
+    const releaseDefinition = host.retainFactoryOccurrence(definition!, {})
+    releaseDefinition()
+    releaseDefinition()
+
+    expect(() => bench.erased.registerFactory({ name: 'test.factory', scope: 'root' }, C))
+      .toThrow(/already has a definition/)
+    dispose()
+    dispose()
+    await Promise.resolve()
+
+    expect(changed).toHaveBeenCalledTimes(2)
+    expect(host.factoryOf('test.factory')).toBeUndefined()
+    expect(bench.svc.spec('t.host')).toBeUndefined()
+    expect(host.isLive(child as never)).toBe(false)
+    disposeChild()
+    expect(() => bench.erased.registerFactory({ name: 'test.factory', scope: 'root' }, C)).not.toThrow()
+  })
+
+  it('keeps committed exclusive stores across effect replay and releases scoped instances', async () => {
+    const bench = await boot()
+    const host = captureHost(bench)
+    const handles: ReturnType<typeof fakeHandle>[] = []
+    const factory = vi.fn(() => {
+      const handle = fakeHandle()
+      handles.push(handle)
+      return handle.handle
+    })
+    const dispose = bench.erased.registerFactory({
+      name: 'test.scoped-factory', scope: 'session-maybe', store: factory,
+    }, C)
+    const definition = host.factoryOf('test.scoped-factory')!
+    const scope = scopedBinding(bench.ctx, 's1')
+    const firstOccurrence = {}
+    const secondOccurrence = {}
+
+    expect(() => host.factoryStoreOf(definition, undefined, firstOccurrence))
+      .toThrow('session-maybe factory store resolution requires a session id')
+    const first = host.factoryStoreOf(definition, scope.binding, firstOccurrence)
+    const repeated = host.factoryStoreOf(definition, scope.binding, firstOccurrence)
+    const second = host.factoryStoreOf(definition, scope.binding, secondOccurrence)
+    const releaseFirst = host.retainFactoryOccurrence(definition, firstOccurrence)
+    const releaseFirstAgain = host.retainFactoryOccurrence(definition, firstOccurrence)
+    const releaseSecond = host.retainFactoryOccurrence(definition, secondOccurrence)
+
+    expect(repeated).toBe(first)
+    expect(second).not.toBe(first)
+    expect(factory).toHaveBeenCalledTimes(2)
+    expect(handles[0]?.created).toHaveLength(1)
+    expect(handles[0]?.handle.create).toHaveBeenCalledWith('s1')
+    expect(handles[1]?.handle.create).toHaveBeenCalledWith('s1')
+
+    releaseFirst()
+    releaseFirst()
+    releaseFirstAgain()
+    const releaseReplay = host.retainFactoryOccurrence(definition, firstOccurrence)
+    expect(host.factoryStoreOf(definition, scope.binding, firstOccurrence)).toBe(first)
+    expect(factory).toHaveBeenCalledTimes(2)
+    releaseReplay()
+
+    await scope.fiber.dispose()
+    expect(handles[0]?.created[0]?.clearPersisted).not.toHaveBeenCalled()
+    expect(handles[1]?.created[0]?.clearPersisted).not.toHaveBeenCalled()
+
+    const replacement = scopedBinding(bench.ctx, 's1')
+    const recreated = host.factoryStoreOf(definition, replacement.binding, secondOccurrence)
+    expect(recreated).not.toBe(second)
+    await replacement.fiber.dispose()
+    expect(handles[1]?.created[1]?.clearPersisted).not.toHaveBeenCalled()
+    releaseSecond()
+
+    dispose()
+    expect(() => host.factoryStoreOf(definition, undefined, {})).toThrow(StaleAuthorizationError)
+    const staleRelease = host.retainFactoryOccurrence(definition, {})
+    expect(() => { staleRelease() }).not.toThrow()
+  })
+
+  it('rejects persistence on an exclusive Factory Store', async () => {
+    const bench = await boot()
+    const host = captureHost(bench)
+    const persistent = fakeHandle('factory.persist')
+    bench.erased.registerFactory({
+      name: 'test.persistent-factory', scope: 'root', store: () => persistent.handle,
+    }, C)
+    const definition = host.factoryOf('test.persistent-factory')!
+
+    expect(() => host.factoryStoreOf(definition, undefined, {}))
+      .toThrow(/exclusive store.*cannot declare persistence/)
+  })
+
+  it('shares a Factory store handle on the ordinary optional-scope axis', async () => {
+    const bench = await boot()
+    const host = captureHost(bench)
+    const { handle } = fakeHandle()
+    bench.erased.registerFactory({ name: 'test.shared-factory', scope: 'session-maybe', store: handle }, C)
+    const definition = host.factoryOf('test.shared-factory')!
+    const scope = scopedBinding(bench.ctx, 's1')
+
+    expect(() => host.factoryStoreOf(definition, undefined, {}))
+      .toThrow('session-maybe store resolution requires a session id')
+    const first = host.factoryStoreOf(definition, scope.binding, {})
+    const second = host.factoryStoreOf(definition, scope.binding, {})
+    const scoped = host.factoryStoreOf(definition, scope.binding, {})
+
+    expect(first).toBe(second)
+    expect(scoped).toBe(first)
+    expect(handle.create).toHaveBeenCalledOnce()
+    expect(handle.create).toHaveBeenCalledWith('s1')
+    await scope.fiber.dispose()
+  })
+})
+
 describe("built-in 'root'", () => {
   it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
     const bench = await boot()
@@ -662,6 +804,8 @@ describe('store instance axis', () => {
     const { handle, created } = fakeHandle()
     bench.erased.register({ name: 't.maybe', store: handle }, C)
     const [entry] = host.entriesOf('t.maybe')
+    expect(() => host.storeOf(entry as never, undefined))
+      .toThrow('session-maybe store resolution requires a session id')
     const scope = scopedBinding(bench.ctx, 's1')
     const before = host.storeOf(entry as never, scope.binding)
 

+ 7 - 0
packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx

@@ -49,9 +49,16 @@ function hostOver(core: SlotCore): SlotRendererHost {
     entriesOf: key => core.entries(key),
     entriesOfSlot: key => core.entriesOfSlot(key),
     reportEntryError: (key, entry, error, info) => { core.reportEntryError(key, entry, error, info) },
+    reportFactoryError: (name, definition, error) => { core.reportFactoryError(name, definition, error) },
     specOf: key => core.specDynamic(key),
     isLive: entry => core.isLive(entry),
     storeOf: () => undefined,
+    factoryStoreOf: () => undefined,
+    retainFactoryOccurrence: () => () => {},
+    subscribeFactory: (name, fn) => core.subscribeFactory(name, fn),
+    getFactoryVersion: name => core.factoryVersion(name),
+    factoryOf: name => core.factory(name),
+    isFactoryLive: definition => core.isFactoryLive(definition),
     root: bindingSource,
     scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
     scope: () => sessionAdapter,

+ 7 - 0
packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx

@@ -160,6 +160,7 @@ function makeHost() {
       abdicated.add(entry)
       bump(key)
     },
+    reportFactoryError: () => {},
     specOf: key => specs.get(key),
     isLive: entry => live.has(entry),
     storeOf: (entry, scopeBinding) => {
@@ -180,6 +181,12 @@ function makeHost() {
       }
       return instance
     },
+    factoryStoreOf: () => undefined,
+    retainFactoryOccurrence: () => () => {},
+    subscribeFactory: () => () => {},
+    getFactoryVersion: () => 0,
+    factoryOf: () => undefined,
+    isFactoryLive: () => false,
     root,
     scopeRevision,
     scope: () => activeScopeAdapter,

+ 7 - 0
packages/client/ui-renderer/tests/session-provider.client.spec.tsx

@@ -74,8 +74,15 @@ function makeHost(body: SessionBody, options: { installRenderArea?: boolean; opt
     entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
     entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
     reportEntryError: () => {},
+    reportFactoryError: () => {},
     specOf: key => key === 'k.session' ? { kind: 'single', scope } : undefined,
     isLive: () => true, storeOf: () => undefined,
+    factoryStoreOf: () => undefined,
+    retainFactoryOccurrence: () => () => {},
+    subscribeFactory: () => () => {},
+    getFactoryVersion: () => 0,
+    factoryOf: () => undefined,
+    isFactoryLive: () => false,
     root, scopeRevision: observable(0), scope: () => sessionAdapter,
   }
   return {

+ 7 - 0
packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx

@@ -54,9 +54,16 @@ function makeHost() {
     // the raw view and crash reports never fire.
     entriesOfSlot: key => entries.get(key) ?? [],
     reportEntryError: () => {},
+    reportFactoryError: () => {},
     specOf: () => ({ kind: 'single', scope: 'root' }),
     isLive: entry => live.has(entry),
     storeOf: () => undefined,
+    factoryStoreOf: () => undefined,
+    retainFactoryOccurrence: () => () => {},
+    subscribeFactory: () => () => {},
+    getFactoryVersion: () => 0,
+    factoryOf: () => undefined,
+    isFactoryLive: () => false,
     root: bindingSource,
     scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
     scope: () => sessionAdapter,

+ 7 - 0
packages/client/ui-renderer/tests/use-projection.client.spec.tsx

@@ -93,9 +93,16 @@ function makeHost() {
     // the raw view and crash reports never fire.
     entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
     reportEntryError: () => {},
+    reportFactoryError: () => {},
     specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
     isLive: () => true,
     storeOf: () => undefined,
+    factoryStoreOf: () => undefined,
+    retainFactoryOccurrence: () => () => {},
+    subscribeFactory: () => () => {},
+    getFactoryVersion: () => 0,
+    factoryOf: () => undefined,
+    isFactoryLive: () => false,
     root,
     scopeRevision: observable(0),
     scope: () => sessionAdapter,

+ 2 - 2
packages/client/ui-slots/README.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 packages/client/ui-slots/README.md
-README.md: d38fe059693f7b15d060b0a52c97edd0b5e050c9
-README.zh.md: 968470edd20d75b7c6bf8f7c8fca898764af757e
+README.md: d82b303d5d6efaebbccaab00d06d874d79165fb1
+README.zh.md: 2a9d0364eda148ddf8db8cc61daf1747381c4875

+ 14 - 7
packages/client/ui-slots/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Slot registry pure core for the dsh web client: SlotMap declaration merging, the single register composition API, four-share props types, store seats, and the renderer install contract."
+description: "Slot registry pure core for the dsh web client: ordinary extension slots, reusable Component Factories, derived props types, store seats, and the renderer install contract."
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-slots` lets web client plugins define and compose typed UI regions. Callers can add components, declare nested regions, attach scoped state, and supply business props through one compile-time-checked API. It supports single, ordered-list, keyed, and self-selecting chain composition, and reports conflicting compositions during plugin loading. Choose it for framework-neutral slot composition; pair it with `ui-renderer` when the client needs React rendering.
+`dsh-client-ui-slots` lets web client plugins define and compose typed UI regions. Ordinary Slots provide parent-owned extension positions; Component Factories provide reusable assemblies with caller-selected local Components. Both APIs derive scoped state, injection, locale, and child-render props from declaration-merged types and report conflicting definitions during plugin loading. Pair this React-free package with `ui-renderer` when the client needs rendering.
 
 ## Table of Contents
 
@@ -27,9 +27,15 @@ English | [中文](README.zh.md)
 
 Compose UI through this package whenever you write a client plugin: register a component into a slot your parent declared, or declare child slots your component renders. The four kinds cover the composition shapes — `single` (one occupant), `list` (ordered entries), `keyed` (dispatch by a key), and `chain` (entries elect themselves).
 
-### The four props shares
+### Reusable Component Factories
 
-Every registered component receives props composed from four shares: the runtime share (`owner` from the parent's renderSlot call site, plus the session standard kit and global seat), the child-render share (`renderSlot` statically narrowed to the declared children keys), the store share (the declared handle's selector hook and draft-stripped actions), and the business share (inferred from the `inject` factory's return). Components reference `ComposedProps`; they never re-type a share locally.
+Use a Component Factory when one package defines an assembly that unrelated parents render independently. Declare its complete type in `SlotFactoryMap`, install the definition with `ctx.slots.registerFactory()`, render occurrences through the injected `renderFactorySlot()`, and select each declared local Component through the call's `slots` option. The definition reads that choice through `useFactorySlot(name, fallback)`.
+
+Factory `children` remain ordinary global Slots and must match `SlotMap`, while local `slots` select one Component per occurrence. An occurrence inherits its render-position scope; `renderFactorySlot()` does not accept a Session identity. Shared Store handles use ordinary scope resolution. A Store factory stays lazy until an occurrence first materializes, then creates one handle for that render position and rejects a persistent Store spec whose key would collide across occurrences.
+
+### The five framework props shares
+
+Every registered component receives props composed from five framework shares: the runtime share (`owner` from the parent's render call site, plus the session standard kit and global seat), the child-render share (`renderSlot` statically narrowed to declared children), the Factory-render share (`renderFactorySlot`), the store share (the declared handle's selector hook and draft-stripped actions), and the business share (inferred from `inject`). Components reference the derived props aliases; they never re-type a share locally.
 
 ### Store seats
 
@@ -47,15 +53,15 @@ Declaring a slot is claiming it: the registering entry becomes the only entry al
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The design is one table: declaration = render authorization = runtime spec. `SlotMap` is declared empty here and merged by consumers via `declare module` augmentation, exactly like the standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`), which the runtime package merges with real members.
+The ordinary Slot design is one table: declaration = render authorization = runtime spec. `SlotMap` is declared empty here and merged by consumers via `declare module` augmentation, exactly like `SlotFactoryMap` and the standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`). Factory definitions use a separate single-definition ledger because their occurrences have no parent declaration.
 
 ### Registration and routing
 
-`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation. `ChainSelect` selectors run in ascending `priority` order (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`). Each key carries a declaration epoch that advances only on declaration and collapse; `ui-renderer` uses it for `ctx.slots.inject`, independently from ordinary entry versions.
+`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation. `ChainSelect` selectors run in ascending `priority` order (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`). Each key carries a declaration epoch that advances only on declaration and collapse; `ui-renderer` uses it for `ctx.slots.inject`, independently from ordinary entry versions. Live inspection uses strict `type: 'slot' | 'factory'` nodes and nests Factory-owned child Slots under their definition.
 
 ### The renderer contract
 
-`renderer.ts` carries the installation contract (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; ui-renderer owns both the implementation and its plugin-lifecycle installation. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding belongs to the render machinery.
+`renderer.ts` carries the installation contract (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; ui-renderer owns both the implementation and its plugin-lifecycle installation. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding belongs to the render machinery. Factory crashes use the ordinary supervision channel, and an idempotent effect retains per-position Store handles only after commit.
 
 </details>
 
@@ -68,6 +74,7 @@ These pages cover the engine, the renderer, and the composition model.
 
 - [ui-renderer](../ui-renderer/README.md) — the React slot renderer implementing this package's install contract.
 - [Slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) — the definitive composition model.
+- [Component Factories](../../../.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md) — reusable definitions, local Component selection, and occurrence lifetimes.
 - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — the loading chain and object layer this registry plugs into.
 
 -----

+ 14 - 7
packages/client/ui-slots/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "dsh Web 客户端的 slot 注册表纯核心:SlotMap 声明合并、单一 register 组合 API、四 share props 类型、store 席位与渲染器安装约定。"
+description: "dsh Web 客户端的 slot 注册表纯核心:普通扩展 slots、可复用 Component Factory、推导 props 类型、store 席位与渲染器安装约定。"
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-`dsh-client-ui-slots` 让 Web 客户端插件定义并组合带类型检查的 UI 区域。调用方可以通过一个在编译期检查的 API 添加组件、声明嵌套区域、附加作用域状态并提供业务 props。它支持单项、有序列表、键控和自行选择的 chain 组合,并会在插件加载期间报告冲突组合。需要与框架无关的 slot 组合时选择本包;客户端需要 React 渲染时与 `ui-renderer` 配合使用。
+`dsh-client-ui-slots` 让 Web 客户端插件定义并组合带类型检查的 UI 区域。普通 Slots 提供 parent-owned 扩展位置;Component Factory 提供带调用方所选局部 Component 的可复用装配。两套 API 都从声明合并类型推导 scoped state、injection、locale 与 child-render props,并在插件加载期间报告冲突 definition。客户端需要渲染时,将这个不依赖 React 的包与 `ui-renderer` 配合使用。
 
 ## 目录
 
@@ -27,9 +27,15 @@ kind: "package-library"
 
 编写客户端插件时都通过本包组合 UI:把组件注册进父级已声明的 slot,或声明组件将要渲染的子 slot。四种 kind 覆盖组合形态——`single`(单个占位者)、`list`(有序条目)、`keyed`(按键分派)与 `chain`(条目自行提名)。
 
-### 四个 props share
+### 可复用 Component Factory
 
-每个已注册组件都会收到由四个 share 组合而成的 props:运行时 share(父级 renderSlot 调用点的 `owner`,加上会话标准工具包与全局席位)、child render share(静态缩窄到已声明 children key 的 `renderSlot`)、store share(已声明句柄的 selector 钩子与移除 draft 的 actions),以及业务 share(从 `inject` factory 返回值推断)。组件引用 `ComposedProps`;它们绝不在本地重新定义任何 share 的类型。
+当一个包定义装配、而互不相关的 parents 需要独立渲染它时,使用 Component Factory。在 `SlotFactoryMap` 中声明完整类型,通过 `ctx.slots.registerFactory()` 安装 definition,通过注入的 `renderFactorySlot()` 渲染 occurrences,并通过调用的 `slots` 选项选择每个已声明的局部 Component。definition 通过 `useFactorySlot(name, fallback)` 读取该选择。
+
+Factory `children` 仍是普通全局 Slots 且必须与 `SlotMap` 匹配,而局部 `slots` 为每个 occurrence 选择一个 Component。occurrence 继承其渲染位置的 scope;`renderFactorySlot()` 不接受 Session identity。共享 Store handle 使用普通 scope 解析。Store factory 保持 lazy,直到 occurrence 首次物化时才为该渲染位置创建一个 handle;若持久化 Store spec 会让 persistence key 在 occurrences 之间冲突,renderer 会拒绝它。
+
+### 五个框架 props share
+
+每个已注册组件都会收到由五个框架 share 组合而成的 props:运行时 share(父级 render 调用点的 `owner`,加上会话标准工具包与全局席位)、child render share(静态缩窄到已声明 children 的 `renderSlot`)、Factory render share(`renderFactorySlot`)、store share(已声明 handle 的 selector 钩子与移除 draft 的 actions),以及业务 share(从 `inject` 推导)。组件引用推导出的 props 别名;它们绝不在本地重新定义任何 share 的类型。
 
 ### Store 席位
 
@@ -47,15 +53,15 @@ register 调用可以用 `store: defineStore(...)` 声明 store 席位:`init`
 <details>
 <summary>实现细节——点击展开</summary>
 
-设计就是一张表:声明 = 渲染授权 = 运行时规范。`SlotMap` 在这里声明为空,由消费方通过 `declare module` 增补合并,标准工具包接口(`SessionStandardProps`、`GlobalStandardProps`)也是如此,由运行时包以真实成员合并
+普通 Slot 设计就是一张表:声明 = 渲染授权 = 运行时规范。`SlotMap` 在这里声明为空,由消费方通过 `declare module` 增补合并;`SlotFactoryMap` 和标准工具包接口(`SessionStandardProps`、`GlobalStandardProps`)也采用同一方式。Factory definition 使用独立的单 definition ledger,因为其 occurrences 没有 parent 声明
 
 ### 注册与路由
 
-`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证。`ChainSelect` selector 按升序 `priority` 运行(相同值按注册顺序);第一个非 null 返回值选中其条目,并成为组件的 `matched` prop;全部返回 null 时使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。每个 key 都携带一个 declaration epoch,它只在声明与移除时递增;`ui-renderer` 将其用于 `ctx.slots.inject`,且与普通条目版本相互独立。
+`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证。`ChainSelect` selector 按升序 `priority` 运行(相同值按注册顺序);第一个非 null 返回值选中其条目,并成为组件的 `matched` prop;全部返回 null 时使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。每个 key 都携带一个 declaration epoch,它只在声明与移除时递增;`ui-renderer` 将其用于 `ctx.slots.inject`,且与普通条目版本相互独立。实时检查使用严格的 `type: 'slot' | 'factory'` 节点,并把 Factory-owned child Slots 嵌套在其 definition 下。
 
 ### 渲染器约定
 
-`renderer.ts` 携带安装约定(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;ui-renderer 负责实现,并在其插件生命周期中完成安装。引擎产物与渲染器宿主约定携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React 钩子——钩子绑定属于渲染机制。
+`renderer.ts` 携带安装约定(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;ui-renderer 负责实现,并在其插件生命周期中完成安装。引擎产物与渲染器宿主约定携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React 钩子——钩子绑定属于渲染机制。Factory 崩溃使用普通监督通道,幂等 effect 仅在 commit 后保留逐渲染位置 Store handle。
 
 </details>
 
@@ -68,6 +74,7 @@ register 调用可以用 `store: defineStore(...)` 声明 store 席位:`init`
 
 - [ui-renderer](../ui-renderer/README.zh.md)——实现本包安装约定的 React slot 渲染器。
 - [slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)——权威组合模型。
+- [Component Factory](../../../.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md)——可复用 definitions、局部 Component 选择与 occurrence 生命周期。
 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——本注册表接入的加载链与对象层。
 
 -----

+ 1 - 1
packages/client/ui-slots/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-slots",
-  "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam",
+  "description": "Slot registry pure core: typed ordinary Slots and reusable Component Factories, derived props, Store seats, and renderer installation",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"

+ 467 - 34
packages/client/ui-slots/src/index.ts

@@ -25,6 +25,9 @@ export * from './renderer.ts'
 /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
 export interface SlotMap {}
 
+/** Reusable Component Factory contract table, extended through declaration merging. */
+export interface SlotFactoryMap {}
+
 /**
  * Locale namespace table. Dictionary owners extend via declaration merging
  * (exactly like {@link SlotMap}, and declared in this entry module for the
@@ -107,8 +110,8 @@ export interface SlotScopeTargetMap {}
  * One SlotMap entry: kind/scope axes plus the optional owner-supplied props
  * share (`owner` is what the parent passes at its renderSlot call site; the
  * framework standard kit and the registrant's injected share never enter this
- * table — full component props compose at the component as the four-share
- * intersection, see {@link ComposedProps}).
+ * table — full component props compose at the component from the framework
+ * shares, see {@link ComposedProps}).
  */
 export interface SlotEntryDef {
   kind: SlotKind
@@ -134,6 +137,23 @@ export interface SlotEntryDef {
   inject?: object
 }
 
+/** One caller-selected Component position inside a reusable Factory. */
+export interface FactoryLocalSlotDef {
+  scope: SlotScope
+  props?: object
+}
+
+/** Complete static definition of one reusable Component Factory. */
+export interface SlotFactoryDef {
+  scope: SlotScope
+  props?: object
+  children?: ChildrenDecl
+  store?: StoreDecl
+  inject?: object
+  locale?: keyof LocaleNamespaceMap & string
+  slots?: Record<string, FactoryLocalSlotDef>
+}
+
 /**
  * Runtime dispatch spec for one slot, recorded from a register call's
  * `children` value. The literal is compile-time checked against the SlotMap
@@ -197,10 +217,10 @@ export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
 export interface SessionStandardProps {}
 
 /**
- * Framework standard kit delivered to current-session-optional slots. Its
- * hooks stay callable while no session is selected and return `undefined`
- * until one becomes current; `ui-session` and domain UI adapters merge the
- * concrete members.
+ * Framework standard kit delivered to session-optional slots. Its hooks stay
+ * callable while no session is selected and return `undefined` until one
+ * becomes current; `ui-session` and domain UI adapters merge the concrete
+ * members.
  */
 export interface SessionMaybeStandardProps {}
 
@@ -218,6 +238,12 @@ export interface GlobalStandardProps {}
  */
 export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? S : string
 
+/** Standard props selected by one declared scope. */
+export type ScopeStandardProps<S extends SlotScope> =
+  (S extends 'session' ? SessionStandardProps
+    : S extends 'session-maybe' ? SessionMaybeStandardProps
+      : object) & GlobalStandardProps
+
 /**
  * Runtime props share for a slot key: owner share (parent's renderSlot call
  * site) + session standard kit (session scope only) + the global seat.
@@ -229,10 +255,7 @@ export type PropsRuntime<
   OwnerOf<K> &
   KeyPropsOf<K, EntryKey> &
   SlotInjectFace<SlotInjectOf<K>> &
-  (ScopeOf<K> extends 'session' ? SessionStandardProps
-    : ScopeOf<K> extends 'session-maybe' ? SessionMaybeStandardProps
-      : object) &
-  GlobalStandardProps
+  ScopeStandardProps<ScopeOf<K>>
 
 /** renderSlot dispatch options: keyed dispatch key, list filtering, and empty fallback. */
 export interface RenderOpts<EntryKey extends string = string> {
@@ -380,6 +403,98 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
  */
 export type SlotComponent<P> = (props: P) => ReactNode
 
+type FactoryDefOf<F extends keyof SlotFactoryMap & string> = SlotFactoryMap[F] & SlotFactoryDef
+type FactoryInputPropsOf<F extends keyof SlotFactoryMap & string> =
+  SlotFactoryMap[F] extends { props: infer P extends object } ? P : object
+type FactoryChildrenOf<F extends keyof SlotFactoryMap & string> =
+  SlotFactoryMap[F] extends { children: infer D extends Record<string, unknown> } ? D : Record<never, never>
+type FactoryStoreOf<F extends keyof SlotFactoryMap & string> =
+  SlotFactoryMap[F] extends { store: infer H extends StoreDecl } ? HandleOf<H> : undefined
+type FactoryInjectOf<F extends keyof SlotFactoryMap & string> =
+  SlotFactoryMap[F] extends { inject: infer I extends object } ? I : object
+type FactoryLocaleOf<F extends keyof SlotFactoryMap & string> =
+  SlotFactoryMap[F] extends { locale: infer N extends keyof LocaleNamespaceMap & string } ? N : undefined
+type FactoryLocalSlotsOf<F extends keyof SlotFactoryMap & string> =
+  SlotFactoryMap[F] extends { slots: infer S extends Record<string, FactoryLocalSlotDef> }
+    ? S
+    : Record<never, never>
+type FactoryLocalNameOf<F extends keyof SlotFactoryMap & string> = keyof FactoryLocalSlotsOf<F> & string
+type FactoryLocalDefOf<
+  F extends keyof SlotFactoryMap & string,
+  N extends FactoryLocalNameOf<F>,
+> = FactoryLocalSlotsOf<F>[N] & FactoryLocalSlotDef
+type FactoryLocalInputPropsOf<
+  F extends keyof SlotFactoryMap & string,
+  N extends FactoryLocalNameOf<F>,
+> = FactoryLocalDefOf<F, N> extends { props: infer P extends object } ? P : object
+
+type FactoryRenderPropsOf<F extends keyof SlotFactoryMap & string> =
+  [keyof FactoryChildrenOf<F> & keyof SlotMap & string] extends [never]
+    ? object
+    : PropsRenderSlots<keyof FactoryChildrenOf<F> & keyof SlotMap & string>
+
+/** Framework-derived props shared by a Factory definition and its local Components. */
+export type FactoryRegistrationPropsOf<F extends keyof SlotFactoryMap & string> =
+  FactoryRenderPropsOf<F>
+  & PropsStore<FactoryStoreOf<F>>
+  & InjectFace<FactoryInjectOf<F>>
+  & PropsLocale<FactoryLocaleOf<F>>
+  & PropsRenderFactories
+
+/** Complete props received by a registered Factory Component. */
+export type FactoryComponentPropsOf<F extends keyof SlotFactoryMap & string> =
+  FactoryInputPropsOf<F>
+  & FactoryRegistrationPropsOf<F>
+  & ScopeStandardProps<FactoryDefOf<F>['scope']>
+  & { useFactorySlot: UseFactorySlot<F> }
+
+/** Complete props received by one caller-selected or fallback local Component. */
+export type FactoryLocalComponentPropsOf<
+  F extends keyof SlotFactoryMap & string,
+  N extends FactoryLocalNameOf<F>,
+> = FactoryLocalInputPropsOf<F, N>
+  & FactoryRegistrationPropsOf<F>
+  & ScopeStandardProps<FactoryLocalDefOf<F, N>['scope']>
+
+/** Component accepted for one declared local Factory position. */
+export type FactoryLocalComponent<
+  F extends keyof SlotFactoryMap & string,
+  N extends FactoryLocalNameOf<F>,
+> = SlotComponent<FactoryLocalComponentPropsOf<F, N>>
+
+/**
+ * Hook exposed only to a Factory definition for selecting a local Component.
+ * @param name - declared local position.
+ * @param fallback - Component used when the occurrence caller makes no selection.
+ * @returns a stable Component accepting only the local occurrence props.
+ */
+export type UseFactorySlot<F extends keyof SlotFactoryMap & string> =
+  <N extends FactoryLocalNameOf<F>>(
+    name: N,
+    fallback: FactoryLocalComponent<F, N>,
+  ) => SlotComponent<FactoryLocalInputPropsOf<F, N>>
+
+/**
+ * Render one independently keyed occurrence of a registered Factory.
+ * @param name - declaration-merged Factory name.
+ * @param props - caller-owned occurrence input.
+ * @param options - local Component selections and the missing-definition fallback.
+ * @returns the Factory occurrence or fallback.
+ */
+export type RenderFactorySlot = <F extends keyof SlotFactoryMap & string>(
+  name: F,
+  props: FactoryInputPropsOf<F>,
+  options?: {
+    slots?: Partial<{ [N in FactoryLocalNameOf<F>]: FactoryLocalComponent<F, N> }>
+    fallback?: ReactNode
+  },
+) => ReactNode
+
+/** Factory rendering capability supplied to every renderer-created Component. */
+export interface PropsRenderFactories {
+  renderFactorySlot: RenderFactorySlot
+}
+
 /**
  * Registrant hooks compartment: bare observable sources (getSnapshot +
  * subscribe pairs) supplied under the reserved `hooks` key of an entry's
@@ -491,7 +606,8 @@ export type ComposedProps<
   I extends object,
   M = never,
   N = undefined,
-> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
+> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsRenderFactories
+  & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
 
 /**
  * Inject factory parameter list, derived from the registration's declaration:
@@ -510,6 +626,130 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
         : [sessionId: SessionIdOf | undefined])
       : ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
 
+/** Factory inject parameters use the same scope/store matrix as ordinary registrations. */
+export type FactoryInjectParams<F extends keyof SlotFactoryMap & string> =
+  FactoryDefOf<F>['scope'] extends 'session'
+    ? ([FactoryStoreOf<F>] extends [StoreDecl]
+      ? [sessionId: SessionIdOf, actions: BoundActions<FactoryStoreOf<F>>]
+      : [sessionId: SessionIdOf])
+    : FactoryDefOf<F>['scope'] extends 'session-maybe'
+      ? ([FactoryStoreOf<F>] extends [StoreDecl]
+        ? [sessionId: SessionIdOf | undefined, actions: BoundActions<FactoryStoreOf<F>> | undefined]
+        : [sessionId: SessionIdOf | undefined])
+      : ([FactoryStoreOf<F>] extends [StoreDecl]
+        ? [actions: BoundActions<FactoryStoreOf<F>>]
+        : [])
+
+type FactoryField<
+  F extends keyof SlotFactoryMap & string,
+  K extends keyof SlotFactoryDef,
+  Value,
+> = K extends keyof SlotFactoryMap[F] ? { [P in K]-?: Value } : { [P in K]?: never }
+
+type RuntimeFactorySlots<F extends keyof SlotFactoryMap & string> = {
+  [N in FactoryLocalNameOf<F>]: { scope: FactoryLocalDefOf<F, N>['scope'] }
+}
+
+type FactoryInjectCollisionKeys<F extends keyof SlotFactoryMap & string> = Extract<
+  keyof InjectFace<FactoryInjectOf<F>>,
+  keyof (
+    FactoryRenderPropsOf<F>
+    & PropsStore<FactoryStoreOf<F>>
+    & PropsLocale<FactoryLocaleOf<F>>
+    & PropsRenderFactories
+    & ScopeStandardProps<FactoryDefOf<F>['scope']>
+    & { useFactorySlot: UseFactorySlot<F> }
+  )
+>
+
+type FactoryInputCollisionKeys<F extends keyof SlotFactoryMap & string> = Extract<
+  keyof FactoryInputPropsOf<F>,
+  keyof (
+    FactoryRegistrationPropsOf<F>
+    & ScopeStandardProps<FactoryDefOf<F>['scope']>
+    & { useFactorySlot: UseFactorySlot<F> }
+  )
+>
+
+type FactoryLocalCollisionKeys<F extends keyof SlotFactoryMap & string> = {
+  [N in FactoryLocalNameOf<F>]: Extract<
+    keyof FactoryLocalInputPropsOf<F, N>,
+    keyof (FactoryRegistrationPropsOf<F> & ScopeStandardProps<FactoryLocalDefOf<F, N>['scope']>)
+  >
+}[FactoryLocalNameOf<F>]
+
+type FactoryRegistrationLocalScopeCollisionKeys<F extends keyof SlotFactoryMap & string> = {
+  [N in FactoryLocalNameOf<F>]: Extract<
+    keyof FactoryRegistrationPropsOf<F>,
+    keyof ScopeStandardProps<FactoryLocalDefOf<F, N>['scope']>
+  >
+}[FactoryLocalNameOf<F>]
+
+type FactoryCollisionCheck<F extends keyof SlotFactoryMap & string> = [
+  FactoryInjectCollisionKeys<F>
+  | FactoryInputCollisionKeys<F>
+  | FactoryLocalCollisionKeys<F>
+  | FactoryRegistrationLocalScopeCollisionKeys<F>,
+] extends [never]
+  ? unknown
+  : {
+    'Factory declaration has overlapping prop ownership':
+      FactoryInjectCollisionKeys<F>
+      | FactoryInputCollisionKeys<F>
+      | FactoryLocalCollisionKeys<F>
+      | FactoryRegistrationLocalScopeCollisionKeys<F>
+  }
+
+type FactoryChildMismatchKeys<F extends keyof SlotFactoryMap & string> = {
+  [K in keyof FactoryChildrenOf<F>]: K extends keyof SlotMap
+    ? FactoryChildrenOf<F>[K] extends SlotSpec<SlotMap[K]> ? never : K
+    : K
+}[keyof FactoryChildrenOf<F>]
+
+type FactoryChildrenCheck<F extends keyof SlotFactoryMap & string> =
+  [FactoryChildMismatchKeys<F>] extends [never]
+    ? unknown
+    : { 'Factory children must match SlotMap': FactoryChildMismatchKeys<F> }
+
+/** Registration options checked against the complete declaration-merged Factory definition. */
+export type RegisterFactoryOptions<F extends keyof SlotFactoryMap & string> = {
+  name: F
+  scope: FactoryDefOf<F>['scope']
+} & FactoryField<F, 'children', FactoryChildrenOf<F>>
+  & FactoryField<F, 'store', FactoryStoreOf<F> | (() => FactoryStoreOf<F>)>
+  & FactoryField<F, 'inject', (...args: FactoryInjectParams<F>) => FactoryInjectOf<F>>
+  & FactoryField<F, 'locale', FactoryLocaleOf<F>>
+  & FactoryField<F, 'slots', RuntimeFactorySlots<F>>
+  & FactoryCollisionCheck<F>
+  & FactoryChildrenCheck<F>
+
+/**
+ * Typed registration method implemented by the renderer-owned SlotRegistry service.
+ * @param options - runtime values checked against the Factory declaration.
+ * @param component - reusable definition Component.
+ * @returns an idempotent definition disposer.
+ */
+export interface RegisterFactory {
+  <F extends keyof SlotFactoryMap & string>(
+    options: RegisterFactoryOptions<F>,
+    component: SlotComponent<FactoryComponentPropsOf<F>>,
+  ): () => void
+}
+
+/** Type-erased Factory definition stored by the runtime registry. */
+export interface StoredFactory {
+  readonly name: string
+  readonly component: unknown
+  readonly scope: SlotScope
+  readonly children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
+  readonly store?: StoreDecl | undefined
+  readonly inject?: ((...args: never[]) => Record<string, unknown>) | undefined
+  readonly locale?: string | undefined
+  readonly slots?: Readonly<Record<string, { scope: SlotScope }>> | undefined
+  /** Diagnostics label of who registered the definition. */
+  readonly registrant?: string | undefined
+}
+
 /**
  * A list-entry display label: a plain string, or a thunk re-evaluated per
  * read so registration-time text (nav rows, tabs) follows the active locale
@@ -669,6 +909,12 @@ interface SlotRecord {
   declarationListeners: Set<() => void>
 }
 
+interface FactoryRecord {
+  definition: StoredFactory | undefined
+  version: number
+  listeners: Set<() => void>
+}
+
 const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
 
 /** JSON-safe live occupant returned by slot inspection. */
@@ -687,8 +933,10 @@ export interface LiveSlotOccupant {
   active: boolean
 }
 
-/** JSON-safe live slot declaration tree. */
+/** JSON-safe live Slot declaration tree. */
 export interface LiveSlotNode {
+  /** Discriminant for an ordinary Slot declaration. */
+  type: 'slot'
   /** Exact SlotMap key. */
   name: string
   /** Slot cardinality. */
@@ -703,6 +951,23 @@ export interface LiveSlotNode {
   children: LiveSlotNode[]
 }
 
+/** JSON-safe live Factory definition with its ordinary child Slot tree. */
+export interface LiveFactoryNode {
+  /** Discriminant for a reusable Factory definition. */
+  type: 'factory'
+  /** Exact SlotFactoryMap key. */
+  name: string
+  /** Runtime data scope inherited by each occurrence. */
+  scope: SlotScope
+  /** Plugin or package that registered the definition, when known. */
+  registrant?: string
+  /** Ordinary Slots declared by the Factory definition. */
+  children: LiveSlotNode[]
+}
+
+/** One root in the live Slot/Factory composition topology. */
+export type LiveCompositionNode = LiveSlotNode | LiveFactoryNode
+
 /**
  * Pure slot registry (no cordis; event emission and the renderer installation contract
  * live in the runtime Service wrapper).
@@ -721,6 +986,7 @@ export interface LiveSlotNode {
  */
 export class SlotCore {
   private records = new Map<string, SlotRecord>()
+  private factories = new Map<string, FactoryRecord>()
   private mutateListeners = new Set<(key: string) => void>()
   /** Shared-handle scope ledger: handle → the scope it first mounted under + live mount count. */
   private handleScopes = new Map<object, { scope: SlotScope; count: number }>()
@@ -737,7 +1003,7 @@ export class SlotCore {
    */
   private abdicated = new WeakSet<StoredEntry>()
   private entryErrorListeners
-    = new Set<(key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void>()
+    = new Set<(key: string, registration: StoredEntry | StoredFactory, error: unknown, info: { abdicated: boolean }) => void>()
 
   constructor() {
     // The a-priori root hole. No markDirty: nothing can observe construction.
@@ -747,6 +1013,112 @@ export class SlotCore {
     root.declarationEpoch = 1
   }
 
+  /** Register one reusable Factory definition. */
+  readonly registerFactory: RegisterFactory = ((rawOptions: object, component: unknown): (() => void) => {
+    const options = rawOptions as {
+      name: string
+      scope: SlotScope
+      children?: Record<string, SlotSpec<SlotEntryDef>>
+      store?: StoreDecl
+      inject?: (...args: never[]) => Record<string, unknown>
+      locale?: string
+      slots?: Record<string, { scope: SlotScope }>
+      registrant?: string
+    }
+    const record = this.factoryRecord(options.name)
+    if (record.definition !== undefined) {
+      throw new Error(`slot factory "${options.name}" already has a definition`)
+    }
+    for (const childKey of Object.keys(options.children ?? {})) {
+      const childRecord = this.records.get(childKey)
+      if (childRecord?.spec !== undefined) {
+        throw new Error(`slot "${childKey}" is already declared (by ${childRecord.declaredBy ?? 'an unknown entry'})`)
+      }
+    }
+    if (options.store !== undefined && typeof options.store !== 'function') {
+      const pinned = this.handleScopes.get(options.store)
+      if (pinned !== undefined && pinned.scope !== options.scope) {
+        throw new Error(
+          `store handle mounted under factory "${options.name}" (scope "${options.scope}") is already mounted under scope "${pinned.scope}" — one handle, one scope`)
+      }
+      if (pinned !== undefined) pinned.count += 1
+      else this.handleScopes.set(options.store, { scope: options.scope, count: 1 })
+    }
+    const definition: StoredFactory = {
+      name: options.name,
+      component,
+      scope: options.scope,
+      ...(options.children === undefined ? {} : { children: options.children }),
+      ...(options.store === undefined ? {} : { store: options.store }),
+      ...(options.inject === undefined ? {} : { inject: options.inject }),
+      ...(options.locale === undefined ? {} : { locale: options.locale }),
+      ...(options.slots === undefined ? {} : { slots: options.slots }),
+      ...(options.registrant === undefined ? {} : { registrant: options.registrant }),
+    }
+    record.definition = definition
+    this.markFactoryDirty(record)
+    const declarations: [string, SlotRecord][] = []
+    for (const [childKey, childSpec] of Object.entries(options.children ?? {})) {
+      const childRecord = this.record(childKey)
+      childRecord.spec = childSpec
+      childRecord.declaredBy = `factory "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
+      childRecord.parent = `factory:${options.name}`
+      childRecord.declarationEpoch += 1
+      declarations.push([childKey, childRecord])
+    }
+    for (const [childKey, childRecord] of declarations) this.markDirty(childKey, childRecord)
+    for (const [, childRecord] of declarations) this.notifyDeclaration(childRecord)
+    return () => {
+      if (record.definition !== definition) return
+      record.definition = undefined
+      this.markFactoryDirty(record)
+      if (definition.store !== undefined && typeof definition.store !== 'function') {
+        const pinned = this.handleScopes.get(definition.store)
+        if (pinned !== undefined && --pinned.count === 0) this.handleScopes.delete(definition.store)
+      }
+      this.releaseChildren(definition.children)
+    }
+  }) as RegisterFactory
+
+  /**
+   * Read one registered Factory definition.
+   * @param name - Factory name.
+   * @returns the live definition, or `undefined` when absent.
+   */
+  factory(name: string): StoredFactory | undefined {
+    return this.factories.get(name)?.definition
+  }
+
+  /**
+   * Read the monotonic definition version for one Factory name.
+   * @param name - Factory name.
+   * @returns the current version.
+   */
+  factoryVersion(name: string): number {
+    return this.factories.get(name)?.version ?? 0
+  }
+
+  /**
+   * Subscribe to one Factory definition's registration lifetime.
+   * @param name - Factory name.
+   * @param listener - callback notified after a definition change.
+   * @returns the unsubscribe function.
+   */
+  subscribeFactory(name: string, listener: () => void): () => void {
+    const record = this.factoryRecord(name)
+    record.listeners.add(listener)
+    return () => { record.listeners.delete(listener) }
+  }
+
+  /**
+   * Return whether a retained Factory definition is still registered.
+   * @param definition - retained definition identity.
+   * @returns whether that exact definition remains live.
+   */
+  isFactoryLive(definition: StoredFactory): boolean {
+    return this.factories.get(definition.name)?.definition === definition
+  }
+
   /**
    * Contribute a component to a declared slot and (optionally) declare child
    * slots, a store seat, and the registrant's business face.
@@ -774,7 +1146,7 @@ export class SlotCore {
    * @param options - registration options: target `name`, `children`
    * declaration table, `store` seat, `inject` business-face factory, kind
    * shape fields (keyed `key`; list `id`/`order`/`label`).
-   * @param component - component honoring the four-share composed props
+   * @param component - component honoring the five-share composed props
    * contract ({@link ComposedProps}); checked at this call site.
    * @returns disposer removing the registration and its declarations
    * (idempotent; stale disposers after a cascade are no-ops).
@@ -805,7 +1177,7 @@ export class SlotCore {
    * factory's return and joins the component's composed-props constraint
    * (factory parameters derive from the declaration, {@link InjectParams}).
    * @param options - registration options plus the `inject` business-face factory.
-   * @param component - component honoring the four-share composed props
+   * @param component - component honoring the five-share composed props
    * contract including the inject share `I`.
    * @returns disposer removing the registration and its declarations.
    */
@@ -1017,11 +1389,13 @@ export class SlotCore {
 
   /**
    * Export the current declaration topology without components or executable hooks.
-   * @param root - exact Slot key to select; omitted returns every live root.
+   * Factory definitions appear as `factory:<name>` parents of their ordinary
+   * child Slots, matching the parent/child topology of ordinary registrations.
+   * @param root - exact Slot or `factory:<name>` key to select; omitted returns every live root.
    * @returns selected live Slot trees, or an empty array when `root` is unavailable.
    */
-  snapshot(root?: string): LiveSlotNode[] {
-    const build = (name: string, seen: Set<string>): LiveSlotNode | undefined => {
+  snapshot(root?: string): LiveCompositionNode[] {
+    const buildSlot = (name: string, seen: Set<string>): LiveSlotNode | undefined => {
       const record = this.records.get(name)
       if (record?.spec === undefined || seen.has(name)) return undefined
       const branch = new Set(seen)
@@ -1030,10 +1404,11 @@ export class SlotCore {
       const children = [...this.records.entries()]
         .filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === name)
         .flatMap(([child]) => {
-          const node = build(child, branch)
+          const node = buildSlot(child, branch)
           return node === undefined ? [] : [node]
         })
       return {
+        type: 'slot',
         name,
         kind: record.spec.kind,
         scope: record.spec.scope,
@@ -1049,17 +1424,41 @@ export class SlotCore {
         children,
       }
     }
+    const buildFactory = (name: string): LiveFactoryNode | undefined => {
+      const definition = this.factories.get(name)?.definition
+      if (definition === undefined) return undefined
+      const nodeName = `factory:${name}`
+      const children = [...this.records.entries()]
+        .filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === nodeName)
+        .flatMap(([child]) => {
+          const node = buildSlot(child, new Set([nodeName]))
+          return node === undefined ? [] : [node]
+        })
+      return {
+        type: 'factory',
+        name,
+        scope: definition.scope,
+        ...(definition.registrant === undefined ? {} : { registrant: definition.registrant }),
+        children,
+      }
+    }
     if (root !== undefined) {
-      const node = build(root, new Set())
+      const node = root.startsWith('factory:')
+        ? buildFactory(root.slice('factory:'.length))
+        : buildSlot(root, new Set())
       return node === undefined ? [] : [node]
     }
-    return [...this.records.entries()]
-      .filter(([, record]) => record.spec !== undefined
-        && (record.parent === undefined || this.records.get(record.parent)?.spec === undefined))
+    const slots = [...this.records.entries()]
+      .filter(([, record]) => record.spec !== undefined && record.parent === undefined)
       .flatMap(([name]) => {
-        const node = build(name, new Set())
+        const node = buildSlot(name, new Set())
         return node === undefined ? [] : [node]
       })
+    const factories = [...this.factories.keys()].flatMap((name) => {
+      const node = buildFactory(name)
+      return node === undefined ? [] : [node]
+    })
+    return [...slots, ...factories]
   }
 
   /**
@@ -1150,16 +1549,30 @@ export class SlotCore {
   }
 
   /**
-   * Observe entry boundary crashes (every render-time entry failure the
-   * boundaries contain, abdicating or not) — the supervision seam for hosts
-   * mirroring contribution health. Fires synchronously per report, after the
-   * registry mutated for abdicating crashes (same listener discipline as
-   * {@link SlotCore.onMutate}).
-   * @param fn - called with the slot key, the crashed entry, the crash
-   * cause, and `abdicated`: whether the crash retired the entry from its cell.
+   * Report a contained Factory occurrence crash through the ordinary entry
+   * supervision channel without retiring the shared definition.
+   * @param name - Factory name whose occurrence crashed.
+   * @param registration - Factory definition or caller registration that owns the crashing Component.
+   * @param error - the crash cause, forwarded to listeners verbatim.
+   */
+  reportFactoryError(name: string, registration: StoredEntry | StoredFactory, error: unknown): void {
+    for (const fn of [...this.entryErrorListeners]) fn(`factory:${name}`, registration, error, { abdicated: false })
+  }
+
+  /**
+   * Observe ordinary entry and Factory occurrence crashes. Fires synchronously
+   * per report, after any ordinary-entry abdication mutation. Factory failures
+   * never retire their shared definition.
+   * @param fn - called with the Slot or `factory:<name>` key, the crashed
+   * registration, the cause, and whether an ordinary entry was retired.
    * @returns unsubscribe.
    */
-  onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
+  onEntryError(fn: (
+    key: string,
+    registration: StoredEntry | StoredFactory,
+    error: unknown,
+    info: { abdicated: boolean },
+  ) => void): () => void {
     this.entryErrorListeners.add(fn)
     return () => { this.entryErrorListeners.delete(fn) }
   }
@@ -1175,8 +1588,12 @@ export class SlotCore {
       const pinned = this.handleScopes.get(entry.store)
       if (pinned && --pinned.count === 0) this.handleScopes.delete(entry.store)
     }
-    if (!entry.children) return
-    for (const childKey of Object.keys(entry.children)) {
+    this.releaseChildren(entry.children)
+  }
+
+  private releaseChildren(children: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined): void {
+    if (children === undefined) return
+    for (const childKey of Object.keys(children)) {
       const childRec = this.records.get(childKey)
       /* v8 ignore next -- defensive: declaring always creates the record */
       if (!childRec) continue
@@ -1210,6 +1627,22 @@ export class SlotCore {
     return rec
   }
 
+  private factoryRecord(name: string): FactoryRecord {
+    let record = this.factories.get(name)
+    if (record === undefined) {
+      record = { definition: undefined, version: 0, listeners: new Set() }
+      this.factories.set(name, record)
+    }
+    return record
+  }
+
+  private markFactoryDirty(record: FactoryRecord): void {
+    record.version += 1
+    queueMicrotask(() => {
+      for (const listener of [...record.listeners]) listener()
+    })
+  }
+
   private markDirty(key: string, rec: SlotRecord): void {
     rec.version += 1
     for (const fn of [...this.mutateListeners]) fn(key)

+ 53 - 1
packages/client/ui-slots/src/renderer.ts

@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { ReactNode } from 'react'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type {
-  SessionAreaProps, SlotEntryDef, SlotScope, SlotSpec, StoredEntry, Translate,
+  SessionAreaProps, SlotEntryDef, SlotScope, SlotSpec, StoredEntry, StoredFactory, Translate,
 } from './index.ts'
 
 /**
@@ -165,6 +165,13 @@ export interface SlotRendererHost {
    * @param info - `abdicate`: whether the crash retires the entry from its cell.
    */
   reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void
+  /**
+   * Report a contained Factory occurrence crash without retiring its shared definition.
+   * @param name - Factory name whose occurrence crashed.
+   * @param registration - Factory definition or caller registration that owns the crashing Component.
+   * @param error - the crash cause.
+   */
+  reportFactoryError(name: string, registration: StoredEntry | StoredFactory, error: unknown): void
   /**
    * Declared runtime spec from the declarations ledger.
    * @param key - slot key.
@@ -185,6 +192,51 @@ export interface SlotRendererHost {
    * @returns the instance, or undefined when the entry declares no store.
    */
   storeOf(entry: StoredEntry, scopeBinding: ScopedStandardSourceBinding | undefined): StoreInstanceLike | undefined
+  /**
+   * Resolve a Factory Store for one render occurrence and inherited scope.
+   * @param definition - live Factory definition.
+   * @param scopeBinding - exact Session binding for scoped Factories, undefined for root scope.
+   * @param occurrence - identity token owned by the render position.
+   * @returns the occurrence Store instance, or undefined without a Store declaration.
+   */
+  factoryStoreOf(
+    definition: StoredFactory,
+    scopeBinding: ScopedStandardSourceBinding | undefined,
+    occurrence: object,
+  ): StoreInstanceLike | undefined
+  /**
+   * Retain an exclusive Factory Store occurrence after React commits it.
+   * Repeated setup and cleanup preserve the occurrence's Store identity.
+   * @param definition - the live Factory definition.
+   * @param occurrence - identity token owned by the mounted render position.
+   * @returns an idempotent cleanup function.
+   */
+  retainFactoryOccurrence(definition: StoredFactory, occurrence: object): () => void
+  /**
+   * Subscribe to one Factory definition's registration lifetime.
+   * @param name - Factory name.
+   * @param fn - change callback.
+   * @returns the unsubscribe function.
+   */
+  subscribeFactory(name: string, fn: () => void): () => void
+  /**
+   * Read the monotonic version for one Factory definition.
+   * @param name - Factory name.
+   * @returns the current definition version.
+   */
+  getFactoryVersion(name: string): number
+  /**
+   * Read one live Factory definition.
+   * @param name - Factory name.
+   * @returns the definition, or undefined while unregistered.
+   */
+  factoryOf(name: string): StoredFactory | undefined
+  /**
+   * Check retained Factory render authority.
+   * @param definition - a previously resolved Factory definition.
+   * @returns whether that exact definition remains live.
+   */
+  isFactoryLive(definition: StoredFactory): boolean
   /** Root standard data assembled from domain-owned contributions. */
   readonly root: HostObservable<StandardSourceBinding>
   /** Monotonic source updated whenever the installed scope-adapter roster changes. */

+ 23 - 0
packages/client/ui-slots/tests/core.client.spec.ts

@@ -80,6 +80,29 @@ describe('a-priori root and declaration gate', () => {
 })
 
 describe('lifecycle cascade (one axis)', () => {
+  it('publishes Factory child mutations only after every sibling declaration is installed', () => {
+    const core = new SlotCore()
+    const observed: unknown[] = []
+    core.onMutate((key) => {
+      if (key === 'test.single') observed.push(core.specDynamic('test.session'))
+    })
+    const registerFactory = core.registerFactory as unknown as (
+      options: object,
+      component: unknown,
+    ) => () => void
+
+    registerFactory({
+      name: 'test.atomic-factory',
+      scope: 'root',
+      children: {
+        'test.single': { kind: 'single', scope: 'root' },
+        'test.session': { kind: 'single', scope: 'session' },
+      },
+    }, Comp)
+
+    expect(observed).toEqual([{ kind: 'single', scope: 'session' }])
+  })
+
   it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
     const core = new SlotCore()
     const disposeFrame = mountFrame(core)

+ 117 - 2
packages/client/ui-slots/tests/type-chain.client.spec.tsx

@@ -1,11 +1,12 @@
-// Terminal-design compile-time samples: the four-share
+// Terminal-design compile-time samples: the five-share
 // composed register constraint — children spec x SlotMap alignment, renderSlot
 // key-set containment, store share matching, inject face completeness — plus
 // the full positive chain. Bodies with @ts-expect-error sites never run.
 import { describe, expect, it } from 'vitest'
 import type { ReactNode } from 'react'
 import type {
-  BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, SlotHookFactory,
+  BoundActions, DefineStore, FactoryComponentPropsOf, FactoryLocalComponentPropsOf,
+  PropsRenderFactories, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, SlotHookFactory,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
 
@@ -28,6 +29,39 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     'chain.tools': { kind: 'keyed'; scope: 'session' }
     'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } }
   }
+
+  interface LocaleNamespaceMap {
+    'chain.factory': 'title'
+  }
+
+  interface SlotFactoryMap {
+    'chain.factory': {
+      scope: 'session'
+      props: { tone: 'quiet' | 'loud' }
+      children: { 'chain.side': { kind: 'single'; scope: 'root' } }
+      store: ChatHandle
+      inject: { send: (text: string) => void }
+      locale: 'chain.factory'
+      slots: { views: { scope: 'session'; props: { active: boolean } } }
+    }
+    'chain.factory.overlap': {
+      scope: 'root'
+      props: { renderFactorySlot: string }
+    }
+    'chain.factory.local-overlap': {
+      scope: 'root'
+      inject: { sessionId: string }
+      slots: { views: { scope: 'session' } }
+    }
+    'chain.factory.bad-child': {
+      scope: 'root'
+      children: { 'chain.conv': { kind: 'single'; scope: 'root' } }
+    }
+    'chain.factory.factory-store': {
+      scope: 'root'
+      store: typeof createPanelStore
+    }
+  }
 }
 
 /** Chain-currency fixture: the owner share carries a union the selectors narrow. */
@@ -60,6 +94,7 @@ type ChatHandle = ReturnType<typeof _chatStore>
 type FrameProps =
   & PropsRuntime<'chain.frame'>
   & PropsRenderSlots<'chain.side' | 'chain.conv'>
+  & PropsRenderFactories
   & PropsStore<ReturnType<typeof createPanelStore>>
   & { openSettings: () => void }
 
@@ -100,6 +135,15 @@ declare function ContextReader(props: ContextProps): 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
+declare function FactoryBody(props: FactoryComponentPropsOf<'chain.factory'>): ReactNode
+declare function FactoryViews(props: FactoryLocalComponentPropsOf<'chain.factory', 'views'>): ReactNode
+declare function NarrowFactoryViews(
+  props: FactoryLocalComponentPropsOf<'chain.factory', 'views'> & { missing: boolean },
+): ReactNode
+declare function OverlappingFactory(props: FactoryComponentPropsOf<'chain.factory.overlap'>): ReactNode
+declare function LocalOverlappingFactory(props: FactoryComponentPropsOf<'chain.factory.local-overlap'>): ReactNode
+declare function BadChildFactory(props: FactoryComponentPropsOf<'chain.factory.bad-child'>): ReactNode
+declare function FactoryStoreBody(props: FactoryComponentPropsOf<'chain.factory.factory-store'>): ReactNode
 
 describe('terminal-design type chain', () => {
   it('holds the positive chain and the compile-time negatives', () => {
@@ -287,6 +331,77 @@ describe('terminal-design type chain', () => {
       const sideOnly: PropsRenderSlots<'chain.side'> = null as never
       // @ts-expect-error only root-scope children declared → no SessionProvider seat
       void sideOnly.SessionProvider
+
+      // Factory registration derives every supplied share from one map entry.
+      core.registerFactory({
+        name: 'chain.factory',
+        scope: 'session',
+        children: { 'chain.side': { kind: 'single', scope: 'root' } },
+        store: chat,
+        inject: (sessionId, actions) => ({
+          send: (text: string) => {
+            const sid: string = sessionId
+            actions.setDraft(text)
+            void sid
+          },
+        }),
+        locale: 'chain.factory',
+        slots: { views: { scope: 'session' } },
+      }, FactoryBody)
+      fp.renderFactorySlot('chain.factory', { tone: 'quiet' }, {
+        slots: { views: FactoryViews },
+      })
+
+      const factoryProps = null as never as FactoryComponentPropsOf<'chain.factory'>
+      const Views = factoryProps.useFactorySlot('views', FactoryViews)
+      Views({ active: true })
+
+      // @ts-expect-error unknown Factory name
+      fp.renderFactorySlot('chain.missing', {})
+      // @ts-expect-error occurrence input is required
+      fp.renderFactorySlot('chain.factory', {})
+      // @ts-expect-error occurrence input has no extra members
+      fp.renderFactorySlot('chain.factory', { tone: 'quiet', extra: true })
+      // @ts-expect-error unknown local slot name
+      fp.renderFactorySlot('chain.factory', { tone: 'quiet' }, { slots: { other: FactoryViews } })
+      // @ts-expect-error selected local Component requires unsupported props
+      fp.renderFactorySlot('chain.factory', { tone: 'quiet' }, { slots: { views: NarrowFactoryViews } })
+      core.registerFactory({
+        name: 'chain.factory',
+        // @ts-expect-error Factory scope must match the declaration
+        scope: 'root',
+        children: { 'chain.side': { kind: 'single', scope: 'root' } },
+        store: chat,
+        inject: (_sessionId, _actions) => ({ send: (_text: string) => {} }),
+        locale: 'chain.factory',
+        slots: { views: { scope: 'session' } },
+      }, FactoryBody)
+      // @ts-expect-error Factory input may not replace a framework-provided prop
+      core.registerFactory({ name: 'chain.factory.overlap', scope: 'root' }, OverlappingFactory)
+      // @ts-expect-error Factory registration props may not replace a local slot's scope props
+      core.registerFactory({
+        name: 'chain.factory.local-overlap',
+        scope: 'root',
+        inject: () => ({ sessionId: 'wrong owner' }),
+        slots: { views: { scope: 'session' } },
+      }, LocalOverlappingFactory)
+      // @ts-expect-error Factory children must match their SlotMap declarations
+      core.registerFactory({
+        name: 'chain.factory.bad-child',
+        scope: 'root',
+        children: { 'chain.conv': { kind: 'single', scope: 'root' } },
+      }, BadChildFactory)
+      core.registerFactory({
+        name: 'chain.factory.factory-store',
+        scope: 'root',
+        store: createPanelStore,
+      }, FactoryStoreBody)
+      core.registerFactory({
+        name: 'chain.factory.factory-store',
+        scope: 'root',
+        // @ts-expect-error a StoreFactory declaration accepts a handle or one StoreFactory, not a nested factory
+        store: () => createPanelStore,
+      }, FactoryStoreBody)
     }
     expect(samples).toBeTypeOf('function')
   })

Разница между файлами не показана из-за своего большого размера
+ 67 - 6
packages/extensions/cordis-client-runner/src/client/api-catalog.ts


+ 14 - 8
packages/extensions/cordis-client-runner/src/client/guard.ts

@@ -3,8 +3,8 @@
  * lifecycle-safe verbs plus optional `ctx.get()` lookup and declared-service
  * property access, with
  * framework internals withheld and Context-valued returns denied. Two seats
- * carry extra machinery: `slots`, where the register proxy assigns the
- * shadowing priority and ledgers the registration — invoking the service with
+ * carry extra machinery: `slots`, where the registration proxy assigns any
+ * shadowing priority and ledgers ordinary entries or Factory definitions — invoking the service with
  * the traced receiver so the effect lands on the CALLING plugin's fiber
  * (SlotRegistry.register must stay a prototype method for exactly that
  * reason) — and `theme`, whose override source is pinned to the package id.
@@ -24,9 +24,9 @@ const CTX_VERBS = new Set([
 ])
 const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
 
-/** One package's slot-registration ledger row (contribution projection source). */
+/** One package's Slot or Factory registration ledger row (contribution projection source). */
 export interface DynamicCordisSlotLedgerRow {
-  /** Target slot name. */
+  /** Target Slot name, or `factory:<name>` for a Factory definition. */
   slot: string
   /** The assigned shadowing priority (globally unique — how winners are matched back to packages). */
   priority: number | undefined
@@ -36,7 +36,7 @@ export interface DynamicCordisSlotLedgerRow {
 export interface DynamicCordisGuardEnv {
   /** The dispatched Package row. */
   pkg: DynamicCordisPackage
-  /** Ledger sink: every slot registration this package makes. */
+  /** Ledger sink: every Slot or Factory registration this package makes. */
   ledger: DynamicCordisSlotLedgerRow[]
   /**
    * Ownership index sink: the component object seated in a slot, so a later
@@ -91,13 +91,13 @@ interface ErasedSlotOptions {
 
 /**
  * The slots seat: automatic shadowing priority and ledger recording around the
- * traced service's own register.
+ * traced service's own Slot and Factory registration methods.
  */
 function guardedSlots(slots: SlotRegistry, env: DynamicCordisGuardEnv): unknown {
   return new Proxy(slots, {
     get(target, prop) {
       const value = Reflect.get(target, prop, target) as unknown
-      if (prop !== 'register') {
+      if (prop !== 'register' && prop !== 'registerFactory') {
         if (typeof value !== 'function') return denyContext(value, 'slots', env)
         return (...args: unknown[]): unknown => denyContext(Reflect.apply(value, target, args), 'slots', env)
       }
@@ -108,7 +108,13 @@ function guardedSlots(slots: SlotRegistry, env: DynamicCordisGuardEnv): unknown
         const options = { ...rawOptions as ErasedSlotOptions }
         const slot = options.name
         if (typeof slot !== 'string' || slot.length === 0) {
-          return rejectGuard(env, 'slots.register options need a string `name` (the target slot key)')
+          return rejectGuard(env, `slots.${prop} options need a string \`name\``)
+        }
+        if (prop === 'registerFactory') {
+          const dispose = Reflect.apply(value as (...args: unknown[]) => unknown, target, [options, component])
+          env.ledger.push({ slot: `factory:${slot}`, priority: undefined })
+          env.claim(component)
+          return dispose
         }
         if (slot === 'tool.view.cordis') {
           if (options.key !== 'self') {

+ 25 - 7
packages/extensions/cordis-client-runner/src/client/providers.ts

@@ -22,14 +22,14 @@ const EVENT_OUTPUT = {
 } as const
 /* jscpd:ignore-end */
 const SUBTREE_OUTPUT = {
-  description: 'Compact purpose/topology trees. With root, selected also contains that Slot\'s full contract and live occupants.',
+  description: 'Compact topology trees. An exact Slot includes its catalog and occupants; an exact Factory includes identity, scope, and registrant.',
 } as const
 const SUBTREE_INPUT = {
   type: 'object',
   properties: {
     root: {
       type: 'string',
-      description: 'Exact live Slot key. When supplied, selected contains the full contract for this Slot.',
+      description: 'Exact live Slot key or factory:<name>. When supplied, selected contains that declaration.',
     },
   },
   additionalProperties: false,
@@ -99,10 +99,10 @@ export function clientInspectProviders(ctx: Context): ClientCordisInspectProvide
     {
       manifest: {
         id: 'Slots',
-        description: 'Progressive live Slot inspection: compact purpose/topology trees plus one exact Slot contract.',
+        description: 'Progressive live Slot inspection with explicit Slot and Factory topology nodes.',
         methods: [{
           name: 'listSubTree',
-          description: 'Return compact live Slot trees for navigation. With root, also return the selected Slot\'s full contract and occupants.',
+          description: 'Return compact live Slot and Factory trees, plus available detail for one exact root.',
           inputSchema: SUBTREE_INPUT,
           outputSchema: SUBTREE_OUTPUT,
         }],
@@ -169,7 +169,7 @@ function readExact(input: JsonValue | undefined, field: string): string | undefi
 }
 /* jscpd:ignore-end */
 
-type LiveSlotNode = ReturnType<SlotRegistry['snapshot']>[number]
+type LiveCompositionNode = ReturnType<SlotRegistry['snapshot']>[number]
 
 const SLOT_CATALOG = new Map(CLIENT_SLOT_API.map(entry => [entry.key, entry]))
 const GUARDED_SLOT_KEYS = new Map<string, {
@@ -185,10 +185,19 @@ const GUARDED_SLOT_KEYS = new Map<string, {
   }],
 ])
 
-function compactSlotTree(node: LiveSlotNode): JsonValue {
+function compactSlotTree(node: LiveCompositionNode): JsonValue {
+  if (node.type === 'factory') {
+    return {
+      type: node.type,
+      name: node.name,
+      scope: node.scope,
+      children: node.children.map(compactSlotTree),
+    }
+  }
   const catalog = SLOT_CATALOG.get(node.name)
   const guardedKeys = catalog === undefined ? undefined : GUARDED_SLOT_KEYS.get(catalog.key)
   return {
+    type: node.type,
     name: node.name,
     kind: node.kind,
     scope: node.scope,
@@ -211,9 +220,18 @@ function compactSlotTree(node: LiveSlotNode): JsonValue {
   }
 }
 
-function inspectLiveSlot(node: LiveSlotNode): JsonValue {
+function inspectLiveSlot(node: LiveCompositionNode): JsonValue {
+  if (node.type === 'factory') {
+    return {
+      type: node.type,
+      name: node.name,
+      scope: node.scope,
+      ...node.registrant === undefined ? {} : { registrant: node.registrant },
+    }
+  }
   const catalog = SLOT_CATALOG.get(node.name)
   return {
+    type: node.type,
     name: node.name,
     kind: node.kind,
     scope: node.scope,

+ 5 - 2
packages/extensions/cordis-client-runner/src/client/runtime.ts

@@ -167,7 +167,7 @@ export interface DynamicCordisLivePackage {
   pluginRunId: CordisDynamicPluginRunId
   /** Label from the define call. */
   name: string
-  /** Slot names this package registered into here. */
+  /** Slot names and `factory:<name>` definitions this package registered here. */
   slots: string[]
   /** Live injected-style tag count. */
   styleCount: number
@@ -504,6 +504,9 @@ export function errorDetails(error: unknown): CordisErrorDetails {
 function renderFailureMessage(slot: string, message: string): string {
   const redirect = Object.entries(DYNAMIC_CLIENT_REDIRECTS)
     .find(([name, text]) => message.includes(name) && !message.includes(text))?.[1]
-  return `your entry in slot "${slot}" crashed while React rendered it: ${message}`
+  const subject = slot.startsWith('factory:')
+    ? `your component in Factory "${slot.slice('factory:'.length)}"`
+    : `your entry in slot "${slot}"`
+  return `${subject} crashed while React rendered it: ${message}`
     + (redirect === undefined ? '' : `\n${redirect}`)
 }

+ 69 - 31
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -385,7 +385,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [
       'client-ui-approval ApprovalPanel',
       'client-ui-subagent SubagentReadOnlyComposer',
@@ -393,7 +393,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174',
   },
   {
     key: 'conversation.composer.bar',
@@ -426,13 +426,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [
       'client-ui-conversation InputBar',
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:192',
   },
   {
     key: 'conversation.composer.dock',
@@ -488,7 +488,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -519,13 +519,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [
       'client-ui-agent-preset AgentPresetSeat',
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -550,11 +550,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
   },
   {
     key: 'conversation.hero.workspace',
@@ -581,13 +581,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [
       'client-ui-workspace WorkspacePicker',
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -660,7 +660,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:194',
   },
   {
     key: 'conversation.input.dock',
@@ -715,7 +715,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [
       'client-ui-conversation QueueDock id \'queue\'',
       'client-ui-conversation TodoDock id \'todo\'',
@@ -723,7 +723,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182',
   },
   {
     key: 'conversation.input.left',
@@ -777,7 +777,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188',
   },
   {
     key: 'conversation.input.model',
@@ -816,7 +816,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
   },
   {
     key: 'conversation.input.overlay',
@@ -874,7 +874,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184',
   },
   {
     key: 'conversation.input.permission',
@@ -913,7 +913,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n      { name: \'conversation.input.permission\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:202',
   },
   {
     key: 'conversation.input.plan',
@@ -952,7 +952,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:200',
   },
   {
     key: 'conversation.input.right',
@@ -1006,7 +1006,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190',
   },
   {
     key: 'conversation.message.images',
@@ -1080,13 +1080,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     keyDomain: '',
     hookContext: '',
     slotInject: '',
-    declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted',
+    declaredBy: 'factory \'conversation.content\' (client-ui-conversation), so it exists while that definition is registered',
     occupants: [
       'client-ui-conversation ConversationSession',
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n      { name: \'conversation.session\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:122',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
   },
   {
     key: 'conversation.session.header',
@@ -1123,7 +1123,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n      { name: \'conversation.session.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1185,7 +1185,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:132',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137',
   },
   {
     key: 'conversation.session.header.corner',
@@ -1224,7 +1224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n      { name: \'conversation.session.header.corner\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:166',
   },
   {
     key: 'conversation.session.header.leading',
@@ -1263,7 +1263,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n      { name: \'conversation.session.header.leading\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1304,7 +1304,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n      { name: \'conversation.session.header.lineage\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:126',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1363,7 +1363,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n      { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143',
   },
   {
     key: 'conversation.trajectory.images',
@@ -1467,7 +1467,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172',
   },
   {
     key: 'main',
@@ -1539,7 +1539,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n      { name: \'main.conversation\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
   },
   {
     key: 'plugins.bundle.config',
@@ -1765,7 +1765,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'root\', () => ctx.slots.register(\n      { name: \'root\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-renderer/src/client/registry.ts:43',
+    source: 'packages/client/ui-renderer/src/client/registry.ts:44',
   },
   {
     key: 'settings.action',
@@ -2347,6 +2347,43 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n      { name: \'sidebar.brand.name\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:27',
   },
+  {
+    key: 'sidebar.chat.conversation',
+    kind: 'single',
+    scope: 'session',
+    summary: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.',
+    doc: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.',
+    registerOptions: [],
+    ownerProps: [],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useResource: UseResource',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionStatus: UseSessionStatus',
+      'useSessionRetainInfo: UseSessionRetainInfo',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useChat: UseChat',
+      'useConversation: UseConversation',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+      'useSession: SessionSnapshotSelector',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useTrajectory: UseTrajectory',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-chat), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-chat ConversationSlotPanel',
+    ],
+    replaceRisk: 'shadows-shipped-ui',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.chat.conversation\', () => ctx.slots.register(\n      { name: \'sidebar.chat.conversation\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-chat/src/client/sidebar-chat/index.tsx:42',
+  },
   {
     key: 'sidebar.footer.action',
     kind: 'list',
@@ -2485,6 +2522,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: 'SidebarRightTabInjected',
     declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-chat SidebarChatTab',
       'client-ui-deliverables ReviewTab',
       'client-ui-sidebar-documentpreview TextPreview',
       'client-ui-sidebar-files FilesBody',

+ 16 - 0
packages/extensions/cordis-client-runner/tests/guard.client.spec.ts

@@ -171,6 +171,22 @@ describe('slots seat', () => {
     expect(bench.ledger).toEqual([{ slot: 'root', priority: 5 }])
   })
 
+  it('ledgers and claims Factory definitions without assigning Slot priority', async () => {
+    const bench = await boot(['slots'])
+    const slots = bench.facade.slots as {
+      registerFactory(options: object, component: unknown): () => void
+    }
+    slots.registerFactory({ name: 'guard.factory', scope: 'root' }, C)
+
+    expect(bench.ledger).toEqual([{ slot: 'factory:guard.factory', priority: undefined }])
+    expect(bench.claimed).toEqual([C])
+    expect(bench.slots.snapshot('factory:guard.factory')).toMatchObject([{
+      type: 'factory', name: 'guard.factory', scope: 'root',
+    }])
+    await bench.dispose()
+    expect(bench.slots.snapshot('factory:guard.factory')).toEqual([])
+  })
+
   it('rejects a malformed register call before touching the registry', async () => {
     const bench = await boot(['slots'])
     const slots = bench.facade.slots as { register(options: unknown, component: unknown): () => void }

+ 50 - 0
packages/extensions/cordis-client-runner/tests/providers.client.spec.ts

@@ -0,0 +1,50 @@
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { clientInspectProviders } from '../src/client/providers.ts'
+
+const Component = () => null
+
+describe('Client inspect providers', () => {
+  it('reports strictly discriminated Slot and Factory topology nodes', async () => {
+    const ctx = new Context()
+    const fiber = ctx.plugin(SlotRegistry)
+    await fiber
+    const slots = ctx.slots as unknown as {
+      registerFactory(options: object, component: unknown): () => void
+    }
+    slots.registerFactory({
+      name: 'provider.factory',
+      scope: 'root',
+      children: { 'provider.factory.child': { kind: 'single', scope: 'root' } },
+    }, Component)
+    const provider = clientInspectProviders(ctx).find(item => item.manifest.id === 'Slots')
+    if (provider === undefined) throw new Error('Slots inspect provider is missing')
+
+    const all = await provider.query('listSubTree', undefined, {} as never) as Record<string, JsonValue>
+    expect(all['trees']).toEqual(expect.arrayContaining([{
+      type: 'factory',
+      name: 'provider.factory',
+      scope: 'root',
+      children: [{
+        type: 'slot',
+        name: 'provider.factory.child',
+        kind: 'single',
+        scope: 'root',
+        children: [],
+      }],
+    }]))
+
+    const exact = await provider.query(
+      'listSubTree', { root: 'factory:provider.factory' }, {} as never,
+    ) as Record<string, JsonValue>
+    expect(exact['selected']).toEqual({
+      type: 'factory',
+      name: 'provider.factory',
+      scope: 'root',
+      registrant: 'root',
+    })
+    await fiber.dispose()
+  })
+})

+ 24 - 3
packages/extensions/cordis-client-runner/tests/runner.client.spec.ts

@@ -61,7 +61,7 @@ interface Bench {
     failure: DynamicCordisRenderFailure
   }[]
   /**
-   * Report one entry crash the way the renderer's boundary does: the runner
+   * Report one Slot entry or Factory crash the way the renderer's boundary does: the runner
    * subscribed through the supervision seam, and this calls what it registered.
    */
   crash: (slot: string, entry: unknown, error: unknown, abdicated?: boolean) => void
@@ -382,6 +382,10 @@ describe('render failures', () => {
     inject: ['slots'],
     apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
   }`
+  const FACTORY_CONTRIBUTOR = `return {
+    inject: ['slots'],
+    apply(ctx) { ctx.slots.registerFactory({ name: 'dynamic.factory', scope: 'root' }, () => null) },
+  }`
 
   it('reports a crash of an entry it seated, under the session the run was for', async () => {
     const bench = await boot()
@@ -411,11 +415,28 @@ describe('render failures', () => {
     expect(bench.reported[0]?.failure.abdicated).toBe(false)
   })
 
+  it('reports a crash of a Factory definition it seated without retiring it', async () => {
+    const bench = await boot()
+    await bench.runner.load(half({ code: FACTORY_CONTRIBUTOR }))
+    const core = (bench.slots as unknown as {
+      _core: { factory(name: string): unknown }
+    })._core
+    const definition = core.factory('dynamic.factory')
+    bench.crash('factory:dynamic.factory', definition, new Error('factory boom'), false)
+
+    expect(bench.reported[0]).toMatchObject({
+      failure: {
+        slot: 'factory:dynamic.factory',
+        message: 'your component in Factory "dynamic.factory" crashed while React rendered it: factory boom',
+        abdicated: false,
+      },
+    })
+  })
+
   it('ignores a crash of an entry no dynamic package seated', async () => {
     const bench = await boot()
     await bench.runner.load(half({ code: CONTRIBUTOR }))
-    // Factory UI crashing is not this runner's business, and neither is an entry
-    // whose component cannot even be indexed by identity.
+    // An entry whose component was not claimed by a dynamic package is not this runner's business.
     bench.crash('root', { component: () => null }, new Error('boom'))
     bench.crash('root', { component: 'not-a-component' }, new Error('boom'))
     bench.crash('root', { component: null }, new Error('boom'))

+ 3 - 2
packages/extensions/tool-cordis/src/index.ts

@@ -53,8 +53,9 @@ export function apply(ctx: Context): void {
       + 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service '
       + 'methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate '
       + 'the compact signature directory, then query the exact service or event for its structured contract and '
-      + 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the '
-      + 'exact root for its complete registration contract and props.',
+      + 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an '
+      + 'exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, '
+      + 'scope, and registrant.',
     parameters: {
       platform: { type: 'string', required: true, enum: ['host', 'client'], description: 'Runtime platform that owns the Provider.' },
       provider: { type: 'string', required: true, description: 'Exact Provider ID returned by cordis_inspect_list.' },

+ 14 - 2
packages/test-support/client-runtime/src/index.ts

@@ -34,8 +34,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {
   ChildrenDecl, ComposedProps, HostObservable, OwnerOf, RenderOpts, SlotComponent, SlotMap, SlotRenderer,
-  ScopedStandardSourceBinding, SessionProviderComponent, SlotEntryDef, SlotRendererHost, SlotSpec,
-  SnapshotSelectorHook, StoreInstanceLike,
+  ScopedStandardSourceBinding, SessionProviderComponent, SlotEntryDef, SlotFactoryMap, SlotRendererHost, SlotSpec,
+  SnapshotSelectorHook, StoredFactory, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import { registerDomSnapshotSerializer } from './snapshot.ts'
 import { TestRemote } from './remote.ts'
@@ -439,6 +439,18 @@ export class SlotTestRuntime {
     return instance
   }
 
+  /**
+   * Read one registered Factory definition for direct contract assertions.
+   * @param name - registered Factory name.
+   * @returns the live Factory definition.
+   */
+  factoryOf(name: keyof SlotFactoryMap & string): StoredFactory {
+    if (this.host === undefined) throw new Error('factoryOf before renderRoot()')
+    const definition = this.host.factoryOf(name)
+    if (definition === undefined) throw new Error(`factoryOf('${name}'): no definition`)
+    return definition
+  }
+
   /**
    * Flush pending ledger/store notifications inside act — for mutations made
    * outside the runtime's own methods (e.g. a direct `slots.register`).

+ 6 - 3
packages/typert/generator/src/cordis-catalog.ts

@@ -124,6 +124,8 @@ export interface CordisCatalogPolicy {
   readonly inheritedEvents: readonly InheritedEntry[]
   /** Manually curated framework context members inherited by every plugin. */
   readonly inheritedServices: readonly InheritedEntry[]
+  /** Maximum rendered characters per runtime type declaration. */
+  readonly runtimeDeclarationMaxChars?: number
 }
 
 /** Complete model-level Cordis projection used by every text renderer. */
@@ -333,6 +335,7 @@ export class CordisCatalogProjector {
   ): { name: string; declaration: string }[] {
     const declarations = new Map<string, string>()
     const ambiguous = new Set<string>()
+    const maxDeclarationChars = this.policy.runtimeDeclarationMaxChars ?? DEFAULT_MAX_DECL_CHARS
     for (const declaration of this.sourceDeclarations) {
       if (declaration.face !== this.face.face
         || (!/^packages\/[^/]+\/[^/]+\/src\/.+\.tsx?$/.test(declaration.location.file)
@@ -343,8 +346,8 @@ export class CordisCatalogProjector {
       }
       declarations.set(
         declaration.name,
-        declaration.text.length > MAX_DECL_CHARS
-          ? `${declaration.text.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
+        declaration.text.length > maxDeclarationChars
+          ? `${declaration.text.slice(0, maxDeclarationChars)} /* …truncated — full shape in source */`
           : declaration.text,
       )
     }
@@ -614,7 +617,7 @@ function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureMod
 }
 
 /** Declarations longer than this render as a truncated stub. */
-const MAX_DECL_CHARS = 1500
+const DEFAULT_MAX_DECL_CHARS = 1_500
 
 /** Render one value as a single-quoted TypeScript literal. */
 function quote(value: string): string {

+ 24 - 0
packages/typert/generator/tests/cordis-catalog.spec.ts

@@ -22,6 +22,12 @@ const workspaceRoot = resolve(import.meta.dirname, '../../../..')
 let cached: ReturnType<typeof projectCordisCatalog> | undefined
 const projection = (): ReturnType<typeof projectCordisCatalog> =>
   (cached ??= projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY))
+let cachedClient: ReturnType<typeof projectCordisCatalog> | undefined
+const clientProjection = (): ReturnType<typeof projectCordisCatalog> =>
+  (cachedClient ??= projectCordisCatalog(workspaceRoot, {
+    ...CORDIS_CATALOG_POLICY,
+    runtimeDeclarationMaxChars: 4_096,
+  }, 'client'))
 
 const SOURCE_LINK_POLICY: CordisCatalogPolicy = {
   linkedTypePages: {},
@@ -93,6 +99,24 @@ describe('Typert-backed Cordis catalog', () => {
     expect(rendered).toContain('ACTIVE,')
   })
 
+  it('keeps the Slots service declaration and its referenced types within the display budget', { timeout: 480_000 }, () => {
+    const { projector, model } = clientProjection()
+    const slots = model.services.find(service => service.key === 'slots')
+    if (slots === undefined) throw new Error('Client slots service is missing')
+    const rendered = projector.renderRuntimeApi({
+      services: [{
+        ...slots,
+        methods: slots.methods.filter(method => /\b(?:inject|register|registerFactory)\b/u.test(method.signature)),
+      }],
+      events: [],
+    })
+    const slotCoreStart = rendered.indexOf("name: 'SlotCore'")
+    const slotCoreEnd = rendered.indexOf('\n  },', slotCoreStart)
+    expect(rendered.slice(slotCoreStart, slotCoreEnd)).not.toContain('truncated')
+    expect(rendered).toContain("name: 'StoredEntry'")
+    expect(rendered).toContain("name: 'SlotLabel'")
+  })
+
   it('resolves each key to the declaration a caller meets, and drops keys no plugin provides', { timeout: 480_000 }, () => {
     const byKey = new Map(projection().model.services.map(service => [service.key, service]))
     // An interface-typed key is described by its Service Definition: that is where

+ 10 - 0
scripts/gen-client-catalog.spec.ts

@@ -134,6 +134,14 @@ describe('client slot projection', () => {
       .toContain('built in')
   })
 
+  it('names the Factory definition that declares an ordinary child seat', () => {
+    const factory = registration({ key: 'demo.factory', children: ['demo.seat'], factory: true })
+    const [entry] = resolveSlotEntries([declaration()], [factory], OWNER_TYPES, kits)
+    expect(entry?.declaredBy)
+      .toContain("factory 'demo.factory' (client-demo)")
+    expect(entry?.occupants).toEqual([])
+  })
+
   it('reports an open keyed domain and the keys already taken', () => {
     const [entry] = resolveSlotEntries(
       [declaration({ kind: 'keyed' })],
@@ -210,5 +218,7 @@ describe('the real workspace surface', () => {
     const root = entries.find(entry => entry.key === 'root')
     expect(root?.replaceRisk).toBe('shadows-shipped-ui')
     expect(root?.occupants.join(' ')).toContain('AppFrame')
+    expect(entries.find(entry => entry.key === 'conversation.session')?.declaredBy)
+      .toContain("factory 'conversation.content' (client-ui-conversation)")
   })
 })

+ 6 - 3
scripts/gen-client-catalog.ts

@@ -208,7 +208,7 @@ export function validateSlotContracts(
     }
   }
   for (const registration of registrations) {
-    if (!byKey.has(registration.key)) {
+    if (registration.factory !== true && !byKey.has(registration.key)) {
       problems.push(`registration into '${registration.key}' (${registration.source}) targets a slot no SlotMap merge declares; either the scan has a blind spot or the registration is dead.`)
     }
     for (const child of registration.children) {
@@ -267,7 +267,8 @@ function entryOf(
   types: ReadonlyMap<string, TypeDeclaration>,
   kits: ReadonlyMap<string, readonly string[]>,
 ): SlotEntry {
-  const occupants = registrations.filter(registration => registration.key === declaration.key)
+  const occupants = registrations.filter(registration =>
+    registration.factory !== true && registration.key === declaration.key)
   const cellOccupied = occupants.some(occupant =>
     declaration.kind === 'single' || occupant.entryKey !== undefined)
   const doc = docProse(declaration.jsDoc)
@@ -287,7 +288,9 @@ function entryOf(
     slotInject: declaration.injectType ?? '',
     declaredBy: declaredBy === undefined
       ? 'the runtime itself (built in; always present)'
-      : `an entry in '${declaredBy.key}' (${shortPackage(declaredBy.package)}), so it exists while that entry is mounted`,
+      : declaredBy.factory === true
+        ? `factory '${declaredBy.key}' (${shortPackage(declaredBy.package)}), so it exists while that definition is registered`
+        : `an entry in '${declaredBy.key}' (${shortPackage(declaredBy.package)}), so it exists while that entry is mounted`,
     occupants: occupants.map(occupant => [
       shortPackage(occupant.package),
       occupant.component,

+ 5 - 2
scripts/gen-cordis-inspect-catalog.ts

@@ -13,7 +13,7 @@ const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
   layout: ['selectPanel', 'beginNavigation', 'toggleSidebar', 'openRightbar', 'closeRightbar'],
   locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'],
   sessions: ['retain', 'using', 'retainInfo', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'],
-  slots: ['register', 'inject'],
+  slots: ['register', 'registerFactory', 'inject'],
   theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'],
   uiWorkspace: [
     'openSession', 'openWorkspace', 'forkSession', 'connectWorkspace', 'startSession', 'archiveSession', 'unarchiveSession',
@@ -66,7 +66,10 @@ function clientModel(model: CordisCatalogModel): CordisCatalogModel {
 }
 
 function main(): void {
-  const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY, 'client')
+  const { projector, model } = projectCordisCatalog(root, {
+    ...CORDIS_CATALOG_POLICY,
+    runtimeDeclarationMaxChars: 4_096,
+  }, 'client')
   const destination = resolve(root, CLIENT_OUT)
   const source = projector.renderRuntimeApi(clientModel(model))
     .replace('Generated by scripts/gen-cordis-api.ts', 'Generated by scripts/gen-cordis-inspect-catalog.ts')

+ 12 - 9
scripts/slot-walk.ts

@@ -1,7 +1,7 @@
 /**
  * AST helpers for the client slot surface: the `SlotMap` declaration merges
- * that type every slot, and the `slots.register` call sites that say who
- * already occupies one. Both readings are lexical (no type-checker program):
+ * that type every slot, and the `slots.register` / `slots.registerFactory`
+ * call sites that say who occupies or declares one. Both readings are lexical:
  * the client catalog generator consumes them, and the same scan doubles as its
  * own exhaustiveness backstop because it reads every source file rather than a
  * reachable-export closure.
@@ -17,8 +17,8 @@ const SLOTS_MODULE = '@deepseek-ai/dsh-client-ui-slots'
 /** Cheap textual prefilter for a slot-contract merge, quote-style agnostic. */
 const MERGE_HEAD = /declare module ['"]@deepseek-ai\/dsh-client-ui-slots['"]/
 
-/** Cheap textual prefilter for a registration call site. */
-const REGISTER_HEAD = /\.register\(/
+/** Cheap textual prefilter for a Slot or Factory registration call site. */
+const REGISTER_HEAD = /\.(?:register|registerFactory)\(/
 
 /** One `SlotMap` member: the slot's contract as its owning package declares it. */
 export interface SlotDeclaration {
@@ -44,7 +44,7 @@ export interface SlotDeclaration {
   source: string
 }
 
-/** One `slots.register({ name, … }, Component)` call site. */
+/** One `slots.register()` or `slots.registerFactory()` call site. */
 export interface SlotRegistration {
   /** Target SlotMap key the entry contributes into. */
   key: string
@@ -58,6 +58,8 @@ export interface SlotRegistration {
   entryKey?: string
   /** SlotMap keys this registration declares as children (they exist while it is mounted). */
   children: string[]
+  /** Whether this call installs a Factory definition instead of occupying an ordinary Slot. */
+  factory?: boolean
   /** Source pointer `packages/…/file.ts:line`. */
   source: string
 }
@@ -183,9 +185,9 @@ export function slotDeclarations(file: ScannedFile): SlotDeclaration[] {
 }
 
 /**
- * Read every registration call site in one scanned file: which slot it
- * occupies, with which component and cell identity, and which child slots it
- * declares. A call whose `name` is not a string literal is skipped — the
+ * Read every Slot or Factory registration call site in one scanned file: which
+ * name it targets, with which component and cell identity, and which ordinary
+ * child slots it declares. A call whose `name` is not a string literal is skipped — the
  * shipped composition always names its target literally, and a computed name
  * carries no catalog fact.
  * @param file - a file returned by {@link scanSlotFiles}.
@@ -196,7 +198,7 @@ export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
   const visit = (node: ts.Node): void => {
     if (ts.isCallExpression(node)
       && ts.isPropertyAccessExpression(node.expression)
-      && node.expression.name.text === 'register'
+      && (node.expression.name.text === 'register' || node.expression.name.text === 'registerFactory')
       && isSlotsReceiver(node.expression.expression, file.sf)
       && node.arguments.length >= 1) {
       const options = node.arguments[0]
@@ -209,6 +211,7 @@ export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
             key,
             package: file.package,
             component: componentText(node.arguments[1], file.sf),
+            ...node.expression.name.text === 'registerFactory' ? { factory: true } : {},
             ...id === undefined ? {} : { id },
             ...entryKey === undefined ? {} : { entryKey },
             children: childKeys(options),

+ 1 - 1
snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md

@@ -71,7 +71,7 @@ interface ToolArgsMap {
   } & Record<string, JsonValue>;
   /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */
   cordis_inspect_list: Record<string, JsonValue>;
-  /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */
+  /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant. */
   cordis_inspect_query: {
     /** Runtime platform that owns the Provider. */
     platform: "host" | "client";

+ 1 - 1
snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json

@@ -55,7 +55,7 @@
     },
     {
       "name": "cordis_inspect_query",
-      "description": "Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.",
+      "description": "Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant.",
       "parameters": {
         "type": "object",
         "properties": {

+ 1 - 1
snapshots/web/cordis-tool-round/tool-schemas.expected.json

@@ -195,7 +195,7 @@
     },
     {
       "name": "cordis_inspect_query",
-      "description": "Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.",
+      "description": "Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant.",
       "parameters": {
         "type": "object",
         "properties": {

Некоторые файлы не были показаны из-за большого количества измененных файлов