소스 검색

refactor(preview): keep renderer loading format independent

yudshj 5 일 전
부모
커밋
4de7612f39
23개의 변경된 파일101개의 추가작업 그리고 53개의 파일을 삭제
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  4. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  5. 2 2
      docs/subsystems/sidebar-right.md
  6. 2 2
      docs/subsystems/sidebar-right.zh.md
  7. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  8. 1 1
      packages/client/ui-sidebar-documentpreview/README.md
  9. 1 1
      packages/client/ui-sidebar-documentpreview/README.zh.md
  10. 13 13
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  11. 3 3
      packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts
  12. 2 2
      packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts
  13. 5 5
      packages/client/ui-sidebar-documentpreview/src/client/face.ts
  14. 2 2
      packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx
  15. 1 1
      packages/client/ui-sidebar-documentpreview/src/client/office/index.ts
  16. 1 1
      packages/client/ui-sidebar-documentpreview/src/client/store.ts
  17. 1 1
      packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx
  18. 2 2
      packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx
  19. 1 1
      packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts
  20. 1 1
      packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts
  21. 1 1
      packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts
  22. 52 4
      packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx
  23. 2 2
      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: 98c0260011296a7afbe029e5ef6eb796199454f0
-2026-09-08-document-preview-operations.zh.md: b0a92befff1d3eeb23789b1acfa2ed75e026117e
+2026-09-08-document-preview-operations.md: ea315641728cefc69bb67be5495e93cbaa39f1fe
+2026-09-08-document-preview-operations.zh.md: 71dfb0f0bdccc355cf0edeb33ce56a72de2974a7

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

@@ -24,7 +24,7 @@ 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. Office-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. The Office definition declares `loading: 'office'`; 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.
+**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
 
 **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads.
 

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

@@ -24,7 +24,7 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界
 
 ## 考虑过的替代方案
 
-**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由 Office 自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。Office 定义声明 `loading: 'office'`;正文接收 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
+**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
 
 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
 

+ 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: c4e117653564d11b006e911bfb7f3900ff505c33
-sidebar-right.zh.md: f6d64964565197d8ae89e9367f1e9cb49b68efd2
+sidebar-right.md: 6b7cc4a29535e5f69dcc42e3ab82f445d830c806
+sidebar-right.zh.md: ef41287ec6465af469467e45b48e9a3e9ffc5c47

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

