Parcourir la source

Merge remote-tracking branch 'origin/master' into worktree/web-context-meter-footer

Yichen Jiang il y a 1 semaine
Parent
commit
d34628703a
100 fichiers modifiés avec 2894 ajouts et 220 suppressions
  1. 6 0
      .agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml
  2. 52 0
      .agents/notes/archived/architecture/2026-09-10-local-office-preview.md
  3. 52 0
      .agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md
  4. 6 0
      .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml
  5. 36 0
      .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md
  6. 36 0
      .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md
  7. 9 0
      .agents/notes/archived/manifest.json
  8. 6 0
      .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml
  9. 32 0
      .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md
  10. 32 0
      .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  12. 5 1
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  13. 5 1
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  14. 6 0
      .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml
  15. 105 0
      .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md
  16. 105 0
      .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md
  17. 6 0
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
  18. 65 0
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
  19. 65 0
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
  20. 2 2
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml
  21. 3 1
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
  22. 3 1
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md
  23. 1 0
      AGENTS.md
  24. 256 7
      apps/web/tests/document-preview.e2e.ts
  25. 7 0
      apps/web/tests/expected/office-font-notice.md
  26. 6 0
      apps/web/tests/fixtures/office/README.i18n.yaml
  27. 7 0
      apps/web/tests/fixtures/office/README.md
  28. 7 0
      apps/web/tests/fixtures/office/README.zh.md
  29. BIN
      apps/web/tests/fixtures/office/preview.doc
  30. BIN
      apps/web/tests/fixtures/office/preview.ppt
  31. BIN
      apps/web/tests/fixtures/office/preview.xls
  32. 52 0
      apps/web/tests/office-fixture.ts
  33. 3 1
      apps/web/tests/preview-boot.e2e.ts
  34. 22 17
      apps/web/tests/seeded-history.e2e.ts
  35. 1 0
      apps/web/tsconfig.json
  36. 2 2
      docs/capability-seams.i18n.yaml
  37. 3 1
      docs/capability-seams.md
  38. 3 1
      docs/capability-seams.zh.md
  39. 2 2
      docs/config-catalog.i18n.yaml
  40. 24 2
      docs/config-catalog.md
  41. 24 2
      docs/config-catalog.zh.md
  42. 2 2
      docs/event-producer-consumer.i18n.yaml
  43. 1 1
      docs/event-producer-consumer.md
  44. 1 1
      docs/event-producer-consumer.zh.md
  45. 2 2
      docs/subsystems/office-to-pdf.i18n.yaml
  46. 26 0
      docs/subsystems/office-to-pdf.md
  47. 26 0
      docs/subsystems/office-to-pdf.zh.md
  48. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  49. 4 2
      docs/subsystems/sidebar-right.md
  50. 4 2
      docs/subsystems/sidebar-right.zh.md
  51. 2 2
      docs/tool-catalog.i18n.yaml
  52. 1 1
      docs/tool-catalog.md
  53. 1 1
      docs/tool-catalog.zh.md
  54. 2 2
      packages/api/remotes/README.i18n.yaml
  55. 1 1
      packages/api/remotes/README.md
  56. 1 1
      packages/api/remotes/README.zh.md
  57. 2 1
      packages/api/remotes/package.json
  58. 3 1
      packages/api/remotes/src/client/index.ts
  59. 3 0
      packages/api/remotes/tsconfig.client.json
  60. 2 2
      packages/bundle/web-app/README.i18n.yaml
  61. 1 1
      packages/bundle/web-app/README.md
  62. 1 1
      packages/bundle/web-app/README.zh.md
  63. 1 1
      packages/bundle/web-app/cordis.patch.yml
  64. 9 1
      packages/bundle/web-app/package.json
  65. 1 1
      packages/bundle/web-app/tests/document-conversion.e2e.ts
  66. 128 0
      packages/bundle/web-app/tests/document-preview.spec.ts
  67. 11 10
      packages/client/AGENTS.md
  68. 2 2
      packages/client/README.i18n.yaml
  69. 1 1
      packages/client/README.md
  70. 1 1
      packages/client/README.zh.md
  71. 1 1
      packages/client/ui-chat/tsconfig.json
  72. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  73. 2 1
      packages/client/ui-conversation/README.md
  74. 2 1
      packages/client/ui-conversation/README.zh.md
  75. 12 2
      packages/client/ui-conversation/src/client/apply.ts
  76. 42 11
      packages/client/ui-conversation/src/client/contract/slots.ts
  77. 2 1
      packages/client/ui-conversation/src/client/index.ts
  78. 11 15
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  79. 9 12
      packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
  80. 3 3
      packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
  81. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  82. 4 2
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  83. 5 1
      packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
  84. 55 16
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  85. 1 2
      packages/client/ui-layout/src/client/index.ts
  86. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  87. 1 1
      packages/client/ui-plugin-manager/README.md
  88. 1 1
      packages/client/ui-plugin-manager/README.zh.md
  89. 12 1
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  90. 33 0
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  91. 2 2
      packages/client/ui-renderer/README.i18n.yaml
  92. 4 3
      packages/client/ui-renderer/README.md
  93. 4 3
      packages/client/ui-renderer/README.zh.md
  94. 1 3
      packages/client/ui-renderer/src/client/bindings.tsx
  95. 4 0
      packages/client/ui-renderer/src/client/errors.ts
  96. 2 1
      packages/client/ui-renderer/src/client/index.ts
  97. 170 17
      packages/client/ui-renderer/src/client/registry.ts
  98. 367 28
      packages/client/ui-renderer/src/client/scoped-slots.tsx
  99. 693 0
      packages/client/ui-renderer/tests/factory-slots.client.spec.tsx
  100. 145 1
      packages/client/ui-renderer/tests/registry.client.spec.ts

+ 6 - 0
.agents/notes/archived/architecture/2026-09-10-local-office-preview.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-local-office-preview.md
+2026-09-10-local-office-preview.md: ea32e37f0b2dcba866b78947c785dcee8e475b29
+2026-09-10-local-office-preview.zh.md: a1ac8337a1cf22f5842485c0790c6554635dc125

+ 52 - 0
.agents/notes/archived/architecture/2026-09-10-local-office-preview.md

