Bladeren bron

fix(client): refine sidebar document previews

imccyu 1 dag geleden
bovenliggende
commit
284ab15519
29 gewijzigde bestanden met toevoegingen van 301 en 234 verwijderingen
  1. 2 2
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml
  2. 3 3
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md
  3. 3 3
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md
  4. 20 9
      apps/web/tests/document-preview.e2e.ts
  5. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  6. 1 1
      packages/client/ui-dockkit/README.md
  7. 1 1
      packages/client/ui-dockkit/README.zh.md
  8. 2 0
      packages/client/ui-dockkit/src/components/dockkit.module.css
  9. 6 0
      packages/client/ui-primitives/src/markdown/CodeBlock.module.css
  10. 1 2
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  11. 42 37
      packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
  12. 40 35
      packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
  13. 21 19
      packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
  14. 20 18
      packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt
  15. 4 3
      packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
  16. 4 3
      packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
  17. 4 3
      packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
  18. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  19. 4 4
      packages/client/ui-sidebar-documentpreview/README.md
  20. 4 4
      packages/client/ui-sidebar-documentpreview/README.zh.md
  21. 16 17
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css
  22. 23 7
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  23. 27 22
      packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css
  24. 1 0
      packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css
  25. 2 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  26. 21 17
      packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx
  27. 4 6
      packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts
  28. 7 2
      packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx
  29. 14 12
      packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.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-09-sidebar-and-preview-interaction-polish.md
-2026-09-09-sidebar-and-preview-interaction-polish.md: df0e768aa1aea913344c356d84c2a7d3386ccc6b
-2026-09-09-sidebar-and-preview-interaction-polish.zh.md: d3f8b00fd86ec12d867d48a917cd4a65cab18ba9
+2026-09-09-sidebar-and-preview-interaction-polish.md: ad99e37984916fab638670bb3fb47dc2ade2d0bf
+2026-09-09-sidebar-and-preview-interaction-polish.zh.md: 037ab2d147a0afad35902dfe57551b148c39ed90

+ 3 - 3
.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md

@@ -18,7 +18,7 @@ Five small interaction defects around the right Sidebar and the document preview
 
 **Menus close on focus entering an iframe.** [Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) adds a window `blur` listener gated on `document.activeElement instanceof HTMLIFrameElement` — the focus move is the only signal a pointerdown inside a cross-origin iframe leaves, and the gate keeps app or tab switches from closing the list.
 
-**The code preview pins its banner and drops the card fill.** With wrap off, [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) sizes the renderer `max-content` so the sticky banner has the full scroll width to ride, and pins the banner `sticky; left: 0; width: 100cqw` against the document scroller (`container-type: inline-size` on the preview body). The shared CodeBlock's fill is routed through a new `--dsl-code-block-background` variable (default unchanged, so chat keeps its gray card) and the shared banner carries an inert `data-code-block-banner` hook; the preview sets the variable to `transparent` so code sits on the pane's own background.
+**The code preview separates its banner from scrolling source and drops the card fill.** The shared CodeBlock wraps its rendered source in a stable `data-code-block-content` node that defaults to `display: contents`, so existing consumers keep their layout. [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) materializes that node as a full-height inner scrollport below the banner; the document owner caches that node for position restoration, paging, and line navigation. The shared CodeBlock's fill is routed through `--dsl-code-block-background` (default unchanged, so chat keeps its gray card), while the preview sets it to `transparent` so code sits on the pane's own background.
 
 ## Alternatives considered
 
@@ -28,8 +28,8 @@ Five small interaction defects around the right Sidebar and the document preview
 
 **A bare window-blur close for menus.** Closes the list on every app or tab switch; the `activeElement` gate scopes the close to the one case the document cannot see.
 
-**An inner code scroller for the horizontal axis.** Restoring `overflow-x: auto` on the `pre` keeps the banner still, but puts the horizontal scrollbar at the bottom of the whole block — unreachable in a long file — and both axes deliberately live in the document owner's scroller.
+**Keep code in the shared document scroller.** A child banner cannot cover its parent's native scrollbar. Giving the stable source wrapper both scroll axes keeps the scrollbar at the visible viewport edge below the adjacent banner, rather than at the end of a long code block.
 
 ## Consequences
 
-`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable and `data-code-block-banner` attribute are the code block's owner-styling seam; no shared stylesheet rule targets the attribute. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules.
+`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable keeps chat's default gray card while the preview uses the pane background, and `data-code-block-content` lets an owner materialize a dedicated source viewport without changing other CodeBlock layouts. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; document-preview specs cover inner scrolling and line navigation; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules.