@@ -108,13 +108,13 @@ A body, title and guide replacement receive the framework-injected `useTabInfo()
 
 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`, `bytes-complete`, or `office`; `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)).
 
-The Office body receives `{ kind: 'office', 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.
+A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, and its body owns font notices above a nested PDF view. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
 
 ## Resource model
 

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

@@ -108,13 +108,13 @@ 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 命名空间注册组件。渲染器注册自己的正文,并可通过子 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` 或 `office`;`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 正文接收 `{ kind: 'office', 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)定义加载生命周期。
+自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文在嵌套 PDF 视图上方管理字体提示。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
 
 ## 资源模型
 

+ 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: 90a39bf6a5825587912b9c2e31804357cbdfb39d
-README.zh.md: 0a404ff5bf0fd4e1849da0562a6385f688f2ebb4
+README.md: 3c5eeb63beb3e3cab329437ffea69a8da15a3b4d
+README.zh.md: 4d616fe1924dce6949d1dd78be0479e11229839e

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

@@ -33,7 +33,7 @@ Preview readable files in the right Sidebar and choose among registered renderer
 
 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.
 
-`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'office'`, the Office body mounts before any bytes are read and receives `content: { kind: 'office', 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.
+`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', 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

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

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 文档实现在 `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 和未匹配的扩展名保留纯文本回退,与加载方式无关。
 
-`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'office'` 时,Office 正文在读取任何字节前挂载,并接收 `content: { kind: 'office', revision, loaded, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 为共享变更提示报告已展示的源版本;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。
+`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 为共享变更提示报告已展示的源版本;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。
 
 <a id="addresses"></a>
 ## 地址

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

@@ -94,7 +94,7 @@ export type TextPreviewProps =
  */
 export function TextPreview({
   useTabInfo, useResource, useStore, actions, loadPage, reloadPages,
-  loadAll, reloadAll, prepareOffice, useDocumentPreviews, renderSlot, t,
+  loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t,
 }: TextPreviewProps): ReactNode {
   const { tab } = useTabInfo()
   const { navigation, signal } = tab
@@ -113,7 +113,7 @@ export function TextPreview({
   }, [definitions, file.path, unviewable])
   const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0]
   const mode = selected?.loading
-  const contentRendererId = mode === 'office' ? selected?.id : 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)
@@ -129,7 +129,7 @@ export function TextPreview({
   const pages = current?.pages
   const loaded = useMemo(() => loadedPages(pages ?? {}), [pages])
   const loadedThrough = lastLineLoaded(loaded)
-  const hasContent = mode === 'office' ? current?.version !== undefined : 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
@@ -149,8 +149,8 @@ export function TextPreview({
     if (started || !canRead || mode === undefined || selected === undefined) return
     if (mode === 'text-pages') loadPage(tab.id, file, 1, signal, meta.value?.version)
     else if (mode === 'bytes-complete') loadAll(tab.id, file, signal, meta.value?.version)
-    else prepareOffice(tab.id, signal, selected.id, meta.value?.version)
-  }, [started, tab.id, file, signal, loadPage, loadAll, prepareOffice, canRead, mode, selected, 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.
@@ -188,14 +188,14 @@ export function TextPreview({
     selected?.id, mode, file, canRead, meta.value?.version,
   ])
 
-  const officeReload = useCallback((): void => {
-    if (canRead && selected !== undefined) prepareOffice(tab.id, signal, selected.id, meta.value?.version, true)
-  }, [canRead, prepareOffice, tab.id, signal, selected?.id, 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 === 'office') {
+    if (mode === 'renderer') {
       if (current === undefined) return undefined
       const revision = current.loadRevision
-      return { kind: 'office', revision, reload: officeReload,
+      return { kind: 'renderer', revision, reload: rendererReload,
         loaded: (version) => { actions.rendered(tab.id, revision, version) } }
     }
     if (mode === 'bytes-complete') {
@@ -205,7 +205,7 @@ export function TextPreview({
     }
     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, current?.loadRevision, officeReload, actions, tab.id])
+  }, [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.
@@ -247,7 +247,7 @@ export function TextPreview({
     if (!canRead) return
     if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version)
     else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, meta.value?.version)
-    else officeReload()
+    else rendererReload()
   }
   return (
     <div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
@@ -345,7 +345,7 @@ export function TextPreview({
             && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()
         }}
       >
-        {mode !== 'office' && !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', {

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

@@ -11,14 +11,14 @@ export interface DocumentTextPage {
 }
 
 /**
- * Ordinary file contents, or a request for the Office body to load its document.
+ * Ordinary file contents, or a request for the selected renderer to load its content.
  * 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 kind: 'office'
+    readonly kind: 'renderer'
     /** 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. */
@@ -31,7 +31,7 @@ export type DocumentContent =
 export interface DocumentBodyOwner {
   /** Original file address, also readable through the standard useResource hook. */
   readonly resourceAddress: string
-  /** Ordinary file content or an Office loading request; text accumulates until eof. */
+  /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */
   readonly content: DocumentContent
   /** The document toolbar's current wrapping preference. */
   readonly wrap: boolean

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

@@ -2,8 +2,8 @@
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import { documentFileName, matchedSuffixLength, normalizeSuffix } from './suffix.ts'
 
-/** Shared text or byte reads, or content loading owned by the Office body. */
-export type DocumentLoadMode = 'text-pages' | 'bytes-complete' | 'office'
+/** Shared text or byte reads, or content loading owned by the renderer. */
+export type DocumentLoadMode = 'text-pages' | 'bytes-complete' | 'renderer'
 
 /** One renderer implementation, independent of its component registration. */
 export interface DocumentPreviewDefinition {

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

@@ -68,14 +68,14 @@ export interface TextInjected {
     tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
   ) => void
   /**
-   * Begin an Office-owned load without reading source bytes.
+   * 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 prepareOffice: (tabId: TabId, signal: AbortSignal, rendererId: string, observedVersion?: string, reload?: boolean) => void
+  readonly prepareRenderer: (tabId: TabId, signal: AbortSignal, rendererId: string, observedVersion?: string, reload?: boolean) => void
 }
 
 /**
@@ -192,11 +192,11 @@ export function textFace(
     }
     return {
       loadPage, reloadPages: restart, loadAll,
-      prepareOffice: (tabId, signal, rendererId, observedVersion, reload = false) => {
+      prepareRenderer: (tabId, signal, rendererId, observedVersion, reload = false) => {
         if (signal.aborted) return
-        modeOf(tabId, signal, 'office', rendererId)
+        modeOf(tabId, signal, 'renderer', rendererId)
         if (reload) actions.reset(tabId)
-        actions.loading(tabId, 'office', observedVersion, rendererId)
+        actions.loading(tabId, 'renderer', observedVersion, rendererId)
       },
       reloadAll: (tabId, file, signal, observedVersion) => { restart(tabId, file, signal, observedVersion, 'bytes-complete') },
     }

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

@@ -43,13 +43,13 @@ export type OfficeBodyProps = DocumentPreviewProps & PropsStore<OfficeStore> & O
 
 /**
  * Load one Office revision and preserve its result while its tab remains open.
- * @param props - Office loading request, tab state, conversion callbacks, and PDF slot.
+ * @param props - renderer loading 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 === 'office' ? props.content : undefined
+  const request = props.content.kind === 'renderer' ? props.content : undefined
   const revision = request?.revision
   const held = props.useStore(state => state.byTab[tab.id])
   const view = held?.revision === revision ? held : undefined

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

@@ -41,7 +41,7 @@ export function apply(ctx: Context, config: Config['office']): void {
   ctx.effect(() => ctx.documentPreviews.register({
     id,
     extensions, binaryExtensions: extensions, priority: 'builtin',
-    title: () => t('title'), loading: 'office', wrap: false,
+    title: () => t('title'), loading: 'renderer', wrap: false,
   }))
   const store = createOfficeStore()
   const retainTab = retainDocumentTabs(ctx)

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

@@ -144,7 +144,7 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
        */
       rendered: (d, tabId: TabId, revision: number, version: string) => {
         const state = d.byTab[tabId]
-        if (state?.mode !== 'office' || state.loadRevision !== revision) return
+        if (state?.mode !== 'renderer' || state.loadRevision !== revision) return
         state.version = version
         state.loading = false
       },

+ 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 : props.content.kind === 'bytes' ? new TextDecoder().decode(props.content.data) : 'office'}
+            {props.content.kind === 'text' ? props.content.text : props.content.kind === 'bytes' ? new TextDecoder().decode(props.content.data) : 'renderer'}
           </div>
         )
       },

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

@@ -171,10 +171,10 @@ describe('document toolbar', () => {
     const view = render(<TextPreview {...h.props()} />)
     await settle()
     const renderSlot = vi.fn(() => null)
-    view.rerender(<TextPreview {...h.props()} useDocumentPreviews={selector => selector([{ ...binary, loading: 'office' }])} renderSlot={renderSlot} />)
+    view.rerender(<TextPreview {...h.props()} useDocumentPreviews={selector => selector([{ ...binary, loading: 'renderer' }])} renderSlot={renderSlot} />)
     expect(view.container.textContent).not.toContain('previous reader content')
     expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.document', expect.objectContaining({
-      content: expect.objectContaining({ kind: 'office' }) as unknown,
+      content: expect.objectContaining({ kind: 'renderer' }) as unknown,
     }), expect.any(Object))
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
     expect(h.bytes).not.toHaveBeenCalled()

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

@@ -416,7 +416,7 @@ it('ignores a complete-read rejection after renderer-owned loading takes over',
   const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions)
   try {
     face.loadAll(TAB_1, FILE, controller.signal)
-    face.prepareOffice(TAB_1, controller.signal, 'office')
+    face.prepareRenderer(TAB_1, controller.signal, 'office')
     pending.reject(new Error('retired'))
     await pending.promise.catch(() => {})
     expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBeUndefined()

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

@@ -148,7 +148,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
     actions: instance.actions,
     loadPage: face.loadPage,
     reloadPages: face.reloadPages,
-    prepareOffice: face.prepareOffice, loadAll: face.loadAll,
+    prepareRenderer: face.prepareRenderer, loadAll: face.loadAll,
     reloadAll: face.reloadAll,
     useDocumentPreviews: () => definitions,
     renderSlot,

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

@@ -88,7 +88,7 @@ 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('office')
+      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)
     }

+ 52 - 4
packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 /** Renderer-owned loads retain displayed versions and retire work on reload, replacement, and close. */
-import { useSyncExternalStore } from 'react'
+import { useEffect, useState, 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'
@@ -20,8 +20,56 @@ beforeEach(() => {
 })
 afterEach(() => { cleanup(); vi.unstubAllGlobals() })
 
+it('lets a non-Office renderer load content, report its version, and reload through the shared toolbar', async () => {
+  const h = harness()
+  const custom: DocumentPreviewDefinition = {
+    id: 'custom-viewer', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Custom', loading: 'renderer',
+  }
+  const read = vi.fn<(signal: AbortSignal) => Promise<{ text: string; version: string }>>()
+    .mockResolvedValueOnce({ text: 'Custom content v1', version: 'v1' })
+    .mockResolvedValueOnce({ text: 'Custom content v2', version: 'v2' })
+  function CustomBody({ content }: { content: DocumentContent }) {
+    const [file, setFile] = useState<{ text: string; version: string; revision: number }>()
+    const request = content.kind === 'renderer' ? content : undefined
+    const revision = request?.revision
+    const displayed = file?.revision === revision ? file : undefined
+    useEffect(() => {
+      if (revision === undefined) return
+      const controller = new AbortController()
+      void read(controller.signal).then((file) => {
+        if (controller.signal.aborted) return
+        setFile({ ...file, revision })
+      })
+      return () => { controller.abort() }
+    }, [revision])
+    useEffect(() => { if (displayed !== undefined) request?.loaded(displayed.version) }, [displayed, request?.loaded])
+    return <p>{displayed?.text ?? 'Loading custom content'}</p>
+  }
+  const renderSlot: TextPreviewProps['renderSlot'] = (_name, input) => {
+    const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
+    return <CustomBody content={owner.content} />
+  }
+  const useDocumentPreviews: TextPreviewProps['useDocumentPreviews'] = selector => selector([custom])
+  const view = render(<TextPreview {...h.props()} renderSlot={renderSlot} useDocumentPreviews={useDocumentPreviews} />)
+  expect(read).toHaveBeenCalledTimes(1)
+  expect(await screen.findByText('Custom content v1')).toBeTruthy()
+  expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1')
+  h.setVersion('v2')
+  view.rerender(<TextPreview {...h.props()} renderSlot={renderSlot} useDocumentPreviews={useDocumentPreviews} />)
+  expect(screen.getByText('changed')).toBeTruthy()
+  expect(read).toHaveBeenCalledTimes(1)
+  fireEvent.click(screen.getByRole('button', { name: 'reloadNow' }))
+  expect(await screen.findByText('Custom content v2')).toBeTruthy()
+  expect(read).toHaveBeenCalledTimes(2)
+  expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2')
+  expect(screen.queryByText('changed')).toBeNull()
+  expect(h.read).not.toHaveBeenCalled()
+  expect(h.bytes).not.toHaveBeenCalled()
+  expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
+})
+
 const definition: DocumentPreviewDefinition = {
-  id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'office',
+  id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'renderer',
 }
 type Result = Awaited<ReturnType<ReadOfficeDocument>>
 const result = (version = 'v1'): Result => ({ ok: true, value: {
@@ -50,10 +98,10 @@ function setup() {
     return selector(useSyncExternalStore(subscribe, snapshot))
   }
   const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message
-  let request: Extract<DocumentContent, { kind: 'office' }> | undefined
+  let request: Extract<DocumentContent, { kind: 'renderer' }> | undefined
   const slots: TextPreviewProps['renderSlot'] = (_key, input, options) => {
     const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
-    if (owner.content.kind !== 'office') return <p>Raw bytes</p>
+    if (owner.content.kind !== 'renderer') 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,

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

@@ -2557,7 +2557,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/** 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 an Office loading 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}',
+      '/** 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 loading 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: [
       'DocumentContent',
@@ -2611,7 +2611,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/** 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 an Office loading 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}',
+      '/** 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 loading 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: [
       'DocumentContent',