@@ -0,0 +1,52 @@
+# Agent Note: Local Office preview through WASM PDF bytes
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-10-local-office-preview.zh.md)
+
+## Problem
+
+Office Open XML files are ZIP archives, so text fallback cannot provide a useful preview. Document conversion must stay local without taking focus or adding document content to a model conversation. Engine success alone cannot prove that the input has the claimed format or that a PDF was produced.
+
+## Decision
+
+A [document-render Service Definition](../../../../packages/document/document-render/README.md), a LibreOffice WASM provider, and Client/API consumers form the conversion capability. [Boot composition](../../../../packages/document/document-render-auto/README.md) mounts the provider and Remote controller only when `wasm.artifactDirectory` is configured. Invalid explicit configuration fails initialization. The Office Client registration always exists so an unconfigured Host receives guidance. Availability follows the generated Remote namespace without executable discovery or a duplicate Client flag.
+
+The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) uses pinned official LibreOffice source, Emscripten, and a LibreOfficeKit adapter. Each Node Worker owns one engine instance, its pthreads, and its memory filesystem. Terminating the Worker cancels synchronous engine calls; every conversion waits for its Worker to stop before returning. OOXML inspection verifies archive membership, content type, and resource bounds before startup.
+
+A VCL callback requests a font family before substitution or missing Unicode characters during glyph fallback. Host code indexes installed font metadata and copies selected files into engine memory, including complete font collections. Exact installed families precede configured alternatives; original-first fontconfig aliases preserve that ordering inside LibreOffice. Optional initial families use the same resolver. The engine has no Host filesystem mount, and rendering neither installs nor downloads fonts. The [build recipe](../../../../native/libreoffice-wasm/README.md) records source and toolchain revisions, patches, and asset hashes. Engine bundles remain immutable during provider use; a different build uses a new directory.
+
+The [independent engine release](../process/2026-09-11-independent-libreoffice-package.md) owns precompiled npm packaging and keeps engine compilation outside ordinary DSH builds.
+
+The provider returns caller-owned PDF bytes and independent `succeeded`, `timedOut`, and `cancelled` facts. PDF bytes are copied out of engine memory before teardown. The API consumer authorizes the source through [Workspace Files](2026-09-09-workspace-file-read-authority.md), requires successful uninterrupted conversion, and encodes the PDF directly. Source path and freshness version survive the PDF transport. Workspace Files limits source reads; the provider's `maxOutputBytes` alone limits generated PDFs. No temporary PDF or file lease is needed.
+
+The Office Client watches the selected Conversation's existing Deliverables projection and warms recent Office files without opening tabs. Its bounded memory cache checks authorized source metadata on every read and shares pending conversions between background and foreground callers. Only the last departing reader cancels a shared conversion; failures are not cached and connection reset discards cached bytes. [Document Preview](2026-09-08-document-preview-operations.md) owns format selection, loading, cancellation, and the PDF.js Worker.
+
+PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
+
+Missing-family observations accompany the PDF through the worker and Remote response. The Office plugin renders a keyed notice above the shared PDF scrollport, so dismissing it removes its layout height. The font resolver intersects explicit source and theme references with actual document requests, excluding engine defaults and font-table inventories; installed aliases and glyph fallback do not by themselves mean a family is absent.
+
+## Alternatives considered
+
+**Keep an installed native LibreOffice provider.** A second provider adds executable discovery, version queries, subprocess supervision, scratch files, and platform-specific confinement. This design uses WASM for all supported Office previews. A native provider needs a demonstrated rendering or deployment requirement that WASM cannot satisfy.
+
+**Return a temporary PDF path.** The Worker already supplies independent PDF bytes. Writing them to disk, authorizing another file read, and maintaining leases adds file ownership without a current consumer. Source authorization and output limits apply directly to the memory result.
+
+**Automate installed Microsoft Office.** Word for Mac requires GUI automation rather than headless conversion, introducing focus changes and per-file operating-system authorization. Native Office automation remains outside this preview capability.
+
+**Use an online converter or download the engine automatically.** Remote conversion uploads document content. Automatic installation adds runtime distribution and update responsibilities; explicit artifact configuration leaves deployment with the operator.
+
+**Convert PDF pages to PNG.** PDF.js already owns PDF presentation. Rasterizing adds another image pipeline and loses the existing PDF controls and selectable text.
+
+**Add a model-facing rendering tool or durable preview events.** Client preview does not supply model input. A model-facing tool requires logged facts and both SDK projections, so it is a separate consumer.
+
+**Scan every font-table entry for warnings.** Font tables include unused styles and templates. Reporting them would make the notice unrelated to rendered content; collection instead starts after engine initialization and ends after PDF save.
+
+## Consequences
+
+The single-provider design assumes WASM covers the required Office rendering behavior; it is not evidence of complete fidelity equivalence with native LibreOffice. Fidelity depends on the engine build and installed fonts. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) cover DOCX, XLSX, PPTX, selectable Chinese text, and embedded system fonts; ABI fixtures alone cannot establish rendering fidelity or assembled browser presentation.
+
+WASM instances have substantial memory and startup costs, so the provider bounds concurrency. The [font reuse and image resolution decision](2026-09-11-wasm-preview-font-and-image-budgets.md) owns shared Host metadata, Worker request memoization, and raster export limits; only selected fonts enter engine memory. Large collections require bounded memory growth, and CFF export requires checked stack space. A fatal abort prevents further calls into C++. A device without a font covering a character cannot render it correctly without additional fonts. Owning the WASM build adds compiler compatibility, patch maintenance, and redistribution obligations.
+
+Prewarming consumes conversion resources to reduce foreground waits; entry and byte limits bound retained Client results. Source and PDF bytes remain outside Session storage and persistent caches. The [provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover independent output facts, input/output rejection, queued cancellation, and active Worker termination. Existing preview registry, file read authority, and Session ownership decisions remain active.

+ 52 - 0
.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md

@@ -0,0 +1,52 @@
+# Agent Note: 通过 WASM PDF 字节进行本地 Office 预览
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-10-local-office-preview.md) | 中文
+
+## 问题
+
+Office Open XML 文件是 ZIP 归档,因此文本回退无法提供有用的预览。文档转换必须留在本地,不抢占焦点,也不将文档内容放入模型对话。仅凭引擎成功状态无法证明输入具有声明的格式或生成了 PDF。
+
+## 决定
+
+[document-render Service Definition](../../../../packages/document/document-render/README.zh.md)、LibreOffice WASM 提供方及 Client/API 消费方构成转换能力。[启动组合](../../../../packages/document/document-render-auto/README.zh.md)仅在配置 `wasm.artifactDirectory` 时挂载提供方和 Remote 控制器。无效的显式配置会使初始化失败。Office Client 注册始终存在,为未配置的 Host 提供指引。可用性由生成的 Remote 命名空间决定,不发现可执行文件,也不维护重复的 Client 标记。
+
+[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)使用固定的官方 LibreOffice 源码、Emscripten 和 LibreOfficeKit 适配器。每个 Node Worker 拥有一个引擎实例、其 pthread 和内存文件系统。终止 Worker 可取消同步引擎调用;每次转换均在返回前等待 Worker 停止。OOXML 检查在启动前验证归档成员、内容类型和资源限制。
+
+VCL 回调在替换前请求字体族,或在字形回退时请求缺失的 Unicode 字符。Host 代码索引已安装字体元数据,将选中的文件复制到引擎内存,包括完整字体集合。精确匹配的已安装字体优先于配置的替代项;原字体优先的 fontconfig 别名在 LibreOffice 内保留此顺序。可选的初始字体族使用同一解析器。引擎不挂载 Host 文件系统,渲染不安装或下载字体。[构建配方](../../../../native/libreoffice-wasm/README.zh.md)记录源码与工具链版本、补丁及产物哈希。提供方使用期间,引擎包保持不可变;不同构建使用新目录。
+
+[独立引擎发布](../process/2026-09-11-independent-libreoffice-package.zh.md)负责预编译 npm 打包,并将引擎编译与普通 DSH 构建隔离。
+
+提供方返回调用方拥有的 PDF 字节,以及独立的 `succeeded`、`timedOut` 和 `cancelled` 事实。PDF 字节在清理前从引擎内存复制出来。API 消费方通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件,要求转换成功且未被中断,并直接编码 PDF。源路径和新鲜度版本保留在 PDF 传输中。Workspace Files 限制源文件读取;生成 PDF 仅受提供方的 `maxOutputBytes` 限制。无需临时 PDF 或文件租约。
+
+Office Client 观察所选 Conversation 现有的 Deliverables 投影,在不打开 tab 的情况下预转换最近的 Office 文件。有上限的内存缓存每次读取都检查已授权的源文件元数据,并在后台和前台调用方之间共享进行中的转换。仅最后一个退出的读取方取消共享转换;失败不缓存,连接重置丢弃缓存字节。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责格式选择、加载、取消和 PDF.js Worker。
+
+PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
+
+缺失字体族观察值随 PDF 经过 Worker 和 Remote 响应。Office 插件在共享 PDF 滚动区域上方呈现 keyed 提示,关闭时移除其布局高度。字体解析器取源文档及主题显式引用与实际文档请求的交集,排除引擎默认字体和字体表清单;已安装别名和字形回退本身不代表字体族缺失。
+
+## 考虑过的替代方案
+
+**保留已安装的原生 LibreOffice 提供方。** 第二个提供方增加可执行文件发现、版本查询、子进程监督、临时文件和平台特定隔离。本设计将 WASM 用于全部受支持的 Office 预览。引入原生提供方需要证明存在 WASM 无法满足的渲染或部署需求。
+
+**返回临时 PDF 路径。** Worker 已提供独立的 PDF 字节。将其写入磁盘、授权另一处文件读取并维护租约,会增加文件所有权,而没有当前消费方需要这些操作。源文件授权和输出限制直接应用于内存结果。
+
+**自动操作已安装的 Microsoft Office。** Word for Mac 需要 GUI 自动化而非 headless 转换,会引入焦点变化和逐文件操作系统授权。原生 Office 自动化不属于本预览能力。
+
+**使用在线转换器或自动下载引擎。** 远程转换会上传文档内容。自动安装增加运行时分发和更新责任;显式产物配置将部署留给运维者。
+
+**将 PDF 页面转换为 PNG。** PDF.js 已负责 PDF 展示。栅格化增加另一条图像流水线,并失去现有 PDF 控件和可选择文本。
+
+**添加面向模型的渲染工具或持久化预览事件。** Client 预览不提供模型输入。面向模型的工具需要记录事实并更新两种 SDK 投影,因此属于独立消费方。
+
+**扫描全部字体表条目生成提示。** 字体表包含未使用的样式和模板。报告这些条目会使提示与实际渲染内容无关,因此收集从引擎初始化完成后开始,在 PDF 保存后结束。
+
+## 影响
+
+单一提供方设计假设 WASM 覆盖所需的 Office 渲染行为;这不是与原生 LibreOffice 完全等价的保真度证据。保真度取决于引擎构建和已安装字体。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)覆盖 DOCX、XLSX、PPTX、可选择中文文本和嵌入系统字体;仅有 ABI fixture 无法证明渲染保真度或完整浏览器展示。
+
+WASM 实例有较高的内存与启动成本,因此提供方限制并发。[字体复用与图像分辨率决策](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)负责共享 Host 元数据、Worker 请求记忆化和栅格导出限制;仅选中字体进入引擎内存。大型集合需要有界内存增长,CFF 导出需要受检查的栈空间。致命 abort 后不再调用 C++。设备没有覆盖某字符的字体时,无法在不添加字体的前提下正确渲染该字符。自行构建 WASM 会增加编译器兼容、补丁维护和再分发义务。
+
+预转换消耗转换资源以减少前台等待;条目数和字节限制约束 Client 保留的结果。源文件和 PDF 字节不进入 Session 存储或持久缓存。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖独立输出事实、输入/输出拒绝、排队取消和活跃 Worker 终止。现有预览注册表、文件读权限和 Session 所有权决策保持有效。

+ 6 - 0
.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.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-11-wasm-preview-font-and-image-budgets.md
+2026-09-11-wasm-preview-font-and-image-budgets.md: ab33b0bb6e28e5749ac38f36c08bc7539a368515
+2026-09-11-wasm-preview-font-and-image-budgets.zh.md: e6ff458e8b68ce08ff5d81ce6767a08a82974a74

+ 36 - 0
.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md

@@ -0,0 +1,36 @@
+# Agent Note: Font reuse and image resolution in WASM previews
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)
+
+## Problem
+
+Office conversion repeats font metadata parsing across documents and font matching within one document. Image-heavy documents also spend substantial PDF export time resampling images above preview resolution. These costs need separate controls because font reuse does not reduce image decoding or resampling.
+
+## Decision
+
+The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) builds a font metadata snapshot once during initialization. Each conversion Worker receives a structured clone. The Host retains names, face attributes, paths, sizes, and modification times; glyph coverage, request caches, and imported bytes belong to the Worker. Reloading the provider refreshes the snapshot. Workers validate indexed files when reading them, and an already imported font remains usable within that conversion.
+
+Each Worker memoizes complete requests: family, style, weight, italic, width, pitch, language, and ordered code points. The cache stores MEMFS paths and missing-family observations. Hits replay those observations because an initialization request can recur after document font collection starts. Worker termination releases both the cache and engine memory.
+
+Runtime PDF filter options reduce raster images to a configurable `maxImageResolution`, defaulting to 192 DPI. The [shared PDF canvas](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts) renders at 96 CSS DPI times the device pixel ratio; the default covers ratio 2. Text and vector graphics remain scalable. Explicit bookmark export preserves LibreOfficeKit's default when JSON filter options replace its implicit filter data.
+
+The [local Office preview decision](2026-09-10-local-office-preview.md) continues to own conversion lifetime, authorization, missing-font presentation, and PDF transport.
+
+## Alternatives considered
+
+**Index fonts inside every conversion Worker.** This keeps discovery off the Host event loop and sees newly installed fonts immediately, but repeats full-file metadata parsing across previews. A provider snapshot removes that repeated work at the cost of synchronous initialization and explicit reload after font changes.
+
+**Cache only the family name.** Style, language, pitch, and missing characters can select different files. A complete request key retains those distinctions and still captures repeated layout requests.
+
+**Keep 300 DPI for every preview.** Higher raster resolution retains detail for high pixel ratios and magnification but increases image export work. The configurable 192-DPI default follows the common display target without rasterizing text or rebuilding the engine.
+
+## Consequences
+
+Cold provider startup includes indexing and remains separate from conversion deadlines. Shared metadata retains no original font buffers, while each Worker still reads selected fonts and lazily decodes coverage. Installing or replacing fonts requires provider reload; a file changed since indexing can fail a later conversion.
+
+Reducing image resolution trades raster detail at high magnification for less export work. It is not an engine memory cap: LibreOffice can still decode full-resolution source images, load large font collections, or exhaust its build-time WASM memory maximum. No persistent font cache, filesystem watcher, or cross-document engine instance is introduced.
+
+[Font tests](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts) cover complete request keys, repeated missing-family observations, snapshot isolation, and failed imports. [Provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover snapshot reuse and reload. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) inspect exported image dimensions with default and overridden DPI settings alongside selectable text and page count; ABI forwarding alone cannot establish filter behavior.

+ 36 - 0
.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md

@@ -0,0 +1,36 @@
+# Agent Note: WASM 预览的字体复用与图像分辨率
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-11-wasm-preview-font-and-image-budgets.md) | 中文
+
+## 问题
+
+Office 转换在不同文档间重复解析字体元数据,并在同一文档内重复匹配字体。图片较多的文档还会在 PDF 导出时花费大量时间,重采样高于预览显示分辨率的图像。这些成本需要分别控制,因为字体复用不会减少图像解码或重采样。
+
+## 决策
+
+[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)在初始化时建立一次字体元数据快照。每个转换 Worker 接收结构化克隆。Host 保留名称、字体面属性、路径、大小和修改时间;字形覆盖、请求缓存及导入字节属于 Worker。重新加载提供方会刷新快照。Worker 读取已索引文件时校验文件,已经导入的字体在该次转换内仍可使用。
+
+每个 Worker 记忆化完整请求:字体族、样式、字重、斜体、字宽、字距类型、语言及有序码点。缓存保存 MEMFS 路径和缺失字体族观察值。命中时重放这些观察值,因为初始化请求可能在文档字体收集开始后再次出现。Worker 终止时释放缓存及引擎内存。
+
+运行时 PDF 过滤器选项将栅格图像降低到可配置的 `maxImageResolution`,默认 192 DPI。[共享 PDF 画布](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts)按 96 CSS DPI 乘以设备像素比渲染;默认值覆盖像素比 2。文本和矢量图形仍可缩放。JSON 过滤器选项替换隐含过滤器数据时,显式导出书签保留 LibreOfficeKit 的默认行为。
+
+[本地 Office 预览决策](2026-09-10-local-office-preview.zh.md)继续负责转换生命周期、授权、缺失字体展示和 PDF 传输。
+
+## 考虑过的替代方案
+
+**在每个转换 Worker 内索引字体。** 这会将发现工作移出 Host 事件循环,并立即识别新安装的字体,但会在不同预览间重复读取完整字体文件并解析元数据。提供方快照消除重复工作,代价是同步初始化,以及字体变化后显式重新加载。
+
+**只缓存字体族名称。** 样式、语言、字距类型和缺失字符可能选择不同文件。完整请求键保留这些区别,同时仍能命中重复排版请求。
+
+**所有预览保持 300 DPI。** 更高栅格分辨率保留高像素比及放大时的细节,但增加图像导出工作。可配置的 192 DPI 默认值符合常见显示目标,无需栅格化文本或重新构建引擎。
+
+## 影响
+
+提供方冷启动包含索引成本,不计入转换时限。共享元数据不保留原始字体缓冲区,各 Worker 仍会读取选中字体并按需解码字形覆盖。安装或替换字体后需要重新加载提供方;索引后发生变化的文件可能使后续转换失败。
+
+降低图像分辨率以高倍缩放下的栅格细节换取更少的导出工作。它不是引擎内存上限:LibreOffice 仍可能解码全分辨率原图、加载大型字体集合,或耗尽构建时设定的 WASM 内存上限。此实现不引入持久化字体缓存、文件系统监听器或跨文档引擎实例。
+
+[字体测试](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts)覆盖完整请求键、重复缺失字体族观察值、快照隔离和失败导入。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖快照复用和重新加载。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)检查默认及覆盖 DPI 设置时的导出图像尺寸、可选择文本和页数;仅验证 ABI 参数转发无法证明过滤器行为。

