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

refactor(preview): let Office own document loading

yudshj 1 неделя назад
Родитель
Сommit
2e779cafca
40 измененных файлов с 666 добавлено и 322 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  3. 2 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
  7. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  8. 3 3
      docs/subsystems/sidebar-right.md
  9. 3 3
      docs/subsystems/sidebar-right.zh.md
  10. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  11. 3 3
      packages/client/ui-sidebar-documentpreview/README.md
  12. 3 3
      packages/client/ui-sidebar-documentpreview/README.zh.md
  13. 23 15
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  14. 24 22
      packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts
  15. 1 4
      packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts
  16. 24 0
      packages/client/ui-sidebar-documentpreview/src/client/document/tab-lifetime.ts
  17. 30 18
      packages/client/ui-sidebar-documentpreview/src/client/face.ts
  18. 0 1
      packages/client/ui-sidebar-documentpreview/src/client/index.ts
  19. 8 6
      packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx
  20. 12 0
      packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css
  21. 94 0
      packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx
  22. 17 3
      packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts
  23. 31 13
      packages/client/ui-sidebar-documentpreview/src/client/office/index.ts
  24. 4 0
      packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts
  25. 53 0
      packages/client/ui-sidebar-documentpreview/src/client/office/store.ts
  26. 20 24
      packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts
  27. 2 9
      packages/client/ui-sidebar-documentpreview/src/client/rpc.ts
  28. 22 7
      packages/client/ui-sidebar-documentpreview/src/client/store.ts
  29. 3 3
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  30. 0 100
      packages/client/ui-sidebar-documentpreview/tests/custom-reader.client.spec.ts
  31. 1 1
      packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx
  32. 7 18
      packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx
  33. 25 0
      packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts
  34. 4 7
      packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts
  35. 14 13
      packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts
  36. 3 11
      packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx
  37. 44 8
      packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts
  38. 156 0
      packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx
  39. 1 1
      packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts
  40. 17 16
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 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: 893b72018eeb27679216b2d5d3e65ffb905ba27c
-2026-09-08-document-preview-operations.zh.md: bcdc4773eb16b9005d7098ccca3044ddc1e44c0a
+2026-09-08-document-preview-operations.md: b0f1a1987a645932361c7593860d4ddf1e019046
+2026-09-08-document-preview-operations.zh.md: 2aa7441d515ecf3c463984aa09530849fd20d5b4

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -24,6 +24,8 @@ PDF.js's official TextLayerBuilder owns selection boundaries and copy normalizat
 
 ## 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 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.

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

@@ -24,6 +24,8 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界
 
 ## 考虑过的替代方案
 
+**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
+
 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
 
 **核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。

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

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

@@ -22,7 +22,7 @@ The kit owns default serif, sans-serif, and monospace preference groups, includi
 
 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 shared 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.
+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.
 

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

@@ -22,7 +22,7 @@ kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中
 
 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` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
+[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。缓存字节限制不约束临时传输或查看器内存。
 

+ 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: e1735f829fe847bcd2a87e468bc6b5594f472748
-sidebar-right.zh.md: af481b1356d847cef7d3222f0fc25e119ae8186e
+sidebar-right.md: e0953bec60ac601491a1b258f4811b68eb95c487
+sidebar-right.zh.md: 2cd7fbccd862716d46355f45091ac1f272c44b98

+ 3 - 3
docs/subsystems/sidebar-right.md

@@ -106,15 +106,15 @@ 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. A renderer can register a shared component under its own id; its optional `read` callback prepares complete bytes while preserving the source file identity and version. 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. 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` 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)).
 
-Office registrations request [Host-rendered PDFs](office-to-pdf.md), then reuse the same PDF body and controls. Their cache rechecks Session source access and version, and the document notice slot displays missing-font information.
+Renderer-owned loading receives `{ kind: 'source', 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
 

+ 3 - 3
docs/subsystems/sidebar-right.zh.md

@@ -106,15 +106,15 @@ 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 命名空间注册组件。渲染器可在自己的 id 下注册共享组件;可选的 `read` 回调准备完整字节,同时保留源文件身份和版本。切换渲染器不改变 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 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`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))。
 
-Office 注册请求 [Host 渲染的 PDF](office-to-pdf.zh.md),然后复用同一个 PDF 正文和控件。其缓存重新检查 Session 源文件访问和版本,文档提示 slot 显示缺失字体信息。
+渲染器自行加载时接收 `{ kind: 'source', 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)定义加载生命周期。
 
 ## 资源模型
 

+ 2 - 2
packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md
-README.md: a69ac05ddc157941fbbd217cbd6ba5f2ce7a96cd
-README.zh.md: 0a5d59f41b3280d8f36f08d62e59523ca07052d7
+README.md: 488ce02f42751f5b697bf5d4d6269280b881eba9
+README.zh.md: 10be248b9ab37a1ceb9b36cec15b3cc0b66046a4

+ 3 - 3
packages/client/ui-sidebar-documentpreview/README.md

@@ -31,9 +31,9 @@ Preview readable files in the right Sidebar and choose among registered renderer
 - **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
 - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
 
-Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, read? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. `binaryExtensions` determines text compatibility independently of loading mode. HTML, SVG, and unmatched extensions retain the plain-text fallback. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. A complete-byte renderer may supply `read` to prepare content and registers its body under its own `id`. The reader preserves the source path and version; changing readers invalidates cached bytes, and reload or tab closure cancels the active read. [Office previews](#office-preview) use this path to deliver locally converted PDFs.
+Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
 
-Converting readers can annotate byte content with missing font families. A keyed `sidebar.right.tab.document.notice` slot above the scrollport uses the selected reader id; the reader owns its notice while the PDF body remains shared.
+`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the body mounts before any bytes are read and receives `content: { kind: 'source', revision, loaded, reload }`. Its injected callbacks own content loading, errors, and cancellation. `loaded(version)` reports the displayed source version for the shared change notice; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store.
 
 <a id="addresses"></a>
 ## Addresses
@@ -80,7 +80,7 @@ A yellow notice identifies fonts unavailable during conversion. Show more opens
 <details>
 <summary>Office implementation — click to expand</summary>
 
