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

feat(sidebar): preview image files

imccyu 1 день назад
Родитель
Сommit
8870a13aa9
20 измененных файлов с 485 добавлено и 28 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  2. 4 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  3. 4 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  6. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  7. 56 1
      apps/web/tests/document-preview.e2e.ts
  8. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  9. 8 6
      packages/client/ui-sidebar-documentpreview/README.md
  10. 8 6
      packages/client/ui-sidebar-documentpreview/README.zh.md
  11. 1 1
      packages/client/ui-sidebar-documentpreview/package.json
  12. 40 0
      packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css
  13. 106 0
      packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx
  14. 41 0
      packages/client/ui-sidebar-documentpreview/src/client/image/index.ts
  15. 27 0
      packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts
  16. 2 0
      packages/client/ui-sidebar-documentpreview/src/client/index.ts
  17. 3 0
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  18. 111 0
      packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx
  19. 63 0
      packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts
  20. 1 0
      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: 316f33371d393846cce5b598ee23289cecd6c178
-2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd
+2026-09-08-document-preview-operations.md: 3703933273e743c8df32bf0352fc276fe21dcb93
+2026-09-08-document-preview-operations.zh.md: b4896e95959d0f276ee69dfeaee9528319981714

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

@@ -18,7 +18,7 @@ Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path ma
 
 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
 
-Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob.
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. They retain intrinsic CSS-pixel dimensions; auto margins centre images smaller than the shared scroller, while larger dimensions extend its horizontal or vertical scroll range. The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
 
 ## Alternatives considered
 
@@ -34,6 +34,8 @@ Markdown and code reuse the incremental primitives with cumulative paged text. H
 
 **A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support.
 
+**Sanitize SVG into the application DOM or an iframe.** A sanitizer would add a second SVG parser and an evolving active-content policy before placing untrusted markup in an interactive document. The `<img>` static-image context preserves native SVG rendering and intrinsic dimensions without giving the markup a script-capable DOM.
+
 ## Consequences
 
-Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering.
+Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, intrinsic raster and SVG rendering with two-axis scrolling, inert SVG scripts, and lazy continuous PDF Worker rendering.

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

@@ -18,7 +18,7 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
 
 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
 
-Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。它们保留固有 CSS 像素尺寸;auto margin 让小于共享滚动区的图片居中,较大的尺寸则扩展横向或纵向滚动范围。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL
 
 ## 考虑过的替代方案
 
@@ -34,6 +34,8 @@ Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和
 
 **本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。
 
+**清理 SVG 后放入应用 DOM 或 iframe。** sanitizer 会增加第二套 SVG parser 和一套持续演进的主动内容策略,之后仍要把不可信标记放进可交互文档。`<img>` 静态图片上下文保留浏览器原生 SVG 渲染与固有尺寸,同时不给标记一个能运行脚本的 DOM。
+
 ## 影响
 
-替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。
+替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、可双轴滚动的固有尺寸位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
-2026-09-05-sidebar-text-preview-and-file-tree.md: 9c0b6db832f19ba758ba2d3851db72f9a1fdb785
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: a5173c20b5203942df3c6f86b2bef5961946fa08
+2026-09-05-sidebar-text-preview-and-file-tree.md: e25e1c55f5434f2b3b1b2703f886cf3c1e9fe80c
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: f313d30fa68f9d8afba84a470e5e80902b13d1e3

+ 2 - 2
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md

@@ -108,12 +108,12 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「
 
 ## Testing
 