+ 9 - 0
.agents/notes/archived/manifest.json

@@ -364,6 +364,12 @@
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
+    "architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8",
+    "architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763",
+    "architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53",
+    "architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e",
+    "architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed",
+    "architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
@@ -1609,6 +1615,9 @@
     "process/2026-09-03-workspace-version-coherence-gate.i18n.yaml": "sha256:ac442dee172d396395b8516d6d38c4fcedbc855d735c86c378aec5c1098ecd8d",
     "process/2026-09-03-workspace-version-coherence-gate.md": "sha256:37b63a78506a8741eb04826dc1eea7e3a64c19fd4494c2d7754054a5602d308b",
     "process/2026-09-03-workspace-version-coherence-gate.zh.md": "sha256:cfa2c8fe6cf1d4665121a987861b70a91b5b41eb99e7ed192c85760a916fddf6",
+    "process/2026-09-11-independent-libreoffice-package.i18n.yaml": "sha256:e8d2fe75e05ac53438513a7c51dab6510bcecf644019391ee6650e73961c0f8d",
+    "process/2026-09-11-independent-libreoffice-package.md": "sha256:1292b409b7b4cbc4420868de5e3b7417b3c855889023ccfd9a61d824bf3019d8",
+    "process/2026-09-11-independent-libreoffice-package.zh.md": "sha256:384783da1614c7e2ea84ea513013af6b20e9d4545a03cf7fa068415078126932",
     "simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml": "sha256:43fe5daadc1491f94a3e34595182cad1591f76b64e1f13b48811b80fd7e53b94",
     "simplification/2026-06-19-drop-mutable-session-summary.md": "sha256:01647a5a14aa4e195328d4d39c6d80eb0723739e5a543115314716b280a93560",
     "simplification/2026-06-19-drop-mutable-session-summary.zh.md": "sha256:22389e0c29158b1f7a5a43ed6073f8795bb87cf51c04cf83be055359cd65c9f5",

+ 6 - 0
.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.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/process/2026-09-11-independent-libreoffice-package.md
+2026-09-11-independent-libreoffice-package.md: 05fd8eb86ffb75b564008eaffda5c98669afee27
+2026-09-11-independent-libreoffice-package.zh.md: b2abf61f8a5cf9b247fa2b94697eb90a252d4cff

+ 32 - 0
.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md

@@ -0,0 +1,32 @@
+# Agent Note: Independent precompiled LibreOffice package
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-11-independent-libreoffice-package.zh.md)
+
+## Problem
+
+Compiling LibreOffice inside ordinary DSH builds would require every contributor and CI job to acquire its toolchain and repeat a large native build. Desktop also needs immutable engine resources that can travel with its offline installation material.
+
+## Decision
+
+The [engine package](../../../../native/libreoffice-wasm/package.json) has its own version and an asset-only manifest export. Its source directory stays outside the main pnpm workspace. Installation has no lifecycle hook, and packaging verifies an existing successful build without compiling. The [release workflow](../../../../.github/workflows/libreoffice-wasm-release.yml) compiles only on explicit dispatch; publication requires a matching engine version tag and the protected npm environment.
+
+Each tarball contains the engine manifest and assets, corresponding source pins, patches, full source diff, and the built distribution's license and notices. Packaging rejects mismatched build receipts, modified assets, unlisted engine files, missing notices, and bundled fonts. A failure never triggers compilation automatically.
+
+The [local preview decision](../architecture/2026-09-10-local-office-preview.md) continues to own conversion and explicit artifact configuration. This change establishes release machinery; it does not add an unpublished engine dependency to DSH. The existing [Desktop package set](../../../../apps/desktop/scripts/prepare-package-set.ts) remains the intended carrier for a future fixed-version dependency and its offline seed.
+
+## Alternatives considered
+
+**Compile during ordinary builds or package installation.** This puts engine toolchain availability and compilation cost on every consumer, even when the engine version has not changed.
+
+**Extract a general Node conversion library.** Font handling, Worker ownership, and the conversion API can remain with the provider. Moving them is unnecessary to distribute precompiled assets or isolate compilation from DSH CI.
+
+**Link the engine source as a workspace dependency.** Workspace linking would replace a published precompiled package with a source directory that lacks its engine files.
+
+## Consequences
+
+The engine can be compiled and versioned independently of DSH. The package exposes files rather than a conversion API; the provider retains runtime ownership. Ordinary builds remain free of LibreOffice compilation, as checked by the [packaging tests](../../../../scripts/libreoffice-package.spec.ts).
+
+Initial publication and fixed-version consumer integration remain incomplete. A source tree that differs from its recorded recipe cannot produce a release tarball. Real-engine conversion, installation from the packed package, and Desktop offline seed qualification must accompany the first consumed engine release.

+ 32 - 0
.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md

@@ -0,0 +1,32 @@
+# Agent Note: 独立的预编译 LibreOffice 包
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-11-independent-libreoffice-package.md) | 中文
+
+## 问题
+
+在普通 DSH 构建中编译 LibreOffice,会要求每个贡献者和 CI job 获取工具链并重复执行大型本机构建。Desktop 也需要能随离线安装材料分发的不可变引擎资源。
+
+## 决策
+
+[引擎包](../../../../native/libreoffice-wasm/package.json)采用独立版本,只导出产物清单。源码目录不属于主 pnpm workspace。安装没有生命周期 hook,打包只校验已有的成功构建,不执行编译。[发布工作流](../../../../.github/workflows/libreoffice-wasm-release.yml)仅在明确调度时编译;发布要求匹配的引擎版本 tag 和受保护的 npm 环境。
+
+每个 tarball 包含引擎清单和产物、对应的源码版本、补丁、完整源码差异,以及构建发行物的许可证和声明。打包拒绝不匹配的构建记录、被修改的产物、未列入清单的引擎文件、缺失的声明和内置字体。失败不会自动触发编译。
+
+[本地预览决策](../architecture/2026-09-10-local-office-preview.zh.md)继续负责转换与显式产物配置。此改动建立发布机制,不向 DSH 添加尚未发布的引擎依赖。现有 [Desktop 包集合](../../../../apps/desktop/scripts/prepare-package-set.ts)仍是未来固定版本依赖及其离线 seed 的预期载体。
+
+## 考虑过的替代方案
+
+**在普通构建或包安装期间编译。** 即使引擎版本没有变化,每个消费方也要承担工具链可用性要求和编译开销。
+
+**抽出通用 Node 转换库。** 字体处理、Worker 所有权和转换 API 可以继续由提供方负责。分发预编译产物及将编译与 DSH CI 隔离不需要迁移这些逻辑。
+
+**将引擎源码链接为 workspace 依赖。** 工作区链接会把已发布的预编译包替换为缺少引擎文件的源码目录。
+
+## 影响
+
+引擎可以独立于 DSH 编译和发布版本。包暴露文件而非转换 API;提供方保留运行时所有权。[打包测试](../../../../scripts/libreoffice-package.spec.ts)检查普通构建不编译 LibreOffice。
+
+首次发布和固定版本消费方接入尚未完成。与记录的配方不一致的源码树不能产生发布 tarball。首个被消费的引擎发行版必须同时完成真实引擎转换、打包安装,以及 Desktop 离线 seed 验证。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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 .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
-2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793
-2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919
+2026-09-08-document-preview-operations.md: 980e2e443db13d6a956d554172bc765a5c11b435
+2026-09-08-document-preview-operations.zh.md: 364ef9ced6f8d98313f9bc287588c98daa408837