+ 3 - 3
.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 **焦点进入 iframe 时关闭菜单。**[Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) 增加 window `blur` 监听,以 `document.activeElement instanceof HTMLIFrameElement` 为门:焦点移动是跨源 iframe 内 pointerdown 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。
 
-**代码预览钉住复制条并去掉卡片填充。**关闭折行时,[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 把渲染器设为 `max-content`,让吸附的复制条拥有完整滚动宽度可骑行,并以 `sticky; left: 0; width: 100cqw` 把它钉在文档滚动区上(预览正文设 `container-type: inline-size`)。共享 CodeBlock 的填充改经新变量 `--dsl-code-block-background`(默认值不变,会话保持灰色卡片),共享复制条带上惰性的 `data-code-block-banner` 钩子;预览把变量设为 `transparent`,代码于是坐在分栏自身的背景上
+**代码预览把复制条与滚动源码分开,并去掉卡片填充。**共享 CodeBlock 用稳定的 `data-code-block-content` 节点包裹渲染后的源码;该节点默认使用 `display: contents`,因此既有消费者保持原布局。[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 将该节点实体化为复制条下方占满剩余高度的内部滚动区;文档 owner 缓存这个节点,用于位置恢复、分页和行跳转。共享 CodeBlock 的填充通过 `--dsl-code-block-background` 设置(默认值不变,会话保持灰色卡片),预览将它设为 `transparent`,代码因此直接使用分栏背景
 
 ## Alternatives considered
 
@@ -28,8 +28,8 @@ Status: implemented
 
 **菜单用裸的 window blur 关闭。**每次应用或标签页切换都会关掉列表;`activeElement` 门把关闭收窄到父文档看不见的那一种情形。
 
-**代码横轴用内层滚动。**在 `pre` 上恢复 `overflow-x: auto` 能让复制条不动,但横向滚动条会落在整个代码块底部——长文件里够不着——而且两个轴本就有意放在文档 owner 的滚动区里
+**让代码继续使用共享文档滚动区。**子级复制条无法覆盖父级的原生滚动条。让稳定的源码包装节点同时承载两个滚动轴,滚动条会停在复制条下方的可见视口边缘,而不是长代码块的末端
 
 ## Consequences
 
-`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量与 `data-code-block-banner` 属性是代码块的 owner 定制接缝;共享样式表没有任何规则指向该属性。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。
+`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量保留 Chat 默认的灰色卡片,Preview 则使用分栏背景;`data-code-block-content` 允许 owner 实体化专用源码视口,而不改变其它 CodeBlock 布局。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;文档预览规格覆盖内部滚动与行跳转;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。

+ 20 - 9
apps/web/tests/document-preview.e2e.ts

@@ -240,6 +240,18 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const iframe = preview.locator('[data-html-preview]')
     await iframe.waitFor({ timeout: 15_000 })
     expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts')
+    expect(await iframe.evaluate((node) => {
+      const host = node.closest('[data-textpreview-body]')
+      if (!(host instanceof HTMLElement)) throw new Error('HTML preview body is unavailable')
+      const outer = host.getBoundingClientRect()
+      const frame = node.getBoundingClientRect()
+      return {
+        top: Math.round(frame.top - outer.top),
+        right: Math.round(outer.right - frame.right),
+        bottom: Math.round(outer.bottom - frame.bottom),
+        left: Math.round(frame.left - outer.left),
+      }
+    })).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
     const html = page.frameLocator('[data-html-preview]')
     await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
     await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
@@ -379,6 +391,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const highlightedLines = preview.locator('.shiki .line')
     await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES)
     const codeBlock = preview.locator('.md-code-block')
+    const codeScrollport = preview.locator('[data-code-block-content]')
     expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true')
     await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content))
       .not.toMatch(/^(?:none|normal)$/u)
@@ -397,33 +410,31 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const prefix = await highlightedLines.allTextContents()
     expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES))
     await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
-    await scrollForNextPage(body)
+    await scrollForNextPage(codeScrollport)
     await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length)
     const completed = await highlightedLines.allTextContents()
     expect(completed).toEqual(codeLines)
     await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
-    const scrollTop = await body.evaluate((node) => {
+    const scrollTop = await codeScrollport.evaluate((node) => {
       const target = Math.floor((node.scrollHeight - node.clientHeight) / 2)
       if (target <= 0) throw new Error('code fixture does not overflow the document body')
       node.scrollTop = target
       return target
     })
-    await expect.poll(() => body.evaluate((node) => {
-      const banner = node.querySelector('.md-code-block')?.firstElementChild
+    await expect.poll(() => codeScrollport.evaluate((node) => {
+      const codeBlock = node.parentElement
+      const banner = codeBlock?.firstElementChild
       const firstLine = node.querySelector('.shiki .line')
       if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line')
       const bounds = node.getBoundingClientRect()
       const clipTop = bounds.top + node.clientTop
       const bannerBounds = banner.getBoundingClientRect()
-      const hit = document.elementFromPoint(bounds.left + node.clientLeft + node.clientWidth / 2, clipTop + 1)
       return {
         scrollTop: node.scrollTop,
-        position: getComputedStyle(banner).position,
-        topGap: bannerBounds.top - clipTop,
+        scrollportBelowBanner: Math.abs(bannerBounds.bottom - bounds.top) < 1,
         firstLineAbove: firstLine.getBoundingClientRect().top < clipTop,
-        topCoveredByBanner: hit !== null && banner.contains(hit),
       }
-    })).toEqual({ scrollTop, position: 'sticky', topGap: 0, firstLineAbove: true, topCoveredByBanner: true })
+    })).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
     await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
     await page.evaluate(() => navigator.clipboard.writeText(''))
     await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()

+ 2 - 2
packages/client/ui-dockkit/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-dockkit/README.md
-README.md: 41100530b77f9776c768010b53ecbc6ad67b5902
-README.zh.md: 237ce5f0aedf1124e4dc3a214bf10529dea1703f
+README.md: f41668ad2eb6c16e13deb5fcc554c8717811a468
+README.zh.md: f92828b99ef5678458614261304c05d382bc34f4

+ 1 - 1
packages/client/ui-dockkit/README.md

@@ -52,7 +52,7 @@ Everything host-specific arrives through props:
 
 `DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
 
-`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
+`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
 
 A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end.
 

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

@@ -52,7 +52,7 @@ kind: "package-reference"
 
 `DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
 
-`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
+`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
 
 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。
 

+ 2 - 0
packages/client/ui-dockkit/src/components/dockkit.module.css

@@ -509,6 +509,7 @@
 .dockScrim {
   position: absolute;
   inset: 0;
+  z-index: 10;
   pointer-events: none;
   /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2`
      is a lighter bluish step than the column's `bg-base`, which tinted the
@@ -520,6 +521,7 @@
 
 .dockHint {
   position: absolute;
+  z-index: 10;
   display: flex;
   box-sizing: border-box;
   padding: 8px;

+ 6 - 0
packages/client/ui-primitives/src/markdown/CodeBlock.module.css

@@ -69,6 +69,12 @@
   font: inherit;
 }
 
+/* Consumers may turn the stable content node into a viewport without changing
+   the default CodeBlock layout. */
+.content {
+  display: contents;
+}
+
 .block :where(pre) {
   font: var(--dsl-code-block-content-font);
   padding: 16px;

+ 1 - 2
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -178,7 +178,6 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
       } as CSSProperties}>
       <div className={css.bannerWrap}>
-        {/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
         <div className={css.banner} data-code-block-banner>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
@@ -188,7 +187,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
           </div>
         </div>
       </div>
-      {body}
+      <div className={css.content} data-code-block-content>{body}</div>
     </div>
   )
 }

+ 42 - 37
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt

@@ -7,22 +7,23 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <div>
-      <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
-        <code>
-          <span class="line">
-            <span style="color:var(--shiki-token-keyword)">
-              #text "const"
-            <span style="color:var(--shiki-token-constant)">
-              #text " answer"
-            <span style="color:var(--shiki-token-keyword)">
-              #text ":"
-            <span style="color:var(--shiki-token-constant)">
-              #text " number"
-            <span style="color:var(--shiki-token-keyword)">
-              #text " ="
-            <span style="color:var(--shiki-token-constant)">
-              #text " 42"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <div>
+        <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
+          <code>
+            <span class="line">
+              <span style="color:var(--shiki-token-keyword)">
+                #text "const"
+              <span style="color:var(--shiki-token-constant)">
+                #text " answer"
+              <span style="color:var(--shiki-token-keyword)">
+                #text ":"
+              <span style="color:var(--shiki-token-constant)">
+                #text " number"
+              <span style="color:var(--shiki-token-keyword)">
+                #text " ="
+              <span style="color:var(--shiki-token-constant)">
+                #text " 42"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -30,9 +31,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "no language"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "no language"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -41,9 +43,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "plain fallback"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "plain fallback"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -52,18 +55,19 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <div>
-      <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
-        <code>
-          <span class="line">
-            <span style="color:var(--shiki-token-keyword)">
-              #text "const"
-            <span style="color:var(--shiki-token-constant)">
-              #text " withMeta"
-            <span style="color:var(--shiki-token-keyword)">
-              #text " ="
-            <span style="color:var(--shiki-token-constant)">
-              #text " true"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <div>
+        <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
+          <code>
+            <span class="line">
+              <span style="color:var(--shiki-token-keyword)">
+                #text "const"
+              <span style="color:var(--shiki-token-constant)">
+                #text " withMeta"
+              <span style="color:var(--shiki-token-keyword)">
+                #text " ="
+              <span style="color:var(--shiki-token-constant)">
+                #text " true"
   <pre>
     <code>
   <div class="_block_9aea57 md-code-block">
@@ -73,6 +77,7 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "indented code block\nsecond line"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "indented code block\nsecond line"

+ 40 - 35
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt

@@ -7,21 +7,22 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
-      <code>
-        <span class="line">
-          <span style="color: var(--shiki-token-keyword);">
-            #text "const"
-          <span style="color: var(--shiki-token-constant);">
-            #text " answer"
-          <span style="color: var(--shiki-token-keyword);">
-            #text ":"
-          <span style="color: var(--shiki-token-constant);">
-            #text " number"
-          <span style="color: var(--shiki-token-keyword);">
-            #text " ="
-          <span style="color: var(--shiki-token-constant);">
-            #text " 42"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
+        <code>
+          <span class="line">
+            <span style="color: var(--shiki-token-keyword);">
+              #text "const"
+            <span style="color: var(--shiki-token-constant);">
+              #text " answer"
+            <span style="color: var(--shiki-token-keyword);">
+              #text ":"
+            <span style="color: var(--shiki-token-constant);">
+              #text " number"
+            <span style="color: var(--shiki-token-keyword);">
+              #text " ="
+            <span style="color: var(--shiki-token-constant);">
+              #text " 42"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -29,9 +30,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "no language"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "no language"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -40,9 +42,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "plain fallback"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "plain fallback"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -51,17 +54,18 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
-      <code>
-        <span class="line">
-          <span style="color: var(--shiki-token-keyword);">
-            #text "const"
-          <span style="color: var(--shiki-token-constant);">
-            #text " withMeta"
-          <span style="color: var(--shiki-token-keyword);">
-            #text " ="
-          <span style="color: var(--shiki-token-constant);">
-            #text " true"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
+        <code>
+          <span class="line">
+            <span style="color: var(--shiki-token-keyword);">
+              #text "const"
+            <span style="color: var(--shiki-token-constant);">
+              #text " withMeta"
+            <span style="color: var(--shiki-token-keyword);">
+              #text " ="
+            <span style="color: var(--shiki-token-constant);">
+              #text " true"
   <pre>
     <code>
   <div class="_block_9aea57 md-code-block">
@@ -71,6 +75,7 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "indented code block\nsecond line"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "indented code block\nsecond line"

+ 21 - 19
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt

@@ -6,9 +6,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "kept blank line follows\n"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "kept blank line follows\n"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -17,21 +18,22 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <div>
-      <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
-        <code>
-          <span class="line">
-            <span style="color:var(--shiki-token-keyword)">
-              #text "const"
-            <span style="color:var(--shiki-token-constant)">
-              #text " doubled"
-            <span style="color:var(--shiki-token-keyword)">
-              #text " ="
-            <span style="color:var(--shiki-token-constant)">
-              #text " true"
-          #text "\n"
-          <span class="line">
-          #text "\n"
-          <span class="line">
+    <div class="_content_9aea57" data-code-block-content="true">
+      <div>
+        <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
+          <code>
+            <span class="line">
+              <span style="color:var(--shiki-token-keyword)">
+                #text "const"
+              <span style="color:var(--shiki-token-constant)">
+                #text " doubled"
+              <span style="color:var(--shiki-token-keyword)">
+                #text " ="
+              <span style="color:var(--shiki-token-constant)">
+                #text " true"
+            #text "\n"
+            <span class="line">
+            #text "\n"
+            <span class="line">
   <p>
     #text "after"

+ 20 - 18
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt

@@ -6,9 +6,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "kept blank line follows\n"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "kept blank line follows\n"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -17,20 +18,21 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
-      <code>
-        <span class="line">
-          <span style="color: var(--shiki-token-keyword);">
-            #text "const"
-          <span style="color: var(--shiki-token-constant);">
-            #text " doubled"
-          <span style="color: var(--shiki-token-keyword);">
-            #text " ="
-          <span style="color: var(--shiki-token-constant);">
-            #text " true"
-        #text "\n"
-        <span class="line">
-        #text "\n"
-        <span class="line">
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
+        <code>
+          <span class="line">
+            <span style="color: var(--shiki-token-keyword);">
+              #text "const"
+            <span style="color: var(--shiki-token-constant);">
+              #text " doubled"
+            <span style="color: var(--shiki-token-keyword);">
+              #text " ="
+            <span style="color: var(--shiki-token-constant);">
+              #text " true"
+          #text "\n"
+          <span class="line">
+          #text "\n"
+          <span class="line">
   <p>
     #text "after"

+ 4 - 3
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt

@@ -39,6 +39,7 @@
             <div class="_action_9aea57">
               <button class="_copyButton_9aea57" type="button">
                 #text "复制"
-        <pre class="_plain_9aea57">
-          <code>
-            #text "fenced inside list"
+        <div class="_content_9aea57" data-code-block-content="true">
+          <pre class="_plain_9aea57">
+            <code>
+              #text "fenced inside list"

+ 4 - 3
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt

@@ -39,6 +39,7 @@
             <div class="_action_9aea57">
               <button class="_copyButton_9aea57" type="button">
                 #text "复制"
-        <pre class="_plain_9aea57">
-          <code>
-            #text "fenced inside list"
+        <div class="_content_9aea57" data-code-block-content="true">
+          <pre class="_plain_9aea57">
+            <code>
+              #text "fenced inside list"

+ 4 - 3
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt

@@ -25,6 +25,7 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "\\sqrt{2}"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "\\sqrt{2}"

+ 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: fbfbfaf4d80e425e5fbc62c082509fb6c4a93962
-README.zh.md: c27a359b608d8b72ace0700c55c34a58a302ec60
+README.md: f86d769b9074cd0f272a5f2882251644574f0688
+README.zh.md: 72693000dfc54e7da5f0d397ae540c69586bbf23

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

@@ -27,7 +27,7 @@ Preview readable files in the right Sidebar and choose among registered renderer
 ## What it registers
 
 - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport.
 - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
 
 Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
@@ -47,18 +47,18 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
 - **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.
+HTML fills the body edge to edge 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/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.
+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; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
 
 <a id="navigation"></a>
 ## Navigation
 
-`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation places the target below its sticky toolbar. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
+`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation scrolls the inner source viewport directly. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
 
 <a id="model-experience"></a>
 ## Model Experience

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

@@ -27,7 +27,7 @@ kind: "package-reference"
 ## 注册了什么
 
 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
-- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动
+- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区
 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
 
 文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
@@ -47,18 +47,18 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
 - **完整字节** —— 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。
+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>
 ## 导航
 
-`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航会把目标放在置顶工具栏下方。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
+`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
 
 <a id="model-experience"></a>
 ## 模型体验

+ 16 - 17
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css

@@ -81,13 +81,9 @@
   position: relative;
   flex: 1 1 auto;
   min-height: 0;
-  /* The files tree's inset and 2px scrollbar offset (ui-sidebar-files
-     FilesBody `.body`), so the two bodies start their text at one edge and
-     both bars sit 2px clear of the pane's edge. */
-  margin-right: 2px;
-  padding: 8px 0 8px 8px;
+  margin: 0;
+  padding: 0;
   overflow: auto;
-  scrollbar-gutter: stable;
   /* The notice and retry surfaces in this sheet are elevated, so the file body's
      scroller rebinds the thumb indirection in a complete pair. */
   --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -99,27 +95,30 @@
   white-space: pre;
 }
 
-/* The bar also stops 2px short of the body's ends, matching its 2px edge
-   offset (ui-sidebar-files FilesBody keeps the same rule). WebKit-only: the
+/* Keep the scrollbar track 2px clear of the body's ends. WebKit-only: the
    Firefox path has no track to inset. */
 .body::-webkit-scrollbar-track {
   margin: 2px;
 }
 
+.body:has([data-code-preview]) {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
 .wrap {
   white-space: pre-wrap;
   word-break: break-word;
 }
 
-/* Sticky code banners meet the scrollport edge; prose owns its initial spacing. */
-.body:has([data-code-preview], [data-document-markdown]) {
-  padding: 0;
-}
-
-/* The code renderer's banner sizes itself to this scroller's visible width
-   (100cqw in CodeBody.module.css); the container makes that width queryable. */
-.body:has([data-code-preview]) {
-  container-type: inline-size;
+/* Plain text keeps the shared document inset while edge-to-edge renderers can
+   fill the scroller themselves. */
+.textDocument {
+  box-sizing: border-box;
+  min-width: 100%;
+  min-height: 100%;
+  padding: 8px;
 }
 
 /* One page of lines; pages abut so the file reads as one. */

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

@@ -69,6 +69,13 @@ export type TextPreviewProps =
   & InjectFace<TextPreviewInjected>
   & PropsLocale<'sidebarDocumentPreview'>
 
+/** The selected renderer's scrolling element; code owns an inner scrollport. */
+function scrollportOf(body: HTMLDivElement | null): HTMLElement | null {
+  if (body === null) return null
+  const code = body.querySelector('[data-code-preview]')
+  return code?.querySelector<HTMLElement>('[data-code-block-content]') ?? body
+}
+
 /**
  * The text type's body, registered under `sidebar.right.pane.tab` as `text`.
  * @param props - composed slot props.
@@ -94,6 +101,7 @@ export function TextPreview({
   const mode = selected?.loading
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
+  const scrollportRef = useRef<HTMLElement | null>(null)
   const pathRef = useRef<HTMLDivElement | null>(null)
   const pathTextRef = useRef<HTMLSpanElement | null>(null)
   const [menuOpen, setMenuOpen] = useState(false)
@@ -107,6 +115,11 @@ export function TextPreview({
   const loadedThrough = lastLineLoaded(loaded)
   const hasContent = loaded.length > 0 || current?.complete !== undefined
 
+  useLayoutEffect(() => {
+    scrollportRef.current = scrollportOf(bodyRef.current)
+    return () => { scrollportRef.current = null }
+  }, [hasContent, selected?.id])
+
   // First mount reads the first page; a body coming back to a tab with content
   // reads nothing, because the store outlives the body.
   const started = current !== undefined
@@ -117,19 +130,19 @@ export function TextPreview({
   }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, meta.value?.version])
 
   // Come back where the reader was once there is content to scroll: on a remount,
-  // and after a reload rebuilt the content. Keyed on content presence only, so a
-  // scroll write never re-lands.
+  // after a reload rebuilt the content, or after the selected renderer changed.
+  // Scroll writes preserve both identities, so they never re-land.
   useEffect(() => {
-    const body = bodyRef.current
+    const body = scrollportRef.current
     if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop
-  }, [hasContent])
+  }, [hasContent, selected?.id])
 
   // Answer a navigation once: a line the pages do not reach yet loads the next
   // page (again, until the pages cover it or the file ends); a line they hold
   // is scrolled to and marked. The store remembers the answer, so a remount
   // restores the reader's place instead.
   useEffect(() => {
-    const body = bodyRef.current
+    const body = scrollportRef.current
     if (current === undefined || body === null || current.revision === navigation.revision) return
     if (line === undefined || mode !== 'text-pages') {
       actions.navigated(tab.id, navigation.revision)
@@ -271,8 +284,11 @@ export function TextPreview({
         className={clsx(css.body, state.wrap && css.wrap)}
         data-textpreview-body
         data-textpreview-wrap={state.wrap ? '' : undefined}
-        onScroll={(event) => {
-          const body = event.currentTarget
+        onScrollCapture={(event) => {
+          const body = scrollportRef.current
+          /* v8 ignore next -- the layout effect binds the scrollport before user input. */
+          if (body === null) return
+          if (event.target !== body) return
           actions.scrolled(tab.id, body.scrollTop)
           if (mode === 'text-pages' && current?.failure === undefined && body.clientHeight > 0
             && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()

+ 27 - 22
packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css

@@ -1,6 +1,12 @@
 .renderer {
-  min-height: 100%;
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
   min-width: 0;
+  min-height: 0;
+  overflow: hidden;
   white-space: normal;
 }
 
@@ -10,13 +16,31 @@
   /* In a sidebar pane the code sits on the pane's own background; the chat
      card's gray fill stays a chat-only default. */
   --dsl-code-block-background: transparent;
-  /* Source lines resolve their offset against the document owner's scroller. */
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
   position: static;
   margin: 0;
   min-width: 0;
 }
 
-/* Both axes scroll in the document owner, not in an inner code window. */
+/* The banner is the scrollport's sibling, so the source scrollbar starts
+   below it. */
+.renderer .code > [data-code-block-content] {
+  position: relative;
+  display: block;
+  flex: 1 1 auto;
+  min-width: 0;
+  min-height: 0;
+  overflow: auto;
+}
+
+.renderer .code > [data-code-block-content]::-webkit-scrollbar-track {
+  margin: 2px;
+}
+
 .renderer .code pre {
   box-sizing: border-box;
   min-width: 100%;
@@ -26,25 +50,6 @@
   overflow-wrap: normal;
 }
 
-/* Unwrapped long lines widen the document owner's scroller past its viewport;
-   sizing the card to that full scroll width gives the sticky banner room to
-   ride horizontal scrolling. Wrap mode keeps the default block width:
-   max-content would defeat soft wrapping. */
-.renderer[data-wrap='false'] {
-  width: max-content;
-  min-width: 100%;
-}
-
-/* The banner sticks to the scrollport's left edge at the owner's visible
-   width (cqw resolves against the owner's .body container), so the copy
-   control stays in view while the card scrolls horizontally. */
-.renderer[data-wrap='false'] .code [data-code-block-banner] {
-  position: sticky;
-  left: 0;
-  box-sizing: border-box;
-  width: 100cqw;
-}
-
 .renderer[data-wrap='true'] .code {
   --dsl-code-block-line-white-space: pre-wrap;
 }

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

@@ -5,6 +5,7 @@
   min-width: 100%;
   height: max-content;
   min-height: 100%;
+  padding: 8px;
   font-family: var(--dsw-font, sans-serif);
   white-space: normal;
 }

+ 2 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css

@@ -1,8 +1,10 @@
 .body {
   display: flex;
   flex-direction: column;
+  box-sizing: border-box;
   width: 100%;
   min-height: 0;
+  padding: 8px;
   overflow-x: hidden;
   font-family: var(--dsw-font, sans-serif);
   white-space: normal;

+ 21 - 17
packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx

@@ -11,21 +11,25 @@ export function TextBody({ content, useTabInfo }: DocumentPreviewProps): ReactNo
   const params = tab.navigation.params
   const target = params !== undefined && 'line' in params ? params.line : undefined
   if (content.kind !== 'text') return null
-  return content.pages.map(page => (
-    <pre key={page.offset} className={css.page} data-textpreview-page={page.offset}>
-      {linesOf(page).map((text, index) => {
-        const number = page.offset + index
-        return (
-          <div
-            key={number}
-            className={clsx(css.line, number === target && css.lineTarget)}
-            data-textpreview-line={number}
-            {...number === target ? { 'data-textpreview-target': number } : {}}
-          >
-            {text}{'\n'}
-          </div>
-        )
-      })}
-    </pre>
-  ))
+  return (
+    <div className={css.textDocument} data-textpreview-plain>
+      {content.pages.map(page => (
+        <pre key={page.offset} className={css.page} data-textpreview-page={page.offset}>
+          {linesOf(page).map((text, index) => {
+            const number = page.offset + index
+            return (
+              <div
+                key={number}
+                className={clsx(css.line, number === target && css.lineTarget)}
+                data-textpreview-line={number}
+                {...number === target ? { 'data-textpreview-target': number } : {}}
+              >
+                {text}{'\n'}
+              </div>
+            )
+          })}
+        </pre>
+      ))}
+    </div>
+  )
 }

+ 4 - 6
packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts

@@ -37,18 +37,16 @@ export function lastLineLoaded(pages: readonly LoadedPage[]): number {
 
 /**
  * Reveal a plain-text or highlighted source line.
- * @param body - scrolling document body.
+ * @param body - scrolling document body or code-content viewport.
  * @param line - 1-based source line to reveal.
  * @returns Whether the current renderer exposes that line.
  */
 export function scrollToLine(body: HTMLElement, line: number): boolean {
+  const innerCode = body.hasAttribute('data-code-block-content')
   const plain = body.querySelector(`[data-textpreview-line="${line}"]`)
-  const code = body.querySelectorAll('[data-code-preview] pre .line').item(line - 1)
+  const code = innerCode ? body.querySelectorAll('pre .line').item(line - 1) : null
   const row = plain ?? code
   if (!(row instanceof HTMLElement)) return false
-  const codeBlock = code === row ? row.closest<HTMLElement>('.md-code-block') : null
-  const toolbar = codeBlock?.firstElementChild
-  const toolbarHeight = toolbar instanceof HTMLElement ? toolbar.offsetHeight : 0
-  body.scrollTop = Math.max(0, row.offsetTop - toolbarHeight)
+  body.scrollTop = Math.max(0, row.offsetTop)
   return true
 }

+ 7 - 2
packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx

@@ -64,6 +64,7 @@ function installStyles(): void {
     .replaceAll('.renderer', `.${css.renderer}`).replaceAll('.code', `.${css.code}`)
   const shared = readFileSync(resolve(directory, '../../ui-primitives/src/markdown/CodeBlock.module.css'), 'utf8')
     .replaceAll('.block', `.${primitiveCss.block}`)
+    .replaceAll('.content', `.${primitiveCss.content}`)
   stylesheet = document.createElement('style')
   // The shared defaults load last to prove the renderer's selectors override them.
   stylesheet.textContent = `${own}\n${shared}`
@@ -147,21 +148,25 @@ describe('CodeBody', () => {
     expect(view.container.querySelector('.md-code-block')).toBeNull()
   })
 
-  it('switches wrapping without truncating text, remounting the highlighter, or taking over scrolling', () => {
+  it('keeps a stable inner scrollport while switching wrapping without remounting the highlighter', () => {
     installStyles()
     const code = `const identifier = "${'x'.repeat(300)}";`
     const content = contents([code])
     const view = render(<CodeBody {...props(content)} />)
     const pre = element(view.container, '.shiki')
     const block = element(view.container, '.md-code-block')
+    const scrollport = element(view.container, '[data-code-block-content]')
     expect(getComputedStyle(block).marginTop).toBe('0px')
     expect(getComputedStyle(block).position).toBe('static')
-    expect(getComputedStyle(element(view.container, '[data-code-preview]')).minHeight).toBe('100%')
+    expect(getComputedStyle(element(view.container, '[data-code-preview]')).height).toBe('100%')
+    expect(getComputedStyle(scrollport).display).toBe('block')
+    expect(getComputedStyle(scrollport).overflow).toBe('auto')
     expect(getComputedStyle(pre).whiteSpace).toBe('pre')
     expect(getComputedStyle(pre).overflow).toBe('visible')
     expect(getComputedStyle(pre).wordBreak).toBe('normal')
     view.rerender(<CodeBody {...props(content, { wrap: true })} />)
     expect(element(view.container, '.shiki')).toBe(pre)
+    expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
     expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
     expect(getComputedStyle(pre).overflowWrap).toBe('anywhere')
     expect(getComputedStyle(pre).overflow).toBe('visible')

+ 14 - 12
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -23,11 +23,9 @@ import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
 import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
 
 const LINE_HEIGHT = 20
-const CODE_TOOLBAR_HEIGHT = 36
 
 const originals = {
   offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'),
-  offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'),
   scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
 }
 
@@ -47,13 +45,6 @@ beforeAll(() => {
     get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 },
     set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value },
   })
-  Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
-    configurable: true,
-    get(this: HTMLElement) {
-      return this.parentElement?.classList.contains('md-code-block') === true
-        && this.parentElement.firstElementChild === this ? CODE_TOOLBAR_HEIGHT : 0
-    },
-  })
 })
 
 afterAll(() => {
@@ -107,6 +98,10 @@ function body(container: HTMLElement): HTMLElement {
   return element
 }
 
+function scrollport(container: HTMLElement): HTMLElement {
+  return container.querySelector<HTMLElement>('[data-code-block-content]') ?? body(container)
+}
+
 function lines(container: HTMLElement): string[] {
   return Array.from(container.querySelectorAll('[data-textpreview-line]'), row => row.textContent ?? '')
 }
@@ -432,7 +427,7 @@ describe('TextPreview — the file\'s metadata', () => {
 
 describe('TextPreview — navigation and view', () => {
   it.each([
-    ['Code', 'code', 2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT],
+    ['Code', 'code', 2 * LINE_HEIGHT],
     ['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT],
   ])('retries a Markdown line navigation after switching to %s', async (_name, rendererId, expectedScrollTop) => {
     PendingIntersectionObserver.instances = []
@@ -460,7 +455,7 @@ describe('TextPreview — navigation and view', () => {
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined()
     act(() => { h.instance.actions.selected(TAB_ID, rendererId) })
     await waitFor(() => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) })
-    expect(body(view.container).scrollTop).toBe(expectedScrollTop)
+    expect(scrollport(view.container).scrollTop).toBe(expectedScrollTop)
   })
 
   it('lands on code lines before and after syntax highlighting is ready', async () => {
@@ -472,13 +467,20 @@ describe('TextPreview — navigation and view', () => {
     expect(view.container.querySelector('[data-code-preview] pre.shiki')).toBeNull()
     expect(view.container.querySelectorAll('[data-code-preview] pre .line')).toHaveLength(3)
     expect(body(view.container).scrollTop).toBe(0)
+    const codeScrollport = scrollport(view.container)
+    expect(codeScrollport.scrollTop).toBe(LINE_HEIGHT)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
+    fireEvent.scroll(body(view.container), { target: { scrollTop: 300 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(LINE_HEIGHT)
+    fireEvent.scroll(codeScrollport, { target: { scrollTop: 300 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(300)
 
     const block = view.container.querySelector('[data-code-preview] .md-code-block')!
     act(() => { PendingIntersectionObserver.instances[0]!.intersect(block) })
     await waitFor(() => { expect(view.container.querySelector('[data-code-preview] pre.shiki')).not.toBeNull() })
+    expect(scrollport(view.container)).toBe(codeScrollport)
     view.rerender(<TextPreview {...codeProps(h, { params: { line: 3 }, revision: 2 })} />)
-    expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT)
+    expect(codeScrollport.scrollTop).toBe(2 * LINE_HEIGHT)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
   })