-The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier.
+The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier. `apps/web/tests/document-preview.e2e.ts` covers centred intrinsic-size images, two-axis image scrolling, and inert SVG scripts.
 
 ## Deferred
 
 - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`.
-- Images, search, a total line count, and an end-of-file marker.
+- Search, a total line count, and an end-of-file marker.
 - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree.
 - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md

@@ -108,12 +108,12 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 
 ## Testing
 
-文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。
+文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。`apps/web/tests/document-preview.e2e.ts` 覆盖居中的固有尺寸图片、双轴图片滚动和不可执行的 SVG 脚本。
 
 ## Deferred
 
 - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。
-- 图片、搜索、总行数与文件末尾标记。
+- 搜索、总行数与文件末尾标记。
 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
 

+ 56 - 1
apps/web/tests/document-preview.e2e.ts

@@ -18,6 +18,10 @@ const PAGE_LINES = 64
 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
 const MODE = webSnapshotMode()
+const TINY_PNG = Buffer.from(
+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+  'base64',
+)
 
 /** Successful render evidence stays outside the committed snapshot inventory. */
 async function successShot(page: Page, name: string): Promise<void> {
@@ -76,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     }
   })
 
-  it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => {
+  it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => {
     onTestFailed(async () => {
       await mkdir(SHOT_DIR, { recursive: true })
       await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
@@ -118,6 +122,13 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'),
       writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
       writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
+      writeFile(join(cwd, 'tiny.png'), TINY_PNG),
+      writeFile(join(cwd, 'large.svg'), [
+        '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1600" viewBox="0 0 1200 1600">',
+        '<script>parent.document.documentElement.setAttribute("data-image-preview-escape","true")</script>',
+        '<rect width="1200" height="1600" fill="#2463eb"/>',
+        '</svg>',
+      ].join('')),
       writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
     ])
 
@@ -292,6 +303,50 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
     ].join('\n'))
 
+    await openFile('tiny.png')
+    await expect.poll(() => viewer.innerText()).toBe('Image')
+    const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
+    await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
+    expect(await tinyImage.evaluate(node => ({
+      width: (node as HTMLImageElement).naturalWidth,
+      height: (node as HTMLImageElement).naturalHeight,
+      draggable: (node as HTMLImageElement).draggable,
+    }))).toEqual({ width: 1, height: 1, draggable: false })
+    const centering = await tinyImage.evaluate((node) => {
+      const image = node.getBoundingClientRect()
+      const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
+      if (scroller === undefined) throw new Error('image document scroller is unavailable')
+      return {
+        horizontal: Math.abs((image.left + image.width / 2) - (scroller.left + scroller.width / 2)),
+        vertical: Math.abs((image.top + image.height / 2) - (scroller.top + scroller.height / 2)),
+      }
+    })
+    expect(centering.horizontal).toBeLessThan(10)
+    expect(centering.vertical).toBeLessThan(10)
+
+    await openFile('large.svg')
+    await expect.poll(() => viewer.innerText()).toBe('Image')
+    const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true })
+    await largeImage.waitFor({ state: 'visible', timeout: 15_000 })
+    expect(await largeImage.evaluate(node => ({
+      naturalWidth: (node as HTMLImageElement).naturalWidth,
+      naturalHeight: (node as HTMLImageElement).naturalHeight,
+      width: getComputedStyle(node).width,
+      height: getComputedStyle(node).height,
+    }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' })
+    expect(await body.evaluate(node => ({
+      horizontal: node.scrollWidth > node.clientWidth,
+      vertical: node.scrollHeight > node.clientHeight,
+    }))).toEqual({ horizontal: true, vertical: true })
+    const scrolled = await body.evaluate((node) => {
+      node.scrollLeft = node.scrollWidth
+      node.scrollTop = node.scrollHeight
+      return { left: node.scrollLeft, top: node.scrollTop }
+    })
+    expect(scrolled.left).toBeGreaterThan(0)
+    expect(scrolled.top).toBeGreaterThan(0)
+    expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
+
     const releaseRead = Promise.withResolvers<undefined>()
     let waitingForRead = false
     const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles)

+ 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: 9f664664a5d00add4d2f360c1c6b6af2e885bf72
-README.zh.md: 2cdad6964ae26e8e10597a405b0925cf70d7a278
+README.md: fbfbfaf4d80e425e5fbc62c082509fb6c4a93962
+README.zh.md: c27a359b608d8b72ace0700c55c34a58a302ec60

+ 8 - 6
packages/client/ui-sidebar-documentpreview/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, PDF and HTML renderers, and plain-text fallback."
+description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, and plain-text fallback."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF and HTML receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`.
+Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`.
 
 ## Table of Contents
 
@@ -44,14 +44,16 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
 
 - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content.
 - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it.
-- **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
+- **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
 - **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice.
 
 HTML runs in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
 
+PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context. The image keeps its intrinsic CSS-pixel dimensions; a smaller image centres in the shared scroller, and larger dimensions scroll on either axis. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
+
 Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
 
-Initial reads, additional pages, and HTML/PDF preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill, and with wrap off both axes scroll in the shared document scroller while the copy banner stays pinned to the visible width.
+Initial reads, additional pages, and HTML/PDF/image preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill, and with wrap off both axes scroll in the shared document scroller while the copy banner stays pinned to the visible width.
 
 <a id="navigation"></a>
 ## Navigation
@@ -71,8 +73,8 @@ No direct effect; what the user reads here never enters a model request.
 
 <a id="known-limitations-and-deferred-work"></a>
 - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks.
-- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap.
-- **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context.
+- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap.
+- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; image horizontal position is never restored, and HTML iframe scrolling belongs to its opaque browsing context.
 - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
 - **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract.
 - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.

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

@@ -1,5 +1,5 @@
 ---
-description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、PDF 和 HTML 渲染器,并以纯文本兜底。"
+description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,并以纯文本兜底。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF 和 HTML 接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。
+在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。
 
 ## 目录
 
@@ -44,14 +44,16 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
 
 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。
 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。
-- **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
+- **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
 
 HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
 
+PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染。图片保持固有 CSS 像素尺寸;小图在共享滚动区内居中,大图可沿任一轴滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
+
 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
 
-首次读取、追加页及 HTML/PDF 准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;关闭折行时两个轴都在共享文档滚动区滚动,复制条钉在可视宽度内不随横向滚动偏移。
+首次读取、追加页及 HTML/PDF/图片准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;关闭折行时两个轴都在共享文档滚动区滚动,复制条钉在可视宽度内不随横向滚动偏移。
 
 <a id="navigation"></a>
 ## 导航
@@ -71,8 +73,8 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`
 
 <a id="known-limitations-and-deferred-work"></a>
 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。
-- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。
-- **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。
+- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
+- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片的横向位置始终不恢复,HTML iframe 的滚动属于其不透明浏览上下文。
 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
 - **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。
 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。

+ 1 - 1
packages/client/ui-sidebar-documentpreview/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview",
-  "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, PDF, HTML, and plain text",
+  "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, images, PDF, HTML, and plain text",
   "version": "0.1.5-alpha.1",
   "publishConfig": {
     "access": "public"

+ 40 - 0
packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css

@@ -0,0 +1,40 @@
+.frame {
+  display: flex;
+  box-sizing: border-box;
+  width: max-content;
+  min-width: 100%;
+  height: max-content;
+  min-height: 100%;
+  font-family: var(--dsw-font, sans-serif);
+  white-space: normal;
+}
+
+.image {
+  display: block;
+  flex: none;
+  width: auto;
+  max-width: none;
+  height: auto;
+  max-height: none;
+  margin: auto;
+  user-select: none;
+}
+
+.image[hidden] {
+  display: none;
+}
+
+.status {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  width: 100%;
+  min-height: 100%;
+  margin: 0;
+  padding: 10px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 13px;
+  line-height: 1.5;
+  white-space: normal;
+}

+ 106 - 0
packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx

@@ -0,0 +1,106 @@
+/** Complete image bytes rendered at their intrinsic CSS-pixel dimensions. */
+import { useEffect, useMemo, useState, type ReactNode } from 'react'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
+import type { DocumentPreviewProps } from '../document/contract.ts'
+import { LoadingIndicator } from '../LoadingIndicator.tsx'
+import { hostFileOf } from '../rpc.ts'
+import type {} from './locales.ts'
+import css from './ImageBody.module.css'
+
+const IMAGE_MEDIA_TYPES = {
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  gif: 'image/gif',
+  webp: 'image/webp',
+  bmp: 'image/bmp',
+  ico: 'image/x-icon',
+  svg: 'image/svg+xml',
+} as const
+
+type ImageMediaType = typeof IMAGE_MEDIA_TYPES[keyof typeof IMAGE_MEDIA_TYPES]
+
+/** Standard document props plus the image renderer's dictionary. */
+export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'>
+
+type ImageSource =
+  | {
+    readonly kind: 'ready'
+    readonly data: Uint8Array<ArrayBuffer>
+    readonly mediaType: ImageMediaType
+    readonly url: string
+  }
+  | { readonly kind: 'failed'; readonly data: Uint8Array<ArrayBuffer>; readonly mediaType: ImageMediaType }
+
+/**
+ * Resolve a supported filename to the media type assigned to its Blob.
+ * @param path - decoded workspace file path.
+ * @returns the image media type, or undefined for an unregistered suffix.
+ */
+export function imageMediaType(path: string): ImageMediaType | undefined {
+  const normalized = path.replaceAll('\\', '/')
+  const name = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
+  const extension = name.slice(name.lastIndexOf('.') + 1) as keyof typeof IMAGE_MEDIA_TYPES
+  return IMAGE_MEDIA_TYPES[extension]
+}
+
+/**
+ * Present complete image bytes without fitting or scaling them to the pane.
+ * @param props - document bytes, resource identity, and locale.
+ * @returns an intrinsic-size image whose containing document body provides scrolling.
+ */
+export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): ReactNode {
+  const path = useMemo(() => hostFileOf(resourceAddress).path, [resourceAddress])
+  const mediaType = imageMediaType(path)
+  const data = content.kind === 'bytes' ? content.data : undefined
+  const [source, setSource] = useState<ImageSource>()
+
+  useEffect(() => {
+    if (data === undefined || mediaType === undefined) return
+    let url: string | undefined
+    try {
+      url = URL.createObjectURL(new Blob([data], { type: mediaType }))
+      setSource({ kind: 'ready', data, mediaType, url })
+    } catch {
+      setSource({ kind: 'failed', data, mediaType })
+    }
+    return () => {
+      if (url !== undefined) URL.revokeObjectURL(url)
+    }
+  }, [data, mediaType])
+
+  if (data === undefined || mediaType === undefined) {
+    return <p className={css.status} role="alert">{t('unsupported')}</p>
+  }
+  if (source?.data !== data || source.mediaType !== mediaType) {
+    return <LoadingIndicator className={css.status} label={t('loading')} />
+  }
+  if (source.kind === 'failed') return <p className={css.status} role="alert">{t('failed')}</p>
+  const { name } = pathPartsOf(path)
+  return <LoadedImage key={source.url} url={source.url} name={name} t={t} />
+}
+
+/** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */
+function LoadedImage({ url, name, t }: {
+  readonly url: string
+  readonly name: string
+  readonly t: ImageBodyProps['t']
+}): ReactNode {
+  const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
+  return <div className={css.frame} data-image-preview>
+    {state === 'loading' && <LoadingIndicator className={css.status} label={t('loading')} />}
+    {state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
+    <img
+      className={css.image}
+      src={url}
+      alt={t('preview', { name })}
+      decoding="async"
+      draggable={false}
+      referrerPolicy="no-referrer"
+      hidden={state !== 'ready'}
+      onLoad={() => { setState('ready') }}
+      onError={() => { setState('failed') }}
+    />
+  </div>
+}

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

@@ -0,0 +1,41 @@
+/** Builtin image metadata and keyed document-body registration. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '../index.ts'
+import type { DocumentPreviewDefinition } from '../document/registry.ts'
+import { ImageBody } from './ImageBody.tsx'
+import { en, zh } from './locales.ts'
+
+/** Image implementation identity, shared by metadata and the keyed slot. */
+export const IMAGE_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/image'
+
+/** File suffixes rendered by the builtin image body. */
+export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg'] as const
+
+/**
+ * Describe the builtin image renderer independently from its keyed body slot.
+ * @param title - locale-owned implementation name.
+ * @returns metadata for complete image files.
+ */
+export function imageBodyDefinition(title: () => string): DocumentPreviewDefinition {
+  return {
+    id: IMAGE_BODY_ID,
+    extensions: IMAGE_EXTENSIONS,
+    priority: 'builtin',
+    title,
+    loading: 'bytes-complete',
+    wrap: false,
+  }
+}
+
+/**
+ * Register the image dictionary, metadata, and body with reversible effects.
+ * @param ctx - owning plugin context.
+ */
+export function apply(ctx: Context): void {
+  const t = ctx.locale.bind('sidebarImage')
+  ctx.effect(() => ctx.locale.register('sidebarImage', { zh, en }), 'document-image: dictionaries')
+  ctx.effect(() => ctx.documentPreviews.register(imageBodyDefinition(() => t('title'))), 'document-image: metadata')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register(
+    { name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' }, ImageBody,
+  )), 'document-image: body')
+}

+ 27 - 0
packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts

@@ -0,0 +1,27 @@
+/** Locale-owned image renderer labels and status text. */
+export const zh = {
+  title: '图片',
+  preview: '图片预览:{name}',
+  loading: '正在打开图片…',
+  failed: '无法显示这张图片。',
+  unsupported: '图片预览需要完整文件内容。',
+} satisfies Record<string, string>
+
+/** Image renderer dictionary keys. */
+export type ImagePreviewKey = keyof typeof zh
+
+/** English dictionary with the same keys as the Chinese dictionary. */
+export const en = {
+  title: 'Image',
+  preview: 'Image preview: {name}',
+  loading: 'Opening image…',
+  failed: 'This image could not be displayed.',
+  unsupported: 'Image preview requires the complete file contents.',
+} satisfies Record<ImagePreviewKey, string>
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Image preview selection, accessible name, and status text. */
+    sidebarImage: ImagePreviewKey
+  }
+}

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

@@ -33,6 +33,7 @@ import { documentTabInfoFactory } from './document/contract.ts'
 import { apply as registerText } from './text/index.ts'
 import { apply as registerMarkdown } from './markdown/index.ts'
 import { apply as registerHtml } from './html/index.ts'
+import { apply as registerImage } from './image/index.ts'
 import { apply as registerPdf } from './pdf/index.ts'
 import { apply as registerCode } from './code/index.ts'
 