+ 5 - 1
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -16,12 +16,16 @@ Document Preview separates resource observation from content reads. The [resourc
 
 Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
 
 Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
 
+PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
+
 ## Alternatives considered
 
+**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
+
 **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads.
 
 **A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview.

+ 5 - 1
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md

@@ -16,12 +16,16 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
 
 可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
 
 Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
 
+PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
+
 ## 考虑过的替代方案
 
+**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
+
 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
 
 **核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。

+ 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。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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-11-node-office-kit.md
+2026-09-11-node-office-kit.md: d77d6c5392532f39a9993af60faad6474bc8f36c
+2026-09-11-node-office-kit.zh.md: a0cbd22e4f06bd4f2878adf27852f9d2378b8b33

+ 65 - 0
.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md

@@ -0,0 +1,65 @@
+# Agent Note: Node Office conversion with independently packaged engines
+
+Status: implemented
+
+English | [中文](2026-09-11-node-office-kit.zh.md)
+
+## Problem
+
+Binary Office and OOXML files require document layout before they can be previewed. Conversion must stay on the device without opening an Office application or adding source bytes to a model conversation. Native engines need platform-specific distribution, while browser conversion duplicates font transport, worker ownership, and resource limits across the Host and Client.
+
+## Decision
+
+The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime.
+
+The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills.
+
+The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
+
+A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes.
+
+The kit owns default serif, sans-serif, and monospace preference groups, including Chinese text families. Missing Chinese glyphs in Western text try the corresponding common text families before searching the remaining catalog, which avoids choosing a handwriting face solely because its file sorts first. The provider's optional `fontFallbacks` replaces these ordered groups without duplicating their defaults. Preferences preserve exact installed fonts and retain other covering fonts as a last resort; they are not a font whitelist. The native adapter writes missing-family choices into its private VCL profile. Installed metric-compatible fonts can resolve before that table, and platform glyph fallback remains available. Native platform selection and whole collection imports require inspecting the fonts actually used in exported PDFs.
+
+DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine.
+
+The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and appear in a dismissible notice above the Office scrollport; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
+
+Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
+
+The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion.
+
+Desktop installs the kit through its existing target-Node pnpm dependency installation and retains the complete dependency tree. Worker paths and executable permissions remain ordinary package files. The [Desktop build guide](../../../../apps/desktop/README.md) owns target selection and packaging; each signed application requires qualification on its target platform.
+
+The [Python executable distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) keeps the kit, target engine, and their dependency closure beside the executable. Its installed-wheel smoke relocates the payload, requires exactly the target backend, and converts DOCX once through that engine. Platform packaging and publication constraints belong to the [platform engine decision](2026-09-15-platform-office-engines.md).
+
+The notices gate permits only the exact API and engine package names at MPL-2.0 and continues to reject unrelated MPL or changed non-permissive terms. Each recipient must retain access to the kit’s corresponding LibreOffice source pin, patches, build instructions, and license notices; the engine packages retain their third-party notices. [MPL source availability](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) applies when distributing covered executables outside the organization.
+
+The [browser-only preview](../../../../packages/experimental/webworker-runtime/README.md) replaces the kit entry with an unavailable converter and excludes its engine dependency tree from the VFS image. Keeping the Host provider loadable preserves the ordinary Office error presentation without shipping Node Workers, native helpers, or WASM conversion resources to the browser.
+
+## Alternatives considered
+
+**Keep conversion in browser Workers.** This requires shipping the engine to the Client, browser font RPC, and shared-memory response headers. Node already owns authorized disk access and can serve the resulting PDF to every Client.
+
+**Use the installed soffice CLI or automate Microsoft Office.** Executable discovery and ambient versions weaken reproducibility; Office GUI automation additionally changes focus and requires application permissions. The distributed helper owns a fixed engine without requiring either application installation.
+
+**Load a native addon in the Host process.** A parser crash or synchronous stall would affect the Host. The separate native helper provides an independently terminable lifetime; its disk exchange also works with the WASM adapter.
+
+**Fall back after any native error.** Retrying a damaged package or failed document with another engine hides release defects, duplicates work, and makes output depend on failure timing. Platform selection requires the kit’s declared native target engine, or WASM when no native target is declared; it does not retry native failures with WASM.
+
+**Return a temporary PDF path, or rasterize pages to PNG.** The Client needs PDF bytes for its existing viewer and selectable text. Exposing temporary paths would add authorization and lease ownership; PNG would create another rendering pipeline and remove PDF controls.
+
+**Index every font for every render, or cache only family names.** Repeated indexing dominates small-document work, while family-only keys lose style and glyph distinctions. Shared metadata and complete per-request keys remove repeated parsing without retaining font buffers or introducing a filesystem watcher.
+
+**Compile at install time, download engines or fonts at runtime, or use an online converter.** These add toolchain or network requirements and may move private content off device. Prebuilt tarballs keep installation and conversion independent of those operations.
+
+**Keep an independent Office UI package.** Its reader, cache, and font notice have the same preview lifecycle and PDF presentation consumers. A separate plugin adds a package, boot wiring, and a Loader switch without an independently evolving UI responsibility. Host conversion and Remote authorization retain their separate plugins.
+
+**Remove the shared Client conversion cache.** Per-tab state cannot share pending conversion or retained PDFs across readers. A bounded cache avoids that repeated work while preserving per-reader cancellation and authorized version checks.
+
+**Add a model-facing rendering tool or durable preview events.** Preview provides no model input. Such a tool would require logged facts and both SDK projections and remains a separate consumer.
+
+## Consequences
+
+Native and WASM fidelity still depends on the build, source formatting, installed fonts, and platform font discovery. A missing glyph cannot be recovered without a covering font. Image resolution limits do not cap image decoding or total process memory. WASM retains bounded memory growth and checked stack space for large collections and CFF fonts; a fatal runtime abort prevents subsequent C++ cleanup calls. Macros and document-link updates are disabled by supported LOKit options and the pinned source patch; this does not constitute an OS sandbox.
+
+The [provider tests](../../../../packages/document/office-to-pdf/tests/provider.spec.ts), [Loader composition](../../../../packages/bundle/web-app/tests/document-preview.spec.ts), and [browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) own DSH lifecycle, authorization, and presentation evidence. Engine qualification additionally requires real DOC/DOCX/XLS/XLSX/PPT/PPTX conversion, external PDF text/font/page/image inspection, relocation and corrupted-package rejection, and same-input native/WASM performance samples. Mock helpers and microbenchmarks do not establish these outcomes. Each target's real builder and Desktop package need independent qualification; a successful local architecture does not prove the full matrix.

+ 65 - 0
.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md

@@ -0,0 +1,65 @@
+# Agent Note: 独立打包引擎的 Node Office 转换
+
+Status: implemented
+
+[English](2026-09-11-node-office-kit.md) | 中文
+
+## 问题
+
+二进制 Office 和 OOXML 文件需要经过文档排版才能预览。转换必须留在设备上,不能打开 Office 应用,也不能将源字节加入模型对话。原生引擎需要按平台分发,而浏览器转换会在 Host 和 Client 两侧重复字体传输、worker 生命周期与资源限额管理。
+
+## 决策
+
+[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。
+
+[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。
+
+服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
+
+converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
+
+kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中文正文字体。西文文本缺少中文字形时,先尝试同类的常用正文字体,再搜索其余字体目录,避免仅因文件排序靠前而选用手写体。provider 的可选 `fontFallbacks` 替换这些有序组,不重复维护默认值。优先规则保留已安装原字体的精确匹配,并以其他覆盖字体作为最后兜底;它们不是字体白名单。原生适配器将缺失字体的选择写入私有 VCL profile。已安装的度量兼容字体可能在查询该表前被选中,平台的字形回退仍然可用。原生平台的选择及完整字体集合的导入要求检查导出 PDF 实际使用的字体。
+
+DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。
+
+[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,在 Office 滚动区上方显示可关闭的提示;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
+
+普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
+
+[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。
+
+Desktop 通过现有的目标 Node pnpm 依赖安装流程安装 kit,并保留完整依赖树。Worker 路径和可执行权限仍由普通包文件承载。[Desktop 构建指南](../../../../apps/desktop/README.zh.md) 负责目标选择与打包;每个签名应用仍需在目标平台验收。
+
+[Python 可执行分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)将 kit、目标引擎及其依赖闭包保留在可执行文件旁。安装后的 wheel 冒烟测试会迁移载荷,要求仅存在目标后端,并通过该引擎转换一次 DOCX。各平台的打包与发布限制由[平台引擎决策](2026-09-15-platform-office-engines.zh.md)说明。
+
+声明检查仅放行精确的 API 与引擎包名及 MPL-2.0 条款,继续拒绝无关 MPL 包或变更后的非宽松条款。每位接收者都必须保有访问 kit 对应 LibreOffice 源码版本、补丁、构建说明与许可证声明的权限;引擎包保留各自的第三方声明。向组织外部分发受覆盖的可执行文件时,须满足 [MPL 源码可用性要求](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)。
+
+[纯浏览器预览](../../../../packages/experimental/webworker-runtime/README.zh.md)用返回不可用错误的转换器替代 kit 入口,并从 VFS 镜像排除其引擎依赖树。保留 Host provider 的可加载性,可以沿用正常的 Office 错误展示,而无需向浏览器分发 Node Worker、原生辅助程序或 WASM 转换资源。
+
+## 考虑过的替代方案
+
+**保留浏览器 Worker 转换。** 这需要向 Client 分发引擎、提供浏览器字体 RPC 和配置共享内存响应头。Node 已经拥有授权磁盘访问能力,可以向所有 Client 提供转换后的 PDF。
+
+**使用已安装的 soffice CLI 或自动化 Microsoft Office。** 可执行文件发现和环境中的版本削弱可复现性;Office GUI 自动化还会改变焦点并需要应用权限。随包辅助进程拥有固定引擎,不要求安装这两类应用。
+
+**在 Host 进程中加载原生 addon。** 解析器崩溃或同步阻塞会影响 Host。独立的原生辅助进程具有可单独终止的生命周期,其磁盘交换方式也适用于 WASM 适配层。
+
+**任何原生错误都触发回退。** 使用另一引擎重试损坏的包或失败文档会隐藏发布缺陷、重复计算,并使输出取决于失败时机。平台选择要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM;原生失败后不会使用 WASM 重试。
+
+**返回临时 PDF 路径,或将页面栅格化为 PNG。** Client 需要 PDF 字节来使用现有查看器和可选择文本。暴露临时路径会增加授权与租约管理;PNG 则会建立另一条渲染管线并丢失 PDF 控件。
+
+**每次渲染重新索引所有字体,或只按字体族缓存。** 重复索引主导小文档成本,而仅以字体族为键会丢失样式和字形差异。共享元数据与完整请求键减少重复解析,无需保留字体缓冲区或引入文件系统监听器。
+
+**安装时编译、运行时下载引擎或字体,或使用在线转换器。** 这些方案增加工具链或网络要求,并可能将私有内容移出设备。预构建 tarball 使安装与转换不依赖这些操作。
+
+**保留独立的 Office UI 包。** 其读取器、缓存和字体提示共享预览生命周期与 PDF 展示消费者。独立插件增加包、启动接线和 Loader 开关,却没有独立演进的 UI 职责。Host 转换与 Remote 授权仍保留独立插件。
+
+**移除共享的 Client 转换缓存。** tab 内状态无法在读取方之间共享进行中的转换或已保留的 PDF。有界缓存减少这些重复工作,同时保留各读取方的取消和已授权版本检查。
+
+**添加面向模型的渲染工具或持久预览事件。** 预览不会提供模型输入。这样的工具需要日志事实及两套 SDK 投影,仍属于独立消费者。
+
+## 后果
+
+原生与 WASM 的保真度仍取决于构建、源文件格式、已安装字体及平台字体发现。没有覆盖字体就无法恢复缺失字形。图片分辨率限额不限制图片解码或总进程内存。WASM 为大型字体集合和 CFF 字体保留有界内存增长与受检查的栈空间;致命运行时中止会阻止后续 C++ 清理调用。宏与文档链接更新由实际支持的 LOKit 选项和固定源码补丁禁用;这不构成操作系统沙箱。
+
+[提供方测试](../../../../packages/document/office-to-pdf/tests/provider.spec.ts)、[Loader 组合](../../../../packages/bundle/web-app/tests/document-preview.spec.ts)和[浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts)负责 DSH 生命周期、授权与展示证据。引擎验收还需要真实 DOC/DOCX/XLS/XLSX/PPT/PPTX 转换、外部 PDF 文本、字体、页数与图片检查、迁移安装和损坏包拒绝,以及同输入的原生/WASM 性能样本。模拟辅助进程和微基准不能证明这些结果。各目标的真实构建机与 Desktop 安装包需要独立验收;一个本地架构成功不能证明整个矩阵。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
-2026-09-15-bounded-office-conversion.md: 75e61ce0c49dafac68d4cf3a653d13cbee096e4c
-2026-09-15-bounded-office-conversion.zh.md: a2accf02ec058fa90b83e36bc761a2b7637eaef9
+2026-09-15-bounded-office-conversion.md: 78a2fc46d97500575262d2263bf5549037872264
+2026-09-15-bounded-office-conversion.zh.md: 88cb15c8cbf2200a9365b9a42cb5802c1bfb65d8

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md

@@ -10,7 +10,7 @@ Office preview and explicit document inspection can request the same conversion.
 
 ## Decision
 
-The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
+The `office-to-pdf` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
 
 The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
 
@@ -34,4 +34,6 @@ Foreground preview and explicit QA requests precede background work. Disabling b
 
 The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
 
+Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output.
+
 Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md

@@ -10,7 +10,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
 
 ## 决策
 
-`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
+`office-to-pdf` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
 
 [宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
 
@@ -34,4 +34,6 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
 
 缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
 
+Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。
+
 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。

+ 1 - 0
AGENTS.md

@@ -114,6 +114,7 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/
 - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers.
 - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
 - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
+- **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly.
 
 ## Secrets / .env
 

+ 256 - 7
apps/web/tests/document-preview.e2e.ts

@@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
-import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
+import { realOfficeBytes } from './office-fixture.ts'
+import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
 import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
@@ -29,6 +30,24 @@ async function successShot(page: Page, name: string): Promise<void> {
   await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true })
 }
 
+/** Exercise native browser selection and copy, including the text overlay's canvas alignment. */
+async function copyPdfText(page: Page, preview: Locator, expected: string): Promise<void> {
+  const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first()
+  await text.waitFor({ state: 'visible' })
+  await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text')
+  await text.click({ clickCount: 3 })
+  await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected)
+  await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
+  await page.keyboard.press('ControlOrMeta+C')
+  await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())).toBe(expected)
+  await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => {
+    const canvas = node.querySelector('canvas')!.getBoundingClientRect()
+    const layer = node.querySelector('.textLayer')!.getBoundingClientRect()
+    return Math.max(Math.abs(layer.width - canvas.width), Math.abs(layer.height - canvas.height),
+      Math.abs(layer.left - canvas.left), Math.abs(layer.top - canvas.top))
+  })).toBeLessThan(1)
+}
+
 /** Trigger the document owner's native scroll handler after a real first page overflows. */
 async function scrollForNextPage(body: Locator): Promise<void> {
   await body.evaluate((node) => {
@@ -51,6 +70,13 @@ async function canvasColor(canvas: Locator): Promise<string> {
   })
 }
 
+/** Select a workspace file through the Files tab and wait for its preview identity. */
+async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise<void> {
+  await filesTab.click()
+  await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
+  await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
+}
+
 describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -130,6 +156,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
         '</svg>',
       ].join('')),
       writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
+      writeFile(join(cwd, 'user-unit.pdf'), pdfFixture(2)),
+      ...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))),
+      writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()),
+      ...['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].map(extension => writeFile(join(cwd, `unavailable.${extension}`), Buffer.from('PK\u0003\u0004OFFICE_BINARY_PREVIEW'))),
       writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])),
     ])
 
@@ -171,12 +201,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     expect(restoredFilesClose).toBe(1)
     expect(restoredAdd).toBe(1)
     const preview = column.locator('[data-document-preview]')
-    const openFile = async (name: string): Promise<void> => {
-      await filesTab.click()
-      await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
-      await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
-    }
-    // Binary suffixes (bitmaps, PDF) drop the plain-text fallback; a single remaining viewer renders no control.
+    const openFile = openPreviewFile.bind(undefined, column, filesTab, preview)
     const viewer = preview.locator('[data-document-viewer-menu]')
     const body = preview.locator('[data-textpreview-body]')
     const sections = ['# Document preview']
@@ -307,6 +332,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const restoredColor = await canvasColor(secondPage)
     expect(restoredColor).toBe('blue')
     expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId)
+    await copyPdfText(page, preview, 'Selectable PDF text')
     await successShot(page, 'pdf')
     sections.push([
       '## PDF', '',
@@ -316,8 +342,81 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
       `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
       `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
+      '- Selected and copied text: Selectable PDF text',
     ].join('\n'))
 
+    await openFile('user-unit.pdf')
+    await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
+    await copyPdfText(page, preview, 'Selectable PDF text')
+    sections.push('## PDF page units\n\n- UserUnit 2: selected and copied text aligns with the canvas')
+
+    const viewportSize = page.viewportSize()!
+    try {
+      for (const rotation of [90, 180, 270]) {
+        await openFile(`rotated-${rotation}.pdf`)
+        for (const width of [viewportSize.width, 1280]) {
+          await page.setViewportSize({ ...viewportSize, width })
+          await copyPdfText(page, preview, 'Selectable PDF text')
+          // The fixture's only black pixels are text; canvas ink is independent of the overlay geometry.
+          await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => {
+            const canvas = node.querySelector('canvas')!
+            const canvasBox = canvas.getBoundingClientRect()
+            const textBox = node.querySelector('.textLayer span')!.getBoundingClientRect()
+            const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
+            let ink = 0
+            let aligned = 0
+            for (let i = 0; i < pixels.length; i += 4) {
+              if (pixels[i + 3]! < 128 || Math.max(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!) > 80) continue
+              ink++
+              const x = canvasBox.left + ((i / 4) % canvas.width + 0.5) * canvasBox.width / canvas.width
+              const y = canvasBox.top + (Math.floor(i / 4 / canvas.width) + 0.5) * canvasBox.height / canvas.height
+              if (x >= textBox.left - 1 && x <= textBox.right + 1 && y >= textBox.top - 1 && y <= textBox.bottom + 1) aligned++
+            }
+            return ink === 0 ? 0 : aligned / ink
+          })).toBeGreaterThan(0.95)
+        }
+      }
+    } finally { await page.setViewportSize(viewportSize) }
+    sections.push('## PDF page rotation\n\n- 90, 180, 270 degrees: selection and copied text align with canvas ink before and after resizing')
+
+    await openFile('selection.pdf')
+    await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
+    const selectionLayer = preview.locator('.textLayer')
+    const selectionText = selectionLayer.locator('span:not(.markedContent)')
+    const titleText = selectionText.filter({ hasText: 'JOURNAL' })
+    const priorityText = selectionText.filter({ hasText: 'HIGH / MEDIUM / LOW' }).first()
+    // The text resize observer aligns the overlay after the canvas becomes visible.
+    await titleText.waitFor({ state: 'visible' })
+    await priorityText.waitFor({ state: 'visible' })
+    const titleBox = await titleText.boundingBox()
+    const priorityBox = await priorityText.boundingBox()
+    if (titleBox === null || priorityBox === null) throw new Error('selection fixture text has no bounds')
+    const start = { x: titleBox.x + 1, y: titleBox.y + titleBox.height / 2 }
+    const end = { x: priorityBox.x + priorityBox.width / 2, y: priorityBox.y - 3 }
+    const drag = async (from: typeof start, to: typeof end, through?: typeof end): Promise<string> => {
+      await page.mouse.move(from.x, from.y)
+      await page.mouse.down()
+      try {
+        if (through !== undefined) await page.mouse.move(through.x, through.y, { steps: 15 })
+        await page.mouse.move(to.x, to.y, { steps: 15 })
+        return await page.evaluate(() => window.getSelection()?.toString() ?? '')
+      } finally { await page.mouse.up() }
+    }
+    const priority = { x: end.x, y: priorityBox.y + priorityBox.height / 2 }
+    const forward = await drag(start, end, priority)
+    expect(forward).toContain('JOURNAL')
+    expect(forward).toContain('THREE TASKS')
+    expect(forward).not.toContain('REFLECTION')
+    expect(forward).not.toContain('AFTER TABLE')
+    const backward = await drag(priority, start)
+    expect(backward).toContain('THREE TASKS')
+    expect(backward).not.toContain('REFLECTION')
+    expect(backward).not.toContain('AFTER TABLE')
+    expect(await selectionLayer.locator('br').first().evaluate(node => getComputedStyle(node, '::selection').backgroundColor))
+      .toBe('rgba(0, 0, 0, 0)')
+    await successShot(page, 'pdf-drag-selection')
+    sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent')
+
     await openFile('tiny.png')
     const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
     await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
@@ -461,6 +560,25 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       `- Tail: ${completed.at(-1)}`,
     ].join('\n'))
 
+    const officeMenus: number[] = []
+    const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.'
+    for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) {
+      await openFile(`unavailable.${extension}`)
+      expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
+      await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 })
+      expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
+      expect(await preview.getByText('OFFICE_BINARY_PREVIEW', { exact: false }).count()).toBe(0)
+      officeMenus.push(await viewer.count())
+    }
+    await successShot(page, 'office-unavailable')
+    sections.push([
+      '## Office unavailable', '',
+      `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
+      `- Guidance: ${configurationGuide}`,
+      '- Binary text shown: false',
+      '- Plain-text option and viewer picker: hidden',
+    ].join('\n'))
+
     await openFile('notes.unknown')
     const plainLines = preview.locator('[data-textpreview-line]')
     await expect.poll(() => plainLines.count()).toBe(2)
@@ -487,3 +605,134 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
   })
 })