-Office registration, caching, and font notices live in `src/client/office/` and reuse the shared PDF body. The registration remains available without the Host renderer. Injected `remote.officeToPdf` and `remote.workspaceFiles` namespaces supply conversion and freshness callbacks; their removal restores the unavailable reader. Registry, locale, and notice registrations are reversible effects. Office UI shares this package's Loader lifetime; the [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, whose descriptor is mounted by `api/remotes`. Office and PDF register the same lazy PDF body under their own ids, each with its own tab state.
+Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The notice sits above the Office scrollport. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`.
 
 The shared `documentFileBytes()` helper decodes ordinary file and converted PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer.
 

+ 3 - 3
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -31,9 +31,9 @@ kind: "package-reference"
 - **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
 
-文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, read? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。文本兼容性由 `binaryExtensions` 决定,与加载方式无关。HTML、SVG 和未匹配的扩展名保留纯文本回退。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。完整字节渲染器可提供 `read` 来准备内容,并以自己的 `id` 注册正文。读取器保留源文件路径和版本;切换读取器会使缓存字节失效,重新载入或关闭 tab 会取消当前读取。[Office 预览](#office-preview) 通过此路径提供本地转换的 PDF。
+文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
 
-转换读取器可在字节内容中附带缺失字体族。滚动区域上方的 keyed `sidebar.right.tab.document.notice` Slot 使用所选读取器 id;读取器负责自己的提示,PDF 正文仍然共享。
+`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,正文在读取任何字节前挂载,并接收 `content: { kind: 'source', revision, loaded, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 为共享变更提示报告已展示的源版本;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。
 
 <a id="addresses"></a>
 ## 地址
@@ -80,7 +80,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
 <details>
 <summary>Office 实现——点击展开</summary>
 
-Office 注册、缓存和字体提示位于 `src/client/office/`,复用共享 PDF 正文。Host 渲染器缺失时,注册仍然可用。注入的 `remote.officeToPdf` 和 `remote.workspaceFiles` 命名空间提供转换与版本检查回调;移除后恢复不可用读取器。注册表、本地化和提示注册均为可撤销的 effect。Office UI 与本包共享 Loader 生命周期;[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,其描述符由 `api/remotes` 挂载。Office 与 PDF 分别以自己的 id 注册相同的惰性 PDF 正文,并各自持有 tab 状态。
+Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。字体提示位于 Office 滚动区上方。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。
 
 共享 `documentFileBytes()` 辅助函数将普通文件与转换后 PDF 的响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。
 

+ 23 - 15
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -82,7 +82,7 @@ export interface TextPreviewInjected extends TextInjected {
 /** The body's composed props: the tab, its navigation, the shared store and face, and copy. */
 export type TextPreviewProps =
   & PropsRuntime<'sidebar.right.pane.tab'>
-  & PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.notice'>
+  & PropsRenderSlots<'sidebar.right.tab.document'>
   & PropsStore<TextStore>
   & InjectFace<TextPreviewInjected>
   & PropsLocale<'sidebarDocumentPreview'>
@@ -94,7 +94,7 @@ export type TextPreviewProps =
  */
 export function TextPreview({
   useTabInfo, useResource, useStore, actions, loadPage, reloadPages,
-  loadAll, reloadAll, useDocumentPreviews, renderSlot, t,
+  loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t,
 }: TextPreviewProps): ReactNode {
   const { tab } = useTabInfo()
   const { navigation, signal } = tab
@@ -113,8 +113,8 @@ export function TextPreview({
   }, [definitions, file.path, unviewable])
   const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0]
   const mode = selected?.loading
-  const readerId = selected?.read === undefined ? undefined : selected.id
-  const current = (state?.mode ?? 'text-pages') === mode && state?.readerId === readerId ? state : undefined
+  const contentRendererId = mode === 'renderer' ? selected?.id : undefined
+  const current = (state?.mode ?? 'text-pages') === mode && state?.contentRendererId === contentRendererId ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
   const scrollportRef = useRef<HTMLElement | null>(null)
   const storedScrollTopRef = useRef(0)
@@ -129,7 +129,7 @@ export function TextPreview({
   const pages = current?.pages
   const loaded = useMemo(() => loadedPages(pages ?? {}), [pages])
   const loadedThrough = lastLineLoaded(loaded)
-  const hasContent = loaded.length > 0 || current?.complete !== undefined
+  const hasContent = mode === 'renderer' ? current?.version !== undefined : loaded.length > 0 || current?.complete !== undefined
   storedScrollTopRef.current = state?.scrollTop ?? 0
   const bindBody = useCallback((body: HTMLDivElement | null): void => {
     const previous = bodyRef.current
@@ -146,10 +146,11 @@ export function TextPreview({
   // reads nothing, because the store outlives the body.
   const started = current !== undefined
   useEffect(() => {
-    if (started || !canRead || mode === undefined) return
+    if (started || !canRead || mode === undefined || selected === undefined) return
     if (mode === 'text-pages') loadPage(tab.id, file, 1, signal, meta.value?.version)
-    else loadAll(tab.id, file, signal, meta.value?.version, selected)
-  }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, selected, meta.value?.version])
+    else if (mode === 'bytes-complete') loadAll(tab.id, file, signal, meta.value?.version)
+    else prepareRenderer(tab.id, signal, selected.id, meta.value?.version)
+  }, [started, tab.id, file, signal, loadPage, loadAll, prepareRenderer, canRead, mode, selected, meta.value?.version])
 
   // Come back where the reader was once there is content to scroll: on a remount,
   // after a reload rebuilt the content, or after the selected renderer changed.
@@ -187,15 +188,24 @@ export function TextPreview({
     selected?.id, mode, file, canRead, meta.value?.version,
   ])
 
+  const rendererReload = useCallback((): void => {
+    if (canRead && selected !== undefined) prepareRenderer(tab.id, signal, selected.id, meta.value?.version, true)
+  }, [canRead, prepareRenderer, tab.id, signal, selected?.id, meta.value?.version])
   const content = useMemo((): DocumentContent | undefined => {
+    if (mode === 'renderer') {
+      if (current === undefined) return undefined
+      const revision = current.loadRevision
+      return { kind: 'source', revision, reload: rendererReload,
+        loaded: (version) => { actions.rendered(tab.id, revision, version) } }
+    }
     if (mode === 'bytes-complete') {
       return current?.complete === undefined ? undefined : {
-        kind: 'bytes', data: current.complete.data, missingFonts: current.complete.missingFonts,
+        kind: 'bytes', data: current.complete.data,
       }
     }
     if (current === undefined || loaded.length === 0) return undefined
     return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof }
-  }, [mode, loaded, current?.complete, current?.eof])
+  }, [mode, loaded, current?.complete, current?.eof, current?.loadRevision, rendererReload, actions, tab.id])
 
   // A known binary suffix with no matching renderer never reads: no plain-text
   // fallback, no viewer control, only the path and the unsupported line.
@@ -236,7 +246,8 @@ export function TextPreview({
   const reload = (): void => {
     if (!canRead) return
     if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version)
-    else reloadAll(tab.id, file, signal, meta.value?.version, selected)
+    else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, meta.value?.version)
+    else rendererReload()
   }
   return (
     <div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
@@ -319,9 +330,6 @@ export function TextPreview({
           </button>
         </Tooltip>
       </div>
-      {content !== undefined && renderSlot('sidebar.right.tab.document.notice', {
-        resourceAddress: tab.contentId, sourceVersion: current?.complete?.version, content,
-      }, { entryKey: selected.id })}
       <div
         ref={bindBody}
         className={clsx(css.body, state.wrap && css.wrap)}
@@ -337,7 +345,7 @@ export function TextPreview({
             && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()
         }}
       >
-        {!hasContent && current?.failure === undefined && (
+        {mode !== 'renderer' && !hasContent && current?.failure === undefined && (
           <LoadingIndicator className={clsx(css.statusLine, css.bodyLoading)} label={t('loading')} />
         )}
         {content !== undefined && renderSlot('sidebar.right.tab.document', {

+ 24 - 22
packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts

@@ -11,39 +11,41 @@ export interface DocumentTextPage {
 }
 
 /**
- * Contents prepared by the preview owner using ordinary file reads.
+ * Ordinary file contents, or a request for a renderer to load its own source.
  * Byte arrays are transient UI input, never persisted layout or Session data.
  */
 export type DocumentContent =
   | { readonly kind: 'text'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }
-  | { readonly kind: 'bytes'; readonly data: Uint8Array<ArrayBuffer>; readonly missingFonts?: readonly string[] | undefined }
+  | { readonly kind: 'bytes'; readonly data: Uint8Array<ArrayBuffer> }
+  | {
+    readonly kind: 'source'
+    /** Changes on reload or implementation replacement; retained contents belong to one revision. */
+    readonly revision: number
+    /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */
+    readonly loaded: (version: string) => void
+    /** Cancel the current load and start a new revision. */
+    readonly reload: () => void
+  }
+
+/** Content and viewing inputs shared by document bodies and nested PDF presentation. */
+export interface DocumentBodyOwner {
+  /** Original file address, also readable through the standard useResource hook. */
+  readonly resourceAddress: string
+  /** Ordinary file content or a renderer-owned source request; text accumulates until eof. */
+  readonly content: DocumentContent
+  /** The document toolbar's current wrapping preference. */
+  readonly wrap: boolean
+  /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */
+  readonly scrollportRef: RefCallback<HTMLElement>
+}
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
-    /** Renderer-owned notice above the document scrollport, selected by the reader id. */
-    'sidebar.right.tab.document.notice': {
-      kind: 'keyed'
-      scope: 'session'
-      owner: {
-        readonly resourceAddress: string
-        readonly sourceVersion: string | undefined
-        readonly content: DocumentContent
-      }
-    }
     /** Document body selected by a registered implementation id. */
     'sidebar.right.tab.document': {
       kind: 'keyed'
       scope: 'session'
-      owner: {
-        /** Original file address, also readable through the standard useResource hook. */
-        readonly resourceAddress: string
-        /** Loaded content; text is an accumulated prefix until eof. */
-        readonly content: DocumentContent
-        /** The document toolbar's current wrapping preference. */
-        readonly wrap: boolean
-        /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */
-        readonly scrollportRef: RefCallback<HTMLElement>
-      }
+      owner: DocumentBodyOwner
       hookContext: UseSidebarRightTabInfo
       inject: {
         hooks: {

+ 1 - 4
packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts

@@ -1,10 +1,9 @@
 /** File-extension preview registrations; component dispatch belongs to the keyed document slot. */
-import type { ReadDocumentBytes } from '../rpc.ts'
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import { documentFileName, matchedSuffixLength, normalizeSuffix } from './suffix.ts'
 
 /** How the document owner delivers file contents to a renderer. */
-export type DocumentLoadMode = 'text-pages' | 'bytes-complete'
+export type DocumentLoadMode = 'text-pages' | 'bytes-complete' | 'renderer'
 
 /** One renderer implementation, independent of its component registration. */
 export interface DocumentPreviewDefinition {
@@ -26,8 +25,6 @@ export interface DocumentPreviewDefinition {
   readonly loading: DocumentLoadMode
   /** Whether the implementation consumes the document's wrap preference. */
   readonly wrap?: boolean
-  /** Prepare complete bytes through a renderer-owned read; source identity and version must be preserved. */
-  readonly read?: ReadDocumentBytes
 }
 
 /**

+ 24 - 0
packages/client/ui-sidebar-documentpreview/src/client/document/tab-lifetime.ts

@@ -0,0 +1,24 @@
+/** Document view state belongs to tab records, including while their bodies are hidden. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+/**
+ * Release retained view state on tab closure or plugin disposal.
+ * @param ctx - owning preview plugin context.
+ * @returns a callback accepting the tab, its lifetime signal, and its store's forget action; repeated holds share one listener.
+ */
+export function retainDocumentTabs(ctx: Context): (tabId: TabId, signal: AbortSignal, forget: (tabId: TabId) => void) => void {
+  const retained = new Map<AbortSignal, () => void>()
+  ctx.effect(() => () => { for (const forget of retained.values()) forget() })
+  return (tabId, signal, forgetTab) => {
+    if (signal.aborted) { forgetTab(tabId); return }
+    if (retained.has(signal)) return
+    const forget = (): void => {
+      signal.removeEventListener('abort', forget)
+      retained.delete(signal)
+      forgetTab(tabId)
+    }
+    retained.set(signal, forget)
+    signal.addEventListener('abort', forget, { once: true })
+  }
+}

+ 30 - 18
packages/client/ui-sidebar-documentpreview/src/client/face.ts

@@ -21,7 +21,7 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from './rpc.ts'
 import type { TextStore } from './store.ts'
-import type { DocumentLoadMode, DocumentPreviewDefinition } from './document/registry.ts'
+import type { DocumentLoadMode } from './document/registry.ts'
 
 /** The preview's injected business face, as the body receives it. */
 export interface TextInjected {
@@ -49,26 +49,33 @@ export interface TextInjected {
   readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void
   /**
    * Read the complete file for a whole-file renderer.
-   * @param reader - optional renderer-owned conversion and identity.
    * @param tabId - owning tab.
    * @param file - the session and workspace path the tab's address names.
    * @param signal - tab lifetime.
    * @param observedVersion - metadata version observed at read start.
    */
   readonly loadAll: (
-    tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, reader?: DocumentPreviewDefinition,
+    tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
   ) => void
   /**
    * Discard the old complete result and read again.
-   * @param reader - optional renderer-owned conversion and identity.
    * @param tabId - owning tab.
    * @param file - the session and workspace path the tab's address names.
    * @param signal - tab lifetime.
    * @param observedVersion - metadata version observed at read start.
    */
   readonly reloadAll: (
-    tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, reader?: DocumentPreviewDefinition,
+    tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
   ) => void
+  /**
+   * Begin a renderer-owned load without reading source bytes.
+   * @param tabId - owning tab.
+   * @param signal - tab lifetime.
+   * @param rendererId - selected implementation.
+   * @param observedVersion - metadata version observed at request start.
+   * @param reload - discard the previous content revision.
+   */
+  readonly prepareRenderer: (tabId: TabId, signal: AbortSignal, rendererId: string, observedVersion?: string, reload?: boolean) => void
 }
 
 /**
@@ -80,7 +87,7 @@ interface TabReads {
   generation: number
   version: string | undefined
   mode: DocumentLoadMode
-  readerId?: string
+  rendererId?: string
   controller?: AbortController
 }
 
@@ -110,12 +117,12 @@ export function textFace(
       }, { once: true })
       return created
     }
-    const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode, readerId?: string): TabReads => {
+    const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode, rendererId?: string): TabReads => {
       const reads = readsOf(tabId, signal)
-      if (reads.mode !== mode || reads.readerId !== readerId) {
+      if (reads.mode !== mode || reads.rendererId !== rendererId) {
         reads.controller?.abort()
-        if (readerId === undefined) delete reads.readerId
-        else reads.readerId = readerId
+        if (rendererId === undefined) delete reads.rendererId
+        else reads.rendererId = rendererId
         reads.mode = mode
         reads.generation++
         reads.version = undefined
@@ -145,17 +152,16 @@ export function textFace(
       })
     }
     const loadAll = (
-      tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, reader?: DocumentPreviewDefinition,
+      tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
     ): void => {
       if (signal.aborted) return
-      const readerId = reader?.read === undefined ? undefined : reader.id
-      const reads = modeOf(tabId, signal, 'bytes-complete', readerId)
+      const reads = modeOf(tabId, signal, 'bytes-complete')
       reads.controller?.abort()
       const controller = new AbortController()
       reads.controller = controller
       const lifetime = AbortSignal.any([signal, controller.signal])
-      actions.loading(tabId, 'bytes-complete', observedVersion, readerId)
-      void (reader?.read ?? readAll)(file, lifetime).then((result) => {
+      actions.loading(tabId, 'bytes-complete', observedVersion)
+      void readAll(file, lifetime).then((result) => {
         if (lifetime.aborted) return
         if (!result.ok) {
           actions.failed(tabId, result.error)
@@ -173,7 +179,7 @@ export function textFace(
       })
     }
     const restart = (
-      tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, mode: DocumentLoadMode = 'text-pages', reader?: DocumentPreviewDefinition,
+      tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, mode: DocumentLoadMode = 'text-pages',
     ): void => {
       if (signal.aborted) return
       const reads = readsOf(tabId, signal)
@@ -182,11 +188,17 @@ export function textFace(
       reads.version = undefined
       actions.reset(tabId)
       if (mode === 'text-pages') loadPage(tabId, file, 1, signal, observedVersion)
-      else loadAll(tabId, file, signal, observedVersion, reader)
+      else loadAll(tabId, file, signal, observedVersion)
     }
     return {
       loadPage, reloadPages: restart, loadAll,
-      reloadAll: (tabId, file, signal, observedVersion, reader) => { restart(tabId, file, signal, observedVersion, 'bytes-complete', reader) },
+      prepareRenderer: (tabId, signal, rendererId, observedVersion, reload = false) => {
+        if (signal.aborted) return
+        modeOf(tabId, signal, 'renderer', rendererId)
+        if (reload) actions.reset(tabId)
+        actions.loading(tabId, 'renderer', observedVersion, rendererId)
+      },
+      reloadAll: (tabId, file, signal, observedVersion) => { restart(tabId, file, signal, observedVersion, 'bytes-complete') },
     }
   }
 }

+ 0 - 1
packages/client/ui-sidebar-documentpreview/src/client/index.ts

@@ -105,7 +105,6 @@ export function apply(ctx: ClientContext): void {
     {
       name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store,
       children: {
-        'sidebar.right.tab.document.notice': { kind: 'keyed', scope: 'session' },
         'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } },
       },
       inject: (sessionId, actions): TextPreviewInjected => ({

+ 8 - 6
packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx

@@ -1,24 +1,26 @@
 /** Missing-font notice and a non-modal details panel for one source version. */
 import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
 import { createPortal } from 'react-dom'
-import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import type {} from '../document/contract.ts'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import { Button, IconCloseOutline16, IconWarningOutline16, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './FontNotice.module.css'
 
 /** Notice inputs supplied by the document owner and Office locale registration. */
-export type FontNoticeProps = PropsRuntime<'sidebar.right.tab.document.notice'> & PropsLocale<'sidebarOffice'>
+export type FontNoticeProps = PropsLocale<'sidebarOffice'> & {
+  readonly resourceAddress: string
+  readonly sourceVersion: string
+  readonly fonts: readonly string[]
+}
 
 /**
  * Show missing fonts; dismissal applies to the same source version while this component stays mounted.
  * @param props - source identity, converted content, and localized copy.
  * @returns a collapsible notice and its anchored details, or nothing when fonts are available.
  */
-export function FontNotice({ resourceAddress, sourceVersion, content, t }: FontNoticeProps): ReactNode {
+export function FontNotice({ resourceAddress, sourceVersion, fonts, t }: FontNoticeProps): ReactNode {
   const identity = JSON.stringify([resourceAddress, sourceVersion])
   const [dismissed, setDismissed] = useState<string>()
   const [expanded, setExpanded] = useState<string>()
-  const fonts = content.kind === 'bytes' ? content.missingFonts ?? [] : []
   const visible = fonts.length > 0 && dismissed !== identity
   const open = visible && expanded === identity
   const root = useRef<HTMLDivElement>(null)
@@ -46,7 +48,7 @@ export function FontNotice({ resourceAddress, sourceVersion, content, t }: FontN
     const observer = new ResizeObserver(measure)
     observer.observe(element)
     return () => { observer.disconnect() }
-  }, [content, t])
+  }, [fonts, t])
   if (fonts.length === 0) return null
   return <>
     <div className={css.space} data-office-font-notice data-dismissed={!visible} aria-hidden={!visible} {...(!visible ? { inert: '' } : {})}>

+ 12 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css

@@ -0,0 +1,12 @@
+.body {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+}
+
+.scrollport {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+}

+ 94 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx

@@ -0,0 +1,94 @@
+/** Office owns source loading, conversion failures, and font notices around the shared PDF view. */
+import { useEffect, type ReactNode } from 'react'
+import type { PropsLocale, PropsRenderSlots, PropsStore, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { Button, FileTypeIcon, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
+import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
+import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { DocumentBodyOwner, DocumentPreviewProps } from '../document/contract.ts'
+import { hostFileOf } from '../rpc.ts'
+import { LoadingIndicator } from '../LoadingIndicator.tsx'
+import type { ReadOfficeDocument } from './cache.ts'
+import type { OfficeStore } from './store.ts'
+import { FontNotice } from './FontNotice.tsx'
+import common from '../TextPreview.module.css'
+import css from './OfficeBody.module.css'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    /** PDF presentation supplied with Office-owned converted bytes. */
+    'sidebar.right.tab.document.office.pdf': {
+      kind: 'keyed'
+      scope: 'session'
+      owner: DocumentBodyOwner
+      hookContext: UseSidebarRightTabInfo
+      inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> } }
+    }
+  }
+}
+
+/** Office loading callbacks supplied by the registration's services. */
+export interface OfficeBodyInjected {
+  readonly read: ReadOfficeDocument
+  /** @param failure - declared file-read failure or conversion exception message. @returns localized display text. */
+  readonly describeFailure: (failure: RemoteFailure | { readonly message: string }) => string
+  /** @param tab - owning tab. @param signal - tab lifetime, including hidden bodies. */
+  readonly retainTab: (tab: TabId, signal: AbortSignal) => void
+}
+
+/** Office body inputs and its private PDF child. */
+export type OfficeBodyProps = DocumentPreviewProps & PropsStore<OfficeStore> & OfficeBodyInjected
+  & PropsLocale<'sidebarOffice'> & PropsRenderSlots<'sidebar.right.tab.document.office.pdf'>
+
+/**
+ * Load one Office revision and preserve its result while its tab remains open.
+ * @param props - source request, tab state, conversion callbacks, and PDF slot.
+ * @returns conversion status or the font notice and PDF scrollport.
+ */
+export function OfficeBody(props: OfficeBodyProps): ReactNode {
+  const { tab } = props.useTabInfo()
+  const { actions, read, retainTab, describeFailure, resourceAddress, t } = props
+  const request = props.content.kind === 'source' ? props.content : undefined
+  const revision = request?.revision
+  const held = props.useStore(state => state.byTab[tab.id])
+  const view = held?.revision === revision ? held : undefined
+  const settled = view?.file !== undefined || view?.failure !== undefined
+  useEffect(() => { retainTab(tab.id, tab.signal) }, [retainTab, tab.id, tab.signal])
+  useEffect(() => {
+    if (revision === undefined || settled || tab.signal.aborted) return
+    const controller = new AbortController()
+    const signal = AbortSignal.any([controller.signal, tab.signal])
+    actions.loading(tab.id, revision)
+    void read(hostFileOf(resourceAddress), signal).then((result) => {
+      if (signal.aborted) return
+      if (result.ok) actions.complete(tab.id, revision, result.value)
+      else actions.failed(tab.id, revision, { code: result.error.code, message: describeFailure(result.error) })
+    }, (error: unknown) => {
+      if (!signal.aborted) actions.failed(tab.id, revision, {
+        code: 'gateway/internal', message: describeFailure({ message: error instanceof Error ? error.message : String(error) }),
+      })
+    })
+    return () => { controller.abort() }
+  }, [revision, resourceAddress, tab.id, tab.signal, read, actions, describeFailure, settled])
+  const file = view?.file
+  useEffect(() => { if (file !== undefined) request?.loaded(file.version) }, [file, request?.loaded])
+  if (request === undefined) return null
+  if (view?.failure !== undefined) {
+    const { name } = pathPartsOf(resourceAddress)
+    return <div className={common.empty} data-textpreview-failed={view.failure.code}>
+      <FileTypeIcon kind={classifyFileType(name)} size={36} />
+      <p className={common.emptyLine}>{view.failure.message}</p>
+      <Button size="sm" onClick={request.reload}>{t('retry')}</Button>
+    </div>
+  }
+  if (file === undefined) return <LoadingIndicator className={common.statusLine} label={t('loading')} />
+  return <div className={css.body}>
+    <FontNotice resourceAddress={resourceAddress} sourceVersion={file.version} fonts={file.missingFonts} t={t} />
+    <div className={css.scrollport} ref={props.scrollportRef}>
+      {props.renderSlot('sidebar.right.tab.document.office.pdf', {
+        resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
+      }, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })}
+    </div>
+  </div>
+}

+ 17 - 3
packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts

@@ -2,12 +2,26 @@
 import type { OfficeToPdfPriority, OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
 import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
 import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
-import type { ReadDocumentBytes, SessionFile } from '../rpc.ts'
+import type { DocumentFileBytes, SessionFile } from '../rpc.ts'
+
+/** PDF bytes and conversion metadata owned by Office preview. */
+export type OfficeFileBytes = DocumentFileBytes & {
+  readonly missingFonts: readonly string[]
+  readonly generation: OfficeToPdfGeneration
+}
+
+/**
+ * Load authorized PDF contents for one Office source.
+ * @param file - Session and source path.
+ * @param signal - this reader's lifetime.
+ * @returns converted PDF bytes or a declared source-access failure.
+ */
+export type ReadOfficeDocument = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<OfficeFileBytes>>
 
 /** Authorized cached reads carry explicit scheduling intent to the Host. */
-export type ReadOfficeBytes = (file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority) => ReturnType<ReadDocumentBytes>
+export type ReadOfficeBytes = (file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority) => ReturnType<ReadOfficeDocument>
 
-type Result = Awaited<ReturnType<ReadDocumentBytes>>
+type Result = Awaited<ReturnType<ReadOfficeDocument>>
 type Success = Extract<Result, { ok: true }>
 type Stat = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<WorkspaceFileStat>>
 interface Pending {

+ 31 - 13
packages/client/ui-sidebar-documentpreview/src/client/office/index.ts

@@ -1,15 +1,20 @@
 /** Office preview registration backed by authorized Host rendering and the existing PDF body. */
 import type { Context } from '@deepseek-ai/cordis'
+import { retainDocumentTabs } from '../document/tab-lifetime.ts'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type {} from '@deepseek-ai/dsh-client-connection/client'
-import { documentFileBytes, type ReadDocumentBytes } from '../rpc.ts'
+import { documentFileBytes } from '../rpc.ts'
+import { failureLine } from '../failure-line.ts'
+import { documentTabInfoFactory } from '../document/contract.ts'
 import { en, zh, type OfficePreviewKey } from './locales.ts'
-import { OfficePreviewCache, type ReadOfficeBytes } from './cache.ts'
-import { registerPdfBody } from '../pdf/index.ts'
-import { FontNotice } from './FontNotice.tsx'
+import { OfficePreviewCache, type ReadOfficeBytes, type ReadOfficeDocument } from './cache.ts'
+import { pdfBodyRegistration } from '../pdf/index.ts'
+import { LazyPdfBody } from '../pdf/LazyPdfBody.tsx'
+import { OfficeBody, type OfficeBodyInjected } from './OfficeBody.tsx'
+import { createOfficeStore } from './store.ts'
 import type { Config } from '../../config.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -26,14 +31,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 export function apply(ctx: Context, config: Config['office']): void {
   const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office'
   const extensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
-  registerPdfBody(ctx, id)
   ctx.effect(() => ctx.locale.register('sidebarOffice', { zh, en }))
-  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.notice', () => ctx.slots.register({
-    name: 'sidebar.right.tab.document.notice', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office',
-    locale: 'sidebarOffice',
-  }, FontNotice)))
   const t = ctx.locale.bind('sidebarOffice')
-  const unavailable: ReadDocumentBytes = (_file, signal) => {
+  const unavailable: ReadOfficeDocument = (_file, signal) => {
     signal.throwIfAborted()
     return Promise.reject(new Error(t('unavailable')))
   }
@@ -41,9 +41,26 @@ export function apply(ctx: Context, config: Config['office']): void {
   ctx.effect(() => ctx.documentPreviews.register({
     id,
     extensions, binaryExtensions: extensions, priority: 'builtin',
-    title: () => t('title'), loading: 'bytes-complete', wrap: false,
-    read: (file, signal) => read(file, signal),
+    title: () => t('title'), loading: 'renderer', wrap: false,
   }))
+  const store = createOfficeStore()
+  const retainTab = retainDocumentTabs(ctx)
+  const documentT = ctx.locale.bind('sidebarDocumentPreview')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
+    name: 'sidebar.right.tab.document', key: id, locale: 'sidebarOffice', store,
+    children: { 'sidebar.right.tab.document.office.pdf': {
+      kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } },
+    } },
+    inject: (_sessionId, actions): OfficeBodyInjected => ({
+      read: (file, signal) => read(file, signal),
+      describeFailure: failure => 'code' in failure ? failureLine(documentT, failure) : documentT('error.unavailable', { message: failure.message }),
+      retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
+    }),
+  }, OfficeBody)))
+  const pdfPresentation = pdfBodyRegistration(ctx)
+  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.office.pdf', () => ctx.slots.register({
+    name: 'sidebar.right.tab.document.office.pdf', key: id, locale: 'sidebarPdf', ...pdfPresentation,
+  }, LazyPdfBody)))
   ctx.inject(['remote', 'remote.officeToPdf', 'remote.workspaceFiles'], (scope) => {
     const convert: ReadOfficeBytes = async (file, signal, priority) => {
       signal.throwIfAborted()
@@ -55,7 +72,8 @@ export function apply(ctx: Context, config: Config['office']): void {
         }
         return result
       }
-      return { ok: true, value: documentFileBytes(result.value) }
+      return { ok: true, value: { ...documentFileBytes(result.value),
+        missingFonts: result.value.missingFonts, generation: result.value.generation } }
     }
     const createCache = () => new OfficePreviewCache(
       async (file, signal) => {

+ 4 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts

@@ -1,6 +1,8 @@
 /** Office preview copy and Host render configuration guidance. */
 export const zh = {
   title: 'Office 文档',
+  loading: '正在读取…',
+  retry: '重试',
   missingFonts: '缺少文档使用的字体:{fonts},可能影响文字和排版。',
   showMore: '显示更多',
   dismissNotice: '关闭字体提示',
@@ -23,6 +25,8 @@ export type OfficePreviewKey = keyof typeof zh
 /** English translations checked against the Chinese key set. */
 export const en = {
   title: 'Office document',
+  loading: 'Reading…',
+  retry: 'Retry',
   missingFonts: 'Fonts used in this document are unavailable: {fonts}. Text and layout may differ.',
   showMore: 'Show more',
   dismissNotice: 'Dismiss font notice',

+ 53 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/store.ts

@@ -0,0 +1,53 @@
+/** Loaded Office previews survive body remounts until reload or tab closure. */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { OfficeFileBytes } from './cache.ts'
+
+/** One requested source revision and its settled preview. */
+export interface OfficeView {
+  readonly revision: number
+  readonly file?: OfficeFileBytes
+  readonly failure?: { readonly code: string; readonly message: string }
+}
+
+/** Office-owned content, isolated by tab identity. */
+export interface OfficeState {
+  byTab: Record<TabId, OfficeView>
+}
+
+type OfficeActions = {
+  loading: (state: OfficeState, tab: TabId, revision: number) => void
+  complete: (state: OfficeState, tab: TabId, revision: number, file: OfficeFileBytes) => void
+  failed: (state: OfficeState, tab: TabId, revision: number, failure: NonNullable<OfficeView['failure']>) => void
+  forget: (state: OfficeState, tab: TabId) => void
+}
+
+/**
+ * Retain Office contents across body remounts within a Session.
+ * @returns the tab-content store declaration.
+ */
+export function createOfficeStore(): EngineStoreHandle<OfficeState, OfficeActions> {
+  return defineStore({
+    init: (): OfficeState => ({ byTab: {} }),
+    actions: {
+      /** @param state - draft. @param tab - owning tab. @param revision - new content revision. */
+      loading(state, tab: TabId, revision: number) { state.byTab[tab] = { revision } },
+      /** @param state - draft. @param tab - owning tab. @param revision - completed revision. @param file - borrowed PDF bytes. */
+      complete(state, tab: TabId, revision: number, file: OfficeFileBytes) {
+        state.byTab[tab] = { revision, file }
+      },
+      /** @param state - draft. @param tab - owning tab. @param revision - failed revision. @param failure - displayable failure. */
+      failed(state, tab: TabId, revision: number, failure: NonNullable<OfficeView['failure']>) {
+        state.byTab[tab] = { revision, failure }
+      },
+      /** @param state - draft. @param tab - closed tab. */
+      forget(state, tab: TabId) {
+        const { [tab]: _closed, ...remaining } = state.byTab
+        state.byTab = remaining
+      },
+    },
+  })
+}
+
+/** Store declaration used by the Office body. */
+export type OfficeStore = ReturnType<typeof createOfficeStore>

+ 20 - 24
packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts

@@ -1,10 +1,13 @@
 /** Builtin PDF registration through document metadata and the keyed body slot. */
 import type { Context } from '@deepseek-ai/cordis'
+import { retainDocumentTabs } from '../document/tab-lifetime.ts'
 import type {} from '../index.ts'
 import type { DocumentPreviewDefinition } from '../document/registry.ts'
 import type { PdfBodyInjected } from './pdf.tsx'
 import { LazyPdfBody } from './LazyPdfBody.tsx'
-import { createPdfStore } from './store.ts'
+import type { BoundActions } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createPdfStore, type PdfStore } from './store.ts'
 import { en, zh } from './locales.ts'
 
 /** PDF metadata and keyed body share this package-local implementation identity. */
@@ -24,34 +27,27 @@ export function apply(ctx: Context): void {
   ctx.effect(() => ctx.locale.register('sidebarPdf', { zh, en }))
   const t = ctx.locale.bind('sidebarPdf')
   ctx.effect(() => ctx.documentPreviews.register(pdfBodyDefinition(() => t('title'))))
-  registerPdfBody(ctx, PDF_BODY_ID)
+  const presentation = pdfBodyRegistration(ctx)
+  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
+    name: 'sidebar.right.tab.document', key: PDF_BODY_ID, locale: 'sidebarPdf', ...presentation,
+  }, LazyPdfBody)))
 }
 
 /**
- * Register a lazy PDF body with its own tab state under a document implementation id.
- * @param ctx - context carrying the slot registry and PDF locale.
- * @param id - document implementation id used as the keyed body entry.
+ * Retain PDF viewing state for a document entry's tab lifetime.
+ * @param ctx - owning registration context.
+ * @returns the store and injection shared by ordinary and Office PDF registrations.
  */
-export function registerPdfBody(ctx: Context, id: string): void {
+export function pdfBodyRegistration(ctx: Context): {
+  store: PdfStore
+  inject: (sessionId: SessionId, actions: BoundActions<PdfStore>) => PdfBodyInjected
+} {
   const store = createPdfStore()
-  const retained = new Map<AbortSignal, () => void>()
-  ctx.effect(() => () => {
-    for (const forget of retained.values()) forget()
-  })
-  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
-    name: 'sidebar.right.tab.document', key: id, locale: 'sidebarPdf', store,
+  const retainTab = retainDocumentTabs(ctx)
+  return {
+    store,
     inject: (_sessionId, actions): PdfBodyInjected => ({
-      retainTab: (tabId, signal) => {
-        if (signal.aborted) { actions.forget(tabId); return }
-        if (retained.has(signal)) return
-        const forget = (): void => {
-          signal.removeEventListener('abort', forget)
-          retained.delete(signal)
-          actions.forget(tabId)
-        }
-        retained.set(signal, forget)
-        signal.addEventListener('abort', forget, { once: true })
-      },
+      retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
     }),
-  }, LazyPdfBody)))
+  }
 }

+ 2 - 9
packages/client/ui-sidebar-documentpreview/src/client/rpc.ts

@@ -9,7 +9,6 @@
 import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceFileBytes, WorkspaceFileRange, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
-import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
 import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
 
 /** The slice of the Client Remote this package calls. */
@@ -83,13 +82,7 @@ export function createReadPage(remote: WorkspaceFilesReadRemote): ReadWorkspaceF
 }
 
 /** Complete document bytes borrowed read-only by renderers; copy before transferring to a Worker. */
-export type DocumentFileBytes = Omit<DocumentWireBytes, 'data'> & { readonly data: Uint8Array<ArrayBuffer> }
-
-/** Complete wire bytes, optionally annotated by a converting reader. */
-export type DocumentWireBytes = WorkspaceFileBytes & {
-  readonly missingFonts?: readonly string[]
-  readonly generation?: OfficeToPdfGeneration
-}
+export type DocumentFileBytes = Omit<WorkspaceFileBytes, 'data'> & { readonly data: Uint8Array<ArrayBuffer> }
 
 /**
  * Read a complete file through the Host endpoint.
@@ -104,7 +97,7 @@ export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Prom
  * @param file - Host byte result with base64 data.
  * @returns the same metadata with native bytes; malformed base64 throws.
  */
-export function documentFileBytes(file: DocumentWireBytes): DocumentFileBytes {
+export function documentFileBytes(file: WorkspaceFileBytes): DocumentFileBytes {
   const binary = atob(file.data)
   const data = new Uint8Array(binary.length)
   for (let index = 0; index < binary.length; index++) data[index] = binary.charCodeAt(index)

+ 22 - 7
packages/client/ui-sidebar-documentpreview/src/client/store.ts

@@ -35,8 +35,10 @@ export interface TextTabState {
   rendererId?: string
   /** Current display-loading mode; absent before the first read. */
   mode?: DocumentLoadMode
-  /** Custom reader that prepared the complete bytes; absent means an ordinary file read. */
-  readerId?: string
+  /** Implementation owning source loading; absent for ordinary file reads. */
+  contentRendererId?: string
+  /** Current content revision, incremented whenever loaded content is discarded. */
+  loadRevision: number
   /** Full byte result used by complete-file renderers. */
   complete?: DocumentFileBytes
   /** The file version the loaded pages belong to; absent before the first page. */
@@ -70,6 +72,7 @@ export interface TextState {
  */
 export function fresh(): TextTabState {
   return {
+    loadRevision: 0,
     version: undefined,
     observedVersion: undefined,
     pages: {},
@@ -90,7 +93,8 @@ function bucket(state: TextState, tabId: TabId): TextTabState {
 /** The preview store's write set; every action names the tab it writes. */
 type TextActions = {
   selected: (draft: TextState, tabId: TabId, rendererId: string | undefined) => void
-  loading: (draft: TextState, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, readerId?: string) => void
+  loading: (draft: TextState, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, contentRendererId?: string) => void
+  rendered: (draft: TextState, tabId: TabId, revision: number, version: string) => void
   complete: (draft: TextState, tabId: TabId, file: DocumentFileBytes) => void
   page: (draft: TextState, tabId: TabId, page: WorkspaceFileText) => void
   failed: (draft: TextState, tabId: TabId, failure: RemoteFailure) => void
@@ -123,17 +127,27 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
        * @param tabId - the tab being drawn.
        * @param mode - selected renderer's loading mode.
        * @param observedVersion - metadata version at read start; later pages retain the initial observation.
-       * @param readerId - custom reader preparing complete bytes.
+       * @param contentRendererId - implementation owning source loading.
        */
-      loading: (d, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, readerId?: string) => {
+      loading: (d, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, contentRendererId?: string) => {
         const state = bucket(d, tabId)
         if (state.version === undefined && !state.loading) state.observedVersion = observedVersion
-        if (readerId === undefined) delete state.readerId
-        else state.readerId = readerId
+        if (contentRendererId === undefined) delete state.contentRendererId
+        else state.contentRendererId = contentRendererId
         state.loading = true
         state.failure = undefined
         if (mode !== undefined) state.mode = mode
       },
+      /**
+       * @param d - draft. @param tabId - owning tab.
+       * @param revision - active content revision. @param version - displayed source version.
+       */
+      rendered: (d, tabId: TabId, revision: number, version: string) => {
+        const state = d.byTab[tabId]
+        if (state?.mode !== 'renderer' || state.loadRevision !== revision) return
+        state.version = version
+        state.loading = false
+      },
       /** @param d - draft. @param tabId - owning tab. @param file - complete byte result for this view. */
       complete: (d, tabId: TabId, file: DocumentFileBytes) => {
         const state = bucket(d, tabId)
@@ -177,6 +191,7 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
        */
       reset: (d, tabId: TabId) => {
         const state = bucket(d, tabId)
+        state.loadRevision++
         state.pages = {}
         delete state.complete
         state.eof = false

+ 3 - 3
packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts

@@ -12,7 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
 import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
 import { apply, inject } from '../src/client/index.ts'
-import { FontNotice } from '../src/client/office/FontNotice.tsx'
+import { OfficeBody } from '../src/client/office/OfficeBody.tsx'
 import { TextPreview } from '../src/client/TextPreview.tsx'
 import { TextTitle } from '../src/client/TextTitle.tsx'
 import { TextBody } from '../src/client/text/TextBody.tsx'
@@ -95,8 +95,8 @@ describe('ui-sidebar-documentpreview apply', () => {
       ['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody],
       ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', LazyPdfBody],
       ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
-      ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarPdf', LazyPdfBody],
-      ['sidebar.right.tab.document.notice', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', FontNotice],
+      ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeBody],
+      ['sidebar.right.tab.document.office.pdf', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarPdf', LazyPdfBody],
     ])
     expect(registered[0]?.store).toBeDefined()
     expect(typeof registered[0]?.inject).toBe('function')

+ 0 - 100
packages/client/ui-sidebar-documentpreview/tests/custom-reader.client.spec.ts

@@ -1,100 +0,0 @@
-/** Converted bytes have distinct read generations and cancel when their tab or renderer changes. */
-import { expect, it, vi } from 'vitest'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
-import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
-import type { DocumentFileBytes } from '../src/client/rpc.ts'
-import { textFace } from '../src/client/face.ts'
-import { createTextStore } from '../src/client/store.ts'
-import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
-import type { ReadDocumentBytes } from '../src/client/rpc.ts'
-
-it('cancels converted reads on reload, raw-reader selection, and tab closure without publishing old results', async () => {
-  const store = createTextStore().create()
-  const source = { sessionId: 'session' as SessionId, path: 'report.docx' }
-  const tab = 'tab' as TabId
-  const lifetime = new AbortController()
-  const result = (data: string): RemoteResult<DocumentFileBytes> => ({
-    ok: true, value: { absolutePath: '/report.docx', version: 'source-v1', offset: 0, eof: true, data: new TextEncoder().encode(data) },
-  })
-  const pending: { signal: AbortSignal; settle: (value: RemoteResult<DocumentFileBytes>) => void }[] = []
-  const read: ReadDocumentBytes = (_file, signal) => new Promise(resolve => pending.push({ signal, settle: resolve }))
-  const reader: DocumentPreviewDefinition = { id: 'office', title: () => 'Office', extensions: ['docx'], loading: 'bytes-complete', read }
-  const raw = vi.fn<ReadDocumentBytes>().mockResolvedValue(result('ZIP'))
-  const face = textFace(vi.fn(), raw)(source.sessionId, store.actions)
-  face.loadAll(tab, source, lifetime.signal, 'source-v1', reader)
-  expect(raw).not.toHaveBeenCalled()
-  face.reloadAll(tab, source, lifetime.signal, 'source-v1', reader)
-  expect(pending[0]!.signal.aborted).toBe(true)
-  pending[0]!.settle(result('obsolete PDF'))
-  await Promise.resolve()
-  expect(store.getSnapshot().byTab[tab]?.complete).toBeUndefined()
-  face.loadAll(tab, source, lifetime.signal)
-  expect(pending[1]!.signal.aborted).toBe(true)
-  pending[1]!.settle(result('also obsolete'))
-  await Promise.resolve()
-  expect(new TextDecoder().decode(store.getSnapshot().byTab[tab]!.complete!.data)).toBe('ZIP')
-  expect(store.getSnapshot().byTab[tab]!.readerId).toBeUndefined()
-  face.loadAll(tab, source, lifetime.signal, 'source-v1', reader)
-  lifetime.abort()
-  expect(pending[2]!.signal.aborted).toBe(true)
-  pending[2]!.settle(result('late PDF'))
-  await Promise.resolve()
-  expect(store.getSnapshot().byTab[tab]).toBeUndefined()
-})
-
-it.each(['reload', 'renderer', 'close'] as const)('ignores a reader rejection after %s', async (transition) => {
-  const store = createTextStore().create()
-  const source = { sessionId: 'session' as SessionId, path: 'report.docx' }
-  const tab = 'tab' as TabId
-  const lifetime = new AbortController()
-  const pending = Promise.withResolvers<RemoteResult<DocumentFileBytes>>()
-  let readSignal: AbortSignal | undefined
-  const read = vi.fn<ReadDocumentBytes>().mockImplementationOnce((_file, signal) => {
-    readSignal = signal
-    return pending.promise
-  }).mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, data: new TextEncoder().encode('PDF') } })
-  const reader: DocumentPreviewDefinition = { id: 'office', title: () => 'Office', extensions: ['docx'], loading: 'bytes-complete', read }
-  const face = textFace(vi.fn(), read)(source.sessionId, store.actions)
-  try {
-    face.loadAll(tab, source, lifetime.signal, 'v1', reader)
-    if (transition === 'reload') face.reloadAll(tab, source, lifetime.signal, 'v1', reader)
-    else if (transition === 'renderer') face.loadAll(tab, source, lifetime.signal, 'v1', { ...reader, id: 'other' })
-    else lifetime.abort()
-    expect(readSignal?.aborted).toBe(true)
-    pending.reject(readSignal?.reason)
-    await pending.promise.catch(() => {})
-    if (transition === 'close') expect(store.getSnapshot().byTab[tab]).toBeUndefined()
-    else {
-      expect(store.getSnapshot().byTab[tab]?.failure).toBeUndefined()
-      expect(new TextDecoder().decode(store.getSnapshot().byTab[tab]?.complete?.data)).toBe('PDF')
-    }
-  } finally {
-    lifetime.abort()
-    pending.resolve({ ok: true, value: { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, data: new Uint8Array() } })
-    await pending.promise.catch(() => {})
-  }
-})
-
-it.each([new Error('conversion connection closed'), 'conversion connection closed'])('reports an active reader rejection (%s) as a Remote failure', async (failure) => {
-  const store = createTextStore().create()
-  const source = { sessionId: 'session' as SessionId, path: 'report.docx' }
-  const tab = 'tab' as TabId
-  const lifetime = new AbortController()
-  const pending = Promise.withResolvers<RemoteResult<DocumentFileBytes>>()
-  const reader: DocumentPreviewDefinition = {
-    id: 'office', title: () => 'Office', extensions: ['docx'], loading: 'bytes-complete', read: () => pending.promise,
-  }
-  try {
-    textFace(vi.fn(), vi.fn())(source.sessionId, store.actions).loadAll(tab, source, lifetime.signal, 'v1', reader)
-    pending.reject(failure)
-    await pending.promise.catch(() => {})
-    expect(store.getSnapshot().byTab[tab]).toMatchObject({
-      loading: false, failure: { code: 'gateway/internal', message: 'conversion connection closed', cause: failure },
-    })
-  } finally {
-    lifetime.abort()
-    pending.resolve({ ok: true, value: { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, data: new Uint8Array() } })
-    await pending.promise.catch(() => {})
-  }
-})

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx

@@ -83,7 +83,7 @@ async function boot() {
             data-renderer={id} data-renderer-tab={tab.id}
             data-renderer-path={resource.value?.absolutePath} data-renderer-version={resource.value?.version}
           >
-            {props.content.kind === 'text' ? props.content.text : new TextDecoder().decode(props.content.data)}
+            {props.content.kind === 'text' ? props.content.text : props.content.kind === 'bytes' ? new TextDecoder().decode(props.content.data) : 'source'}
           </div>
         )
       },

+ 7 - 18
packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx

@@ -27,7 +27,6 @@ function codeProps(h: ReturnType<typeof harness>): TextPreviewProps {
   return {
     ...props,
     useDocumentPreviews: selector => selector([definition]),
-    // The fixture has no notice contribution.
     renderSlot: documentSlots((_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />),
   }
 }
@@ -167,27 +166,17 @@ describe('document toolbar', () => {
     expect(view.queryByRole('status')).toBeNull()
   })
 
-  it('withholds previous pages while the selected renderer prepares its own complete bytes', async () => {
+  it('mounts a renderer-owned body before content is available and withholds the previous pages', async () => {
     const h = harness({ 1: page(1, ['previous reader content'], true) })
     const view = render(<TextPreview {...h.props()} />)
     await settle()
-    expect(view.container.textContent).toContain('previous reader content')
-    const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.bytes>>>()
-    const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new Uint8Array([1]), bytes: 1, eof: true } }
-    onTestFinished(async () => {
-      h.controller.abort()
-      pending.resolve(result)
-      await pending.promise
-    })
-    const read = vi.fn(() => pending.promise)
-    view.rerender(<TextPreview {...h.props()} useDocumentPreviews={selector => selector([{ ...binary, read }])} renderSlot={() => null} />)
+    const renderSlot = vi.fn(() => null)
+    view.rerender(<TextPreview {...h.props()} useDocumentPreviews={selector => selector([{ ...binary, loading: 'renderer' }])} renderSlot={renderSlot} />)
     expect(view.container.textContent).not.toContain('previous reader content')
-    expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
-    await act(async () => { pending.resolve(result); await pending.promise })
-    expect(view.queryByRole('status')).toBeNull()
-    expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete?.bytes).toBe(1)
-    expect(h.instance.getSnapshot().byTab[TAB_ID]?.readerId).toBe(binary.id)
-    expect(read).toHaveBeenCalledWith(FILE, expect.any(AbortSignal))
+    expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.document', expect.objectContaining({
+      content: expect.objectContaining({ kind: 'source' }) as unknown,
+    }), expect.any(Object))
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
     expect(h.bytes).not.toHaveBeenCalled()
   })
 

+ 25 - 0
packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts

@@ -397,3 +397,28 @@ describe('textFace', () => {
     expect(second.tab()?.version).toBe('v2')
   })
 })
+
+it.each([new Error('invalid bytes'), 'invalid bytes'])('reports an active complete-read decoding failure: %s', async (failure) => {
+  const instance = createTextStore().create()
+  const controller = new AbortController()
+  const read = vi.fn<ReadDocumentBytes>().mockRejectedValue(failure)
+  try {
+    textFace(vi.fn(), read)(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal)
+    await Promise.resolve()
+    expect(instance.getSnapshot().byTab[TAB_1]?.failure).toMatchObject({ code: 'gateway/internal', message: 'invalid bytes' })
+  } finally { controller.abort() }
+})
+
+it('ignores a complete-read rejection after renderer-owned loading takes over', async () => {
+  const instance = createTextStore().create()
+  const controller = new AbortController()
+  const pending = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions)
+  try {
+    face.loadAll(TAB_1, FILE, controller.signal)
+    face.prepareRenderer(TAB_1, controller.signal, 'office')
+    pending.reject(new Error('retired'))
+    await pending.promise.catch(() => {})
+    expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBeUndefined()
+  } finally { controller.abort() }
+})

+ 4 - 7
packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts

@@ -8,7 +8,7 @@
  * not the slot runtime.
  */
 import { onTestFinished, vi } from 'vitest'
-import type { PropsRenderSlots, OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { Mock } from 'vitest'
 import { act } from '@testing-library/react'
 import { createElement, useSyncExternalStore } from 'react'
@@ -29,11 +29,8 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 
 type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot']
 
-/** Adapt body-only fixtures to a preview with an empty notice slot. */
-export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] {
-  return (key: string, owner: object, opts?: object) => key === 'sidebar.right.tab.document.notice' ? null
-    : body('sidebar.right.tab.document', owner as OwnerOf<'sidebar.right.tab.document'>, opts as Parameters<BodySlot>[2])
-}
+/** Preserve the body-slot callback used by component fixtures. */
+export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { return body }
 
 export const TAB_ID = 'tab-1' as TabId
 export const SESSION = 's-1' as SessionId
@@ -151,7 +148,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
     actions: instance.actions,
     loadPage: face.loadPage,
     reloadPages: face.reloadPages,
-    loadAll: face.loadAll,
+    prepareRenderer: face.prepareRenderer, loadAll: face.loadAll,
     reloadAll: face.reloadAll,
     useDocumentPreviews: () => definitions,
     renderSlot,

+ 14 - 13
packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts

@@ -3,12 +3,13 @@ import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ReadDocumentBytes, SessionFile } from '../src/client/rpc.ts'
+import type { SessionFile } from '../src/client/rpc.ts'
+import type { ReadOfficeDocument } from '../src/client/office/cache.ts'
 import { OfficePreviewCache } from '../src/client/office/cache.ts'
 
 const file = { sessionId: 's1' as SessionId, path: 'report.docx' }
-const result = (version = 'v1', text = 'pdf!', generation = 'renderer'): Awaited<ReturnType<ReadDocumentBytes>> => ({
-  ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, data: new TextEncoder().encode(text), generation: (generation as OfficeToPdfGeneration) },
+const result = (version = 'v1', text = 'pdf!', generation = 'renderer'): Awaited<ReturnType<ReadOfficeDocument>> => ({
+  ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, missingFonts: [], data: new TextEncoder().encode(text), generation: (generation as OfficeToPdfGeneration) },
 })
 function harness(entries = 2, bytes = 32, pending = 8, readers = 32) {
   const stat = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } })
@@ -21,7 +22,7 @@ function harness(entries = 2, bytes = 32, pending = 8, readers = 32) {
 it('shares a pending conversion between readers and reauthorizes cached reads', async () => {
   const h = harness()
   const started = Promise.withResolvers<AbortSignal>()
-  const completed = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise })
   const background = new AbortController()
   const first = h.read(background.signal)
@@ -44,7 +45,7 @@ it('rejects stale or unauthorized reuse and never caches a declared conversion f
   const h = harness()
   await h.read()
   h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v2' } })
-  const failure = { ok: false, error: { message: 'failed' } } as Awaited<ReturnType<ReadDocumentBytes>>
+  const failure = { ok: false, error: { message: 'failed' } } as Awaited<ReturnType<ReadOfficeDocument>>
   h.convert.mockResolvedValueOnce(failure).mockResolvedValue(result('v2'))
   expect(await h.read()).toEqual(failure)
   expect(await h.read()).toEqual(result('v2'))
@@ -57,7 +58,7 @@ it('rejects stale or unauthorized reuse and never caches a declared conversion f
 it('discards a cancelled conversion that finishes late without replacing a newer result', async () => {
   const h = harness()
   const started = Promise.withResolvers<undefined>()
-  const late = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const late = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementationOnce(() => { started.resolve(undefined); return late.promise })
   const caller = new AbortController()
   const pending = h.read(caller.signal)
@@ -114,7 +115,7 @@ it('reuses the borrowed binary PDF at the exact byte budget without copying', as
 it('does not retain a different canonical source even when its version token matches', async () => {
   const h = harness()
   h.convert.mockResolvedValue({ ok: true, value: {
-    absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, data: new Uint8Array([1]),
+    absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, missingFonts: [], generation: 'renderer' as OfficeToPdfGeneration, data: new Uint8Array([1]),
   } })
   try {
     await h.read()
@@ -143,7 +144,7 @@ it('cancels before source authorization and after a delayed stat without startin
 it.each([new Error('transport'), 'transport'])('retries rejected conversions (%s) and waits for cancelled Host work when disposed', async (failure) => {
   const h = harness()
   const started = Promise.withResolvers<AbortSignal>()
-  const completed = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   try {
     h.convert.mockRejectedValueOnce(failure)
     await expect(h.read()).rejects.toMatchObject(failure instanceof Error ? { message: 'transport' } : {
@@ -171,7 +172,7 @@ it('preserves an AbortSignal cancellation reason while waiting for the shared co
   const h = harness()
   const caller = new AbortController()
   const started = Promise.withResolvers<undefined>()
-  const completion = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const completion = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementation(() => { started.resolve(undefined); return completion.promise })
   const pending = h.read(caller.signal)
   const rejected = expect(pending).rejects.toMatchObject({ message: 'Office preview cancelled', cause: 'left document' })
@@ -200,7 +201,7 @@ it('clears Client reuse when the Host renderer generation changes', async () =>
 
 it('bounds metadata readers and unsettled conversion RPCs including cancellation teardown', async () => {
   const h = harness(2, 32, 1, 2)
-  const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise })
   const caller = new AbortController()
   const first = expect(h.read(caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
@@ -215,7 +216,7 @@ it('bounds metadata readers and unsettled conversion RPCs including cancellation
 
 it('sends foreground intent to the Host when joining an in-flight prewarm', async () => {
   const h = harness()
-  const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementation(() => { entered.resolve(undefined); return completed.promise })
   const prewarm = h.cache.read(file, new AbortController().signal, 'background')
   await entered.promise
@@ -286,7 +287,7 @@ it('restarts a stale generation query without invalidating the current renderer'
 it('restarts a pending conversion when a newer renderer generation is accepted', async () => {
   const h = harness()
   const entered = Promise.withResolvers<AbortSignal>()
-  const completed = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementationOnce((_file, signal) => {
     signal.addEventListener('abort', () => { completed.reject(signal.reason) }, { once: true })
     entered.resolve(signal)
@@ -346,7 +347,7 @@ it.each([[1, 32], [8, 1]])('leaves foreground admission available when pending/r
 it.each([[2, 32], [8, 2]])('reserves the final pending/reader slot for foreground promotion with %i/%i', async (pending, readers) => {
   const h = harness(2, 32, pending, readers)
   h.stat.mockImplementation(async (requested: SessionFile) => ({ ok: true, value: { absolutePath: `/${requested.path}`, version: 'v1' } }))
-  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
   h.convert.mockImplementation(() => { entered.resolve(undefined); return complete.promise })
   const prewarm = h.cache.read(file, new AbortController().signal, 'background')
   await entered.promise

+ 3 - 11
packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx

@@ -16,9 +16,9 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() })
 function props(overrides: Partial<FontNoticeProps> = {}): FontNoticeProps {
   return {
     resourceAddress: 'dsh-resource://file/session/s-1/report.docx', sourceVersion: 'v1',
-    content: { kind: 'bytes', data: new Uint8Array(), missingFonts: ['Consolas', 'Missing Serif'] },
+    fonts: ['Consolas', 'Missing Serif'],
     t: makeTranslate(en), ...overrides,
-  } as FontNoticeProps
+  }
 }
 
 it('opens details, restores focus on Escape or close, and dismisses outside without stealing focus', () => {
@@ -78,15 +78,7 @@ it('has localized copy and does not reserve a notice for fonts that are availabl
   const view = render(<FontNotice {...props({ t: makeTranslate(zh) })} />)
   fireEvent.click(screen.getByRole('button', { name: zh.showMore }))
   expect(screen.getByRole('dialog', { name: zh.missingFontsTitle })).toBeDefined()
-  view.rerender(<FontNotice {...props({ content: { kind: 'bytes', data: new Uint8Array(), missingFonts: [] } })} />)
+  view.rerender(<FontNotice {...props({ fonts: [] })} />)
   expect(view.container.childElementCount).toBe(0)
   expect(screen.queryByRole('dialog')).toBeNull()
 })
-
-it.each([
-  { kind: 'bytes', data: new Uint8Array() },
-  { kind: 'text', text: 'source', pages: [], eof: true },
-] as const)('omits the notice for ordinary $kind readers without conversion font metadata', (content) => {
-  const view = render(<FontNotice {...props({ content })} />)
-  expect(view.container.childElementCount).toBe(0)
-})

+ 44 - 8
packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts

@@ -9,8 +9,11 @@ import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime
 import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
 import { apply } from '../src/client/office/index.ts'
 import { Config } from '../src/config.ts'
-import { FontNotice } from '../src/client/office/FontNotice.tsx'
+import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx'
+import type { OfficeStore } from '../src/client/office/store.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { en, zh } from '../src/client/office/locales.ts'
+import { en as documentEn } from '../src/client/locales.ts'
 
 const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' }
 const generation = ('renderer' as OfficeToPdfGeneration)
@@ -18,17 +21,44 @@ const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: tr
 const pdf = new Uint8Array([37, 80, 68, 70])
 const converted = { ok: true as const, value: { ...source, generation, missingFonts: ['Missing Serif'] } }
 
+it('retains Office view state across remounts and releases it on tab close or plugin disposal', async () => {
+  const h = await harness()
+  const first = 'first' as TabId
+  const second = 'second' as TabId
+  const closed = new AbortController()
+  const retained = new AbortController()
+  try {
+    h.instance.actions.loading(first, 1)
+    h.injected.retainTab(first, closed.signal)
+    h.injected.retainTab(first, closed.signal)
+    expect(h.instance.getSnapshot().byTab[first]).toBeDefined()
+    closed.abort()
+    expect(h.instance.getSnapshot().byTab[first]).toBeUndefined()
+    h.instance.actions.loading(first, 2)
+    h.injected.retainTab(first, closed.signal)
+    expect(h.instance.getSnapshot().byTab[first]).toBeUndefined()
+    h.instance.actions.loading(second, 1)
+    h.injected.retainTab(second, retained.signal)
+    const message = documentEn['error.unavailable'].replace('{message}', 'conversion stopped')
+    expect(h.injected.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message)
+    expect(h.injected.describeFailure({ message: 'conversion stopped' })).toBe(message)
+    await h.close()
+    expect(h.instance.getSnapshot().byTab[second]).toBeUndefined()
+  } finally { closed.abort(); retained.abort(); await h.close() }
+})
+
 async function harness(config: Partial<Config['office']> = {}, missing?: 'remote' | 'render' | 'files') {
   const ctx = new Context()
   const registry = new DocumentPreviewRegistry()
   const removeLocale = vi.fn()
-  const locale = { register: vi.fn(() => removeLocale), bind: () => makeTranslate(en) }
+  const locale = { register: vi.fn(() => removeLocale), bind: (name: string) => name === 'sidebarDocumentPreview' ? makeTranslate(documentEn) : makeTranslate(en) }
   const render = vi.fn<ClientRemote['officeToPdf']['render']>().mockResolvedValue(converted)
   const rendererGeneration = vi.fn<ClientRemote['officeToPdf']['generation']>().mockResolvedValue({ ok: true, value: generation })
   const stat = vi.fn<ClientRemote['workspaceFiles']['stat']>().mockResolvedValue({ ok: true, value: source })
   const readBytes = vi.fn<ClientRemote['workspaceFiles']['readBytes']>().mockResolvedValue({ ok: true, value: source })
   const removeNotice = vi.fn()
-  const register = vi.fn(() => removeNotice)
+  const recorded: { options: { name: string; store: OfficeStore; inject: (id: SessionId, actions: ReturnType<OfficeStore['create']>['actions']) => OfficeBodyInjected }; component: unknown }[] = []
+  const register = vi.fn((options: typeof recorded[number]['options'], component: unknown) => { recorded.push({ options, component }); return removeNotice })
   ctx.provide('documentPreviews', registry)
   ctx.provide('slots', { inject: (_name: string, effect: () => () => void) => effect(), register } as never)
   ctx.provide('locale', locale as never)
@@ -41,8 +71,12 @@ async function harness(config: Partial<Config['office']> = {}, missing?: 'remote
     apply(scope, Config({ office: config }).office)
   } })
   await fiber.await()
-  return { ctx, registry, locale, removeLocale, render, rendererGeneration, stat, readBytes, register, removeNotice,
-    read: (signal = new AbortController().signal, path = file.path) => registry.candidates(path)[0]!.read!({ ...file, path }, signal),
+  const entry = recorded.find(entry => entry.component === OfficeBody)!.options
+  const instance = entry.store.create()
+  const injected = entry.inject(file.sessionId, instance.actions)
+  return { ctx, registry, instance, injected, recorded, locale, removeLocale, render, rendererGeneration,
+    stat, readBytes, register, removeNotice,
+    read: (signal = new AbortController().signal, path = file.path) => injected.read({ ...file, path }, signal),
     close: () => fiber.dispose(),
   }
 }
@@ -54,15 +88,17 @@ it.each(['remote', 'render', 'files'] as const)('keeps Office registration and g
     for (const path of ['a.DOC', 'b.DOCX', 'c.XLS', 'd.xlsx', 'e.PPT', 'f.pptx']) {
       expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
       expect(h.registry.candidates(path)[0]!.title()).toBe(en.title)
+      expect(h.registry.candidates(path)[0]!.loading).toBe('renderer')
+      expect(h.registry.candidates(path)[0]).not.toHaveProperty('read')
       await expect(h.read(undefined, path)).rejects.toThrow(en.unavailable)
     }
     expect(h.render).not.toHaveBeenCalled()
   } finally { await h.close() }
   expect(h.registry.getSnapshot()).toEqual([])
   expect(h.removeLocale).toHaveBeenCalledOnce()
-  expect(h.register).toHaveBeenCalledWith({
-    name: 'sidebar.right.tab.document.notice', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', locale: 'sidebarOffice',
-  }, FontNotice)
+  expect(h.register).toHaveBeenCalledWith(expect.objectContaining({
+    name: 'sidebar.right.tab.document', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', locale: 'sidebarOffice',
+  }), OfficeBody)
   expect(h.removeNotice).toHaveBeenCalledTimes(2)
 })
 

+ 156 - 0
packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx

@@ -0,0 +1,156 @@
+// @vitest-environment jsdom
+/** Renderer-owned loads retain displayed versions and retire work on reload, replacement, and close. */
+import { useSyncExternalStore } from 'react'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
+import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
+import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
+import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
+import type { DocumentContent } from '../src/client/document/contract.ts'
+import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx'
+import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx'
+import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts'
+import type { ReadOfficeDocument } from '../src/client/office/cache.ts'
+import { en } from '../src/client/office/locales.ts'
+import { harness, ABSOLUTE_PATH, TAB_ID, settle } from './fixtures.client.ts'
+
+beforeEach(() => {
+  vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
+})
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+
+const definition: DocumentPreviewDefinition = {
+  id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'renderer',
+}
+type Result = Awaited<ReturnType<ReadOfficeDocument>>
+const result = (version = 'v1'): Result => ({ ok: true, value: {
+  absolutePath: ABSOLUTE_PATH, version, data: new TextEncoder().encode(`PDF ${version}`),
+  offset: 0, eof: true, missingFonts: [], generation: 'engine' as OfficeToPdfGeneration,
+} })
+
+function setup() {
+  const h = harness()
+  const office = createOfficeStore().create()
+  const pending: { signal: AbortSignal; deferred: ReturnType<typeof Promise.withResolvers<Result>> }[] = []
+  const read = vi.fn<ReadOfficeDocument>().mockImplementation((_file, signal) => {
+    const deferred = Promise.withResolvers<Result>()
+    pending.push({ signal, deferred })
+    return deferred.promise
+  })
+  const retained = new Set<AbortSignal>()
+  const retainTab: OfficeBodyProps['retainTab'] = (tab, signal) => {
+    if (retained.has(signal)) return
+    retained.add(signal)
+    signal.addEventListener('abort', () => { office.actions.forget(tab) }, { once: true })
+  }
+  const subscribe = (listener: () => void) => office.subscribe(listener)
+  const snapshot = () => office.getSnapshot()
+  function useOffice<T>(selector: (state: OfficeState) => T): T {
+    return selector(useSyncExternalStore(subscribe, snapshot))
+  }
+  const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message
+  let request: Extract<DocumentContent, { kind: 'source' }> | undefined
+  const slots: TextPreviewProps['renderSlot'] = (_key, input, options) => {
+    const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
+    if (owner.content.kind !== 'source') return <p>Raw bytes</p>
+    request = owner.content
+    // The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario.
+    const props = { ...h.props(), ...owner, useTabInfo: options.hookContext, useStore: useOffice,
+      actions: office.actions, read, retainTab, describeFailure,
+      t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => (
+        <p data-test-pdf>{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}</p>
+      ),
+    } as unknown as OfficeBodyProps
+    return <OfficeBody {...props} />
+  }
+  function View({ renderer = true }: { renderer?: boolean }) {
+    return <TextPreview {...h.props()} renderSlot={slots}
+      useDocumentPreviews={selector => selector([renderer ? definition : { ...definition, id: 'raw', loading: 'bytes-complete' }])} />
+  }
+  onTestFinished(async () => {
+    h.controller.abort()
+    for (const { deferred } of pending) deferred.resolve(result())
+    await Promise.allSettled(pending.map(item => item.deferred.promise))
+  })
+  return { h, office, pending, read, View, request: () => request! }
+}
+
+it('loads without reading raw bytes, retains content across remounts, and reloads only after a source-change action', async () => {
+  const h = setup()
+  let mounted = render(<h.View />)
+  expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading)
+  expect(h.h.bytes).not.toHaveBeenCalled()
+  await act(async () => { h.pending[0]!.deferred.resolve(result()) })
+  expect(screen.getByText('PDF v1')).toBeTruthy()
+  expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1')
+  expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
+  mounted.unmount()
+  mounted = render(<h.View />)
+  expect(screen.getByText('PDF v1')).toBeTruthy()
+  expect(h.read).toHaveBeenCalledTimes(1)
+  h.h.setVersion('v2')
+  mounted.rerender(<h.View />)
+  expect(screen.getByText('changed')).toBeTruthy()
+  expect(h.read).toHaveBeenCalledTimes(1)
+  fireEvent.click(screen.getByRole('button', { name: 'reloadNow' }))
+  expect(h.read).toHaveBeenCalledTimes(2)
+  await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) })
+  expect(screen.queryByText('changed')).toBeNull()
+  expect(screen.getByText('PDF v2')).toBeTruthy()
+})
+
+it('aborts a superseded load and rejects its late bytes and version report', async () => {
+  const h = setup()
+  render(<h.View />)
+  const previous = h.request()
+  fireEvent.click(screen.getByRole('button', { name: 'reload' }))
+  expect(h.pending[0]!.signal.aborted).toBe(true)
+  await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) })
+  await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') })
+  expect(screen.getByText('PDF v2')).toBeTruthy()
+  expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2')
+})
+
+it.each(['replace', 'close', 'hide'] as const)('retires pending conversion on %s and ignores a late rejection', async (transition) => {
+  const h = setup()
+  h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } })
+  const mounted = render(<h.View />)
+  if (transition === 'replace') mounted.rerender(<h.View renderer={false} />)
+  else if (transition === 'close') act(() => { h.h.controller.abort() })
+  else mounted.unmount()
+  expect(h.pending[0]!.signal.aborted).toBe(true)
+  await act(async () => { h.pending[0]!.deferred.reject(new Error('late error')) })
+  if (transition === 'close') {
+    expect(h.office.getSnapshot().byTab[TAB_ID]).toBeUndefined()
+    expect(h.h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined()
+  } else expect(h.office.getSnapshot().byTab[TAB_ID]?.failure).toBeUndefined()
+  if (transition === 'replace') {
+    await settle()
+    expect(screen.getByText('Raw bytes')).toBeTruthy()
+  }
+})
+
+it.each(['declared', 'exception', 'foreign'] as const)('shows %s conversion failures and retries through the public reload action', async (kind) => {
+  const h = setup()
+  render(<h.View />)
+  await act(async () => {
+    if (kind === 'declared') h.pending[0]!.deferred.resolve({ ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) })
+    else h.pending[0]!.deferred.reject(kind === 'exception' ? new Error('Conversion failed') : 'Conversion failed')
+  })
+  expect(screen.getByText(kind === 'declared' ? 'Missing file' : 'Conversion failed')).toBeTruthy()
+  fireEvent.click(screen.getByRole('button', { name: en.retry }))
+  await act(async () => { h.pending[1]!.deferred.resolve(result()) })
+  expect(screen.getByText('PDF v1')).toBeTruthy()
+  expect(h.h.bytes).not.toHaveBeenCalled()
+})
+
+it('starts no conversion when a body receives ordinary shared content', () => {
+  const h = setup()
+  const props = { ...h.h.props(), content: { kind: 'bytes', data: new Uint8Array() },
+    useStore: () => undefined, read: h.read, retainTab: vi.fn(), describeFailure: vi.fn(),
+  } as unknown as OfficeBodyProps
+  const view = render(<OfficeBody {...props} />)
+  expect(view.container.childElementCount).toBe(0)
+  expect(h.read).not.toHaveBeenCalled()
+})

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts

@@ -74,7 +74,7 @@ describe('text store', () => {
     instance.actions.navigated(TAB_1, 3)
     instance.actions.reset(TAB_1)
     expect(instance.getSnapshot().byTab[TAB_1]).toEqual({
-      ...fresh(), scrollTop: 120, wrap: false, revision: 3,
+      ...fresh(), loadRevision: 1, scrollTop: 120, wrap: false, revision: 3,
     })
   })
 

+ 17 - 16
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -2557,10 +2557,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/**\n * Contents prepared by the preview owner using ordinary file reads.\n * Byte arrays are transient UI input, never persisted layout or Session data.\n */\nexport type DocumentContent =\n  | { readonly kind: \'text\'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }\n  | { readonly kind: \'bytes\'; readonly data: Uint8Array<ArrayBuffer>; readonly missingFonts?: readonly string[] | undefined }',
+      '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n  /** Original file address, also readable through the standard useResource hook. */\n  readonly resourceAddress: string\n  /** Ordinary file content or a renderer-owned source request; text accumulates until eof. */\n  readonly content: DocumentContent\n  /** The document toolbar\'s current wrapping preference. */\n  readonly wrap: boolean\n  /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n  readonly scrollportRef: RefCallback<HTMLElement>\n}',
     ],
     ownerPropsReferences: [
-      'DocumentTextPage',
+      'DocumentContent',
     ],
     standardProps: [
       'useResource: UseResource',
@@ -2588,19 +2588,20 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-sidebar-documentpreview HtmlBody',
       'client-ui-sidebar-documentpreview ImageBody',
       'client-ui-sidebar-documentpreview MarkdownBody',
+      'client-ui-sidebar-documentpreview OfficeBody',
       'client-ui-sidebar-documentpreview LazyPdfBody',
       'client-ui-sidebar-documentpreview TextBody',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.document\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.document\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:34',
+    source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:45',
   },
   {
-    key: 'sidebar.right.tab.document.notice',
+    key: 'sidebar.right.tab.document.office.pdf',
     kind: 'keyed',
     scope: 'session',
-    summary: 'Renderer-owned notice above the document scrollport, selected by the reader id.',
-    doc: 'Renderer-owned notice above the document scrollport, selected by the reader id.',
+    summary: 'PDF presentation supplied with Office-owned converted bytes.',
+    doc: 'PDF presentation supplied with Office-owned converted bytes.',
     registerOptions: [
       {
         name: 'key',
@@ -2610,10 +2611,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/**\n * Contents prepared by the preview owner using ordinary file reads.\n * Byte arrays are transient UI input, never persisted layout or Session data.\n */\nexport type DocumentContent =\n  | { readonly kind: \'text\'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }\n  | { readonly kind: \'bytes\'; readonly data: Uint8Array<ArrayBuffer>; readonly missingFonts?: readonly string[] | undefined }',
+      '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n  /** Original file address, also readable through the standard useResource hook. */\n  readonly resourceAddress: string\n  /** Ordinary file content or a renderer-owned source request; text accumulates until eof. */\n  readonly content: DocumentContent\n  /** The document toolbar\'s current wrapping preference. */\n  readonly wrap: boolean\n  /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n  readonly scrollportRef: RefCallback<HTMLElement>\n}',
     ],
     ownerPropsReferences: [
-      'DocumentTextPage',
+      'DocumentContent',
     ],
     standardProps: [
       'useResource: UseResource',
@@ -2632,16 +2633,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'useProjection: UseProjection',
       'useTrajectory: UseTrajectory',
     ],
-    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-client-ui-sidebar-documentpreview/office',
-    hookContext: '',
-    slotInject: '',
-    declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted',
+    keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
+    hookContext: 'UseSidebarRightTabInfo',
+    slotInject: '{ hooks: { tabInfo: SlotHookFactory<\'sidebar.right.tab.document\', UseSidebarRightTabInfo> } }',
+    declaredBy: 'an entry in \'sidebar.right.tab.document\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted',
     occupants: [
-      'client-ui-sidebar-documentpreview FontNotice key \'@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office\'',
+      'client-ui-sidebar-documentpreview LazyPdfBody',
     ],
-    replaceRisk: 'shadows-shipped-ui',
-    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.document.notice\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.document.notice\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:24',
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.document.office.pdf\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.document.office.pdf\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:21',
   },
   {
     key: 'sidebar.right.tab.guide',