dsh-smooth-scroll 0.1.1-rc.2 → 0.1.2-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,75 +1,71 @@
1
- # dsh-smooth-scroll
2
-
3
- ## 简介
4
-
5
- DSH 的会话滚底从"官方瞬时跳变"变成**流畅顺滑的跟随滚动**:会话装载与"回到最新"
6
- 保持瞬时(这是"跳"的正确语义),而流式内容增长期间,消息列以**恒定速度平滑跟随**,
7
- 起步轻柔、收尾绵软。
8
-
9
- 实现上**不触碰任何 transform**——直接对原生 `scrollTop` 做有节奏的动画,并用一个
10
- **合成 getter** 在动画期间向 DSH 报告"目标值",因此它的跟随状态机(是否在底部、
11
- 是否用户滚动)完全不受影响:不会误判脱钩、不会重复钉底、输入栏等 UI 丝毫不被扰动。
12
- 系统开启"减少动效"(`prefers-reduced-motion`)时自动回退官方瞬时行为。
13
-
14
- ## 行为(最终架构:无 transform)
15
-
16
- 直接在**原生 scrollTop** 上做有节奏的平滑滚动,配合**合成 getter**:平滑期间
17
- `scrollTop` 读到"目标值",DSH 自身的状态机(movedByReader/atBottom)永远看不到中间
18
- 位置——不会误判脱钩、不会重复钉底。
19
-
20
- - **速度剖面(velocity chase + 缓启动 + 软尾)**
21
- - 起步:0 → 0.9px/ms,240ms smoothstep 爬升;
22
- - 巡航:恒速 0.9px/ms(内容增长只更新目标点,曲线永不逐段重启);
23
- - 收尾:停止增长 >240ms 且剩余 ≤120px 时,220ms ease-out 软着陆;
24
- - **同目标兜底写入**(DSH 每滚动事件重跑 `toBottom`,目标不变)→ 忽略,动画不被逐帧重启;
25
- - **用户滚动接管**:真实位置偏离动画自身写入(唯一只能来自用户)→ 立即停动画、
26
- getter 恢复真实、DSH 正常脱钩("回到最新"按钮照常);
27
- - `prefers-reduced-motion: reduce`:完全回退官方瞬时行为;
28
- - **不应用任何 transform**:输入栏 overlay/sticky 永不受扰(无瞬移、无毛边、无飞出)。
29
-
30
- ## 历史教训(为什么不是 transform/rAF 手写)
31
-
32
- 1. transform 补偿方案与 DSH 的输入栏 overlay 测量偶发交错 → 输入栏瞬移/毛边(已弃);
33
- 2. JS rAF 手写动画会冻结/重启问题(已弃)——最终版由 getter 掩盖中间态 + 恒速模型。
34
-
35
- ## 构建
36
-
37
- ```sh
38
- pnpm install # 安装 esbuild devDependency
39
- pnpm run build # 产出 lib/index.js + lib/client.js
40
- ```
41
-
42
- `lib/` 已随仓库提交,克隆后可直接安装使用;改源码才需要重新构建。
43
-
44
- ## 安装(永久生效)
45
-
46
- ```sh
47
- bash install-real-profile.sh # Windows 用 Git Bash / WSL;Linux/macOS 直接 bash
48
- ```
49
-
50
- 脚本执行:`pnpm add link:<本目录>`(~/.dsh/profiles/web/package.json)+ 向
51
- `cordis.patch.yml` 追加 `- insert: [{ id: smooth-scroll, name: 'dsh-smooth-scroll' }]`。
52
- 然后**重启 dsh web**。注意:源目录为 link 安装,改 `src/client.js` 后重建即可热更到下次启动。
53
-
54
- ## 卸载
55
-
56
- 删除 `cordis.patch.yml` 对应 insert + `pnpm remove dsh-smooth-scroll`,重启。
57
-
58
- ## 调参
59
-
60
- 所有参数都在 `src/client.js` 顶部常量区:
61
-
62
- | 常量 | 默认 | 含义 |
63
- |---|---|---|
64
- | `VEL` | 0.9 | 巡航速度(px/ms) |
65
- | `RAMP_MS` | 240 | 起步缓加速时长(smoothstep) |
66
- | `QUIET_MS` | 240 | 判定"停止增长"的静默窗口 |
67
- | `TAIL_PX` | 120 | 进入软尾的剩余距离阈值 |
68
- | `TAIL_MS` | 220 | 软尾缓动时长 |
69
-
70
- 改完:`pnpm run build` → 重启 dsh web。
71
-
72
- ## 源码
73
-
74
- - `src/client.js` — 浏览器半部(核心逻辑)
75
- - `src/index.ts` — 宿主半部(空 apply,仅为组合可见)
1
+ <div align="center">
2
+
3
+ # dsh-smooth-scroll
4
+
5
+ **让 DSH 会话滚底从瞬时跳变变成恒速平滑跟随**
6
+
7
+ 装上后,流式内容增长时消息列平滑跟随到底部,回到最新仍保持瞬时。
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/dsh-smooth-scroll">
11
+ <img src="https://img.shields.io/npm/v/dsh-smooth-scroll/alpha?style=flat&colorA=000000&colorB=000000" />
12
+ </a>
13
+ <a href="https://github.com/VinciBeans/dsh-smooth-plugin/blob/main/LICENSE">
14
+ <img src="https://img.shields.io/github/license/VinciBeans/dsh-smooth-plugin?style=flat&colorA=000000&colorB=000000" />
15
+ </a>
16
+ </p>
17
+
18
+ </div>
19
+
20
+ ## Install
21
+
22
+ 从 npm 安装(`alpha` dist-tag;当前版本 0.1.2-alpha.4,适配 dsh v0.1.2-alpha.1 ~ alpha.4):
23
+
24
+ ```sh
25
+ dsh plugin --profile web add dsh-smooth-scroll@alpha
26
+ ```
27
+
28
+ 需要已安装 DSH,并至少成功启动过一次 Web GUI。
29
+
30
+ npm `latest`(0.1.1-rc.2)仍是旧 client-runtime 代,仅适配 dsh 0.1.1-rc.2;安装它的命令为 `dsh plugin --profile web add dsh-smooth-scroll`。源码安装:`dsh plugin --profile web add .`。
31
+
32
+ ## Quickstart
33
+
34
+ ```sh
35
+ dsh plugin --profile web add dsh-smooth-scroll@alpha
36
+ dsh --profile web --dump-config # 看到 dsh-smooth-scroll 层即安装成功
37
+ # 重启 dsh web,打开会话,流式内容平滑滚到底部
38
+ ```
39
+
40
+ ## 滚动行为
41
+
42
+ 直接在原生 `scrollTop` 上做有节奏的平滑滚动,配合合成 getter:平滑期间 `scrollTop` 读到目标值,DSH 自身状态机看不到中间位置,不误判脱钩、不重复钉底。
43
+
44
+ - **速度剖面:** 起步 0 到 0.9px/ms 用 240ms 缓升,巡航恒速 0.9px/ms,收尾 220ms 软着陆。
45
+ - **用户滚动接管:** 真实位置连续偏离动画写入 ≥2 帧才停动画,DSH 正常脱钩,回到最新按钮照常;单帧偏差(浏览器滚动锚定等一次性非用户位移)自动重基后继续钉底跟随——发送消息等操作引发的非用户位移不会导致误判脱钩或停在信息处;内容收窄导致的夹紧则在 2 帧内停止于新底部(同样不掉队)。与之对称,孤立的一次性小幅定位(如滚动条点一格)也会被当作非用户位移吸收、随后被跟随回底部;只有持续 ≥2 帧的偏离才视为用户接管。另:主机内(输入栏除外)的指针按下立即停动画并让 getter 回落真实值——turn 导轨跳转(`landOnRow` 的 `el.scrollTop += flowTop - 24` 复合读改写)由此读到真实位置,落点不被合成目标偏移(真实宿主 e2e 实测:流式中点击「跳转到第 N 轮」后漂移 0px)。
46
+ - **同目标兜底:** DSH 每滚动事件重跑 toBottom 而目标不变时忽略,动画不被逐帧重启。
47
+ - **减少动效:** prefers-reduced-motion 时完全回退官方瞬时行为。
48
+ - **无 transform:** 不应用任何 transform,输入栏 overlay/sticky 永不受扰。
49
+
50
+ ## 参数
51
+
52
+ | 常量 | 默认 | 含义 |
53
+ | --- | --- | --- |
54
+ | VEL | 0.9 | 巡航速度(px/ms) |
55
+ | RAMP_MS | 240 | 起步缓加速时长 |
56
+ | QUIET_MS | 240 | 判定停止增长的静默窗口 |
57
+ | TAIL_PX | 120 | 进入软尾的剩余距离阈值 |
58
+ | TAIL_MS | 220 | 软尾缓动时长 |
59
+ | DIVERGE_STOP_FRAMES | 2 | 真实位置连续偏离动画写入的帧数阈值(≥2 帧判定读者接管) |
60
+
61
+ 改参数:编辑 `src/client.js` 顶部常量区,`pnpm run build` 后重启 dsh web。
62
+
63
+ ## 兼容性
64
+
65
+ - **dsh v0.1.2-alpha.1 ~ alpha.4(支持):** 插件的唯一 DOM 锚点 `[data-conversation-scroll]`(会话滚动容器,ConversationRoot 的 scrollBody)与宿主跟随状态机(`observedTopRef` / `movedByReader` / ResizeObserver follow、`el.scrollTop` 读写面)在 `dsh-v0.1.2-alpha.1` ~ `dsh-v0.1.2-alpha.4` 四个 tag 上一致;turn 导轨跳转 `landOnRow` 仍为 `el.scrollTop += flowTop - 24` 复合读改写,pointerdown 接管先停动画、复合写读到真实位置。alpha.4 把宿主滚动几何采样改为每 500ms 一次并以 `scrollend` 提前采样(`ChatView` 的 `SCROLL_SAMPLE_INTERVAL_MS`);真实宿主行为 e2e(见验证)确认该节奏下追击无停顿(流式最大离底 0px、追击停顿 0ms)、滚轮接管后漂移 0px 且宿主正常脱钩。
66
+ - **0.1.1-rc.2 及更早(不支持):** 该代使用 `@deepseek-ai/dsh-client-runtime`,滚动宿主结构不同,不兼容。
67
+ - 验证:`pnpm test`(契约冒烟,bundle 自包含)+ `node test/scroll-follow.test.mjs`(15 个 e2e 场景,含「追击中点击导轨」回归点;需 Playwright 与 Chromium)+ `node test/alpha4-realhost-e2e.mjs <token>`(真实 `dsh web` 0.1.2-alpha.4 + 真 Chromium:流式追击、滚轮接管、导轨跳转、reduced-motion、控制台错误;token 取自 `dsh web` 启动输出)。
68
+
69
+ ## License
70
+
71
+ MIT
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh 组合包层(bundle layer):把 dsh-smooth-scroll 插入 web 配置树。
2
2
  #