+
+describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+
+  afterAll(async () => {
+    try { await browser?.close() } finally { await scaffold?.close() }
+  })
+
+  it('rejects renamed text and renders Chinese Office documents through the PDF worker', async () => {
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false,
+      extraOverlayPath: fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)),
+    })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    const tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    onTestFailed(async () => {
+      await saveFailureShot(page, `screenshots/0908-document-preview/office-${process.pid}`)
+    })
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
+    if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
+    await Promise.all([
+      writeFile(join(cwd, 'renamed.docx'), 'This is plain text renamed to docx.'),
+      writeFile(join(cwd, 'chinese.docx'), realOfficeBytes('docx', 'DSH Missing Preview Font')),
+      writeFile(join(cwd, 'chinese.xlsx'), realOfficeBytes('xlsx')),
+      writeFile(join(cwd, 'chinese.pptx'), realOfficeBytes('pptx')),
+      ...(['doc', 'xls', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
+      ...['doc', 'xls', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
+    ])
+    const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert')
+    try {
+      const column = page.locator('[data-rightbar-col]')
+      await page.locator('[data-sidebar-right-expand]').click()
+      await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+      await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
+      await column.locator('[data-files-reload]').click()
+      const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
+      const preview = column.locator('[data-document-preview]')
+      await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click()
+      expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
+      const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
+      await canvas.waitFor({ state: 'visible', timeout: 60_000 })
+      await expect.poll(() => canvas.evaluate((node) => {
+        const canvas = node as HTMLCanvasElement
+        const context = canvas.getContext('2d')
+        if (context === null) return false
+        const bytes = context.getImageData(0, 0, canvas.width, canvas.height).data
+        for (let index = 0; index < bytes.length; index += 4) {
+          if (bytes[index + 3] === 255 && bytes[index]! < 200 && bytes[index + 1]! < 200 && bytes[index + 2]! < 200) return true
+        }
+        return false
+      }), { timeout: 30_000 }).toBe(true)
+      const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
+      expect(workerNames).toContain('dsh-pdf')
+      expect(workerNames.some(name => /libreoffice|soffice/i.test(name))).toBe(false)
+      await copyPdfText(page, preview, 'Office preview')
+      await copyPdfText(page, preview, '中文文档')
+      expect((await preview.locator('[data-pdf-text]').allTextContents()).join('')).toContain('中文文档')
+      expect(convert).toHaveBeenCalledTimes(1)
+      await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
+      await canvas.waitFor({ state: 'visible' })
+      expect(convert).toHaveBeenCalledTimes(1)
+      const notice = preview.locator('[data-office-font-notice]')
+      const more = notice.getByRole('button', { name: 'Show more', exact: true })
+      await more.waitFor({ state: 'visible' })
+      const before = await canvas.evaluate(node => node.getBoundingClientRect().top)
+      await more.click()
+      const details = page.getByRole('dialog', { name: 'Missing fonts', exact: true })
+      await details.getByText('DSH Missing Preview Font', { exact: true }).waitFor({ state: 'visible' })
+      await successShot(page, 'office-font-details')
+      await page.keyboard.press('Escape')
+      await expect.poll(() => details.count()).toBe(0)
+      expect(await more.evaluate(node => node === document.activeElement)).toBe(true)
+      await more.click()
+      await page.getByRole('button', { name: 'Close font details', exact: true }).click()
+      expect(await more.isVisible()).toBe(true)
+      await notice.getByRole('button', { name: 'Dismiss font notice', exact: true }).click()
+      await expect.poll(() => notice.evaluate(node => node.getBoundingClientRect().height)).toBe(0)
+      const after = await canvas.evaluate(node => node.getBoundingClientRect().top)
+      expect(before - after).toBeGreaterThan(40)
+      const topInset = await preview.evaluate((node) => {
+        const body = node.querySelector('[data-textpreview-body]')!.getBoundingClientRect()
+        const canvas = node.querySelector('canvas')!.getBoundingClientRect()
+        return canvas.top - body.top
+      })
+      expect(topInset).toBe(0)
+      await successShot(page, 'office-font-dismissed')
+      await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [
+        '# Office font notice', '',
+        '- Requested absent family is listed: true',
+        '- Escape restores focus to Show more: true',
+        '- Closing details preserves the notice: true',
+        '- Dismissing the notice collapses its occupied height: 0',
+        `- Document top inset after dismissal: ${topInset}px`,
+      ].join('\n'), MODE)
+      await successShot(page, 'office-docx')
+      for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) {
+        await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
+        await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
+        await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
+        await successShot(page, `office-${extension}`)
+      }
+      expect(convert).toHaveBeenCalledTimes(6)
+      await openPreviewFile(column, filesTab, preview, 'chinese.docx')
+      await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
+      await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
+      await expect.poll(() => convert.mock.calls.length).toBe(7)
+      await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
+      await openPreviewFile(column, filesTab, preview, 'renamed.docx')
+      await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
+      expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
+      await successShot(page, 'office-invalid')
+      expect(convert).toHaveBeenCalledTimes(8)
+      for (const extension of ['doc', 'xls', 'ppt']) {
+        await openPreviewFile(column, filesTab, preview, `renamed.${extension}`)
+        await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
+        expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
+      }
+      expect(convert).toHaveBeenCalledTimes(11)
+      expect(tripwire.pageErrors).toEqual([])
+    } finally { convert.mockRestore() }
+  })
+})