@@ -111,6 +112,7 @@ export function apply(ctx: ClientContext): void {
   registerText(ctx)
   registerMarkdown(ctx)
   registerHtml(ctx)
+  registerImage(ctx)
   registerPdf(ctx)
   registerCode(ctx)
 }

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

@@ -21,6 +21,8 @@ import { MarkdownBody } from '../src/client/markdown/MarkdownBody.tsx'
 import { MARKDOWN_BODY_ID } from '../src/client/markdown/index.ts'
 import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
 import { HTML_BODY_ID } from '../src/client/html/index.ts'
+import { ImageBody } from '../src/client/image/ImageBody.tsx'
+import { IMAGE_BODY_ID } from '../src/client/image/index.ts'
 import { PdfBody } from '../src/client/pdf/PdfBody.tsx'
 import { PDF_BODY_ID } from '../src/client/pdf/index.ts'
 import { CodeBody } from '../src/client/code/CodeBody.tsx'
@@ -93,6 +95,7 @@ describe('ui-sidebar-documentpreview apply', () => {
       ['sidebar.right.tab.document', PLAIN_BODY_ID, undefined, TextBody],
       ['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody],
       ['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody],
+      ['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody],
       ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', PdfBody],
       ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
     ])

+ 111 - 0
packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx

@@ -0,0 +1,111 @@
+// @vitest-environment jsdom
+/** Image Blob ownership, media types, intrinsic rendering, and failure states. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { ImageBody, imageMediaType, type ImageBodyProps } from '../src/client/image/ImageBody.tsx'
+import { en } from '../src/client/image/locales.ts'
+
+const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
+let createDescriptor: PropertyDescriptor | undefined
+let revokeDescriptor: PropertyDescriptor | undefined
+const create = vi.fn<(blob: Blob) => string>()
+const revoke = vi.fn<(url: string) => void>()
+
+beforeEach(() => {
+  createDescriptor = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
+  revokeDescriptor = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL')
+  create.mockReset().mockImplementation(() => `blob:https://preview.invalid/${create.mock.calls.length}`)
+  revoke.mockReset()
+  Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: create })
+  Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revoke })
+})
+
+afterEach(() => {
+  try { cleanup() } finally {
+    if (createDescriptor === undefined) Reflect.deleteProperty(URL, 'createObjectURL')
+    else Object.defineProperty(URL, 'createObjectURL', createDescriptor)
+    if (revokeDescriptor === undefined) Reflect.deleteProperty(URL, 'revokeObjectURL')
+    else Object.defineProperty(URL, 'revokeObjectURL', revokeDescriptor)
+  }
+})
+
+function props(path = 'asset.png', data: Uint8Array<ArrayBuffer> = new Uint8Array([1, 2, 3])): ImageBodyProps {
+  return {
+    resourceAddress: `dsh-resource://file/session/image/${path}`,
+    content: { kind: 'bytes', data },
+    wrap: false,
+    sessionId: 'image' as SessionId,
+    useTabInfo: () => ({ tab: { signal: new AbortController().signal } }),
+    useResource: () => ({ value: undefined }),
+    t: (key, params) => {
+      const value = translations.get(key) ?? key
+      return params === undefined ? value : value.replace('{name}', String(params.name))
+    },
+  } as ImageBodyProps
+}
+
+describe('ImageBody', () => {
+  it.each([
+    ['png', 'image/png'],
+    ['jpg', 'image/jpeg'],
+    ['jpeg', 'image/jpeg'],
+    ['gif', 'image/gif'],
+    ['webp', 'image/webp'],
+    ['bmp', 'image/bmp'],
+    ['ico', 'image/x-icon'],
+    ['svg', 'image/svg+xml'],
+  ] as const)('assigns .%s bytes the %s Blob media type', async (extension, mediaType) => {
+    const view = render(<ImageBody {...props(`asset.${extension}`)} />)
+    const image = await screen.findByRole('img', { hidden: true })
+    expect(create.mock.calls[0]?.[0].type).toBe(mediaType)
+    expect(image.getAttribute('src')).toBe('blob:https://preview.invalid/1')
+    view.unmount()
+    expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
+  })
+
+  it('keeps the image unscaled and non-draggable, then shows it after decoding succeeds', async () => {
+    render(<ImageBody {...props('photo.svg')} />)
+    const image = await screen.findByRole('img', { hidden: true })
+    expect(image.getAttribute('alt')).toBe('Image preview: photo.svg')
+    expect(image.getAttribute('decoding')).toBe('async')
+    expect(image.getAttribute('draggable')).toBe('false')
+    expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
+    expect(image.hasAttribute('hidden')).toBe(true)
+    fireEvent.load(image)
+    expect(image.hasAttribute('hidden')).toBe(false)
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('revokes replaced bytes and reports image decode and Blob creation failures', async () => {
+    const initial = props()
+    const view = render(<ImageBody {...initial} />)
+    const first = await screen.findByRole('img', { hidden: true })
+    fireEvent.error(first)
+    expect(screen.getByRole('alert').textContent).toBe(en.failed)
+    const changed = props('asset.png', new Uint8Array([4, 5, 6]))
+    view.rerender(<ImageBody {...changed} />)
+    await screen.findByRole('img', { hidden: true })
+    expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/1')
+    create.mockImplementationOnce(() => { throw new Error('Blob unavailable') })
+    view.rerender(<ImageBody {...props('changed.png', new Uint8Array([7]))} />)
+    expect((await screen.findByRole('alert')).textContent).toBe(en.failed)
+    view.unmount()
+    expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/2')
+  })
+
+  it('rejects text delivery and an unregistered suffix without creating a Blob', () => {
+    const initial = props()
+    const view = render(<ImageBody {...initial} content={{ kind: 'text', text: 'plain', pages: [], eof: true }} />)
+    expect(screen.getByRole('alert').textContent).toBe(en.unsupported)
+    view.rerender(<ImageBody {...props('asset.unknown')} />)
+    expect(screen.getByRole('alert').textContent).toBe(en.unsupported)
+    expect(create).not.toHaveBeenCalled()
+  })
+
+  it('matches media types case-insensitively on decoded path suffixes', () => {
+    expect(imageMediaType('folder/PHOTO.JPEG')).toBe('image/jpeg')
+    expect(imageMediaType('folder/no-extension')).toBeUndefined()
+    expect(imageMediaType('folder/photo.avif')).toBeUndefined()
+  })
+})

+ 63 - 0
packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts

@@ -0,0 +1,63 @@
+/** Image metadata, keyed slot, dictionary, and disposal registration. */
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
+import { ImageBody } from '../src/client/image/ImageBody.tsx'
+import { apply, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts'
+import { en, zh } from '../src/client/image/locales.ts'
+
+let dispose: (() => Promise<void>) | undefined
+afterEach(async () => { await dispose?.(); dispose = undefined })
+
+describe('image registration', () => {
+  it('claims common image suffixes as a builtin complete-byte renderer without wrap', () => {
+    const title = vi.fn(() => 'localized image')
+    const definition = imageBodyDefinition(title)
+    expect(definition).toEqual({
+      id: IMAGE_BODY_ID,
+      extensions: IMAGE_EXTENSIONS,
+      priority: 'builtin',
+      title,
+      loading: 'bytes-complete',
+      wrap: false,
+    })
+    expect(title).not.toHaveBeenCalled()
+    expect(definition.title()).toBe('localized image')
+  })
+
+  it('registers its dictionary and matching keyed body, then removes every contribution', async () => {
+    const ctx = new Context()
+    const registry = new DocumentPreviewRegistry()
+    const dictionaries = new Map<string, unknown>()
+    const bodies = new Map<string, unknown>()
+    const register = vi.fn((options: { key: string }, body: unknown) => {
+      bodies.set(options.key, body)
+      return () => { bodies.delete(options.key) }
+    })
+    ctx.provide('documentPreviews', registry)
+    ctx.provide('slots', { inject: (_key: string, callback: () => () => void) => callback(), register } as never)
+    ctx.provide('locale', {
+      bind: () => (key: keyof typeof en) => en[key],
+      register: (name: string, value: unknown) => {
+        dictionaries.set(name, value)
+        return () => { dictionaries.delete(name) }
+      },
+    } as never)
+    const fiber = ctx.plugin({ apply })
+    dispose = async () => { await fiber.dispose() }
+    await fiber.await()
+    for (const extension of IMAGE_EXTENSIONS) {
+      expect(registry.candidates(`ASSET.${extension}`).map(entry => entry.id)).toEqual([IMAGE_BODY_ID])
+    }
+    expect(registry.getSnapshot()[0]?.title()).toBe(en.title)
+    expect(dictionaries.get('sidebarImage')).toEqual({ zh, en })
+    expect(register).toHaveBeenCalledExactlyOnceWith(
+      { name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' },
+      ImageBody,
+    )
+    await dispose()
+    expect(registry.getSnapshot()).toEqual([])
+    expect(bodies.size).toBe(0)
+    expect(dictionaries.size).toBe(0)
+  })
+})

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

@@ -2356,6 +2356,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [
       'client-ui-sidebar-documentpreview CodeBody',
       'client-ui-sidebar-documentpreview HtmlBody',
+      'client-ui-sidebar-documentpreview ImageBody',
       'client-ui-sidebar-documentpreview MarkdownBody',
       'client-ui-sidebar-documentpreview PdfBody',
       'client-ui-sidebar-documentpreview TextBody',