3
- # `name` 必须是包名(而非源码相对路径);dsh 的 client-modules 节点半部扫描到这一行,
3
+ # `name` 必须是包名(而非源码相对路径);client-modules 节点半部扫描到这一行,
4
4
  # 读到 dsh.client 声明后把浏览器 bundle 挂到 /plugins/dsh-smooth-scroll/client.js。
5
5
  - insert:
6
6
  - id: 'smooth-scroll'
package/lib/client.js CHANGED
@@ -28,6 +28,7 @@ var RAMP_MS = 240;
28
28
  var QUIET_MS = 240;
29
29
  var TAIL_PX = 120;
30
30
  var TAIL_MS = 220;
31
+ var DIVERGE_STOP_FRAMES = 2;
31
32
  var easeOut = (t) => 1 - Math.pow(1 - t, 3);
32
33
  var smooth = (t) => t * t * (3 - 2 * t);
33
34
  var now = () => typeof performance === "object" && performance !== null && typeof performance.now === "function" ? performance.now() : Date.now();
@@ -38,15 +39,13 @@ function apply(ctx) {
38
39
  if (typeof MutationObserver !== "function" || typeof requestAnimationFrame !== "function") return;
39
40
  const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
40
41
  const states = /* @__PURE__ */ new Map();
41
- let announced = false;
42
42
  const readTop = (state) => state.chase || state.settle !== null ? state.target : state.desc.get.call(state.el);
43
43
  const stopAll = (state) => {
44
44
  state.chase = false;
45
45
  state.settle = null;
46
- state.rafId = 0;
46
+ state.farN = 0;
47
47
  };
48
48
  const tick = (state, ts) => {
49
- state.rafId = 0;
50
49
  if (!state.el.isConnected) {
51
50
  stopAll(state);
52
51
  return;
@@ -54,6 +53,27 @@ function apply(ctx) {
54
53
  if (state.lastTs === 0) state.lastTs = ts;
55
54
  const dt = Math.min(50, ts - state.lastTs);
56
55
  state.lastTs = ts;
56
+ const pos = state.desc.get.call(state.el);
57
+ if (state.settle === null && !state.chase) {
58
+ state.farN = 0;
59
+ return;
60
+ }
61
+ if (Math.abs(pos - state.lastWrite) > 0.5) {
62
+ state.farN += 1;
63
+ if (state.farN >= DIVERGE_STOP_FRAMES) {
64
+ state.farN = 0;
65
+ stopAll(state);
66
+ return;
67
+ }
68
+ state.lastWrite = pos;
69
+ if (state.settle !== null) {
70
+ state.settle = null;
71
+ state.chase = true;
72
+ state.chaseStart = ts;
73
+ }
74
+ } else {
75
+ state.farN = 0;
76
+ }
57
77
  if (state.settle !== null) {
58
78
  const s = state.settle;
59
79
  const t = Math.min(1, (ts - s.start) / s.ms);
@@ -65,15 +85,14 @@ function apply(ctx) {
65
85
  state.lastTs = 0;
66
86
  return;
67
87
  }
68
- state.rafId = requestAnimationFrame((t2) => tick(state, t2));
88
+ requestAnimationFrame((t2) => tick(state, t2));
69
89
  return;
70
90
  }
71
- if (!state.chase || state.target === null) {
91
+ if (!state.chase) {
72
92
  state.chase = false;
73
93
  state.lastTs = 0;
74
94
  return;
75
95
  }
76
- const pos = state.desc.get.call(state.el);
77
96
  const remaining = Math.max(0, state.target) - pos;
78
97
  if (Math.abs(remaining) <= 0.5) {
79
98
  state.desc.set.call(state.el, Math.max(0, state.target));
@@ -84,7 +103,7 @@ function apply(ctx) {
84
103
  if (Math.abs(remaining) <= TAIL_PX && now() - state.lastPinTs > QUIET_MS) {
85
104
  state.settle = { from: pos, to: state.target, start: ts, ms: TAIL_MS };
86
105
  state.chase = false;
87
- state.rafId = requestAnimationFrame((t2) => tick(state, t2));
106
+ requestAnimationFrame((t2) => tick(state, t2));
88
107
  return;
89
108
  }
90
109
  const ramp = Math.min(1, Math.max(0, (ts - state.chaseStart) / RAMP_MS));
@@ -93,19 +112,23 @@ function apply(ctx) {
93
112
  const nv = pos + step;
94
113
  state.lastWrite = nv;
95
114
  state.desc.set.call(state.el, nv);
96
- state.rafId = requestAnimationFrame((t2) => tick(state, t2));
115
+ requestAnimationFrame((t2) => tick(state, t2));
97
116
  };
98
117
  const writeScrollTop = (state, desc, value) => {
99
118
  const el = state.el;
100
- const current = desc.get.call(el);
101
119
  const max = Math.max(0, el.scrollHeight - el.clientHeight);
120
+ const current = desc.get.call(el);
102
121
  const target = Math.min(value, max);
103
- state.pinnedTop = target;
104
- const isPin = value >= max - 1 && Math.abs(target - current) > 0.5;
122
+ const pinIntent = value >= max - 1;
123
+ const isPin = pinIntent && Math.abs(target - current) > 0.5;
105
124
  if (!isPin) {
106
125
  if (Math.abs(target - current) > 0.5) {
107
126
  stopAll(state);
108
127
  desc.set.call(el, value);
128
+ } else if (pinIntent && (state.chase || state.settle !== null)) {
129
+ state.target = target;
130
+ stopAll(state);
131
+ state.lastWrite = current;
109
132
  }
110
133
  return;
111
134
  }
@@ -130,7 +153,9 @@ function apply(ctx) {
130
153
  state.chase = true;
131
154
  state.chaseStart = now();
132
155
  state.lastTs = 0;
133
- state.rafId = requestAnimationFrame((t2) => tick(state, t2));
156
+ state.lastWrite = current;
157
+ state.farN = 0;
158
+ requestAnimationFrame((t2) => tick(state, t2));
134
159
  }
135
160
  };
136
161
  const attach = (el) => {
@@ -149,18 +174,25 @@ function apply(ctx) {
149
174
  const state = {
150
175
  el,
151
176
  desc,
152
- flow: null,
153
- pinnedTop: -1,
154
177
  firstPin: true,
155
178
  chase: false,
156
179
  settle: null,
157
180
  target: null,
158
181
  lastPinTs: 0,
159
182
  lastTs: 0,
160
- rafId: 0,
161
183
  lastWrite: 0,
162
- chaseStart: 0
184
+ chaseStart: 0,
185
+ farN: 0
186
+ };
187
+ const stopOnPointerDown = (event) => {
188
+ if (!state.chase && state.settle === null) return;
189
+ const target = event.target;
190
+ if (!(target instanceof Element)) return;
191
+ if (target.closest("[data-composer-seat]") !== null) return;
192
+ stopAll(state);
163
193
  };
194
+ state.stopOnPointerDown = stopOnPointerDown;
195
+ el.addEventListener("pointerdown", stopOnPointerDown, true);
164
196
  states.set(el, state);
165
197
  Object.defineProperty(el, "scrollTop", {
166
198
  configurable: true,
@@ -172,20 +204,14 @@ function apply(ctx) {
172
204
  writeScrollTop(state, desc, value);
173
205
  }
174
206
  });
175
- if (!announced) {
176
- announced = true;
177
- console.log("[dsh-smooth-scroll] attached to the conversation scroller");
178
- }
179
207
  };
180
208
  const disposeState = (state) => {
181
209
  stopAll(state);
182
- if (Object.prototype.hasOwnProperty.call(state.el, "scrollTop")) {
183
- try {
184
- delete state.el.scrollTop;
185
- } catch (e) {
186
- }
210
+ state.el.removeEventListener("pointerdown", state.stopOnPointerDown, true);
211
+ const scrollDesc = Object.getOwnPropertyDescriptor(state.el, "scrollTop");
212
+ if (scrollDesc !== void 0 && scrollDesc.configurable) {
213
+ delete state.el.scrollTop;
187
214
  }
188
- state.flow = null;
189
215
  states.delete(state.el);
190
216
  };
191
217
  const scan = () => {
@@ -201,23 +227,11 @@ function apply(ctx) {
201
227
  scan();
202
228
  });
203
229
  };
204
- const onDocScroll = (event) => {
205
- const target = event.target;
206
- if (target === null || target === void 0) return;
207
- const state = states.get(target);
208
- if (state === void 0) return;
209
- const real = state.desc.get.call(state.el);
210
- if ((state.chase || state.settle !== null) && Math.abs(real - state.lastWrite) > 0.5) {
211
- stopAll(state);
212
- }
213
- };
214
- doc.addEventListener("scroll", onDocScroll, { capture: true, passive: true });
215
230
  const observer = new MutationObserver(queueScan);
216
231
  observer.observe(root, { childList: true, subtree: true });
217
232
  queueScan();
218
233
  return () => {
219
234
  observer.disconnect();
220
- doc.removeEventListener("scroll", onDocScroll, true);
221
235
  for (const state of [...states.values()]) disposeState(state);
222
236
  };
223
237
  });
package/package.json CHANGED
@@ -1,46 +1,45 @@
1
- {
2
- "name": "dsh-smooth-scroll",
3
- "version": "0.1.1-rc.2",
4
- "description": "DSH 平滑滚底插件:会话装载与“回到最新”瞬时钉底,流式内容以恒速平滑跟随(缓启动/软尾);无 transform、不扰动输入栏等 UI、不干扰 DSH 自身跟随状态机,prefers-reduced-motion 自动回退官方行为。适配 dsh-0.1.1-rc.2。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": {
9
- "default": "./lib/index.js"
10
- },
11
- "./client": {
12
- "default": "./lib/client.js"
13
- },
14
- "./package.json": "./package.json"
15
- },
16
- "files": [
17
- "lib",
18
- "cordis.patch.yml"
19
- ],
20
- "publishConfig": {
21
- "access": "public"
22
- },
23
- "dsh": {
24
- "client": {
25
- "platform": "web"
26
- },
27
- "bundle": {
28
- "patch": "./cordis.patch.yml"
29
- }
30
- },
31
- "scripts": {
32
- "build": "node scripts/build.mjs",
33
- "prepublishOnly": "node scripts/build.mjs"
34
- },
35
- "devDependencies": {
36
- "esbuild": "^0.25.0"
37
- },
38
- "keywords": [
39
- "dsh",
40
- "dsh-plugin",
41
- "deepseek-harness",
42
- "smooth-scroll",
43
- "scroll"
44
- ],
45
- "license": "MIT"
46
- }
1
+ {
2
+ "name": "dsh-smooth-scroll",
3
+ "version": "0.1.2-alpha.4",
4
+ "description": "DSH 平滑滚底插件:会话装载与“回到最新”瞬时钉底,流式内容以恒速平滑跟随(缓启动/软尾);无 transform、不扰动输入栏等 UI、不干扰 DSH 自身跟随状态机,prefers-reduced-motion 自动回退官方行为。适配 dsh-v0.1.2-alpha.1 ~ alpha.4。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml"
19
+ ],
20
+ "dsh": {
21
+ "client": {
22
+ "platform": "web"
23
+ },
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "build": "node scripts/build.mjs",
30
+ "prepack": "npm run build",
31
+ "test": "node test/smoke.test.mjs",
32
+ "test:e2e": "node test/scroll-follow.test.mjs"
33
+ },
34
+ "devDependencies": {
35
+ "esbuild": "^0.25.0"
36
+ },
37
+ "keywords": [
38
+ "dsh",
39
+ "dsh-plugin",
40
+ "deepseek-harness",
41
+ "smooth-scroll",
42
+ "scroll"
43
+ ],
44
+ "license": "MIT"
45
+ }