+ 7 - 0
apps/web/tests/expected/office-font-notice.md

@@ -0,0 +1,7 @@
+# Office font notice
+
+- Requested absent family is listed: true
+- Escape restores focus to Show more: true
+- Closing details preserves the notice: true
+- Dismissing the notice collapses its occupied height: 0
+- Document top inset after dismissal: 0px

+ 6 - 0
apps/web/tests/fixtures/office/README.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 apps/web/tests/fixtures/office/README.md
+README.md: affa0b57b72414beb4b8e75f19481a92b8745d27
+README.zh.md: ff49b01c51754ae00c613a45ebc8d14f142da9c1

+ 7 - 0
apps/web/tests/fixtures/office/README.md

@@ -0,0 +1,7 @@
+# Binary Office fixtures
+
+English | [中文](README.zh.md)
+
+`preview.doc`, `preview.xls`, and `preview.ppt` contain `Office preview 中文文档`. They are the `one-page.doc`, `one-sheet.xls`, and `one-slide.ppt` fixtures from [LibreOffice Kit](https://github.com/deepseek-harness/libreoffice-kit/tree/main/test/fixtures), exported from Harness-authored OOXML with LibreOffice 26.8.0.3 using the Word, Excel, and PowerPoint 97 filters. The source documents retain the Harness MIT license.
+
+The browser regression reads these committed OLE files and verifies actual conversion and selectable PDF text. They contain no user documents and require no Office application or fixture generator during tests. They cover simple legacy imports, not complex-document fidelity.

+ 7 - 0
apps/web/tests/fixtures/office/README.zh.md

@@ -0,0 +1,7 @@
+# 二进制 Office 测试文件
+
+[English](README.md) | 中文
+
+`preview.doc`、`preview.xls` 和 `preview.ppt` 包含 `Office preview 中文文档`。它们来自 [LibreOffice Kit](https://github.com/deepseek-harness/libreoffice-kit/tree/main/test/fixtures) 的 `one-page.doc`、`one-sheet.xls` 和 `one-slide.ppt`,由 Harness 编写的 OOXML 通过 LibreOffice 26.8.0.3 的 Word、Excel 和 PowerPoint 97 过滤器导出。源文档保留 Harness 的 MIT 许可证。
+
+浏览器回归读取这些检入的 OLE 文件,验证真实转换和可选取的 PDF 文字。文件不含用户文档,测试期间不需要 Office 应用或文件生成器。它们覆盖简单的旧格式导入,不代表复杂文档的保真度。

BIN
apps/web/tests/fixtures/office/preview.doc


BIN
apps/web/tests/fixtures/office/preview.ppt


BIN
apps/web/tests/fixtures/office/preview.xls


+ 52 - 0
apps/web/tests/office-fixture.ts

@@ -0,0 +1,52 @@
+/** Small binary Office and OOXML documents for exercising the installed converter through the Web preview. */
+import { readFileSync } from 'node:fs'
+import { strToU8, zipSync } from 'fflate'
+
+const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
+const OFFICE_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
+const CONTENT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types'
+const TEXT = 'Office preview 中文文档'
+
+/**
+ * Create a valid one-page Office document containing Latin and Chinese text.
+ * @param extension - Office application and format to exercise.
+ * @param font - Latin family requested by generated OOXML documents; binary fixtures retain their stored fonts.
+ * @returns Compressed document bytes accepted by the production converter.
+ */
+export function realOfficeBytes(extension: 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx', font = 'Liberation Sans'): Uint8Array {
+  const files: Record<string, string> = {}
+  let main: string
+  let parts: Record<string, string>
+  switch (extension) {
+    case 'doc': case 'xls': case 'ppt':
+      return readFileSync(new URL(`./fixtures/office/preview.${extension}`, import.meta.url))
+    case 'docx':
+      main = 'word/document.xml'
+      parts = { [main]: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml' }
+      files[main] = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="宋体"/></w:rPr><w:t>${TEXT}</w:t></w:r></w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>`
+      break
+    case 'xlsx':
+      main = 'xl/workbook.xml'
+      parts = {
+        [main]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml',
+        'xl/worksheets/sheet1.xml': 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml',
+      }
+      files[main] = `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="${OFFICE_REL_NS}"><sheets><sheet name="Preview" sheetId="1" r:id="rId1"/></sheets></workbook>`
+      files['xl/_rels/workbook.xml.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`
+      files['xl/worksheets/sheet1.xml'] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><cols><col min="1" max="1" width="45" customWidth="1"/></cols><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>${TEXT}</t></is></c></row></sheetData></worksheet>`
+      break
+    case 'pptx':
+      main = 'ppt/presentation.xml'
+      parts = {
+        [main]: 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml',
+        'ppt/slides/slide1.xml': 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml',
+      }
+      files[main] = `<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="${OFFICE_REL_NS}"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`
+      files['ppt/_rels/presentation.xml.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/slide" Target="slides/slide1.xml"/></Relationships>`
+      files['ppt/slides/slide1.xml'] = `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Preview"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="7315200" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="2400"><a:latin typeface="${font}"/><a:ea typeface="宋体"/></a:rPr><a:t>${TEXT}</a:t></a:r><a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
+      break
+  }
+  files['_rels/.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/officeDocument" Target="${main}"/></Relationships>`
+  files['[Content_Types].xml'] = `<Types xmlns="${CONTENT_NS}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${Object.entries(parts).map(([part, type]) => `<Override PartName="/${part}" ContentType="${type}"/>`).join('')}</Types>`
+  return zipSync(Object.fromEntries(Object.entries(files).map(([path, content]) => [path, strToU8(content)])))
+}

+ 3 - 1
apps/web/tests/preview-boot.e2e.ts

@@ -18,6 +18,7 @@
  */
 import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { readFile } from 'node:fs/promises'
+import { once } from 'node:events'
 import { createServer } from 'node:http'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { tmpdir } from 'node:os'
@@ -211,7 +212,8 @@ async function respond(
  */
 async function serveDist(overrides: ReadonlyMap<string, string>): Promise<Site> {
   const server = createServer((request, response) => { void respond(request, response, overrides) })
-  await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
+  server.listen(0, '127.0.0.1')
+  await once(server, 'listening')
   const address = server.address()
   if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
   return {

+ 22 - 17
apps/web/tests/seeded-history.e2e.ts

@@ -471,24 +471,29 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await fileLink.waitFor({ timeout: 10_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
-    await fileLink.click()
-    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
     const column = page.locator('[data-rightbar-col]')
-    await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt'])
-    // Path label survives from the recorded args (a.txt).
-    await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
-    const path = column.locator('[data-textpreview-path]')
-    const absolutePath = join(scaffold.workspaceCwd, 'a.txt')
-    await expect.poll(() => path.textContent()).toBe(absolutePath)
-    expect(await path.getAttribute('title')).toBe(absolutePath)
-    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
-    const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
-    // Put the column back so the later goldens see the default frame.
-    await column.locator('[data-sidebar-right-toggle]').click()
-    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
-    await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
-    await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
+    try {
+      await fileLink.click()
+      await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
+      await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt'])
+      // Path label survives from the recorded args (a.txt).
+      await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
+      const path = column.locator('[data-textpreview-path]')
+      const absolutePath = join(scaffold.workspaceCwd, 'a.txt')
+      await expect.poll(() => path.textContent()).toBe(absolutePath)
+      expect(await path.getAttribute('title')).toBe(absolutePath)
+      await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
+      const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
+    } finally {
+      // Later cases share this page and require the sidebar closed even after a failed assertion.
+      if (await frame.getAttribute('data-rightbar-collapsed') !== 'true') {
+        await column.locator('[data-sidebar-right-toggle]').click()
+        await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
+      }
+      await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
+      await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
+    }
   })
 
   it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => {

+ 1 - 0
apps/web/tsconfig.json

@@ -45,6 +45,7 @@
     "tests/lifecycle-chrome.e2e.ts",
     "tests/details-session-lifecycle.e2e.ts",
     "tests/document-preview.e2e.ts",
+    "tests/office-fixture.ts",
     "tests/plugin-config.e2e.ts",
     "tests/plugin-manager.e2e.ts",
     "tests/plugin-install-cancel.e2e.ts",

+ 2 - 2
docs/capability-seams.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/capability-seams.md
-capability-seams.md: b2799a1f0965d687a95af23b67873de1b86a8560
-capability-seams.zh.md: 23fbcdb5621f3129ac699284bd17359941646041
+capability-seams.md: a93d18226eeba66d025fb097d988be8f2ff1f482
+capability-seams.zh.md: 192aff1aa0b5d6306d3165573dd7d478428ea27e

+ 3 - 1
docs/capability-seams.md

@@ -28,6 +28,7 @@ flowchart LR
   pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
   pkg_office_to_pdf["office-to-pdf"]
   svc_officeToPdf["ctx.officeToPdf<br/>Office to PDF conversion"]
+  pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
   pkg_attachment["attachment"]
   svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
   pkg_attachment_local["attachment-local"]
@@ -434,6 +435,7 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_mcpResources --> pkg_mcp_resources
+  svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
   svc_pluginManager --> pkg_plugin_manager
   svc_pluginManager --> pkg_ui_settings_plugin_inventory
   svc_profileContext --> pkg_plugin_manager
@@ -536,7 +538,7 @@ flowchart LR
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. |
-| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. |
+| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
 | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |

+ 3 - 1
docs/capability-seams.zh.md

@@ -30,6 +30,7 @@ flowchart LR
   pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
   pkg_office_to_pdf["office-to-pdf"]
   svc_officeToPdf["ctx.officeToPdf<br/>Office to PDF conversion"]
+  pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
   pkg_attachment["attachment"]
   svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
   pkg_attachment_local["attachment-local"]
@@ -436,6 +437,7 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_mcpResources --> pkg_mcp_resources
+  svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
   svc_pluginManager --> pkg_plugin_manager
   svc_pluginManager --> pkg_ui_settings_plugin_inventory
   svc_profileContext --> pkg_plugin_manager
@@ -538,7 +540,7 @@ flowchart LR
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 |
-| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 |
+| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
 | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |

+ 2 - 2
docs/config-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/config-catalog.md
-config-catalog.md: dd30690fbf89051c7242f0dc8104e15f79e8c163
-config-catalog.zh.md: 42054765eb75afdf3a6cb0598d7b81b612243fc7
+config-catalog.md: eeeaff21533f7d39934a5dd1b7bad24c6966456f
+config-catalog.zh.md: db3022cae52790f7e02e35e2fc6a755d0f325fd2

+ 24 - 2
docs/config-catalog.md

@@ -446,6 +446,29 @@ export interface Config {
 
 Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
+<a id="deepseek-aidsh-client-ui-sidebar-documentpreview"></a>
+
+## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`
+
+```ts config-catalog
+/** Transient Office conversion reuse within one Client connection. */
+export interface Config {
+  /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */
+  office: {
+    /** Maximum retained completed PDFs. */
+    maxCachedEntries: number
+    /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */
+    maxCachedBytes: number
+    /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */
+    maxPending: number
+    /** Maximum readers including source and renderer metadata lookups. */
+    maxReaders: number
+  }
+}
+```
+
+Source: [`packages/client/ui-sidebar-documentpreview/src/config.ts:5`](../packages/client/ui-sidebar-documentpreview/src/config.ts)
+
 <a id="deepseek-aidsh-compaction-basic"></a>
 
 ## `@deepseek-ai/dsh-compaction-basic`
@@ -1766,7 +1789,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts)
+Source: [`packages/document/office-to-pdf/src/index.ts:31`](../packages/document/office-to-pdf/src/index.ts)
 
 <a id="deepseek-aidsh-permission-presets"></a>
 
@@ -3789,7 +3812,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))

+ 24 - 2
docs/config-catalog.zh.md

@@ -448,6 +448,29 @@ export interface Config {
 
 来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
+<a id="deepseek-aidsh-client-ui-sidebar-documentpreview"></a>
+
+## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`
+
+```ts config-catalog
+/** Transient Office conversion reuse within one Client connection. */
+export interface Config {
+  /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */
+  office: {
+    /** Maximum retained completed PDFs. */
+    maxCachedEntries: number
+    /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */
+    maxCachedBytes: number
+    /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */
+    maxPending: number
+    /** Maximum readers including source and renderer metadata lookups. */
+    maxReaders: number
+  }
+}
+```
+
+来源:[`packages/client/ui-sidebar-documentpreview/src/config.ts:5`](../packages/client/ui-sidebar-documentpreview/src/config.ts)
+
 <a id="deepseek-aidsh-compaction-basic"></a>
 
 ## `@deepseek-ai/dsh-compaction-basic`
@@ -1768,7 +1791,7 @@ export interface Config {
 }
 ```
 
-来源: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts)
+来源: [`packages/document/office-to-pdf/src/index.ts:31`](../packages/document/office-to-pdf/src/index.ts)
 
 <a id="deepseek-aidsh-permission-presets"></a>
 
@@ -3791,7 +3814,6 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: b494af3ece876b5b10b30c3602d6912f25e5088a
-event-producer-consumer.zh.md: 36f97f867de1a897982a0dd7a463a8c20e85484d
+event-producer-consumer.md: e587a790acff79530ebce242678faabed15c304e
+event-producer-consumer.zh.md: b0489c1a8c729c7397abc4e1d6af11fbe393d6f6

+ 1 - 1
docs/event-producer-consumer.md

@@ -74,7 +74,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -76,7 +76,7 @@
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/subsystems/office-to-pdf.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/subsystems/office-to-pdf.md
-office-to-pdf.md: 71254a3a8a8b4665d8d42b935cc836cb7c644b10
-office-to-pdf.zh.md: c11889fc9bd4693d90ec60696fce4b57693fd027
+office-to-pdf.md: 65f46ded856e81f9bcd7890c11c82f0a22f769c4
+office-to-pdf.zh.md: df46af01d78241bbc88f6b85a83c47551faf4bfe

+ 26 - 0
docs/subsystems/office-to-pdf.md

@@ -10,6 +10,7 @@ The [document package family](../../packages/document/README.md) converts Office
 |---|---|
 | [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching |
 | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers |
+| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices |
 
 ## Requests and results
 
@@ -26,6 +27,14 @@ The [document package family](../../packages/document/README.md) converts Office
 
 The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads.
 
+## Preview reads
+
+`RenderedDocumentBytes` extends the workspace byte response with `missingFonts` and `generation`; the original source identity accompanies the converted PDF.
+
+The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The response carries base64 PDF bytes with the source absolute path and freshness version. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events.
+
+The `api/remotes` assembly mounts the conversion service's generated Remote descriptor. The shared Document Preview package registers Office formats with complete-byte loading and its existing PDF.js Worker. Each preview read rechecks renderer generation, source authorization, and version before sharing an in-flight conversion or cached PDF. Connection resets and plugin disposal cancel requests and clear cached bytes. Missing services show localized configuration guidance.
+
 ## Engine selection and limits
 
 The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC.
@@ -55,6 +64,23 @@ A provider lifetime owns all converters, queued calls, and temporary files.
  * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
  */
 convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfResult>
+
+/**
+ * Read and convert one Office file using the Session's ordinary filesystem authorization.
+ * @param workspaceFileScope - Session header lookup shared with workspaceFiles.
+ * @param path - absolute or workspace-relative Office path.
+ * @param priority - foreground preview or speculative background work.
+ * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
+ * @returns complete base64 PDF with original source identity and missing font families.
+ */
+@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
+
+/**
+ * Read the current rendering generation before reusing a Client PDF.
+ * @param signal - Remote caller cancellation.
+ * @returns provider lifetime, replaced with rendering, font, or engine configuration.
+ */
+@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration
 ```
 
 Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts)

+ 26 - 0
docs/subsystems/office-to-pdf.zh.md

@@ -10,6 +10,7 @@
 |---|---|
 | [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 |
 | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 |
+| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 |
 
 ## 请求和结果
 
@@ -26,6 +27,14 @@
 
 提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。
 
+## 预览读取
+
+`RenderedDocumentBytes` 在工作区字节响应上增加 `missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。
+
+`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。
+
+`api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。
+
 ## 引擎选择和限制
 
 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。
@@ -55,6 +64,23 @@ A provider lifetime owns all converters, queued calls, and temporary files.
  * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
  */
 convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfResult>
+
+/**
+ * Read and convert one Office file using the Session's ordinary filesystem authorization.
+ * @param workspaceFileScope - Session header lookup shared with workspaceFiles.
+ * @param path - absolute or workspace-relative Office path.
+ * @param priority - foreground preview or speculative background work.
+ * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
+ * @returns complete base64 PDF with original source identity and missing font families.
+ */
+@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
+
+/**
+ * Read the current rendering generation before reusing a Client PDF.
+ * @param signal - Remote caller cancellation.
+ * @returns provider lifetime, replaced with rendering, font, or engine configuration.
+ */
+@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration
 ```
 
 Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts)

+ 2 - 2
docs/subsystems/sidebar-right.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/subsystems/sidebar-right.md
-sidebar-right.md: 3b6e5eb08fc2c0a0bac74369bde4a48b2ee0fdb6
-sidebar-right.zh.md: 680e2ae16f8f8aeeea57b11172be8c8c9e359122
+sidebar-right.md: 6b7cc4a29535e5f69dcc42e3ab82f445d830c806
+sidebar-right.zh.md: ef41287ec6465af469467e45b48e9a3e9ffc5c47

+ 4 - 2
docs/subsystems/sidebar-right.md

@@ -106,14 +106,16 @@ A body, title and guide replacement receive the framework-injected `useTabInfo()
 
 ## Document renderers
 
-The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
+The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. A renderer registers its own body and can reuse shared presentation through its child slots. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
 
-The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control.
+The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. Suffixes declared in `binaryExtensions` suppress the plain-text alternative, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages`, `bytes-complete`, or `renderer`; `wrap` advertises support for the shared source-wrap control.
 
 [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array<ArrayBuffer>` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes.
 
 Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)).
 
+A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, and its body owns font notices above a nested PDF view. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
+
 ## Resource model
 
 The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch.

+ 4 - 2
docs/subsystems/sidebar-right.zh.md

@@ -106,14 +106,16 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 
 
 ## 文档渲染器
 
-`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
+`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。渲染器注册自己的正文,并可通过子 slot 复用共享展示组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
 
-[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。
+[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages`、`bytes-complete` 或 `renderer`;`wrap` 声明是否支持共享的源码换行控件。
 
 [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。
 
 Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
 
+自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文在嵌套 PDF 视图上方管理字体提示。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
+
 ## 资源模型
 
 模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。

+ 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
 {

+ 2 - 2
packages/api/remotes/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/api/remotes/README.md
-README.md: 756022485beba09751bc39737c3acd97b2194c75
-README.zh.md: a6a6af909c1603148c83b356f495f60629279c79
+README.md: 4e365165f0b1c406e3d5693c772dbcc21f125589
+README.zh.md: 913e08ca7e35e5a6d2756347bf24a840660f85e0

+ 1 - 1
packages/api/remotes/README.md

@@ -27,7 +27,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
 
 [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy.
 
-The Client assembly mounts Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
+The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
 
 This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md).
 

+ 1 - 1
packages/api/remotes/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包只选择并挂载生成的会话 contribution,不复制激活策略。
 
-Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件与会话引用、只读 Host 插件清单、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
+Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、动态 Cordis、文件与会话引用、只读 Host 插件清单、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
 
 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。
 

+ 2 - 1
packages/api/remotes/package.json

@@ -88,6 +88,7 @@
     "zod": "^4.4.3",
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
     "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
-    "@deepseek-ai/dsh-plugin-manager": "workspace:^"
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^",
+    "@deepseek-ai/dsh-office-to-pdf": "workspace:^"
   }
 }

+ 3 - 1
packages/api/remotes/src/client/index.ts

@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote'
 import commandsRemote from '@deepseek-ai/dsh-commands/remote'
 import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
+import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote'
 import goalsRemote from '@deepseek-ai/dsh-goal/remote'
 import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -33,6 +34,7 @@ export type {} from '@deepseek-ai/dsh-agent-presets/remote'
 export type {} from '@deepseek-ai/dsh-commands/remote'
 export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
 export type {} from '@deepseek-ai/dsh-goal/remote'
+export type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
 export type {} from '@deepseek-ai/dsh-llm/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
@@ -166,7 +168,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
       pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
-      permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
+      permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, officeToPdfRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))
     }

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -91,6 +91,9 @@
     },
     {
       "path": "../../boot/plugin-manager"
+    },
+    {
+      "path": "../../document/office-to-pdf"
     }
   ]
 }

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: f733218e857eaf42a8fb2779646b941c5cc1bd4f
-README.zh.md: 5c058067c6f849486229e9e400109565eccc3aea
+README.md: d74489dd294bbf0f8c09df02ffc42984517267cd
+README.zh.md: 9d80190bcf332e1271f1f00bceee34c72416cce4

+ 1 - 1
packages/bundle/web-app/README.md

@@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle.
+The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
 
 ### Patch semantics
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
 <details>
 <summary>实现细节——点击展开</summary>
 
-此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。
+此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
 
 ### patch 语义
 

+ 1 - 1
packages/bundle/web-app/cordis.patch.yml

@@ -229,7 +229,7 @@
       name: '@deepseek-ai/dsh-office-to-pdf'
 
     # The right Sidebar's document tab: bounded file reads with selectable
-    # Markdown, code, HTML, PDF, and plain-text renderers.
+    # Markdown, code, HTML, PDF, Office, and plain-text renderers.
     - id: ui-sidebar-documentpreview
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 

+ 9 - 1
packages/bundle/web-app/package.json

@@ -133,8 +133,16 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-fs-local": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-shell-env": "workspace:^",
-    "@deepseek-ai/dsh-system-prompt": "workspace:^"
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-typert-registry": "workspace:^",
+    "@deepseek-ai/libreoffice-kit": "0.0.1"
   }
 }

+ 1 - 1
packages/bundle/web-app/tests/document-conversion.e2e.ts

@@ -41,7 +41,7 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal
   await ctx.loader.await()
   const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-to-pdf')!
   await entry.fiber!.await()
-  expect(ctx.get('documentRenderController')).toBeUndefined()
+  expect(ctx.get('workspaceFiles')).toBeUndefined()
   expect(ctx.get('skills')).toBeUndefined()
   const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url))
   let reads = 0

+ 128 - 0
packages/bundle/web-app/tests/document-preview.spec.ts

@@ -0,0 +1,128 @@
+/** The Web bundle's Office rows retain independent configuration and Session file authorization. */
+import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
+import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
+import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
+import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
+import { FsError } from '@deepseek-ai/dsh-fs'
+import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
+import { expect, it, onTestFinished, vi } from 'vitest'
+
+const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
+vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
+
+it('loads the shipped Office rows with separately patched settings and authorized PDF output', async () => {
+  const directory = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-office-')))
+  const ctx = new Context()
+  onTestFinished(async () => {
+    try { await ctx.fiber.dispose() }
+    finally { vi.restoreAllMocks(); await rm(directory, { recursive: true, force: true }) }
+  })
+  const configPath = join(directory, 'cordis.yml')
+  const expectedRows = {
+    'office-to-pdf': '@deepseek-ai/dsh-office-to-pdf',
+    'ui-sidebar-documentpreview': '@deepseek-ai/dsh-client-ui-sidebar-documentpreview',
+  }
+  const rows = loadOverlayPatches('web-office-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
+    .flatMap(patch => patch.insert ?? []).filter(row => row.id !== undefined && Object.hasOwn(expectedRows, row.id))
+  expect(rows.map(row => [row.id, row.name])).toEqual(Object.entries(expectedRows))
+  const providerConfig = { maxInputBytes: 4096, maxConcurrentConversions: 1, fontFallbacks: [['Missing Serif', 'Available Serif']] }
+  const clientConfig = DocumentPreview.Config({ office: { maxCachedEntries: 3, maxCachedBytes: 8192 } })
+  const configured = applyEntryPatches(rows, [
+    { id: 'office-to-pdf', config: providerConfig },
+    { id: 'ui-sidebar-documentpreview', config: clientConfig },
+  ], (message) => { throw new Error(message) })
+  expect(configured.find(row => row.id === 'ui-sidebar-documentpreview')!.config).toEqual(clientConfig)
+  await writeFile(configPath, JSON.stringify([
+    { name: '@deepseek-ai/dsh-session' },
+    { name: '@deepseek-ai/dsh-session-projection' },
+    { name: '@deepseek-ai/dsh-sandbox-policy', config: { workspaceRoot: directory } },
+    { name: '@deepseek-ai/dsh-fs-local', config: { cwd: directory } },
+    { name: '@deepseek-ai/dsh-typert-registry' },
+    { name: '@deepseek-ai/dsh-api-workspace-files', config: { maxFileBytes: 1 } },
+    ...configured,
+  ]))
+  const pdf = Buffer.from('%PDF-1.7\nLoader preview\n%%EOF\n')
+  const render = vi.fn<Converter['render']>().mockImplementation(async ({ inputPath, outputPath }) => {
+    expect(await readFile(inputPath)).toEqual(Buffer.from('authorized OOXML'))
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: ['Missing Serif'] }
+  })
+  kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} })
+  ctx.baseUrl = pathToFileURL(directory).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  // Loader's native imports must share the test's source-plane Service classes.
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-session', SessionStore],
+    ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
+    ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
+    ['@deepseek-ai/dsh-fs-local', LocalFileSystem],
+    ['@deepseek-ai/dsh-typert-registry', TypertRegistry],
+    ['@deepseek-ai/dsh-api-workspace-files', WorkspaceFiles],
+    ['@deepseek-ai/dsh-office-to-pdf', OfficeToPdf],
+    ['@deepseek-ai/dsh-client-ui-sidebar-documentpreview', DocumentPreview],
+  ])
+  ctx.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof ctx.loader.internal>
+  await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
+  await ctx.loader.await()
+  const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry]))
+  for (const row of configured) await entries.get(row.id)!.fiber!.await()
+  const injections: IndexInjection[] = []
+  ctx.emit('webserver/index-inject', injections)
+  expect(injections).toEqual([
+    { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig },
+  ])
+
+  const id = SessionId('office-loader')
+  const session = ctx.sessions.create(id, { meta: { cwd: directory } })
+  const lookup = ctx.typert.lookups.get('workspaceFileScope')!
+  const scope = await lookup.resolve(id) as WorkspaceFileScope | undefined
+  if (scope === undefined) throw new Error('Expected the Session workspace scope')
+  expect(await lookup.resolve(SessionId('missing'))).toBeUndefined()
+  const sourcePath = join(directory, 'report.docx')
+  await writeFile(sourcePath, 'authorized OOXML')
+  const signal = new AbortController().signal
+  const version = (await ctx.workspaceFiles.stat(scope, 'report.docx', signal)).version
+  const before = session.seq
+  const result = await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)
+  expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length,
+    data: pdf.toString('base64'), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation })
+  const readAgain = vi.spyOn(ctx.fs, 'readBytes')
+  expect(await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).toEqual(result)
+  expect(readAgain).not.toHaveBeenCalled()
+  expect(await readFile(sourcePath, 'utf8')).toBe('authorized OOXML')
+  expect(session.seq).toBe(before)
+  expect(ctx.get('agents')).toBeUndefined()
+  const { maxConcurrentConversions: _count, ...kitOptions } = providerConfig
+  expect(kit.create).toHaveBeenCalledWith(expect.objectContaining(kitOptions))
+  await expect(ctx.officeToPdf.render(scope, 'missing.docx', 'foreground', signal))
+    .rejects.toMatchObject({ code: 'workspace-file/not-found' })
+  const refusal = new FsError('read refused', 'FS_SANDBOX_DENIED')
+  vi.spyOn(ctx.fs, 'stat').mockRejectedValueOnce(refusal)
+  await expect(ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal)
+  if (process.platform !== 'win32') {
+    await symlink(sourcePath, join(directory, 'link.docx'))
+    await expect(ctx.officeToPdf.render(scope, 'link.docx', 'foreground', signal))
+      .rejects.toMatchObject({ code: 'workspace-file/not-regular-file' })
+  }
+  expect(render).toHaveBeenCalledOnce()
+})

