Explorar o código

fix(client): keep preview scrollports current

imccyu hai 1 mes
pai
achega
19b3c4c34b

+ 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: ad99e37984916fab638670bb3fb47dc2ade2d0bf
-2026-09-09-sidebar-and-preview-interaction-polish.zh.md: 037ab2d147a0afad35902dfe57551b148c39ed90
+2026-09-09-sidebar-and-preview-interaction-polish.md: eba8bd3a2314c7eaf18cd9982aa4e599665dbc62
+2026-09-09-sidebar-and-preview-interaction-polish.zh.md: 93d88d41e99f50621c80d69d615e39852b715c89

+ 1 - 1
.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 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.
+**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 code renderer reports it through a callback ref, so the document owner follows Slot replacement 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
 

+ 1 - 1
.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 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。
 
-**代码预览把复制条与滚动源码分开,并去掉卡片填充。**共享 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`,代码因此直接使用分栏背景。
+**代码预览把复制条与滚动源码分开,并去掉卡片填充。**共享 CodeBlock 用稳定的 `data-code-block-content` 节点包裹渲染后的源码;该节点默认使用 `display: contents`,因此既有消费者保持原布局。[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 将该节点实体化为复制条下方占满剩余高度的内部滚动区;代码渲染器通过 callback ref 报告该节点,使文档 owner 在 Slot 替换后仍能用当前节点恢复位置、分页和跳转代码行。共享 CodeBlock 的填充通过 `--dsl-code-block-background` 设置(默认值不变,会话保持灰色卡片),预览将它设为 `transparent`,代码因此直接使用分栏背景。
 
 ## Alternatives considered
 

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: aebb0be860ebaba4543a8fd262599507ead64556
-README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18
+README.md: 1919e923c6edfae58e38849996f3979723198075
+README.zh.md: e0c90fd14a284aac6a2420f5974eef6ad78138c7

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

@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
+| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |

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

@@ -52,7 +52,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
+| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |

+ 5 - 3
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -1,5 +1,5 @@
 import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
-import type { CSSProperties, ReactNode } from 'react'
+import type { CSSProperties, ReactNode, Ref } from 'react'
 import clsx from 'clsx'
 import { writeClipboard } from '../clipboard.ts'
 import {
@@ -25,6 +25,8 @@ export interface CodeBlockProps {
   streaming?: boolean | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
+  /** Ref for the stable source-content wrapper, for owners that use it as a scrollport. */
+  contentRef?: Ref<HTMLDivElement> | undefined
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   lineNumbers?: boolean | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
@@ -59,7 +61,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
 }
 
-export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({ code, lang, streaming, className, contentRef, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
@@ -187,7 +189,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
           </div>
         </div>
       </div>
-      <div className={css.content} data-code-block-content>{body}</div>
+      <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
     </div>
   )
 }

+ 11 - 0
packages/client/ui-primitives/tests/code-block.client.spec.tsx

@@ -59,6 +59,17 @@ describe('highlightToHtml', () => {
 })
 
 describe('CodeBlock', () => {
+  it('reports the stable source-content wrapper to its owner', () => {
+    const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
+    const view = render(<CodeBlock code="plain text" contentRef={contentRef} />)
+    const content = view.container.querySelector('[data-code-block-content]')
+    expect(contentRef).toHaveBeenCalledWith(content)
+    view.rerender(<CodeBlock code="updated text" contentRef={contentRef} />)
+    expect(view.container.querySelector('[data-code-block-content]')).toBe(content)
+    view.unmount()
+    expect(contentRef).toHaveBeenLastCalledWith(null)
+  })
+
   it('renders the highlighted tree for TypeScript', () => {
     const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
     const pre = view.container.querySelector('pre.shiki')

+ 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: f86d769b9074cd0f272a5f2882251644574f0688
-README.zh.md: 72693000dfc54e7da5f0d397ae540c69586bbf23
+README.md: e3659ed08a7c82f7c5ef314806ea07262f421caa
+README.zh.md: 2c2fe2225a87b333f501e58f4988ff8034c14273

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

@@ -27,10 +27,10 @@ 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 body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport.
+- **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. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
 - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
 
-Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, 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.
+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`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
 
 <a id="addresses"></a>
 ## Addresses

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

@@ -27,10 +27,10 @@ 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 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
 - **共享加载与视图状态**,会话作用域、按 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`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
+文档实现在 `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`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
 
 <a id="addresses"></a>
 ## 地址

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

@@ -1,6 +1,6 @@
-/* The pane body is a block scroller with a definite height, not a flex
-   container, so the preview takes that height outright: the header row stays
-   put and the file body below is the one scroller, however short the file. */
+/* The preview takes the pane's definite height outright, so the header stays
+   put while the body below owns the remaining height. A renderer may replace
+   the body's scrolling role with an inner scrollport. */
 .preview {
   display: flex;
   flex: 1 1 auto;

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

@@ -10,7 +10,7 @@
  * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of
  * the path row; the Sidebar's strip carries none of them.
  */
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import type { ReactNode, RefObject } from 'react'
 import clsx from 'clsx'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -69,13 +69,6 @@ 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.
@@ -102,6 +95,7 @@ export function TextPreview({
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
   const scrollportRef = useRef<HTMLElement | null>(null)
+  const storedScrollTopRef = useRef(0)
   const pathRef = useRef<HTMLDivElement | null>(null)
   const pathTextRef = useRef<HTMLSpanElement | null>(null)
   const [menuOpen, setMenuOpen] = useState(false)
@@ -114,11 +108,17 @@ export function TextPreview({
   const loaded = useMemo(() => loadedPages(pages ?? {}), [pages])
   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])
+  storedScrollTopRef.current = state?.scrollTop ?? 0
+  const bindBody = useCallback((body: HTMLDivElement | null): void => {
+    const previous = bodyRef.current
+    bodyRef.current = body
+    if (scrollportRef.current === null || scrollportRef.current === previous) scrollportRef.current = body
+  }, [])
+  const bindScrollport = useCallback((scrollport: HTMLElement | null): void => {
+    const next = scrollport ?? bodyRef.current
+    scrollportRef.current = next
+    if (scrollport !== null) scrollport.scrollTop = storedScrollTopRef.current
+  }, [])
 
   // First mount reads the first page; a body coming back to a tab with content
   // reads nothing, because the store outlives the body.
@@ -280,7 +280,7 @@ export function TextPreview({
         </Tooltip>
       </div>
       <div
-        ref={bodyRef}
+        ref={bindBody}
         className={clsx(css.body, state.wrap && css.wrap)}
         data-textpreview-body
         data-textpreview-wrap={state.wrap ? '' : undefined}
@@ -298,7 +298,7 @@ export function TextPreview({
           <LoadingIndicator className={css.statusLine} label={t('loading')} />
         )}
         {content !== undefined && renderSlot('sidebar.right.tab.document', {
-          resourceAddress: tab.contentId, content, wrap: state.wrap,
+          resourceAddress: tab.contentId, content, wrap: state.wrap, scrollportRef: bindScrollport,
         }, {
           entryKey: selected.id, hookContext: useTabInfo,
           fallback: <p className={css.statusLine}>{t('rendererUnavailable', { name: selected.title() })}</p>,

+ 2 - 1
packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx

@@ -12,7 +12,7 @@ import css from './CodeBody.module.css'
 export type CodeBodyProps = DocumentPreviewProps & PropsLocale<'sidebarCodePreview'>
 
 /** @param props - accumulated document contents and framework props. @returns one stable CodeBlock, or no body for byte contents. */
-export function CodeBody({ resourceAddress, content, wrap, t }: CodeBodyProps): ReactNode {
+export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: CodeBodyProps): ReactNode {
   if (content.kind !== 'text') return null
   const file = parseFileAddress(resourceAddress)
   if (file === undefined) throw new Error(`ui-sidebar-documentpreview: not a file address "${resourceAddress}"`)
@@ -21,6 +21,7 @@ export function CodeBody({ resourceAddress, content, wrap, t }: CodeBodyProps):
     <div className={css.renderer} data-code-preview data-wrap={wrap}>
       <CodeBlock
         className={css.code}
+        contentRef={scrollportRef}
         code={content.text}
         lang={language}
         streaming={!content.eof}

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

@@ -1,6 +1,7 @@
 /** Document renderer slot: the owner supplies shared file state, renderers own their presentation. */
 import type { PropsRuntime, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
 import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { RefCallback } from 'react'
 
 /** One loaded text window, retaining source line positions. */
 export interface DocumentTextPage {
@@ -30,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
         readonly content: DocumentContent
         /** The document toolbar's current wrapping preference. */
         readonly wrap: boolean
+        /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */
+        readonly scrollportRef: RefCallback<HTMLElement>
       }
       hookContext: UseSidebarRightTabInfo
       inject: {

+ 1 - 0
packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx

@@ -54,6 +54,7 @@ describe('Markdown implementation registration', () => {
       resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md',
       content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true },
       wrap: false,
+      scrollportRef: vi.fn(),
     }, { entryKey: MARKDOWN_BODY_ID, hookContext: useTabInfo, fallback: <span data-missing-markdown /> }))
     const view = runtime.renderRoot()
     expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined()

+ 22 - 0
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -426,6 +426,28 @@ describe('TextPreview — the file\'s metadata', () => {
 })
 
 describe('TextPreview — navigation and view', () => {
+  it('rebinds scrolling when the selected Slot body is replaced without changing the renderer id', async () => {
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
+    const code = codeProps(h, { revision: 1 })
+    const fallback: TextPreviewProps = { ...code, renderSlot: () => <div data-late-renderer /> }
+    const view = render(<TextPreview {...fallback} />)
+    await settle()
+    const outer = body(view.container)
+    fireEvent.scroll(outer, { target: { scrollTop: 120 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(120)
+
+    view.rerender(<TextPreview {...code} />)
+    const inner = scrollport(view.container)
+    expect(inner).not.toBe(outer)
+    expect(inner.scrollTop).toBe(120)
+    fireEvent.scroll(inner, { target: { scrollTop: 240 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(240)
+
+    view.rerender(<TextPreview {...fallback} />)
+    fireEvent.scroll(outer, { target: { scrollTop: 360 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(360)
+  })
+
   it.each([
     ['Code', 'code', 2 * LINE_HEIGHT],
     ['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT],