dsh-scroll-boost

uitested: passnpmMIT

Skips offscreen chat and trajectory rows so long sessions scroll smoothly; measured row heights keep the scrollbar steady, visible content unchanged.

下载量 / Downloads
1,213
依赖体积 / node_modules
0.1 MB
测试时间 / Tested
2026-11-06
DSH 版本 / Version
0.2.2-rc.1

Install

dsh plugin --profile web add dsh-scroll-boost

Sandbox test results

StageResult
1 install✓
2 list✓
3 mount✓
4 boot—

Four stages run in an isolated sandbox: install (network on) → list → mount → boot (last three with no network).

README

dsh-scroll-boost

一个纯客户端的 DeepSeek Harness Web GUI 滚动优化插件。

它不卸载插件、不改 React 组件、不改主题,也不降低画面质量:只在空闲时给「真正可滚动容器里的

长列表直接子行」加上 content-visibility: auto,让浏览器跳过屏幕外行的布局与绘制,从而减少

滚动时的合成和重排压力。

![GitHub](https://github.com/TWFU-wcy/dsh-scroll-boost)

![Release](https://github.com/TWFU-wcy/dsh-scroll-boost/releases)

![License](LICENSE)

解决什么问题

安装 @changfenhuang/dsh-genui 等会在 DOM 中生成大量围栏内容的插件后,长会话滚动可能出现掉帧。

根因通常是浏览器仍在对整页 DOM 做布局/绘制,而滚动帧预算只有约 16.7ms。

dsh-scroll-boost 的做法是:只让屏幕外的「行」不参与渲染,屏幕内的画面完全不变。

认表面,而不是猜列表

通用做法是「在页面里找一个看起来可滚动的容器,再把它的直接子节点当成行」。这在 DSH 里失效,

因为 聊天记录被渲染进一个 display: contents 的槽位——槽位自己没有盒子,那套「向下走直到

找到行列表」的搜索永远看不到它。

所以插件直接认 DSH 自己的稳定 data-* 表面,并按表面类型决定策略:

表面 · 选择器 · 策略 · 原因

chat-flow · [data-slot="conversation.chat.flow"] · 标记其直接子行 · 聊天记录没有虚拟化,行数随会话增长,是收益最大的地方

conversation-scroll · [data-conversation-scroll] · 只加 will-change 提示 · 这是真正的滚动口

trajectory · [data-trajectory-scroll] · 静态规则只跳内容单元格 · DSH 每帧重建行,JS 写的属性活不到绘制

summary-region · [data-summary-scroll-region] · 只加提示 · 摘要区是短列表,不需要跳行

composer-input · [data-input-scroll] · 只加提示 · 输入框内部滚动口,没有行可跳;认出来是为了滚动时暂停扫描

approval-panel · [data-approval-scroll] · 只加提示 · 审批卡片内部滚动口,同上

question-panel · [data-question-scroll] · 只加提示 · 提问面板内部滚动口,同上

认不出来的界面仍走通用回退路径,所以 DSH 改版不会让插件彻底失效。文档缩放视口

[data-document-zoom-scrollport] 有意不认:它是缩放变换的目标,加合成提示会改变文档的

栅格化方式。

优化分层

层 · 做什么 · 关键点

A. 离屏跳过(核心) · 给长列表的直接子行加 content-visibility: auto + contain-intrinsic-size · 先把真实行高写成字面量,再打标记;不改变可见区域

B. 容器提示 · 给面积最大的最多 8 个滚动容器加 will-change: scroll-position · 只加属性,不每帧写 DOM;这是唯一会改像素的一层,可用 setPixelSafe(true) 关掉

C. 空闲调度 · requestIdleCallback 分片扫描,每片约 8ms · 滚动期间暂停扫描/测量,滚动结束后继续

D. 增量维护 · MutationObserver 监听 childList + 少量关键属性,50ms 去抖 · 新消息到达后只补新增行,不重扫全页

E. 首屏快扫 · 视图挂载后下一帧只标记视口上下 1.5 屏内的行 · 上限 120 行 / 300 次 rect 读,剩下的交给空闲分片

F. 块级跳过 · 长思考正文按直接块子元素加 content-visibility,高度先实测再钉 · 只碰 [data-markdown-variant="compact"];只在正文落定后打标记

G. 步级跳过 · PTC 过程组按 step 跳过:一组一个盒子,而不是几十个段落 · 认 [data-step-process-content][data-chat-flow] 的直接子节点;组外仍走 F 层兜底

H. 流式 containment · 正文还在流式增长时只加 contain: layout · 不打跳过标记、不钉高,等正文落定再交给 F / G

I. 生命周期与缓存 · bfcache 暂停/恢复、失联表面剪枝、resize 从观察条目取值 · 这些都只在「缓存已经错了」时触发;稳态下不增加每帧成本

扫描顺序是:表面发现 → 候选容器 → 行列表 → 逐行测量/标记。每一步都按空闲时间分片,避免启动时

产生长任务。首屏快扫是例外:它只跑一帧、只碰近端行,用来把「第一次可见帧就已经被优化」从几百

毫秒提前到十几毫秒。分片内部采用批量读写:一个分片里的行先全部读完、再全部写入,把每行一次

布局重算压成每批一次。

行高必须是真值

DSH 自己的「当前读到第几行」是拿 getBoundingClientRect() 做二分的。一旦占位高度和真实高度对不上,

跳转、跟随尾巴就会落到错误的行——画面不对,比掉帧更严重。所以行高在这里是一等公民:

  • 离屏行不读布局,直接用按 data-chat-flow-key 记住的历史高度(WeakMap + localStorage);
  • 可见带才实测一次,且减去 padding / border(contain-intrinsic-size 描述的是内容盒);
  • 高度以 0.01px 精度直接写成 contain-intrinsic-size: auto <N>px 字面量。不走 var()——每个被跳过的

盒子在每次样式重算时都要解析一次自定义属性,实测比字面量慢约 6 倍(18.1 ms vs 2.8 ms);

--sb-h 仍然写,只作为 report() 和人工排查的读数;

  • ResizeObserver 盯着近端行,行高变化时立即纠正;校正时只读观察回调自带的 contentRect,

绝不在回调里调 getBoundingClientRect()(原因见「宽度变化」一节)。

实测(600 行聊天记录,headless Edge):标记后 scrollHeight 完全不变,行位置最大偏差 9px,

DSH 的二分定位依然命中正确的行。

轨迹表:为什么要一条静态规则

轨迹面板确实做了虚拟化,但它在滚动时每帧重建 <tr>(tbody.replaceChildren)。插件原本的

打标记方式是「读一次高度 → 写一个 data-* 属性」,属性写上去的下一帧就被新节点换掉了,等于什么

都没做——轨迹页的手感反而回到没装插件时。

所以换成一条静态 CSS 规则:规则随样式表一次性生效,浏览器在它自己的样式计算里应用,不需要

主线程逐行写 DOM,也就不会被重建冲掉。

[data-trajectory-scroll] tbody tr[data-record-index]:not([data-turn-start]):not([data-terminal-request-boundary]):not([data-request-only]):not([data-collapsed-summary]) > td:last-child {
  content-visibility: auto;
  contain-intrinsic-size: auto 30px;
}

三个约束来自对真实轨迹表的像素级验证:

  • 只标内容单元格(td:last-child)。事件列里有绝对定位、故意越界的 turnRail 与

requestBoundaryControl,content-visibility 的 paint containment 会把它们裁掉。

  • 排除 data-turn-start 行。这类行用 td:before 在自己上边缘画 1px 分隔线,同样会被裁。
  • 排除非标准高度行(9px 边界行、0px 请求行、24px 折叠摘要行)。它们的占位高度和真实高度

不一致,会让滚动条漂移。

实测(2000 行虚拟化轨迹表,每帧重建 46 行,富文本内容):每帧脚本+布局耗时中位数 6.3 → 5.0 ms

(−21%),p95 11.6 → 8.3 ms(−28%);行高集合与 scrollHeight 完全不变;同位置截图与未安装

插件时 diff = 0 像素。

长思考正文:按块跳过

展开一条很长的思考过程(尤其 PTC 模式下多轮 reasoning 叠加)后滚动会卡,原因和轨迹页完全不同:

整段思考正文是一个 Markdown 子树,高度远超视口,所以「按行跳过」永远没有可跳的行——

它在视口里,content-visibility 就不生效,每帧都要对整棵子树做一次 style + layout。

把跳过粒度从「行」下沉到「块」:只在思考正文的根上工作,给它高度超过 1.5 屏的直接块子元素

(段落、代码块、列表、表格)逐个打标记。

[data-scrollboost-chunk] {
  content-visibility: auto;
  contain-intrinsic-size: auto 120px;   /* 真实高度由脚本逐个写回 */
}

命中根用的是 [data-markdown-variant="compact"]:DSH 只在思考正文上输出这个属性(普通回答的

Markdown 没有),所以不依赖任何哈希类名。

关键约束是必须先钉真实高度。块在离屏时读不到布局,contain-intrinsic-size 若给一个估算值,

整段正文的 scrollHeight 会塌掉(实测 480 块时漂移 −23444px),页面顶部那个 sticky 的思考头也会

被顶出屏幕。所以每个块都是「先量、再钉、后打标」,并且:

  • 流式输出中的正文一律不动(data-state 还在 running)——它的文字每个 token 都在变,钉住的高度

立刻过期;

  • 流结束时 data-state 翻转、但没有任何节点新增,这条属性变化本身会触发一次分块;
  • 块数量变化时(React 重建了正文)整组解钉重来,不会留下过期的占位;
  • disable() 会把所有钉高与标记清干净,scrollHeight 完全还原。

实测(12 个思考块组 × 42 块、真实类名、headless Edge):每帧脚本+布局耗时中位数 3.3 → 0.2 ms,

p95 6.3 → 0.3 ms;scrollHeight 前后一致(48512),每个正文根的高度不变(4016),sticky 思考头

位置不变;离屏块 checkVisibility 确认被跳过;disable() 后块标记清零、scrollHeight 还原。

像素核对:把「插件整体」与「插件但块规则不生效」两组截图对比,diff = 0——块级跳过本身不改变
任何像素。整体与未安装时仍有少量差异,来自 B 层的 will-change: scroll-position(见「代价」一节),
与这一层无关。

PTC 过程组:按步跳过

PTC 模式下的「思考过程」不是一个 Markdown 子树,而是一个过程组:DSH 把每一轮子调用渲染成组里

的一个 step,展开后整组直接铺在会话滚动流里。

<div data-step-process data-group-expanded-mode>
  <div data-step-process-body>            <!-- 展开时 max-height:none,没有内层滚动口 -->
    <div data-step-process-content data-chat-flow>
      <div data-chat-flow-key>…</div>     <!-- 一个 step,往往就有好几千像素 -->
      …

块级跳过在这里会失效,而且失效得很隐蔽:它要求「正文根」高过 1.5 屏、且至少有 6 个直接块子元素,

可一个 step 的正文常常两条都不满足,于是整组一块都没跳。即

…(truncated)

Rendered from the README published on npm. Formatted only, not edited.

Versions

Published on npm::5,最后更新 2026-11-03

Links

GitHub · Homepage · npm

Same category