+ 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 控件、图标与内容渲染器 | — |

+ 1 - 1
packages/client/ui-chat/tsconfig.json

@@ -99,7 +99,7 @@
       "path": "../ui-workspace"
     },
     {
-      "path": "../ui-sidebar-documentpreview"
+      "path": "../ui-sidebar-documentpreview/tsconfig.client.json"
     },
     {
       "path": "../ui-input-trigger"

+ 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: e938bf2bd9f271616e587206ecb171ed34cc5f44
-README.zh.md: edd5cad195f08c235d580b7305433860a3f5c471
+README.md: 23690a0c71f9a3b81639fa763c6a095e1bf93011
+README.zh.md: 251fa9c6513773cfca3e092742a045b0ac57030c

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

@@ -46,7 +46,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.
 
@@ -130,6 +130,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

@@ -46,7 +46,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 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。
 
@@ -130,6 +130,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-plugin-manager/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-plugin-manager/README.md
-README.md: 2cfeaad06d2b0247057f5824213411019a8a3cef
-README.zh.md: e61a79b1b58d66fbec543966d12b7a8c15e402c9
+README.md: e7128dd2146cc0553ab36fdef5c31470be7877f2
+README.zh.md: ff7706f1d9747f553bf91f45a76fa5f50472330d

+ 1 - 1
packages/client/ui-plugin-manager/README.md

@@ -37,7 +37,7 @@ During installation, **Cancel install** asks the Host to stop the run and shows
 
 ### Switching a bundle
 
-A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The installation's own bundles are not on the page; the Settings Plugins section's Plugin list tab inspects them.
+A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
 
 ### Switching one row of a bundle
 

+ 1 - 1
packages/client/ui-plugin-manager/README.zh.md

@@ -37,7 +37,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用
 
 ### 切换一个组合包
 
-组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。随安装提供的组合包不在本页;设置中「插件」分区的「插件列表」标签页负责查看它们。
+组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
 
 ### 切换组合包里的一行
 

+ 12 - 1
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -51,6 +51,16 @@ type RowPhase = NonNullable<PackageRow['phase']>
 /** How long the list marks a package an install just enabled. */
 const HIGHLIGHT_MS = 2_400
 
+/** Built-in profile bundles stay out of this page even when the profile declares them as dependencies. */
+const BUILTIN_PROFILE_BUNDLES = new Set([
+  '@deepseek-ai/dsh-base',
+  '@deepseek-ai/dsh-web-app',
+  '@deepseek-ai/dsh-headless',
+  '@deepseek-ai/dsh-sdk-app',
+  '@deepseek-ai/dsh-acp-app',
+  '@deepseek-ai/dsh-sdk-minimal',
+])
+
 /** How long a toast holds: long enough to read a failure that names what broke. */
 function toastHoldMs(text: string): number {
   return Math.min(8_000, Math.max(3_000, text.length * 80))
@@ -848,7 +858,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
   // The page manages what the person installed, what the installation ships for them to switch on, and a
   // selected name the Host cannot read; the installation's other bundles are inspected in the Settings
   // Plugins section's Plugin list tab.
-  const listed = state.packages.filter(pkg => pkg.installed || pkg.optional || pkg.error !== undefined)
+  const listed = state.packages.filter(pkg => !BUILTIN_PROFILE_BUNDLES.has(pkg.name)
+    && (pkg.installed || pkg.optional || pkg.error !== undefined))
   const mine = listed.filter(pkg => pkg.installed || !pkg.optional)
   const official = listed.filter(pkg => pkg.optional && !pkg.installed)
   const loaded = state.status === 'ready' || state.status === 'error'

+ 33 - 0
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -155,6 +155,39 @@ describe('PluginManagerPage', () => {
     expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired)
   })
 
+  it('omits built-in profile dependencies from cards and counts while retaining optional and third-party bundles', () => {
+    renderTab({
+      packages: [
+        ...[
+          '@deepseek-ai/dsh-base',
+          '@deepseek-ai/dsh-web-app',
+          '@deepseek-ai/dsh-headless',
+          '@deepseek-ai/dsh-sdk-app',
+          '@deepseek-ai/dsh-acp-app',
+          '@deepseek-ai/dsh-sdk-minimal',
+        ].map(name => pkg({ name })),
+        pkg({ name: '@acme/dsh-base', readOnlyReason: 'management-required' }),
+        pkg({ name: 'dsh-better-sidebar' }),
+        pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true }),
+      ],
+    })
+    expect(screen.getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))).toEqual([
+      '@deepseek-ai/dsh-experimental-agent-team-profile', '@acme/dsh-base', 'dsh-better-sidebar',
+    ])
+    expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '2'])
+  })
+
+  it.each([false, true])('shows an empty list for built-in bundles with errors and installed=%s', (installed) => {
+    renderTab({
+      packages: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'].map(name => pkg({
+        name, installed, error: { code: 'operation-error', diagnostic: 'Unreadable bundle' },
+      })),
+    })
+    expect(screen.getByText(en.empty)).toBeTruthy()
+    expect(screen.queryAllByRole('listitem')).toHaveLength(0)
+    expect(document.querySelectorAll('[data-plugin-count]')).toHaveLength(0)
+  })
+
   it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => {
     const { actions } = renderTab({
       packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })],

+ 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)
 

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff