dsh-streamfold 0.5.7 → 0.7.0

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/lib/client.js CHANGED
@@ -2,7 +2,7 @@ window.__ModuleLoader__.load({ id: "dsh-streamfold", factory: (require) => {
2
2
  var module = { exports: {} }; var exports = module.exports;
3
3
  "use strict";
4
4
  /**
5
- * dsh-streamfold —— 流式折叠(DSH Web 显示增强)
5
+ * dsh-streamfold v0.2 —— 流式折叠(DSH Web 显示增强)
6
6
  *
7
7
  * 模型只有两条:
8
8
  * 1) 正在跑的那一轮:只自动展开本轮**最新的那一个**思考小窗;被取代的旧窗按 supersedeDelay 秒后折回一行摘要
@@ -25,11 +25,14 @@ const NS = "dsh-streamfold";
25
25
  const NATIVE_NS = "ui-chat";
26
26
  const NATIVE_FIELD = "transcriptView";
27
27
  const DEFAULTS = {
28
- mode: "native", // native = 官方标准/紧凑;fold = 本插件
29
- foldHistory: true, // 非运行中的轮次是否自动折叠(默认是)
30
- keepInterleavedText: false, // 折叠时保留"穿插正文"行(只有正文,不含思考)
31
- windowHeight: 260,
32
- autoFollow: true,
28
+ mode: "fold", // fold = 本插件(用户裁决:默认折叠);native = 官方标准/紧凑
29
+ foldHistory: true,
30
+ keepInterleavedText: true, // 折叠时保留"穿插正文"行(只有正文,不含思考)—— 默认值按用户当前设置固化(2026-10-04)
31
+ keepUserQuestions: true, // LLM 提问工具卡(ask_user_question)及其回复节点(question-reply)不折;关掉则随其余内容一起折叠
32
+ windowHeight: 360, // 默认值按用户当前设置固化(2026-10-04)
33
+ followMaxSpeed: 240, // 跟随"追赶"速度上限(px/s);默认值按用户当前设置固化(2026-10-04)
34
+ followMaxAccel: 10000, // 跟随加速度上限(px/s²);默认值按用户当前设置固化(2026-10-04)
35
+ returnToTail: true, // 停在中途(我方无目标)时:出现你新发的一条消息 ⇒ 滑回尾部;关掉则一律停在原地
33
36
  showJumpButton: true,
34
37
  animations: true,
35
38
  autoExpandReasoning: true, // 运行中的那一轮自动展开思考
@@ -37,7 +40,6 @@ const DEFAULTS = {
37
40
  settleToPrompt: true, // 一轮跑完后平滑移到本轮提问处(读者已接管滚动时不动)
38
41
  pinPrompt: true, // 置顶显示提问:跟着"正在看的那一轮"换,超过一行只显示一行
39
42
  smoothGrow: 0.15, // 跟随底部时每帧吃掉多少差距(越小越柔)
40
- smoothMin: 1, // 每 16ms 至少推进多少像素
41
43
  supersedeDelay: 2, // 运行中旧的思考窗被取代后,延迟多少秒再自动折回
42
44
  sparks: true, // 思考小窗底部的火星特效(独立模块,见「火星特效」一节)
43
45
  sparkColor: "#4fa8ff", // 火星颜色(核心自动提亮成白热)
@@ -66,17 +68,9 @@ let nativeScope = null;
66
68
  const NATIVE_MODES = ["compact", "standard", "detailed", "verbose"];
67
69
  const NATIVE_ALIAS = { normal: "standard", expanded: "detailed" };
68
70
  const NATIVE_LABELS = { compact: "简洁", standard: "标准", detailed: "详细", verbose: "完全展开", fold: "折叠" };
69
- const NATIVE_LABELS_EN = { compact: "Compact", standard: "Standard", detailed: "Detailed", verbose: "Verbose", fold: "Fold" };
70
71
  let tChat = null; // 官方 chat 词典:标签就跟官方设置页一致,改动由官方那边带过来
71
- /** 界面语言:官方词典 zh 里 compact 是「简洁」、en 里是 Compact。
72
- * 官方**没有** fold 键,所以「折叠」这一档和设置页的两处标题必须自带双语,不能只靠 tChat 回退。 */
73
- const isZh = () => {
74
- if (!tChat) return true;
75
- try { return tChat("settings.transcript.compact") === NATIVE_LABELS.compact; } catch { return true; }
76
- };
77
- const uiText = (zh, en) => (isZh() ? zh : en);
78
72
  const modeLabel = (mode) => {
79
- const fallback = (isZh() ? NATIVE_LABELS : NATIVE_LABELS_EN)[mode] || mode;
73
+ const fallback = NATIVE_LABELS[mode] || mode;
80
74
  if (!tChat) return fallback;
81
75
  try {
82
76
  const text = tChat("settings.transcript." + mode);
@@ -108,26 +102,17 @@ function displayChoice() {
108
102
  }
109
103
  let nativePinned = false; // 官方取值是否已校正(只做一次,避免写值 → 订阅 → 再写的回环)
110
104
  let nativeProbing = false; // 写入进行中(挡住订阅回调重入)
111
- let nativeTries = 0; // 写入被拒的次数(失败会经订阅回调回到这里,不能无限重写)
112
- const NATIVE_TRIES_MAX = 3;
113
105
  /** 折叠模式必须让官方「别自己折」:0.1.7 的四档里只有 verbose 是 foldCompletedTurns=false + stepGrouping=none
114
106
  * (= 完全展开),其余三档官方自己就会折叠或分组,会和我们的折叠双重生效。
115
107
  * 镜像没就绪时不写、也不落定 —— 订阅回调会再来一次。 */
116
108
  async function enforceNativeBaseline() {
117
- if (nativePinned || nativeProbing || nativeTries >= NATIVE_TRIES_MAX || state.mode !== "fold" || !nativeScope) return;
109
+ if (nativePinned || nativeProbing || state.mode !== "fold" || !nativeScope) return;
118
110
  const section = nativeScope.getSnapshot();
119
111
  if (!section || section.status !== "ready") return;
120
112
  nativeProbing = true;
121
113
  try {
122
114
  const value = section.value ? section.value[NATIVE_FIELD] : undefined;
123
- let ok = value === "verbose";
124
- // set() 的返回值 = 宿主是否接受写入。被拒(只读/内存态宿主)时必须当作没做成:
125
- // 否则我们谎报 nativeValue、官方那条 hidden 永远清不掉(React 的 layout effect 依赖不变、不会自愈)。
126
- if (!ok) {
127
- nativeTries += 1;
128
- try { ok = (await nativeScope.set(NATIVE_FIELD, "verbose")) === true; } catch { ok = false; }
129
- }
130
- if (!ok) return; // 订阅回调会重试,最多 NATIVE_TRIES_MAX 次
115
+ if (value !== "verbose") { try { await nativeScope.set(NATIVE_FIELD, "verbose"); } catch { /* ignore */ } }
131
116
  nativeValue = "verbose";
132
117
  nativePinned = true;
133
118
  } finally { nativeProbing = false; }
@@ -151,15 +136,24 @@ const STYLE_ID = "dsh-streamfold/styles";
151
136
  const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全局 CSS 对象,本插件已不再用 CSS.highlights)
152
137
  ":root{--dshsf-h:260px}",
153
138
  "[data-chat-flow]{overflow-anchor:none}", // 滚动位置只由我们和读者决定,别让浏览器锚定插一脚
154
- "[data-dshsf-native-row]{display:none!important}",
155
139
  /* 限高滚动窗:一个位置只有一个滚动条 */
156
140
  "[data-dshsf-window]{max-height:var(--dshsf-h);overflow:auto;overscroll-behavior:contain;contain:content;scrollbar-gutter:stable;",
157
141
  "scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35)) transparent;",
158
142
  "border-radius:8px;padding:2px 8px 2px 6px;margin-right:4px;",
159
143
  "transition:background-color .2s ease,box-shadow .2s ease}", // 高度由 tickFollow 每帧推进:固定过渡跟不上快输出,不要用
160
144
  "[data-dshsf-window='reasoning']{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.05))}",
145
+ /* 穿插正文小窗:折叠后仍保留的正文行,限高内滚 + 毛玻璃底(与思考小窗同一套跟随/回底) */
146
+ "[data-dshsf-textwindow]{max-height:var(--dshsf-h);overflow:auto;overscroll-behavior:contain;contain:content;scrollbar-gutter:stable;",
147
+ "scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35)) transparent;",
148
+ "border-radius:12px;padding:8px 12px;margin:4px 0 8px;",
149
+ "border:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.3));",
150
+ "background:rgba(127,127,127,.08);",
151
+ "background:color-mix(in srgb,var(--dsw-alias-label-primary,#7a869a) 5%,transparent);",
152
+ "backdrop-filter:blur(14px) saturate(150%);",
153
+ "-webkit-backdrop-filter:blur(14px) saturate(150%);",
154
+ "box-shadow:0 1px 3px rgba(0,0,0,.06)}",
161
155
  /* 穿插正文与正式答案之间的分隔线(用 l3:l1/l2 的 alpha 太低,画了看不见) */
162
- "[data-dshsf-answer-sep]{border-top:1px solid var(--dsw-alias-border-l4,rgba(127,127,127,.38));padding-top:6px}",
156
+ "[data-dshsf-answer-sep]{border-top:1px solid var(--dsw-alias-border-l4,rgba(127,127,127,.38));padding-top:10px}",
163
157
  "@keyframes dshsf-window-in{from{max-height:0;opacity:0;transform:translateY(-3px)}to{max-height:var(--dshsf-hw,var(--dshsf-h));opacity:1;transform:none}}",
164
158
  "html[data-dshsf-anim] [data-dshsf-window]{animation:dshsf-window-in .26s var(--ds-ease-out,cubic-bezier(.2,.8,.2,1)) backwards}",
165
159
  "[data-dshsf-window][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
@@ -168,12 +162,14 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
168
162
  "[class*='_bodyWrap'][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
169
163
  "html[data-dshsf-anim] [data-context-injection-body][data-dshsf-reveal]{overflow:auto}", // 官方这块正文自带内滚,别被上面的通用规则压成 hidden
170
164
  "[data-dshsf-reveal][data-dshsf-closing],[data-context-injection-body][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
171
- "[data-dshsf-window]::-webkit-scrollbar{width:8px;height:8px}",
172
- "[data-dshsf-window]::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35));border-radius:4px}",
173
- "[data-dshsf-window]::-webkit-scrollbar-track{background:transparent}",
165
+ "[data-dshsf-window]::-webkit-scrollbar,[data-dshsf-textwindow]::-webkit-scrollbar{width:8px;height:8px}",
166
+ "[data-dshsf-window]::-webkit-scrollbar-thumb,[data-dshsf-textwindow]::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35));border-radius:4px}",
167
+ "[data-dshsf-window]::-webkit-scrollbar-track,[data-dshsf-textwindow]::-webkit-scrollbar-track{background:transparent}",
174
168
  "[data-dshsf-window][data-dshsf-offbottom]{box-shadow:inset 0 -14px 12px -14px var(--dsw-alias-label-caption,rgba(0,0,0,.4))}",
175
169
  /* 折叠 / 折叠条 / 回到底部 */
176
170
  "[data-dshsf-row]{transition:max-height .3s var(--ds-ease-in-out,ease),opacity .22s ease}",
171
+ /* T9:官方「工作步骤展示」行只在我们的行渲染成功时隐藏(可逆;只改可见性,不改其文字/属性/菜单) */
172
+ "[data-dshsf-hide-official]{display:none!important}",
177
173
  "[data-dshsf-folded]{max-height:0;opacity:0;overflow:hidden;margin-top:0!important;margin-bottom:0!important}",
178
174
  // 折叠内容跳过渲染走 hidden="until-found"(长会话最大的一笔省项):
179
175
  // 它和 content-visibility:hidden 一样不布局,但**允许 Ctrl+F 搜到**并在命中前派发 beforematch。
@@ -187,17 +183,16 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
187
183
  "color:var(--dsw-alias-label-tertiary,#888);font-size:12px;line-height:18px;cursor:pointer;text-align:left;",
188
184
  "transition:background-color .18s ease,color .18s ease}",
189
185
  "[data-dshsf-chip]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1));color:var(--dsw-alias-label-secondary,#666)}",
190
- "[data-dshsf-jump]{position:sticky;bottom:6px;float:left;z-index:3;display:inline-flex;align-items:center;justify-content:center;gap:4px;",
191
- "margin:0 0 -27px 6px;height:27px;box-sizing:border-box;", // 负下边距:窗口是 BFC,会把浮动包进高度 —— 不抵消每个窗底下白多 27px
186
+ "[data-dshsf-jump]{position:sticky;bottom:6px;float:left;margin-left:6px;z-index:3;display:inline-flex;align-items:center;justify-content:center;gap:4px;",
192
187
  "padding:4px 7px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));",
193
188
  "background:var(--dsw-alias-button-floating-fill,var(--dsw-alias-bg-layer-1,#fff));color:var(--dsw-alias-label-secondary,#555);",
194
189
  "font-size:12px;line-height:18px;cursor:pointer;box-shadow:var(--dsw-elevation-panel,0 2px 8px rgba(0,0,0,.12));",
195
- "opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .2s ease,transform .2s ease}", // 藏起来时不占位也不挡正文点击
196
- "[data-dshsf-jump][data-dshsf-visible]{opacity:1;pointer-events:auto;transform:translateY(0)}",
190
+ "opacity:0;transform:translateY(4px);transition:opacity .2s ease,transform .2s ease}",
191
+ "[data-dshsf-jump][data-dshsf-visible]{opacity:1;transform:translateY(0)}",
197
192
  /* 主窗口那颗回底按钮:外观与位置都用官方的,只把"何时出现"接管过来——默认藏起来,
198
193
  只有我们判定"读者接管了、而且确实离底"时才放出来(官方自己按 25px 阈值无滞回地切 = 闪)。 */
199
- "html[data-dshsf-mode='fold'] [class*='_toBottomSlot']{display:none}",
200
- "html[data-dshsf-mode='fold'][data-dshsf-jumpgate] [class*='_toBottomSlot']{display:flex}",
194
+ "[class*='_toBottomSlot']{display:none}",
195
+ "html[data-dshsf-jumpgate] [class*='_toBottomSlot']{display:flex}",
201
196
  "[data-dshsf-jump]>svg{display:block}",
202
197
  /* 置顶的提问:固定层贴在会话视口顶端,跟着"正在看的那一轮"换;超过一行只显示一行,它自己在视口里时让位。 */
203
198
  "[data-dshsf-pin]{position:fixed;z-index:7;display:none;cursor:pointer;box-sizing:border-box;",
@@ -208,7 +203,10 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
208
203
  "white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
209
204
  "[data-dshsf-pin][data-dshsf-visible]{display:block}",
210
205
  /* 火星特效:零高度 sticky 容器贴住可视底边,画布往上铺——容器不参与布局,窗口高度与 scrollHeight 都不受影响 */
211
- "[data-dshsf-spark]{position:fixed;left:0;top:0;display:block;pointer-events:none;z-index:9999;will-change:transform}", // 固定层:位置由 JS 每帧摆
206
+ /* 层级契约(2026-10-01 真机取证):画布挂在 body 的 fixed 层,与官方界面同处根堆叠上下文,只看 z-index。
207
+ 官方 composer 座位 z-index:7、设置弹窗遮罩 z-index:1000 —— 旧值 9999 把两者都盖住。火星只是正文的装饰,
208
+ 必须待在「正文之上、界面之下」:取 6,正好压 composer 座位(7)下面一档。别再往上抬。 */
209
+ "[data-dshsf-spark]{position:fixed;left:0;top:0;display:block;pointer-events:none;z-index:6;will-change:transform}", // 固定层:位置由 JS 每帧摆;层级见上
212
210
  /* 设置页 */
213
211
  ".dshsf-row{display:flex;align-items:flex-start;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.16))}",
214
212
  ".dshsf-row:last-child{border-bottom:none}",
@@ -240,7 +238,7 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
240
238
  ".dshsf-actions{display:flex;gap:8px;margin-top:14px}",
241
239
  ".dshsf-btn{padding:5px 14px;border-radius:8px;font-size:12px;cursor:pointer;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-primary);transition:background-color .16s ease}",
242
240
  ".dshsf-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
243
- "@media (prefers-reduced-motion: reduce){[data-dshsf-window],[data-dshsf-row],[data-dshsf-chip],[data-dshsf-jump],.dshsf-switch,.dshsf-switch:after{transition:none!important;animation:none!important}}",
241
+ "@media (prefers-reduced-motion: reduce){[data-dshsf-window],[data-dshsf-textwindow],[data-dshsf-row],[data-dshsf-chip],[data-dshsf-jump],.dshsf-switch,.dshsf-switch:after{transition:none!important;animation:none!important}}",
244
242
  ].join("");
245
243
 
246
244
  function injectStyles() {
@@ -254,15 +252,46 @@ function injectStyles() {
254
252
  document.head.appendChild(tag);
255
253
  return () => { tag.remove(); };
256
254
  }
255
+ /** task-30:官方档位 = **完全放手**:清我方全部内联视觉痕迹(小窗正文限高/变量/数据集、根变量、残余标记、按钮),
256
+ * 可逆 —— 切回「折叠」时 rootVars()/tagThinkWindow()/syncWindows() 会重新施加。不隐藏任何官方节点(含官方行)。 */
257
+ function releaseOurVisuals() {
258
+ const targets = document.querySelectorAll("[data-dshsf-window], [data-dshsf-textwindow]");
259
+ for (const el of targets) {
260
+ try {
261
+ el.style.maxHeight = "";
262
+ el.style.removeProperty("--dshsf-hw");
263
+ el.style.overflow = "";
264
+ } catch { /* ignore */ }
265
+ try { delete el.dataset.dshsfH; delete el.dataset.dshsfClosing; } catch { /* ignore */ }
266
+ winH.delete(el);
267
+ for (const attr of ["data-dshsf-window", "data-dshsf-textwindow", "data-dshsf-offbottom"]) el.removeAttribute(attr);
268
+ const jump = el.querySelector(":scope > [data-dshsf-jump]");
269
+ if (jump !== null) jump.removeAttribute("data-dshsf-visible");
270
+ }
271
+ // Ctrl+F 期间被我们藏起来的正文:只摘标记会把 hidden="until-found" 留在原地(内容永远看不见,且丢了归属标记)
272
+ // —— 必须走和"搜索命中"同一条收回路径(摘标记 + 摘 hidden + 回到每帧清单)。
273
+ clearSearchHidden(document);
274
+ for (const el of document.querySelectorAll("[data-dshsf-answer-sep], [data-dshsf-row], [data-dshsf-chip]")) {
275
+ for (const attr of ["data-dshsf-answer-sep", "data-dshsf-row", "data-dshsf-chip"]) el.removeAttribute(attr);
276
+ }
277
+ // 行级跳过标记(hidden="until-found",认 data-dshsf-hidden 归属)与动画/收尾标记:切回折叠时不许留残余过渡。
278
+ for (const el of document.querySelectorAll("[data-dshsf-hidden], [data-dshsf-reveal], [data-dshsf-closing]")) {
279
+ clearSkipHidden(el);
280
+ el.removeAttribute("data-dshsf-reveal");
281
+ el.removeAttribute("data-dshsf-closing");
282
+ }
283
+ const root = document.getElementById("root") || document.documentElement;
284
+ try { root.style.removeProperty("--dshsf-h"); } catch { /* ignore */ }
285
+ document.documentElement.removeAttribute("data-dshsf-jumpgate");
286
+ document.documentElement.removeAttribute("data-dshsf-anim");
287
+ for (const el of document.querySelectorAll("[data-dshsf-jump]")) el.removeAttribute("data-dshsf-visible");
288
+ hidePinBar(); // 置顶条是固定层、不在 flow 里,之前只由 tick 的官方档门收——这里一并收干净
289
+ }
257
290
  function rootVars() {
258
291
  const root = document.getElementById("root") || document.documentElement;
259
292
  const h = Math.max(80, Number(state.windowHeight) || 260) + "px";
260
293
  if (root.style.getPropertyValue("--dshsf-h") !== h) root.style.setProperty("--dshsf-h", h);
261
- // 非折叠模式 = 插件整体撒手:样式也按模式分支(官方那颗回底按钮只在折叠模式由我们接管显隐),
262
- // 动效属性同样只在折叠模式挂。
263
- const fold = state.mode === "fold";
264
- document.documentElement.setAttribute("data-dshsf-mode", fold ? "fold" : "native");
265
- document.documentElement.toggleAttribute("data-dshsf-anim", fold && state.animations !== false && allowAnim);
294
+ document.documentElement.toggleAttribute("data-dshsf-anim", state.animations !== false && allowAnim);
266
295
  }
267
296
 
268
297
  /* ------------------------------------------------------------- 常量/工具 -- */
@@ -276,12 +305,13 @@ const CTX_BODY = "[data-context-injection-body]"; // 上下文注入行
276
305
  const TOOL_BODY = '[class*="_bodyScroll"], [class*="_bodyWrap"]';
277
306
  // 一次遍历拿到"所有已挂载的正文",替代三次全量查询(思考/工具/上下文各一次)
278
307
  const BODY_ANY = '[class*="_thinkBody"], [class*="_bodyScroll"], [class*="_bodyWrap"], [data-context-injection-body]';
308
+ /** A1:主容器**内部**的嵌套滚动体(思考小窗 / 工具正文 / 上下文注入正文)—— 在它们上面的滚轮不是"主容器读者接管"的意图。 */
309
+ const NESTED_SCROLL = "[data-dshsf-window], [data-dshsf-textwindow], " + TOOL_BODY + ", " + CTX_BODY;
279
310
  // 运行中的标记:思考行 data-state=running、正文 data-streaming、工具卡 data-state=running|preparing
280
311
  // (工具执行期间没有流式文本,只靠前两个会误判成"跑完了" → 运行中来一次全量折叠)
281
312
  const RUNNING = '[data-state="running"], [data-state="preparing"], [data-streaming]';
282
313
  const RUN_GRACE_MS = 700; // 运行标记消失多久才算真跑完(步骤/工具交接的空档不算)
283
314
  let runningSticky = false;
284
- let contractWarned = false; // 官方收尾标记的契约自检只报一次
285
315
  let lastRunningAt = 0;
286
316
  let runGraceTimer = 0;
287
317
  const INDEPENDENT = new Set(["user", "steering", "system-prompt", "turn-trigger", "turn-error", "turn-max-tokens", "turn-tail", "turn-process", "compaction", "manual-compaction", "command", "request-prompt"]); // 前 8 个与官方 TURN_PROCESS_INDEPENDENT_KINDS 对齐(0.1.7 新增 turn-trigger)
@@ -305,27 +335,16 @@ function animateClose(el, done) {
305
335
  el.style.maxHeight = el.getBoundingClientRect().height + "px";
306
336
  void el.offsetHeight;
307
337
  requestAnimationFrame(() => { el.style.maxHeight = "0px"; el.style.opacity = "0"; });
308
- setTimeout(() => { if (disposed) return; if (typeof done === "function") done(); finishClose(el); }, 200);
309
- }
310
- /** 合成 click 没能让 React 收起时(控件被换掉/被拦下),把"正在收"的标记与内联样式还原 ——
311
- * 否则这条正文会永久停在 inline max-height:0 + opacity:0,revealBody/帧循环都按 closing 早退、再也不碰它。 */
312
- function finishClose(body) {
313
- if (!body || !body.isConnected || body.dataset.dshsfClosing !== "1") return;
314
- delete body.dataset.dshsfClosing;
315
- body.style.maxHeight = ""; body.style.opacity = ""; body.style.overflow = "";
338
+ setTimeout(done, 200);
316
339
  }
317
- /** 每个模块实例一个接线令牌:热重载后新实例必须能重新挂自己的监听(见 wireDisclosure)。 */
318
- const WIRE_TOKEN = "wire-" + Math.random().toString(36).slice(2, 8);
319
340
  /** 用户点收起时先播动画再放行:捕获阶段拦下 React 的 onClick。 */
320
341
  function wireDisclosure(root, isOpen, bodySel, onUserClose) {
321
- // 认亲而不是认「已接线」:写死 "1" 时,旧实例留下的标记会把新实例挡在门外 ——
322
- // 旧 handler 已被 disposed 挡住、新 handler 又挂不上 = 收起动画拦截静默失效到刷新。
323
- if (root.dataset.dshsfWired === WIRE_TOKEN) return;
324
- root.dataset.dshsfWired = WIRE_TOKEN;
342
+ if (root.dataset.dshsfWired === "1") return;
343
+ root.dataset.dshsfWired = "1";
325
344
  root.addEventListener("click", (event) => {
326
- if (disposed || synthetic || !isOpen(root)) return; // 卸载/热重载后不再拦点击 // 我方事件 / 是展开动作 → 放行
345
+ if (synthetic || !isOpen(root)) return; // 我方事件 / 是展开动作 → 放行
327
346
  if (event.target instanceof Element && event.target.closest(bodySel)) return; // 点在正文里 → 放行
328
- if (state.mode !== "fold" || state.animations === false || !allowAnim) return; // 非折叠档 / 关了动效:直接放行
347
+ if (state.animations === false || !allowAnim) return; // 关了动效就直接放行
329
348
  const body = bodyOf(root, bodySel);
330
349
  if (!body || body.dataset.dshsfClosing === "1") return;
331
350
  event.preventDefault();
@@ -373,16 +392,14 @@ function hasRowText(el) {
373
392
  };
374
393
  return walk(el);
375
394
  }
376
- let textCache = new WeakMap(); // 行 → { turn, value }:有没有正文。跨扫描保留,但**必须认轮号** ——
377
- // React 会把同一个行节点复用给别的轮次/别的会话,只认节点会把旧结论套到新内容上
378
- // (缓存说「没正文」时,承载正式回答的行会被当过程行折掉)。切会话时整表重建。
379
- let hotRows = new WeakSet(); // 最近两轮的行:正文可能还在变 → 每次扫描重算
395
+ let textCache = new WeakMap(); // 行 → 有没有正文:跨扫描保留;定向失效由 MutationObserver 负责(A8,见 startController)
396
+ let hotRows = new WeakSet(); // 运行中的轮 + 最近 3 轮:正文可能还在变 → 每次扫描重算
380
397
  /** assistant-step 行里除思考外是否真有正文。 */
381
398
  function hasText(row) {
382
399
  if (rowKind(row) !== "assistant-step") return false;
383
400
  if (!hotRows.has(row)) {
384
401
  const hit = textCache.get(row);
385
- if (hit !== undefined && hit.turn === (row.getAttribute("data-chat-turn") || "")) return hit.value; // 老轮次:正文不会再变,直接复用(轮号对不上=节点被复用,重算)
402
+ if (hit !== undefined) return hit; // 老轮次:正文不会再变,直接用上次结果(省掉整棵子树的文本遍历)
386
403
  }
387
404
  cost.counts.hasText = (cost.counts.hasText || 0) + 1;
388
405
  const t = performance.now();
@@ -391,7 +408,7 @@ function hasText(row) {
391
408
  // 跑完收尾时连回答一起折掉(就是"全量折叠")。
392
409
  const result = hasRowText(row);
393
410
  cost.phases.hasText = +((cost.phases.hasText || 0) + (performance.now() - t)).toFixed(2);
394
- textCache.set(row, { turn: row.getAttribute("data-chat-turn") || "", value: result });
411
+ textCache.set(row, result);
395
412
  return result;
396
413
  }
397
414
  /** 行里承载「正式正文」的容器。0.1.5 是 row > markdown > body;0.1.7 多包了一层,
@@ -446,29 +463,18 @@ function collapse(root, isOpen, bodySel, atKey, triesKey) {
446
463
  const target = disclosureTarget(root);
447
464
  if (!target) return false;
448
465
  const body = bodyOf(root, bodySel);
449
- const canAnim = !!(body && state.animations !== false && allowAnim && animBudget > 0 && body.dataset.dshsfClosing !== "1");
450
- lastClose = { // 诊断:这次收起为什么播/没播动画(probe().anim.lastClose)
451
- at: Math.round(performance.now()), anim: canAnim, kind: bodySel === THINK_BODY ? "think" : "tool",
452
- setting: state.animations !== false, allowAnim, budget: animBudget, tries,
453
- body: body ? 1 : 0, closing: body && body.dataset.dshsfClosing === "1" ? 1 : 0,
454
- };
455
- if (canAnim) {
466
+ if (body && state.animations !== false && allowAnim && animBudget > 0 && body.dataset.dshsfClosing !== "1") {
456
467
  animBudget -= 1;
457
468
  layoutReads += 1;
458
- // 只写一处:dataset 落到 DOM 上就是 data-dshsf-closing="1"(CSS 按"属性在不在"匹配)。
459
- // 再补一句 setAttribute(…, "") 会把值改成空串,下面所有 `=== "1"` 的守卫全部失效
460
- // (tickFollow 不再给收起的窗让路、revealBody/wireDisclosure 也认不出"正在收")——
461
- // 帧循环每帧把 max-height 写回内容高度,收起过渡被反复拽回去:自动折叠只剩淡出、高度不动。
462
469
  body.dataset.dshsfClosing = "1";
470
+ body.setAttribute("data-dshsf-closing", "");
463
471
  body.style.overflow = "hidden";
464
472
  body.style.maxHeight = body.getBoundingClientRect().height + "px";
465
473
  void body.offsetHeight;
466
474
  requestAnimationFrame(() => { body.style.maxHeight = "0px"; body.style.opacity = "0"; });
467
475
  setTimeout(() => {
468
- if (disposed) return; // 卸载后别再派发合成 click(拦截器有守卫,这一次没有)
469
476
  const late = disclosureTarget(root);
470
477
  if (late) clickOnce(late);
471
- finishClose(body); // 合成 click 没能让 React 收起时别把窗留在"透明且 0 高"
472
478
  }, 200);
473
479
  return true;
474
480
  }
@@ -478,27 +484,24 @@ function closeThink(root) {
478
484
  if (root.dataset.dshsfKeepOpen === "1") return false; // 用户点开的,永远不动
479
485
  if (!thinkOpen(root)) { delete root.dataset.dshsfAuto; autoThinks.delete(root); return false; }
480
486
  if (root.dataset.dshsfAuto !== "1") { root.dataset.dshsfKeepOpen = "1"; autoThinks.delete(root); return false; } // 非我方打开 = 用户
481
- const ok = collapse(root, thinkOpen, THINK_BODY, "dshsfCloseAt", "dshsfCloseTries");
482
- if (ok) autoThinks.delete(root); // 真收起来了才注销登记:失败(控件被换掉/被节流/tries 用满)时留着,下一轮还能重试
483
- return ok;
487
+ autoThinks.delete(root);
488
+ return collapse(root, thinkOpen, THINK_BODY, "dshsfCloseAt", "dshsfCloseTries");
484
489
  }
485
490
  function closeTool(root) {
486
- if (!toolOpen(root)) { delete root.dataset.dshsfToolAuto; autoTools.delete(root); return false; }
487
- if (root.dataset.dshsfToolAuto !== "1") { autoTools.delete(root); return false; } // 用户自己开的,不动
488
- const ok = collapse(root, toolOpen, TOOL_BODY, "dshsfToolCloseAt", "dshsfToolCloseTries");
489
- if (ok) autoTools.delete(root); // 同上:收起失败要留着重试,否则旧窗整轮都敞着
490
- return ok;
491
+ autoTools.delete(root);
492
+ if (!toolOpen(root)) { delete root.dataset.dshsfToolAuto; return false; }
493
+ if (root.dataset.dshsfToolAuto !== "1") return false; // 用户自己开的,不动
494
+ return collapse(root, toolOpen, TOOL_BODY, "dshsfToolCloseAt", "dshsfToolCloseTries");
491
495
  }
492
496
  /** 展开后按实测高度播揭示动画:固定 2000px 上限对矮卡片等于"瞬间全开",只有量到真实高度才看得见展开。 */
493
497
  let lastReveal = null;
494
498
  let lastWindow = null;
495
- let lastClose = null; // 最近一次"收起一行/一个窗"的动画决策(诊断用)
496
499
  const ANIM_MAX_PX = 1200; // 超过这个高度就别逐帧算 max-height 了:一次展开要重排几百毫秒,肉眼看不出区别
497
500
  function revealBody(body, preHeight) {
498
501
  if (!body || body.dataset.dshsfReveal === "1" || body.dataset.dshsfNoReveal === "1" || body.dataset.dshsfClosing === "1") return;
499
502
  if (state.animations === false || !allowAnim) return;
500
503
  let height = Math.ceil(preHeight === undefined ? body.getBoundingClientRect().height : preHeight);
501
- if (body.parentElement && body.parentElement.closest("[data-dshsf-window]")) {
504
+ if (body.parentElement && body.parentElement.closest("[data-dshsf-window], [data-dshsf-textwindow]")) {
502
505
  height = Math.min(height, windowCap() + 40); // 窗外那部分本来就被裁掉,动画目标不必长到几千像素
503
506
  } else if (height > ANIM_MAX_PX) {
504
507
  body.dataset.dshsfNoReveal = "1"; // 太高:直接出现
@@ -520,7 +523,6 @@ function scheduleSupersedeClose(root, ms) {
520
523
  if (running) clearTimeout(running);
521
524
  root.dataset.dshsfCloseTimer = String(setTimeout(() => {
522
525
  delete root.dataset.dshsfCloseTimer;
523
- if (state.mode !== "fold") return; // 已切到官方档位:别再动用户的窗口
524
526
  const flow = chatScope();
525
527
  const row = root.closest(ROW);
526
528
  if (!row || !thinkOpen(root)) return; // 已经不在了
@@ -528,7 +530,6 @@ function scheduleSupersedeClose(root, ms) {
528
530
  if (root === newestInTurn(flow, THINK, row.getAttribute("data-chat-turn"))) return; // 又成最新了
529
531
  if (closeBudget <= 0) { scheduleSupersedeClose(root, 300); return; } // 一次扫描的收起配额用完了,稍后再收
530
532
  if (closeThink(root)) closeBudget -= 1;
531
- else delete root.dataset.dshsfSupersededAt; // 这次没收起(控件被换掉/被节流):撤掉"已排定时器"标记,让下一轮扫描重排
532
533
  }, Math.max(0, ms)));
533
534
  }
534
535
  function openTool(root) {
@@ -568,8 +569,10 @@ function ensureWindowJump(el) {
568
569
  jump.addEventListener("click", (event) => {
569
570
  event.preventDefault();
570
571
  event.stopPropagation();
571
- el.dataset.dshsfFollow = "1";
572
- el.scrollTo({ top: el.scrollHeight, behavior: state.animations === false || !allowAnim ? "auto" : "smooth" });
572
+ const s = winOf(el);
573
+ s.writer = "us"; // 我方导航:取回写权,绝不交出
574
+ s.nav = true; // 一次性回底:不设 cap,滑到窗尾后转为常规跟随
575
+ ensureTickLoop();
573
576
  jump.removeAttribute("data-dshsf-visible");
574
577
  });
575
578
  }
@@ -581,36 +584,56 @@ function jumpButton() {
581
584
  const btn = document.createElement("button");
582
585
  btn.type = "button";
583
586
  btn.dataset.dshsfJump = "";
584
- btn.setAttribute("aria-label", uiText("回到底部", "Back to bottom"));
585
- btn.title = uiText("回到底部", "Back to bottom");
587
+ btn.setAttribute("aria-label", "回到底部");
588
+ btn.title = "回到底部";
586
589
  btn.innerHTML = JUMP_ICON;
587
590
  return btn;
588
591
  }
589
592
  const wired = new WeakSet();
590
593
  const liveWindows = new Set();
591
- const atBottom = (el) => el.scrollHeight - el.scrollTop - el.clientHeight <= 24;
594
+ /** 唯一允许"跟尾(触底吸附)"的小窗 = 本轮 running 的最新思考正文;null = 谁都不跟。
595
+ * 来源与 sparks 焦点同源(syncWindows 的 newestThink),与 state.sparks 开关无关:
596
+ * 被取代的旧窗 / 上一轮的窗 / 跑完的窗一律不跟、也不 win-snap——静态窗不为任何外部滚动回写。 */
597
+ let activeWindow = null;
598
+ const winH = new WeakMap(); // 上一帧实测窗高:want=null 的 CSS 过渡收缩没有 JS 写入,只能靠实测差补负 dh
599
+ /** 小窗的写权 + 速度状态(与主容器同一模型、同一个滑行律):{ writer, nav, vFeed, vChase, prev }。
600
+ * 小窗只有一个目标 = 窗尾,所以没有独立的 goal 量;点回底按钮那一次 nav=true(不设追赶上限)。 */
601
+ const winState = new WeakMap();
602
+ /** Ctrl+F 搜索期间小窗一律撒手(writer=reader、不贴底、不跟尾);新登记/重挂载的窗也按读者态起步。
603
+ * 浏览器不给"搜索关闭"事件 ⇒ 解除放在输入层:下一笔真实页面输入(滚轮/触摸/按键/点击)即视为回到页面。 */
604
+ let searchHold = false;
605
+ const winOf = (el) => { let s = winState.get(el); if (s === undefined) { s = { writer: searchHold ? "reader" : "us", nav: false, ...newVel() }; winState.set(el, s); } return s; };
606
+ const winAtFloor = (el) => el.scrollHeight - el.clientHeight - el.scrollTop <= 0.5;
607
+ /** Ctrl+F:主容器与小窗一起撒手(搜索期间小窗零程序化写)。取回写权仍走既有入口:
608
+ * 读者真实滚回窗尾 / 点该窗回底按钮 / 会话边界——不新造机制。 */
609
+ function releaseWindows() {
610
+ searchHold = true;
611
+ for (const el of liveWindows) { const s = winOf(el); s.writer = "reader"; s.nav = false; resetVel(s); }
612
+ }
592
613
 
593
- function wireWindow(el, followBottom = true) {
614
+ function wireWindow(el) {
594
615
  ensureWindowJump(el);
595
616
  liveWindows.add(el);
617
+ const fresh = !winState.has(el); // 首次登记:只贴底一次(已展开/已登记的窗不二次重贴)
618
+ const ws = winOf(el); // 空闲值显式:新窗 = 我方跟尾(搜索期间 = 读者态)
619
+ if (searchHold) { ws.writer = "reader"; ws.nav = false; resetVel(ws); } // 搜索期间接线/重挂载:读者态起步,不贴底
620
+ if (fresh && el.scrollHeight > el.clientHeight) {
621
+ if (!searchHold && el === activeWindow) { // 只给活动窗贴底:搜索闩置位、或不是本轮在跑的那个窗,都不 win-snap
622
+ const floor = el.scrollHeight - el.clientHeight;
623
+ writeTop(el, floor, undefined, "win-snap"); // 首帧直接贴底(不走滑行:5000px 正文不该从顶部爬 10s;仍走唯一写点)
624
+ ws.prev = floor; // 前馈别把这“跳”当成目标速度
625
+ ws.wantS = floor; // 小窗跟随低通状态也从窗尾起算
626
+ }
627
+ }
596
628
  if (wired.has(el)) return;
597
629
  wired.add(el);
598
- // 穿插正文窗不跟底:静态段落跟到底只会看到"最后一行 + 一屏空白"(内容短时浏览器会把 scrollTop 夹回 0)
599
- el.dataset.dshsfFollow = followBottom ? "1" : "";
600
- const noteIntent = () => { if (disposed) return; el.dataset.dshsfIntentAt = String(performance.now()); };
601
- for (const type of ["wheel", "touchstart", "touchmove"]) el.addEventListener(type, noteIntent, { passive: true });
602
- el.addEventListener("pointerdown", (event) => { if (event.target === el) noteIntent(); }, { passive: true });
630
+ const toReader = () => { const s = winOf(el); s.writer = "reader"; s.nav = false; searchHold = false; }; // 真实输入 ⇒ 交出这个窗的写权(无时间窗)+ 解除搜索闩(用户回到页面)
631
+ for (const type of ["wheel", "touchstart", "touchmove"]) el.addEventListener(type, toReader, { passive: true });
632
+ el.addEventListener("pointerdown", (event) => { if (event.target === el) toReader(); }, { passive: true });
603
633
  el.addEventListener("scroll", () => {
604
- if (disposed) return;
605
634
  ensureTickLoop(); // 收敛后帧循环是停的:读者一滚就得排一帧,否则按钮要等下一次 DOM 变化才冒出来
606
- const mine = Number(el.dataset.dshsfMine);
607
- if (Number.isFinite(mine) && Math.abs(el.scrollTop - mine) <= 2) return; // 我方推进造成的滚动,不算读者
608
- // 位置被**往回拉**(小于我方最后写入值)= 只有读者会这么做(我们的追赶只增加 scrollTop);
609
- // 这条不依赖"600ms 内有输入信号"——输入信号只在 wheel/touch/拖滚动条时才有,漏一种读者就被当布局变化,
610
- // 跟随不收手 → 窗口被一直拽回底部 → 回底按钮永远不出现("流式中的小窗没有回底按钮"就是这么来的)。
611
- const up = Number.isFinite(mine) && el.scrollTop < mine - 3;
612
- const since = performance.now() - Number(el.dataset.dshsfIntentAt || 0);
613
- if ((up || since <= USER_SCROLL_MS) && !atBottom(el)) el.dataset.dshsfFollow = "";
635
+ // 位置落到窗尾 ⇒ 取回写权、目标=尾(与主容器规则 4 同一条)
636
+ if (!searchHold && winOf(el).writer !== "us" && winAtFloor(el)) winOf(el).writer = "us"; // 搜索闩置位时几何不算读者意图(浏览器 find 落在窗尾也是几何;取回只认真实输入)
614
637
  }, { passive: true });
615
638
  }
616
639
 
@@ -618,23 +641,14 @@ function wireWindow(el, followBottom = true) {
618
641
  function tagThinkWindow(body, preHeight) {
619
642
  if (!body || body.getAttribute("hidden") === SKIP) return; // 为搜索藏起的正文不渲染:打小窗会强制布局,白花
620
643
  if (body.dataset.dshsfWindow === "reasoning") { wireWindow(body); return; } // 已打过标:补登记(折叠期间被移出过清单)
621
- if (body.parentElement && body.parentElement.closest("[data-dshsf-window]")) return;
644
+ if (body.parentElement && body.parentElement.closest("[data-dshsf-window], [data-dshsf-textwindow]")) return;
622
645
  // 先按实测高度写 --dshsf-hw 再打标:固定 260px 上限对"刚起步只剩一两行"的新思考,
623
646
  // 动画十几毫秒就跑完了,看着跟没动效一样;按真实高度播才有完整 0.26s 的展开过程。
624
647
  const cap = Math.max(80, Number(state.windowHeight) || 260);
625
- const rect = body.getBoundingClientRect();
626
- // 底边不许越过下边界(composer 上沿):新窗常常就长在输入框上方,按实测高度播入场动画会直接长到输入框底下。
627
- const visible = Math.max(WINDOW_MIN_H, Math.round(Math.min(preHeight === undefined ? rect.height : preHeight, cap, roomAbove(rect.top, lowerBound()))));
628
- body.style.setProperty("--dshsf-hw", visible + "px");
629
- body.dataset.dshsfH = String(visible);
648
+ const visible = Math.min(Math.round(preHeight === undefined ? body.getBoundingClientRect().height : preHeight), cap);
649
+ body.style.setProperty("--dshsf-hw", Math.max(16, visible) + "px");
650
+ body.dataset.dshsfH = String(Math.max(16, visible));
630
651
  body.dataset.dshsfWindow = "reasoning";
631
- // 入场动画期间渲染高度由 CSS 动画说了算(而它的目标已经被夹在余量内),这段时间不做底边补偿:否则会按我们的
632
- // 目标值多滚一截、在输入框上方开出一条空隙。没开动效就不标,补偿立刻生效。
633
- if (document.documentElement.hasAttribute("data-dshsf-anim")) {
634
- body.dataset.dshsfEntering = "1";
635
- body.dataset.dshsfEnterH = String(visible); // 动画的落点:帧里在动画跑完前不许越过它
636
- setTimeout(() => { delete body.dataset.dshsfEntering; }, 320);
637
- }
638
652
  wireWindow(body);
639
653
  const record = { h: Math.round(body.getBoundingClientRect().height), cap: visible };
640
654
  lastWindow = record;
@@ -647,28 +661,79 @@ function tagThinkWindow(body, preHeight) {
647
661
  /** 每帧一次:先只读、后集中写(滚动卡顿的主因是读写交错)。 */
648
662
  const supersedeDelay = () => Math.min(60, Math.max(0, num(state.supersedeDelay, 2))); // 单位:秒
649
663
  const windowCap = () => Math.max(80, Number(state.windowHeight) || 260);
650
- /** 小窗的最小高度:下边界正好贴到窗顶时也得留一条能看见的缝。 */
651
- const WINDOW_MIN_H = 16;
652
- /** 会话可视区的下边界(视口坐标 y):官方把 composer 以 sticky bottom:0 盖在滚动口底部([data-composer-seat]),
653
- * 它上沿以下就是输入框区域 —— 小窗的底边不许越过去,否则新窗一长就压到输入框上。拿不到座位 = 不限制。 */
654
- let seatEl, seatFor;
655
- function composerSeat(scroller) {
656
- if (scroller !== seatFor) { seatFor = scroller; seatEl = undefined; } // 换容器 = 重新查一次(别每帧走 querySelector)
657
- if (seatEl === undefined || (seatEl !== null && !seatEl.isConnected)) seatEl = scroller ? scroller.querySelector("[data-composer-seat]") : null;
658
- return seatEl;
659
- }
660
- function lowerBound() {
661
- const seat = composerSeat(followScroller || mainScroller());
662
- return seat ? seat.getBoundingClientRect().top : null;
663
- }
664
- /** 这个窗在「底边不越过下边界」的前提下还能长多高;bound 为 null(拿不到下边界)就不限制。 */
665
- const roomAbove = (top, bound) => (bound === null ? Infinity : Math.max(WINDOW_MIN_H, bound - top));
664
+ const WIN_TAU_MS = 150; // 小窗跟随目标的一阶低通常量:与主容器 TAIL_TAU_MS 同值(用户要求小窗与主窗口手感一致;小窗只有 ~260px 高,滞后会比主窗口更显眼)
666
665
  const num = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
667
666
  const smoothGrow = () => Math.min(0.9, Math.max(0.05, num(state.smoothGrow, 0.25))); // 每帧吃掉的差距比例
668
- const smoothMin = () => Math.min(12, Math.max(0, num(state.smoothMin, 2))); // 每 16ms 的最小推进像素
669
- const minStep = (dt) => smoothMin() * (dt / 16); // 换算成本帧:与屏幕刷新率无关
667
+ /** 时间常数(τ_smooth):校准到「60Hz 单帧推进 = 旧线性式在 60Hz 的推进」。K = state.smoothGrow(DEFAULTS 0.15,函数里的 0.25 只是缺值兜底)
668
+ * ⇒ 默认 tau = -16.667 / ln(1 - 0.15·16.667/16) ≈ **98.0973ms**(实测每帧 alpha 0.1562、稳态 gap = v·tau)。 */
669
+ const smoothTau = () => -16.667 / Math.log(1 - Math.min(0.9, smoothGrow() * 16.667 / 16));
670
+ /** 指数律的精确单帧位移:dy·(1−e^(−dt/τ))——任意 dt 序列下 gap(T)=gap0·e^(−T/τ),与刷新率/帧数无关(不再有 minStep 下限)。 */
671
+ const expStep = (dy, dt) => dy * (1 - Math.exp(-dt / smoothTau()));
672
+ const FEED_TAU = 150; // 前馈 EMA 时间常数(ms):"目标自身速度"的慢估计
673
+ /** 每写者一份速度状态:vFeed=前馈(目标自己在长的速度,不夹上限);vChase=追赶速度(受加速度与速度上限约束)。
674
+ * 唯一状态量;换目标/重开帧循环必须 resetVel()。 */
675
+ const newVel = () => ({ vFeed: 0, vChase: 0, prev: null });
676
+ const resetVel = (st) => { st.vFeed = 0; st.vChase = 0; st.prev = null; };
677
+ /**
678
+ * **唯一滑行律**(主容器跟随 / 一次性导航 / 小窗跟随·回底共用)。每帧重算目标 want。
679
+ * ① 前馈 vFeed = 目标自身速度的慢 EMA(clamp ≥0,不吃 vMax:内容是自己在长,我们的义务是跟上)
680
+ * ② 追赶 vWant = 指数律精确复合 /(dt/1000);③ 加速度受限;④ vMax 只夹追赶那一段;v = vFeed + vChase
681
+ * ⑤ |dy|≤0.5 精确落点并清零 vChase。vMax<=0 = 不设追赶上限(一次性导航)。
682
+ * 退化性:a→∞ 且 vFeed=0 ⇒ step = min(vMax·dt/1000, 指数律)(旧律)。
683
+ * 返回是否还在动。写点唯一:writeTop。
684
+ */
685
+ function slide(el, want, dt, vMax, st, curHint, tag, maxHint) {
686
+ const t = tag || "slide";
687
+ const cur = curHint === undefined ? el.scrollTop : curHint; // task-22:调用方已知(计划里读过)就别再读一次
688
+ const curPx = snapPx(cur), wantPx = snapPx(want);
689
+ if (curPx === wantPx) { // ⑤ 到位(物理像素意义):不留亚像素残差,也不继续排帧
690
+ st.vChase = 0; st.prev = want;
691
+ return false;
692
+ }
693
+ const dtS = dt / 1000;
694
+ if (Math.abs(wantPx - curPx) <= 1 / pxDpr() + 1e-9) { // 目标像素最多差一格:直接落到目标像素(否则速度永远凑不出一步 ⇒ 180 帧卡死)
695
+ st.vChase = 0; st.prev = want;
696
+ writeTop(el, wantPx, cur, t);
697
+ return false;
698
+ }
699
+ const dy = want - cur;
700
+ if (Math.abs(dy) <= 0.5) { // ⑤ 精确落点:不留残差
701
+ if (dy !== 0) writeTop(el, want, cur, t);
702
+ st.vChase = 0;
703
+ st.prev = want;
704
+ return false;
705
+ }
706
+ if (st.prev === null) st.vFeed = 0; // 目标刚换人:前馈从 0 起(不携带陈旧速度)
707
+ else st.vFeed += (Math.max(0, (want - st.prev) / dtS) - st.vFeed) * (1 - Math.exp(-dt / FEED_TAU));
708
+ st.prev = want;
709
+ const vWant = expStep(dy, dt) / dtS; // ② 指数律的精确复合(不是线性式)
710
+ const dv = accelValue() * dtS; // ③ 加速度受限(dt 归一)
711
+ st.vChase += Math.max(-dv, Math.min(dv, vWant - st.vChase));
712
+ if (vMax > 0) st.vChase = Math.max(-vMax, Math.min(vMax, st.vChase)); // ④ 上限只作用于追赶
713
+ let step = (st.vFeed + st.vChase) * dtS;
714
+ if (Math.abs(step) > Math.abs(dy)) step = dy;
715
+ if (!(Math.abs(step) > 1e-6)) { st.vChase = 0; return false; }
716
+ const wrote = writeTop(el, cur + step, cur, t);
717
+ if (!wrote) { // task-25:不写成功时用解析预测判"是否已到位",**不回读 scrollTop**(那是写后同帧读 ⇒ 强制布局)
718
+ const predicted = maxHint === undefined ? el.scrollTop : Math.min(maxHint, Math.max(0, snapPx(cur + step)));
719
+ if (snapPx(predicted) === wantPx) { st.vChase = 0; st.prev = want; return false; }
720
+ }
721
+ return true;
722
+ }
670
723
  // 自身耗时统计:只统计"我们的 JS",不含浏览器的布局/绘制(那部分不在我们手里)
671
724
  const cost = { frames: 0, tickMs: 0, scans: 0, scanMs: 0, lastMs: 0, maxMs: 0, phases: {}, counts: {}, worst: null, lastError: null, pinMs: 0, pinFrames: 0 };
725
+ /* task-19 ②:tick 内分阶段计时(plan=读尺寸+低通 / main=主容器推进 / win=小窗写)。尖峰归谁看这里。 */
726
+ const tickPhase = {}; // 各相位累计(probe 里除以 n)
727
+ const phaseMs = { plan: 0, main: 0, win: 0, winStyle: 0, winSlide: 0, winJump: 0, sparks: 0, pin: 0, announce: 0, absorb: 0, mainPlan: 0, mainSlide: 0, mainGate: 0 };
728
+ let pinRects = 0; // task-25:pinTick 里的 getBoundingClientRect 调用次数(diagnostic)
729
+ const phaseAcc = (k, t0) => { phaseMs[k] += performance.now() - t0; };
730
+ const tickSamples = []; // 最近 200 帧的 tickMs(p50/p95/max)
731
+ let phPlanMs = 0, phMainMs = 0, phWinMs = 0; // tickFollowInner 写、tickFollow 取(分阶段计时)
732
+ let readCount = 0; // 诊断:本帧的强制布局读次数(scrollHeight/clientHeight)
733
+ let frameTop = 0, frameSh = 0, frameCh = 0; // task-19 ②:本帧主容器几何的一次读结果(全帧复用;frameTop 为写后值)
734
+ let frameToken = 0, vtStamp = -1, vtRect = null; // task-19 ②:viewTop 的帧内容器 rect 缓存(仅 tick 帧内有效,帧外走原式)
735
+ /** task-20:显式 scrollTop 读的统一入口(口径(S2-2)计数的一部分);调用方已知值请传 curHint,不要多读。 */
736
+ const readTop = (el) => { readCount += 1; return el.scrollTop; };
672
737
  /** 分位数:单次采样会被外部卡顿(远程桌面/后台节流/GC)污染,中位数才反映我们自己的成本。 */
673
738
  function percentile(list, q) {
674
739
  if (!list.length) return 0;
@@ -684,125 +749,175 @@ function topPhases(phases) {
684
749
  .slice(0, 5);
685
750
  }
686
751
  function resetCost() { scanSamples.length = 0; cost.frames = 0; cost.tickMs = 0; cost.scans = 0; cost.scanMs = 0; cost.lastMs = 0; cost.maxMs = 0; cost.phases = {}; cost.counts = {}; cost.worst = null; cost.lastError = null; cost.pinMs = 0; cost.pinFrames = 0; sparks.resetCost(); }
687
- let followTickAt = 0;
688
752
  let ticking = false;
689
753
  let rafId = 0; // 帧循环句柄(dispose 要能停掉它)
690
754
  let disposed = false; // 实例已收摊:所有异步入口早退(热重载后旧实例不许再动 DOM)
691
755
  /** 未收敛就自己接着排帧:只靠 MutationObserver 的话,两次输出之间的那段就冻住不动了。 */
692
- const TICK_MIN_MS = 8; // 上限 120fps:60Hz 屏每帧都推进(16.7ms > 8ms),高刷屏最多 120fps
693
- let lastTickRun = 0;
694
- function tickLoop() {
756
+ let lastTickTs = 0;
757
+ let gutterSet = false; // H16:稳定滚动条槽(bootOver 之后才设;三因子隔离证明静态版单独无判据影响,但仍推迟以避开初始化大步)
758
+ const TICK_MIN_MS = 5; // H16:推进循环最小间隔(320Hz 隔帧 ~160Hz;必要参数:每次 scrollHeight 都是强制重排)
759
+ function tickLoop(timestamp) {
695
760
  if (disposed) { ticking = false; return; }
696
- const now = performance.now();
697
- if (now - lastTickRun >= TICK_MIN_MS) {
698
- lastTickRun = now;
699
- if (!tickFollow()) { ticking = false; return; }
761
+ const ts = timestamp || performance.now(); // rAF 回调时间戳 = 上一帧渲染结束时刻(不用 performance.now())
762
+ const prevTs = lastTickTs;
763
+ if (prevTs !== 0 && ts - prevTs < TICK_MIN_MS) { rafId = requestAnimationFrame(tickLoop); return; }
764
+ const dt = prevTs === 0 ? 16.667 : Math.min(500, Math.max(1, ts - prevTs)); // A14:首帧按一个 60Hz 帧记(内层本就以 16.667 兜底;trace 不再记 ts-ts=0);解析式对任意 dt 精确 → 只夹极端值防巨跳
765
+ lastTickTs = ts;
766
+ if (!tickFollow(dt)) {
767
+ ticking = false;
768
+ if (tickRetry && !disposed) { tickRetry = false; ensureTickLoop(); } // 异常后立即重排:外围不能因一行异常整条停摆
769
+ return;
700
770
  }
701
771
  rafId = requestAnimationFrame(tickLoop);
702
772
  }
703
773
  function ensureTickLoop() {
704
774
  if (ticking || disposed) return;
705
775
  ticking = true;
776
+ lastTickTs = 0; // 帧循环重启:首帧 dt 从 0 起算(不把停摆期间的时间差当一帧)
777
+ resetVel(mainVel); // 重启时速度复位(不携带陈旧前馈/追赶速度)
778
+ for (const el of liveWindows) { const s = winState.get(el); if (s !== undefined) resetVel(s); }
706
779
  rafId = requestAnimationFrame(tickLoop);
707
780
  }
708
- function tickFollow() {
781
+ let tickErrors = 0; // 帧循环体内异常次数(诊断):一行异常不得再打死整条循环
782
+ let lastTickError = null; // 最近一次帧循环异常 {message, stack, at}
783
+ let tickRetry = false; // 本轮循环体抛过异常 ⇒ 不认"已收敛",由 tickLoop 收尾处立即重排重试
784
+ function tickFollow(dt) {
709
785
  const t0 = performance.now();
710
- const moving = tickFollowInner();
711
- cost.frames += 1;
712
- cost.tickMs += performance.now() - t0;
786
+ const w0 = writeCount, a0 = announceCount, r0 = readCount, g0 = followScroller === null ? null : frameTop; // task-19:g0 = 上一帧写后位置(不再回读)
787
+ const ph0 = { ...phaseMs };
788
+ for (const k in writeTally) delete writeTally[k]; // task-22:本帧写来源从零开始记
789
+ let moving = false;
790
+ try {
791
+ moving = tickFollowInner(dt);
792
+ const tickMs = performance.now() - t0;
793
+ if (moving || writeCount !== w0 || announceCount !== a0) { // 空闲不记:这一帧什么都没发生就不进轨迹
794
+ const sc = followScroller;
795
+ const fl = sc === null ? 0 : Math.max(0, frameSh - frameCh); // task-19:复用本帧几何
796
+ if (tickSamples.length >= 200) tickSamples.shift();
797
+ tickSamples.push(+tickMs.toFixed(2));
798
+ const tickEnd = t0 + tickMs; // 分阶段:plan=建计划(读尺寸+低通) / main=主容器推进 / win=小窗写
799
+ const phPlan = phMainMs - phPlanMs, phMain = phWinMs - phMainMs, phWin = tickEnd - phWinMs;
800
+ const phD = { plan: +phPlan.toFixed(2), main: +phMain.toFixed(2), win: +phWin.toFixed(2) };
801
+ for (const k in phaseMs) if (k !== "plan" && k !== "main" && k !== "win") phD[k] = +(phaseMs[k] - ph0[k]).toFixed(2);
802
+ phD.other = +Math.max(0, tickMs - (phPlan + phMain + phWin + (phaseMs.sparks - ph0.sparks))).toFixed(2);
803
+ for (const k in phD) tickPhase[k] = (tickPhase[k] || 0) + phD[k];
804
+ tickPhase.n = (tickPhase.n || 0) + 1;
805
+ traceFrame({
806
+ dt: +dt.toFixed(2), writes: writeCount - w0, announces: announceCount - a0,
807
+ tickMs: +tickMs.toFixed(2), reads: readCount - r0,
808
+ ph: phD,
809
+ wsrc: Object.keys(writeTally).length ? { ...writeTally } : undefined,
810
+ step: sc === null || g0 === null ? 0 : +(frameTop - g0).toFixed(2),
811
+ gap: sc === null ? 0 : +(fl - frameTop).toFixed(2), writer, goal: goalKind, // task-19:复用本帧写后位置
812
+ scanMs: +scanMsSinceFrame.toFixed(2), scans: scanCountSinceFrame, // 归因 dt 尖峰:这一帧我们自己扫了多久
813
+ });
814
+ }
815
+ } catch (err) {
816
+ tickErrors += 1;
817
+ lastTickError = { message: String((err && err.message) || err), stack: String((err && err.stack) || "").split("\n")[1] || "", at: Math.round(performance.now()) };
818
+ ticking = false; // 解死锁:异常路径曾把 ticking 永久留在 true ⇒ ensureTickLoop() 永久早退
819
+ tickRetry = true; // 重排交给 tickLoop 收尾处(避免与它重复排帧 ⇒ 双链)
820
+ } finally {
821
+ cost.frames += 1;
822
+ cost.tickMs += performance.now() - t0;
823
+ scanMsSinceFrame = 0; scanCountSinceFrame = 0; // 每帧都清零:这帧没记进轨迹也不许把账留给下一帧
824
+ }
713
825
  return moving;
714
826
  }
715
- function tickFollowInner() {
827
+ function tickFollowInner(dt) {
716
828
  tickCount += 1;
717
- const now = performance.now();
718
- const dt = Math.min(64, Math.max(1, now - (followTickAt || now))); // 下限 1ms:320Hz 帧间隔只有 3.1ms,别再钳
719
- followTickAt = now;
720
- let moving = mainFollowTick(dt);
721
- if (sparks.tick(dt)) moving = true; // 火星:模块自带"该不该跑",熄灭且不发光时返回 false(空闲零成本)
722
- if (liveWindows.size === 0) { if (moving) ensureTickLoop(); return moving; }
723
- const scroller = mainScroller();
724
- const bound = lowerBound(); // 每帧只读一次:所有小窗共用同一条下边界
725
- const scrollAt = scroller ? scroller.scrollTop : 0; // 读阶段读一次:写阶段要拿它做"底边补偿",不能在写完高度后再读
726
- // 跟随中位置归我们管:小窗长出来的量用同量的滚动补掉(底边钉住、窗往上长),既不会伸进输入框,
727
- // 也不会因为"看不见余量"而永远长不大。读者接管了位置就不是我们的:那时只能夹高度。
728
- const pinBottom = bound !== null && scroller !== null && followOn && state.autoFollow !== false;
829
+ if (!(dt > 0)) dt = 16.667; // 同 mainFollowTick:首帧 dt=0 的兜底(小窗跟随/回底也不能白跑一帧)
830
+ if (gateOfficialMode()) return false; // 官方档位:小窗也不写、按钮收起(整个运动层退出)
831
+ // 小窗计划先只读建好、只算不写(A-noA4:不再把窗的剩余目标折进主吸附)
832
+ phPlanMs = performance.now();
729
833
  const plan = [];
730
834
  for (const el of liveWindows) {
731
- if (!el.isConnected) { liveWindows.delete(el); continue; }
732
- if (el.clientHeight === 0) continue;
835
+ if (!el.isConnected) { liveWindows.delete(el); continue; } // React 卸载:只退登记
836
+ const ch = el.clientHeight;
837
+ if (ch === 0) continue;
733
838
  const sh = el.scrollHeight; // 每帧只读一次:读尺寸在 DOM 脏的时候会强制重排
734
- const max = sh - el.clientHeight;
735
- const gap = max - el.scrollTop;
839
+ const max = sh - ch;
840
+ const st = winOf(el);
841
+ if (st.snapped !== true) { // 小窗首次跟随:直接贴底(5000px 正文不该从顶部爬 10s;只做一次)
842
+ st.snapped = true;
843
+ if (st.writer === "us" && el === activeWindow && max > 0.5 && el.scrollTop < max - 0.5) { writeTop(el, max, undefined, "win-snap"); resetVel(st); st.wantS = max; } // 只有活动窗首帧贴底
844
+ }
845
+ const top0 = el.scrollTop; // task-22:本帧该窗只读这一次 scrollTop(原 gap/plan 各读一次)
846
+ const gap = max - top0;
736
847
  const bottom = gap <= 24;
737
- const follow = state.autoFollow !== false && (bottom || el.dataset.dshsfFollow === "1");
848
+ if (!searchHold && st.writer !== "us" && gap <= 0.5) { st.writer = "us"; st.nav = false; resetVel(st); st.wantS = undefined; } // 规则 4:读者落到窗尾 ⇒ 取回写权(速度状态复位 + 低通重新对齐);搜索闩置位时不认几何
849
+ // 小窗跟随目标独立低通(task-18 ④):一次性回底(nav)不走低通,首次/收缩直接对齐
850
+ if (st.nav || st.wantS === undefined || st.wantS > max + 1) st.wantS = max;
851
+ else st.wantS += (max - st.wantS) * (1 - Math.exp(-dt / WIN_TAU_MS));
852
+ const winWant = st.nav ? max : st.wantS;
853
+ // 跟尾 = 写权在我方 ∧ 这还是本轮在跑的那个活动窗;st.nav 是用户点回底的一次性请求(不算吸附)。
854
+ const follow = st.writer === "us" && (el === activeWindow || st.nav);
738
855
  // 流式输出是一行一行来的:max-height 跟住内容高度(长出来而不是跳一格)
739
- const rect = el.getBoundingClientRect();
740
- const top = rect.top;
741
- const entering = el.dataset.dshsfEntering === "1";
742
- // 入场动画期间渲染高度归 CSS 动画管:这段时间目标高度不许超过动画目标 —— 否则动画一结束,我们早就推到上限的
743
- // inline 值整块落地(渲染高度从动画值一步跳到上限),窗就在那一刻"掉下去"。
744
- const cap = entering ? Math.max(WINDOW_MIN_H, Number(el.dataset.dshsfEnterH) || 0) : windowCap();
745
- const want = el.dataset.dshsfClosing === "1" ? null : Math.min(sh, cap, pinBottom ? Infinity : roomAbove(top, bound));
746
- // 回底按钮查登记表(jumpOf),不再每帧每窗查一次 DOM:只要它还挂在窗口里就直接用,
747
- // 不在(被 React 挪走/删掉)才走 ensureWindowJump 的完整自愈(那里面有查询 + 去重 + 补挂)。
748
- const knownJump = jumpOf.get(el);
749
- const jump = knownJump !== undefined && knownJump.isConnected && knownJump.parentElement === el ? knownJump : null;
750
- plan.push({ el, jump, bottom, gap, follow, cur: el.scrollTop, want, top, renderedH: rect.height, entering });
751
- }
752
- let over = 0; // 这一帧被小窗长出去、越过下边界的量:跟随中要用同量滚动补掉(底边钉住)
856
+ const want = el.dataset.dshsfClosing === "1" ? null : Math.min(sh, windowCap());
857
+ const known = el.dataset.dshsfH;
858
+ const cur = known === undefined ? want : Number(known);
859
+ const h0 = winH.get(el); winH.set(el, ch); // 只读计划里记下实测高:收窗帧的缩量由它给出
860
+ const dh = want === null ? (h0 === undefined ? 0 : ch - h0) // CSS 过渡收窗:没有 JS 写入,缩量 = 实测高差
861
+ : (Math.abs(want - cur) <= 0.5 ? 0 : expStep(want - cur, dt));
862
+ let jump = jumpOf.get(el); // task-20 A10:帧路径优先信自己的登记(DOM 挪动/多实例都不会认错),缺失才查询并登记
863
+ if (jump === undefined) {
864
+ jump = el.querySelector(":scope > [data-dshsf-jump]");
865
+ if (jump !== null && jump !== undefined) jumpOf.set(el, jump);
866
+ }
867
+ plan.push({ el, jump, bottom, gap, follow, cur, want, dh, known, top: top0, max, sh, nav: st.nav, st, winWant });
868
+ }
869
+ phMainMs = performance.now();
870
+ let moving = mainFollowTick(dt);
871
+ // task-19 例外:这一句被 static-check 逐字钉住("每帧必须同步几何基线"),且几何基线语义上就是"此刻真实几何";
872
+ // 保留这 2 次读。滑行帧其余重复读已复用 frameTop/frameSh/frameCh 同一份。
873
+ vtStamp = -1; // task-19 ②:帧结束 ⇒ viewTop 缓存失效(帧外调用回原式)
874
+ if (followScroller !== null) { baseSt = followScroller.scrollTop; baseSh = followScroller.scrollHeight; } // 帧基线:下一笔外来写的"同期布局变化"基准
875
+ { const tS = performance.now(); if (sparks.tick(dt)) moving = true; phaseAcc("sparks", tS); } // 火星:模块自带"该不该跑",熄灭且不发光时返回 false(空闲零成本)
876
+ phWinMs = performance.now();
877
+ if (plan.length === 0) { if (moving) ensureTickLoop(); return moving; }
753
878
  for (const item of plan) {
879
+ readCount += 2; // 每窗:clientHeight + scrollHeight 两次强制布局读
880
+ item.topAfter = item.top; // task-25:写 maxHeight 之后的预测位置(默认不变)
881
+ item.maxAfter = item.max; // 写 maxHeight 之后的可滚动上限(= sh - newHeight)
882
+ let nextH = null; // task-27:本轮要写的高度(先算出来,但**样式最后写**)
754
883
  if (item.want !== null) {
755
- const known = item.el.dataset.dshsfH;
756
- const cur = known === undefined ? item.want : Number(known);
757
- const gap = item.want - cur;
758
- if (Math.abs(gap) > 0.5) {
759
- const step = Math.min(Math.abs(gap), Math.max(minStep(dt), Math.abs(gap) * smoothGrow() * (dt / 16)));
760
- const next = (cur + Math.sign(gap) * step).toFixed(1);
761
- item.el.dataset.dshsfH = next;
762
- item.el.style.setProperty("--dshsf-hw", next + "px"); // 入场动画还在跑时,动画目标也跟着长(避免交接跳一下)
763
- item.el.style.maxHeight = next + "px";
884
+ if (item.known === undefined) item.el.dataset.dshsfH = String(item.want);
885
+ if (item.dh !== 0) { // dh 带符号,来自只读计划里的同一个指数律:与主吸附本帧推进同速
886
+ nextH = (item.cur + item.dh).toFixed(1);
887
+ item.maxAfter = Math.max(0, item.sh - Number(nextH)); // 目标必须按**变更后的 extent** 算(否则长高那几帧会被旧 max 截断)
888
+ item.topAfter = Math.min(item.top, item.maxAfter);
764
889
  moving = true;
765
- // 只补"这一帧新长出来、而且会加深越界"的那部分:已经越界的旧滞后仍归跟随公式慢慢收,否则等于把平滑跟随一键瞬移掉。
766
- // 用实测渲染高度当基准(不是 dataset 的目标值):入场动画期间渲染高度和我们的目标值不是一回事。
767
- if (pinBottom && !item.entering) over = Math.max(over, Math.min(Number(next) - item.renderedH, item.top + Number(next) - bound));
768
- } else {
769
- if (known === undefined) item.el.dataset.dshsfH = String(item.want);
770
- // 自愈:内联 max-height 可能停在旧值或被 React 重渲染抹掉(dataset 记的是目标值)—— 只比字符串、
771
- // 不读布局。不补这一刀,窗口就会一直停在旧高度(用户截图里那种"半空的玻璃面板")。
772
- const target = item.want + "px";
773
- if (item.el.style.maxHeight !== target) { item.el.style.maxHeight = target; moving = true; }
774
890
  }
775
891
  }
776
892
  // 跟随中:朝底部"追"过去,而不是直接跳到 max —— 内容才有顺流而下的感觉
777
- if (item.follow && item.gap > 0.5) {
778
- const step = Math.min(item.gap, Math.max(minStep(dt), item.gap * smoothGrow() * (dt / 16)));
779
- const next = item.cur + step;
780
- item.el.scrollTop = next;
781
- item.el.dataset.dshsfMine = String(next); // 记下"这是我写的",scroll 监听据此分辨是不是读者
782
- moving = true;
893
+ if (item.follow) {
894
+ // 与主容器同一个滑行律(slide):跟随 step=min(cap, rate);回底按钮发起的那一次不设 cap。
895
+ // 不给 gap 设门槛:|dy|≤0.5 的精确落点在 slide 内部完成(门槛会让窗永远差 <0.5px 停住)。
896
+ const vMaxW = (() => { const v = Number(state.followMaxSpeed); return Number.isFinite(v) && v > 0 ? v : DEFAULTS.followMaxSpeed; })(); // 单一真源(px/s)
897
+ const tSli = performance.now();
898
+ if (slide(item.el, item.winWant, dt, item.nav ? 0 : vMaxW, item.st, item.topAfter, "win", item.maxAfter)) moving = true; // task-27:**先写 scrollTop**(cur/max 用预测的变更后 extent)
899
+ phaseAcc("winSlide", tSli);
900
+ }
901
+ if (nextH !== null) { // task-27:样式写放到最后(帧内不再出现"写完布局→再写滚动"的栅栏)
902
+ const tSty = performance.now();
903
+ item.el.dataset.dshsfH = nextH;
904
+ item.el.style.setProperty("--dshsf-hw", nextH + "px"); // 入场动画还在跑时,动画目标也跟着长(避免交接跳一下)
905
+ item.el.style.maxHeight = nextH + "px";
906
+ phaseAcc("winStyle", tSty);
783
907
  }
784
- if (item.bottom && item.el.dataset.dshsfTextwindow !== "1" && item.el.dataset.dshsfFollow !== "1") item.el.dataset.dshsfFollow = "1"; // 正文窗永不自开跟底(跟底 = 只看得到尾巴)
908
+ if (item.nav && item.topAfter >= item.max - 0.5) winOf(item.el).nav = false; // task-25:用预测值判到位(不读 scrollTop)
785
909
  item.el.toggleAttribute("data-dshsf-offbottom", item.gap > 24);
786
910
  if (!item.jump) item.jump = ensureWindowJump(item.el); // 自愈:登记过的窗任何时候都必须有按钮
787
911
  if (item.jump) {
788
- // 读者接管了 → 给按钮;跟随中只有"落后很多"才给(追赶时的轻微落后不该让按钮一闪一闪)
789
912
  // 显隐带滞回:点亮要"读者接管或落后很多",熄灭只在真正回到贴底(gap<12)——
790
913
  // 跟随状态/差距在阈值附近抖动时按钮不该跟着闪。
914
+ const tJm = performance.now();
791
915
  if (state.showJumpButton === false) item.jump.removeAttribute("data-dshsf-visible");
792
916
  else if (item.jump.hasAttribute("data-dshsf-visible")) { if (item.gap < 12) item.jump.removeAttribute("data-dshsf-visible"); }
793
917
  else if (item.gap > 24 && (!item.follow || item.gap > 120)) item.jump.setAttribute("data-dshsf-visible", "");
918
+ phaseAcc("winJump", tJm);
794
919
  }
795
920
  }
796
- if (over > 0) {
797
- // 窗长多少,视图就跟着走多少:底边不越过下边界。
798
- // ⚠️ 这一笔必须登记进 followWrote(和 mainFollowTick 里一样),否则下一帧的"官方抢跑回补"会把它当成别人的写入
799
- // 拽回旧点:窗掉回输入框下面(补偿白做),而且每帧"补/拽"来回 = 主窗口抖动。
800
- const next = scrollAt + over;
801
- writeTop(scroller, next);
802
- followWrote = next;
803
- moveCount += 1;
804
- moving = true;
805
- }
806
921
  if (moving) ensureTickLoop();
807
922
  return moving;
808
923
  }
@@ -815,36 +930,33 @@ const manualState = new Map(); // turn -> "open" | "fold"
815
930
  * 会话 id 由官方挂在对话根上([data-conversation-session])。 */
816
931
  let sessionKey = null;
817
932
  function syncSession() {
818
- // 同上:只在"当前会话"里找,document 序第一个可能正是不相关的那个会话根
819
- // (那会让切主会话不触发复位,或反过来把主会话的手动展开状态清掉)。
820
- const scope = chatScope();
821
- // 官方把 data-conversation-session 挂在滚动容器的**父层**(div.body[data-conversation-content]),
822
- // 在 scroll 元素内部 querySelector 恒为 null —— 必须从自己往上找,否则退化成 document 序第一个,
823
- // 右栏子代理会话排在前面时「切主会话」根本不复位。
824
- const holder = (scope && scope.closest ? scope.closest("[data-conversation-session]") : null)
825
- || scope.querySelector("[data-conversation-session]")
826
- || document.querySelector("[data-conversation-session]");
933
+ const holder = document.querySelector("[data-conversation-session]");
827
934
  const key = holder ? holder.getAttribute("data-conversation-session") : null;
828
935
  if (key === null || key === "") return; // 当前视图没有会话信息(设置页/嵌入态):状态不动
829
936
  if (sessionKey === null) { sessionKey = key; return; }
830
937
  if (key === sessionKey) return;
831
938
  sessionKey = key;
832
- manualState.clear();
833
- snapOwn = false; // 换会话:位置所有权归零(新会话的首次贴底让官方做)
834
- glide = null; // 滑行 / 钉住补偿 / 记下的写入位置全是上一会话的:留着会继续把新会话的视角拖走
835
- anchorStop();
836
- followWrote = null;
837
- announcedTop = -1;
838
- announcedHeight = -1;
939
+ clearSettle("session"); // 会话边界:协调作废(跨会话不许留任何进行中的状态)
940
+ lastUserKey = null; // 会话边界:回尾判据重新开始记录(换会话不许误判成"你刚发了消息")
941
+ // 会话边界:复位运动模型自己的状态量(陈旧坐标不许跨会话参与写回;公式与机制一行不动)
942
+ if (followScroller) { followScroller.removeEventListener("scroll", onMainScroll); followScroller.removeEventListener("pointerdown", onScrollerPointerDown); }
943
+ followScroller = null; // 强制重挂监听 + takeOverSnap
944
+ followWrote = null; // 陈旧坐标不许参与吸收时的恢复
839
945
  followHeight = -1;
946
+ announcedTop = -1; announcedHeight = -1; lastAnnounceAt = 0; // 切换期停发合成 scroll 事件
947
+ lastStep = null;
948
+ // 会话边界 = 交出写权的两个入口之一(另一个是读者真实输入)。只钉不跟:接回跟尾由
949
+ // 「换容器语义判定」与 tick 的「落末端」规则负责(不依赖任何标志,与 tick 的先后顺序无关)。
950
+ release("reset"); // 已是 reader 时只记原因(release 里已清 navIntent)
951
+ goalKind = "none"; goalEl = null; navActive = false;
952
+ resetVel(mainVel); // 会话边界:速度复位
953
+ for (const el of liveWindows) { const s = winOf(el); s.writer = "us"; s.nav = false; } // 小窗写权同样归零
954
+ activeWindow = null; // 会话边界:跟尾对象作废(新会话由扫描重新认领)
955
+ manualState.clear();
956
+ qCardTurns.clear(); // A4:提问卡"已展开"按轮号记 —— 跨会话必须先清,否则新会话同轮号不再自动展开
840
957
  wasRunning = false;
841
958
  lastRunningAt = 0;
842
959
  runningSticky = false;
843
- // 换会话必须把上一会话的「收尾折叠 hold」清掉:data-chat-turn 每会话都从 1 起,
844
- // 留着它会让新会话里同号的历史轮被当成"刚跑完那轮"——那不是不折,是主动展开(走 toUnfoldAll)。
845
- settleHoldTurn = null;
846
- settleHoldUntil = 0;
847
- textCache = new WeakMap(); // 换会话:行节点会被复用,正文缓存整表作废
848
960
  clearTimeout(runGraceTimer);
849
961
  for (const chip of document.querySelectorAll("[data-dshsf-chip]")) { try { chip.remove(); } catch { /* ignore */ } }
850
962
  }
@@ -866,7 +978,7 @@ function skipSoon(row) {
866
978
  setTimeout(() => {
867
979
  if (!row.hasAttribute("data-dshsf-folded")) return;
868
980
  setSkipHidden(row);
869
- for (const el of row.querySelectorAll("[data-dshsf-window]")) liveWindows.delete(el); // 不渲染的窗不必每帧跟进
981
+ for (const el of row.querySelectorAll("[data-dshsf-window], [data-dshsf-textwindow]")) liveWindows.delete(el); // 不渲染的窗不必每帧跟进
870
982
  }, 320);
871
983
  }
872
984
  function applyFold(row, fold, animate) {
@@ -877,13 +989,13 @@ function applyFold(row, fold, animate) {
877
989
  if (fold && !isSkipped(row)) skipSoon(row); // 已经是折的状态(热重载/旧状态)也补挂
878
990
  return;
879
991
  }
880
- if (!animate || (animBudget <= 0 && !scanAnimateAll)) { // 配额用完 → 直接切;用户手点那次(scanAnimateAll)不看配额,展开方向也得看得见
992
+ if (!animate || animBudget <= 0) { // 配额用完 → 直接切(一次要动几十个元素时,动画本来也看不清)
881
993
  // 瞬时路径:不读、不 flush,一次布局都不做
882
994
  row.style.maxHeight = "";
883
995
  row.style.overflow = "";
884
996
  row.toggleAttribute("data-dshsf-folded", fold);
885
997
  if (fold) setSkipHidden(row);
886
- else { clearSkipHidden(row); delete row.dataset.dshsfCards; } // 展开后子树可能再变:计数缓存作废
998
+ else clearSkipHidden(row);
887
999
  return;
888
1000
  }
889
1001
  animBudget -= 1;
@@ -897,13 +1009,16 @@ function applyFold(row, fold, animate) {
897
1009
  skipSoon(row);
898
1010
  return;
899
1011
  }
1012
+ unfoldRowAnimated(row);
1013
+ }
1014
+ /** task-22:单行动画展开(**不消耗 animBudget**)—— 供"从上往下每 3 行"的错峰路径调用。
1015
+ * 与 applyFold 的展开分支同一语义:摘 skip → 去 folded → 量高 → 起始 0 → rAF 到高度 → 340ms 清。 */
1016
+ function unfoldRowAnimated(row) {
1017
+ if (!row.hasAttribute("data-dshsf-row")) row.setAttribute("data-dshsf-row", "");
900
1018
  clearSkipHidden(row); // 先摘掉(只摘我们挂的那份),否则量到的高度是 0(内容根本没渲染)
901
- // 非点击展开(Ctrl+F 命中 / 程序化展开 / 关掉「折叠历史轮次」)也要把窗接回每帧清单:
902
- // 否则它停在折叠前的内联 max-height,不回底、没阴影 —— 就是「半空的玻璃面板」。
903
- if (!disposed) for (const el of row.querySelectorAll("[data-dshsf-window]")) wireWindow(el, el.dataset.dshsfFollow !== "");
904
1019
  row.removeAttribute("data-dshsf-folded");
905
- delete row.dataset.dshsfCards;
906
1020
  row.style.maxHeight = "";
1021
+ row.style.overflow = "hidden";
907
1022
  layoutReads += 1;
908
1023
  const height = row.getBoundingClientRect().height;
909
1024
  if (height > ANIM_MAX_PX) { row.style.overflow = ""; return; } // 太高的行直接展开,省掉 0.3s 的逐帧重排
@@ -917,14 +1032,14 @@ function applyFold(row, fold, animate) {
917
1032
  * 逐个"量一个写一个"会让每次量都强制一次整树重排:一次折叠几十行就是几十次重排
918
1033
  * (实测这台 i5-2300 上一次扫描 885ms,全在 syncTurns 里)。
919
1034
  */
920
- function foldBatch(rows, animate, force) {
1035
+ function clearFoldTimers() { for (const id of foldTimers) clearTimeout(id); foldTimers.length = 0; }
1036
+ /** 批量收起:先全部量一次高、写起始 max-height、一次 flush;然后**从最下面往上一组 3 行**错峰收
1037
+ * (用户指定节奏)。错峰靠定时器推进;新一轮决策(有行要折/要展开)由调用方 clearFoldTimers() 作废。
1038
+ * 注意:行不是共同外框(按 data-chat-turn 属性分组的兄弟节点),所以"整块"只能靠这种节奏表达。 */
1039
+ function foldBatch(rows, animate) {
921
1040
  const targets = rows.filter((row) => !row.hasAttribute("data-dshsf-folded"));
922
1041
  if (targets.length === 0) return;
923
- // force:刚跑完那一轮的收尾折叠 —— 你此刻可能已经滚到别处(settle 因此不触发),但它仍是「刚看着跑完的那一轮」,
924
- // 收起来必须看得见动效,不能因为目标多就瞬时切。
925
- const flat = !animate || (!force && targets.length > ANIM_BUDGET && !scanAnimateAll);
926
- noteFold({ at: Math.round(performance.now()), rows: targets.length, animate: !!animate, flat, force: !!force, budget: ANIM_BUDGET, all: scanAnimateAll, hold: settleHoldTurn, gliding: glide !== null });
927
- if (flat) { for (const row of targets) applyFold(row, true, false); return; }
1042
+ if (!animate || foldIsInstant(targets)) { for (const row of targets) applyFold(row, true, false); return; }
928
1043
  layoutReads += 1;
929
1044
  const heights = targets.map((row) => row.getBoundingClientRect().height); // ① 只读
930
1045
  for (let i = 0; i < targets.length; i += 1) { // ② 写起始值
@@ -934,14 +1049,28 @@ function foldBatch(rows, animate, force) {
934
1049
  }
935
1050
  layoutReads += 1;
936
1051
  void targets[0].offsetHeight; // ③ 一次 flush,让过渡有起点
937
- for (const row of targets) { // ④ 写终值
938
- row.setAttribute("data-dshsf-folded", "");
939
- row.style.maxHeight = "0px";
940
- skipSoon(row);
1052
+ for (let end = targets.length; end > 0; end -= FOLD_CHUNK) { // ④ 从最下面一组,往上一组组收
1053
+ const chunk = targets.slice(Math.max(0, end - FOLD_CHUNK), end);
1054
+ const delay = Math.floor((targets.length - end) / FOLD_CHUNK) * FOLD_CHUNK_MS;
1055
+ const run = () => {
1056
+ for (const row of chunk) {
1057
+ row.setAttribute("data-dshsf-folded", "");
1058
+ row.style.maxHeight = "0px";
1059
+ skipSoon(row);
1060
+ }
1061
+ };
1062
+ if (delay === 0) run();
1063
+ else foldTimers.push(setTimeout(run, delay));
941
1064
  }
942
1065
  }
943
- /** 数一段子树里的匹配元素:嵌在同类里的(工具卡套工具卡)只算最外层那一个。 */
1066
+ const cardCountCache = new WeakMap(); // task-19 A9:row → { kids, thinks, tools };子节点数变化或 observer 定向失效即重算
1067
+ /** 数一段子树里的匹配元素:嵌在同类里的(工具卡套工具卡)只算最外层那一个。
1068
+ * task-19 A9:同一行的 THINK/TOOL_CARD 计数跨扫描复用(只在该行子树结构变化时重算)。 */
944
1069
  function countCards(row, selector) {
1070
+ const key = selector === THINK ? "thinks" : "tools";
1071
+ let e = cardCountCache.get(row);
1072
+ if (e === undefined || e.kids !== row.children.length) { e = { kids: row.children.length }; cardCountCache.set(row, e); }
1073
+ if (e[key] !== undefined) return e[key];
945
1074
  cost.counts.cards = (cost.counts.cards || 0) + 1;
946
1075
  const t = performance.now();
947
1076
  let n = 0;
@@ -951,32 +1080,13 @@ function countCards(row, selector) {
951
1080
  n += 1;
952
1081
  }
953
1082
  cost.phases.cards = +((cost.phases.cards || 0) + (performance.now() - t)).toFixed(2);
1083
+ e[key] = n;
954
1084
  return n;
955
1085
  }
956
- /** 折叠条文案要的两个计数。冷行(不在最近两轮)折叠后子树不会再变 → 缓存进 dataset:
957
- * 否则每次扫描都要对**每个折叠行**各走两遍子树查询(长会话流式期间每 300ms 一次)。
958
- * 键里带轮号:React 跨会话可能复用同一个 DOM 节点,只比 dataset 会读到上一轮的计数。
959
- * 行一被展开就必须删缓存(下面 applyFold/unfoldAll 各删一次),否则再次折叠会沿用旧数字。 */
960
- function cardsOf(row, turn) {
961
- const cached = row.dataset.dshsfCards;
962
- if (cached !== undefined && !hotRows.has(row)) {
963
- const cut = cached.indexOf("|");
964
- if (cut > 0 && cached.slice(0, cut) === turn) {
965
- const parts = cached.slice(cut + 1).split(",");
966
- return [Number(parts[0]) || 0, Number(parts[1]) || 0];
967
- }
968
- }
969
- const thinks = countCards(row, THINK);
970
- const tools = countCards(row, TOOL_CARD);
971
- if (!hotRows.has(row)) row.dataset.dshsfCards = turn + "|" + thinks + "," + tools;
972
- return [thinks, tools];
973
- }
974
1086
  function unfoldAll(container) {
975
1087
  for (const row of container.querySelectorAll("[data-dshsf-row]")) {
976
1088
  clearSkipHidden(row);
977
- if (!disposed) for (const el of row.querySelectorAll("[data-dshsf-window]")) wireWindow(el, el.dataset.dshsfFollow !== ""); // 同上:展开后接回每帧清单(卸载中不接线)
978
1089
  row.removeAttribute("data-dshsf-folded");
979
- delete row.dataset.dshsfCards;
980
1090
  row.style.maxHeight = "";
981
1091
  row.style.overflow = "";
982
1092
  }
@@ -993,10 +1103,9 @@ function clearChips(container) {
993
1103
  * 工具卡的正文同理,但一次要渲染几百个大块,代价太高,这里只补思考。
994
1104
  */
995
1105
  let searchPrepRunning = false;
996
- let searchPrepTimer = 0; // 分片挂载的定时器句柄:卸载/热重载要能停掉,否则剩余分片继续展开+藏正文
997
1106
  function hideForSearch(body) {
998
- if (!body || body.getAttribute("hidden") === SKIP) return; // 已经藏着(含官方挂的那份):照样能搜,别再动
999
- setSkipHidden(body); // 认归属:摘的时候只摘我们自己挂的那份
1107
+ if (!body || body.getAttribute("hidden") === SKIP) return;
1108
+ body.setAttribute("hidden", SKIP);
1000
1109
  body.dataset.dshsfSearch = "1";
1001
1110
  liveWindows.delete(body); // 不渲染的窗不必每帧跟进(与折叠行同一套处理)
1002
1111
  }
@@ -1005,21 +1114,18 @@ function clearSearchHidden(scope) {
1005
1114
  if (!scope || !scope.querySelectorAll) return;
1006
1115
  for (const body of scope.querySelectorAll("[data-dshsf-search]")) {
1007
1116
  body.removeAttribute("data-dshsf-search");
1008
- clearSkipHidden(body); // 同上:官方 useSearchableHidden 的 hidden 归官方
1009
-
1117
+ if (body.getAttribute("hidden") === SKIP) body.removeAttribute("hidden");
1010
1118
  wireWindow(body); // 重新回到每帧清单(它会从 16px 平滑长到窗口上限)
1011
1119
  }
1012
1120
  }
1013
1121
  function searchPrep() {
1014
- if (disposed || searchPrepRunning || state.mode !== "fold") return; // 官方档位不接管 Ctrl+F
1122
+ if (searchPrepRunning) return;
1015
1123
  const flow = chatScope();
1016
1124
  const roots = Array.from(flow.querySelectorAll(THINK)).filter((root) => !thinkOpen(root));
1017
1125
  if (roots.length === 0) return;
1018
1126
  searchPrepRunning = true;
1019
1127
  let cursor = 0;
1020
1128
  const chunk = () => {
1021
- searchPrepTimer = 0;
1022
- if (disposed) { searchPrepRunning = false; return; } // 卸载/热重载:剩余分片不许再动 DOM
1023
1129
  let done = 0;
1024
1130
  while (cursor < roots.length && done < 12) { // 分片挂载:别让 Ctrl+F 卡住主线程
1025
1131
  const root = roots[cursor++];
@@ -1030,7 +1136,7 @@ function searchPrep() {
1030
1136
  hideForSearch(root.querySelector(THINK_BODY)); // 挂上就藏回去,界面外观不变(隐藏的前提是 until-found 所以照样能搜)
1031
1137
  // 行整行藏着(折叠轮次)时不必再藏正文,但正文同样挂上了 —— 展开那一轮时它才现形
1032
1138
  }
1033
- if (cursor < roots.length) searchPrepTimer = setTimeout(chunk, 0);
1139
+ if (cursor < roots.length) setTimeout(chunk, 0);
1034
1140
  else searchPrepRunning = false;
1035
1141
  };
1036
1142
  chunk();
@@ -1040,7 +1146,11 @@ function searchPrep() {
1040
1146
  * 0.1.5:行是 `[data-chat-flow]` 的直接子节点;0.1.7 把每个「过程组」各自包了一层
1041
1147
  * `[data-chat-flow]`(组内还有自己的滚动体),按 children 取只能看到一个组 —— 所以在
1042
1148
  * 主滚动容器里按属性收,两个版本都对。 */
1149
+ let scanRows = null; // task-19 A9:一次扫描内共用的行列表(null = 扫描外,语义不变)
1150
+ let scanRunningMark = null; // task-19 A9:扫描开头取一次 RUNNING 标记
1151
+ let scanRunningKnown = false;
1043
1152
  function chatRows() {
1153
+ if (scanRows !== null) return scanRows; // task-19 A9:扫描内复用同一次全树查询
1044
1154
  const scope = mainScroller() || document;
1045
1155
  const rows = Array.from(scope.querySelectorAll("[data-chat-flow-kind]"));
1046
1156
  if (rows.length) lastRowSeen = rows[rows.length - 1]; // 供帧路径做缓存校验:省掉每帧一次全文档查询
@@ -1052,16 +1162,19 @@ function chatRows() {
1052
1162
  * 0.1.7 的 tail 行:<div data-turn-tail="<轮次>" data-actions-reveal="always|hover">。 */
1053
1163
  function turnClosed(turn) {
1054
1164
  if (turn === null || turn === undefined || turn === "") return false;
1055
- // 作用域必须跟折叠/滚动一致:右栏开着子代理会话时页面上同时有两个会话根(ui-subagent 用
1056
- // variant:"embedded" 走同一套骨架),而轮号两边都从 1 开始撞号 —— document 上查到的那条 tail
1057
- // 未必属于本会话(会让"正在跑的这一轮"被误判成已收尾 → 运行中折一次)。
1058
- const tail = chatScope().querySelector('[data-turn-tail="' + turn + '"]');
1165
+ const tail = document.querySelector('[data-turn-tail="' + turn + '"]');
1059
1166
  return tail !== null && tail.hasAttribute("data-actions-reveal");
1060
1167
  }
1061
1168
  /** 本轮正式回答行 = 最后一个「有正文」的 assistant-step 行(没有正文行才退回最后一个 assistant-step)。
1062
1169
  * 0.1.7 会把一个 step 拆成 reasoning / response 两个行,只看"最后一个"未必落在正文上。
1063
1170
  * 按轮次 memo:一次扫描里 syncTurns 与 syncTextWindows 都要用,避免双倍整树遍历。 */
1064
1171
  let answerRowCache = new Map();
1172
+ const assistantRowsCache = new WeakMap(); // task-20 A11:turnRows 数组(每扫描一份)→ assistant-step 行;syncTurns 与 syncTextWindows 共用,避免重复整轮 filter
1173
+ function assistantRowsOf(turnRows) {
1174
+ let v = assistantRowsCache.get(turnRows);
1175
+ if (v === undefined) { v = turnRows.filter((row) => rowKind(row) === "assistant-step"); assistantRowsCache.set(turnRows, v); }
1176
+ return v;
1177
+ }
1065
1178
  function answerRowOf(turnRows) {
1066
1179
  const key = turnRows.length ? turnRows[0].getAttribute("data-chat-turn") : null;
1067
1180
  if (key !== null && answerRowCache.has(key)) return answerRowCache.get(key);
@@ -1081,7 +1194,7 @@ function collectTurns() {
1081
1194
  }
1082
1195
  // 运行中那一轮 = 带运行标记的那一行所属的轮次。比"最后一行"可靠:0.1.7 会往末尾补
1083
1196
  // 独立的 tail / 收尾行,它们的 data-chat-turn 未必等于正在跑的那一轮。
1084
- const mark = chatScope().querySelector(RUNNING);
1197
+ const mark = scanRunningKnown ? scanRunningMark : chatScope().querySelector(RUNNING); // task-19 A9:扫描内复用开头那次 RUNNING 查询
1085
1198
  const markRow = mark && mark.closest ? mark.closest(ROW) : null;
1086
1199
  const runningTurn = markRow ? markRow.getAttribute("data-chat-turn") : null;
1087
1200
  return { rows, turns, lastTurn: rows.length ? rows[rows.length - 1].getAttribute("data-chat-turn") : null, runningTurn };
@@ -1093,7 +1206,6 @@ function syncTurns(flow, running, collected) {
1093
1206
  // 批量折叠收集到"整个扫描"这一层:foldBatch 每次要"读一遍 + flush 一遍"布局,
1094
1207
  // 逐轮调用 = 每轮两遍(18 轮就 36 遍):单次扫描的主要成本都在这里
1095
1208
  const toFoldAll = [];
1096
- const toFoldForce = []; // 刚跑完那一轮的过程行:强制播动画(视野外也要有动效)
1097
1209
  const toUnfoldAll = [];
1098
1210
  const thinkFoldsAll = [];
1099
1211
  let tFilter = 0; let tRows = 0; let tChip = 0;
@@ -1102,7 +1214,7 @@ function syncTurns(flow, running, collected) {
1102
1214
  const tF = performance.now();
1103
1215
  const isRunningTurn = running && turn === (runningTurn === null || runningTurn === undefined ? lastTurn : runningTurn);
1104
1216
  const manual = manualState.get(turn);
1105
- const assistantRows = turnRows.filter((row) => rowKind(row) === "assistant-step");
1217
+ const assistantRows = assistantRowsOf(turnRows); // task-20 A11:同一轮的 assistant 行本扫描只 filter 一次
1106
1218
  const answerRow = answerRowOf(turnRows);
1107
1219
  // 锻打锚点用**整行**(flowItem 在一轮里是稳定的),不用会随 React 重渲染被换掉的段落:
1108
1220
  // 段落一换,火星就得等下一次扫描才回得来(看着像闪);行稳定则挂上去的画布一直有效。
@@ -1110,15 +1222,14 @@ function syncTurns(flow, running, collected) {
1110
1222
  // 可折叠候选:过程行(答案行 / 独立行除外;按设置保留穿插正文行)
1111
1223
  const foldableSet = new Set();
1112
1224
  const foldable = turnRows.filter((row) => {
1113
- if (row === answerRow || INDEPENDENT.has(rowKind(row))) return false;
1225
+ const qKind = rowKind(row); // 提问开关覆盖两个节点:提问工具卡(tool-call + data-tool) 与 回复节点(kind question-reply / user-question-reply)
1226
+ if (row === answerRow || INDEPENDENT.has(qKind) || (state.keepUserQuestions === true && (qKind === "question-reply" || qKind === "user-question-reply" || row.querySelector('[data-tool="ask_user_question"],[data-tool="request_user_input"]')))) return false;
1114
1227
  if (state.keepInterleavedText !== false && rowKind(row) === "assistant-step" && hasText(row)) return false;
1115
1228
  foldableSet.add(row);
1116
1229
  return true;
1117
1230
  });
1118
- // 刚跑完的那一轮先不折:等回到提问处的滑行结束再折(probe().settleLog 能看到这条 hold)。
1119
- const heldForSettle = settleHoldTurn !== null && String(turn) === settleHoldTurn;
1120
- const forceAnim = justFinishedTurn !== null && String(turn) === justFinishedTurn;
1121
- const shouldFold = !heldForSettle && (manual === "fold"
1231
+ const settling = settleTurn !== null && String(turn) === String(settleTurn); // 正在协调的那一轮:折叠压到滑完
1232
+ const shouldFold = !settling && (manual === "fold"
1122
1233
  || (manual !== "open" && !isRunningTurn && (state.foldHistory !== false || running)));
1123
1234
  tFilter += performance.now() - tF;
1124
1235
  const tR = performance.now();
@@ -1131,18 +1242,15 @@ function syncTurns(flow, running, collected) {
1131
1242
  if (fold === isFolded) {
1132
1243
  if (fold && !isSkipped(row)) skipSoon(row);
1133
1244
  if (!row.hasAttribute("data-dshsf-row")) row.setAttribute("data-dshsf-row", "");
1134
- } else if (fold) { if (forceAnim) toFoldForce.push(row); else toFoldAll.push(row); }
1245
+ } else if (fold) toFoldAll.push(row);
1135
1246
  else toUnfoldAll.push(row);
1136
1247
  if (fold) {
1137
1248
  folded += 1;
1138
- const cards = cardsOf(row, turn);
1139
- thinks += cards[0];
1140
- tools += cards[1];
1249
+ thinks += countCards(row, THINK);
1250
+ tools += countCards(row, TOOL_CARD);
1141
1251
  }
1142
- // 非运行中的轮次:思考回到一行摘要(我只收自己展开的那些)。
1143
- // 冷行(不在最近两轮)+ 已经折着 → 内容既不变也不渲染,没什么可收的:跳过整棵子树查询。
1144
- // (实测 18 轮时每扫描 37 次 querySelectorAll(THINK) ≈ 2N+1,长会话下是 phases.turnRows 的主要成分。)
1145
- if (!isRunningTurn && rowKind(row) === "assistant-step" && !(fold && isFolded && !hotRows.has(row))) {
1252
+ // 非运行中的轮次:思考回到一行摘要(我只收自己展开的那些)
1253
+ if (!isRunningTurn && rowKind(row) === "assistant-step") {
1146
1254
  for (const root of row.querySelectorAll(THINK)) {
1147
1255
  // 行没被折掉(答行、保留的穿插正文行)时,它里面的思考也得跟着收:
1148
1256
  // 折叠状态下连"一行摘要"都不该露头;用户手动展开本轮(manual=open)时还原成可点摘要
@@ -1172,34 +1280,31 @@ function syncTurns(flow, running, collected) {
1172
1280
  chip.dataset.dshsfChip = turn;
1173
1281
  chip.addEventListener("click", () => {
1174
1282
  const anyFolded = flow.querySelector('[data-chat-turn="' + turn + '"][data-dshsf-folded]') !== null;
1175
- manualState.set(turn, anyFolded ? "open" : "fold");
1283
+ setManual(turn, anyFolded ? "open" : "fold");
1176
1284
  allowAnim = true;
1177
- scanAnimateAll = true; // 用户手点的折叠:即使整轮一起折也要看得到动效
1178
- releaseSettleHold(); // 用户的点击压过「刚跑完那轮先别折」的 hold:否则这一下会被吞掉一次扫描
1179
- followOn = false; // 先撒手再展开:跟随会把刚展开的内容拖回底部,按钮就飞了
1285
+ hold(); // 我方定位:取回写权(官方贴底写不再拖我们),位置交给 anchorDuring
1180
1286
  const before = chip.getBoundingClientRect().top;
1181
- scan();
1287
+ try { scan(); } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 200); } // F4:调用点兜底
1182
1288
  anchorDuring(chip, before, 500);
1183
1289
  });
1184
1290
  }
1185
1291
  const anchor = turnRows.find((row) => !INDEPENDENT.has(rowKind(row))) || turnRows[0];
1186
1292
  if (anchor && chip.nextElementSibling !== anchor && anchor.parentElement) anchor.parentElement.insertBefore(chip, anchor);
1187
1293
  const summary = [];
1188
- // 量纲:thinks 是思考**块**数(一轮可能多段),不是轮数;文案一律走 uiText,英文界面不再露中文
1189
- if (thinks > 0) summary.push(uiText(thinks + " 段思考", thinks + " thinking block" + (thinks > 1 ? "s" : "")));
1190
- if (tools > 0) summary.push(uiText(tools + " 次工具调用", tools + " tool call" + (tools > 1 ? "s" : "")));
1294
+ if (thinks > 0) summary.push(thinks + " 轮思考");
1295
+ if (tools > 0) summary.push(tools + " 次工具调用");
1191
1296
  const label = folded > 0
1192
- ? uiText("已折叠 " + (summary.length ? summary.join(" · ") : folded + " 行") + " · 点击展开",
1193
- "Collapsed " + (summary.length ? summary.join(" · ") : folded + " rows") + " · click to expand")
1194
- : uiText("收起本轮过程", "Collapse this turn");
1297
+ ? "已折叠 " + (summary.length ? summary.join(" · ") : folded + " 行") + " · 点击展开"
1298
+ : "收起本轮过程";
1195
1299
  if (chip.textContent !== label) chip.textContent = label; // 没变就别写:textContent 赋值是一次真实 DOM 变更
1196
1300
  tChip += performance.now() - tC;
1197
1301
  }
1198
1302
  sparks.forge(forgeBody); // 只有运行中才有写头;跑完自动撒手(火星烧完即摘画布)
1199
1303
  const tB = performance.now();
1200
- foldBatch(toFoldForce, animate, true); // 刚跑完那一轮:哪怕在视野外也播动画
1201
- foldBatch(toFoldAll.concat(thinkFoldsAll), animate); // 其余整批:整个扫描读一遍 + flush 一遍
1202
- for (const row of toUnfoldAll) applyFold(row, false, animate);
1304
+ if (toFoldAll.length > 0 || thinkFoldsAll.length > 0 || toUnfoldAll.length > 0) clearFoldTimers(); // 新一轮决策 ⇒ 作废未播完的批次
1305
+ foldBatch(toFoldAll, animate); // 行:读一遍 + 一次 flush,然后从下往上每 3 行错峰收
1306
+ foldBatch(thinkFoldsAll, animate); // 行内思考摘要:同一套节奏(单独一组)
1307
+ unfoldBatch(toUnfoldAll, animate); // 行:从上往下每 3 行错峰展(跨 ≥2 轮或超护栏才瞬时)
1203
1308
  cost.phases.turnFilter = +tFilter.toFixed(2);
1204
1309
  cost.phases.turnRows = +tRows.toFixed(2);
1205
1310
  cost.phases.turnChip = +tChip.toFixed(2);
@@ -1226,25 +1331,25 @@ function anchorStop() {
1226
1331
  anchorCtl = null;
1227
1332
  }
1228
1333
  function anchorSync() {
1229
- if (disposed) { anchorStop(); return false; } // 卸载后旧实例不许再写 scrollTop
1230
- if (!anchorCtl) return false;
1231
- const { el, scroller } = anchorCtl;
1232
- if (performance.now() > anchorCtl.until || !el.isConnected) { anchorStop(); return false; }
1233
- const delta = el.getBoundingClientRect().top - anchorCtl.top;
1234
- if (delta === 0) return false;
1235
- scroller.scrollTop += delta;
1236
- anchorCtl.top = el.getBoundingClientRect().top;
1237
- if (!anchorCtl.announced) {
1238
- anchorCtl.announced = true;
1239
- try { scroller.dispatchEvent(new Event("scroll", { bubbles: true })); } catch { /* ignore */ }
1240
- }
1241
- return true;
1334
+ try {
1335
+ if (!anchorCtl) return false;
1336
+ const { el, scroller } = anchorCtl;
1337
+ if (performance.now() > anchorCtl.until || !el.isConnected) { anchorStop(); return false; }
1338
+ if (navActive) return false; // 我方一次性导航期间位置由滑行律独占:锚定补偿不插写
1339
+ const delta = el.getBoundingClientRect().top - anchorCtl.top;
1340
+ if (delta === 0) return false;
1341
+ writeTop(scroller, scroller.scrollTop + delta, undefined, "anchor"); // 锚定补偿是我方写入(ownWrite)⇒ 吸收器放行
1342
+ anchorCtl.top = el.getBoundingClientRect().top;
1343
+ if (!anchorCtl.announced) {
1344
+ anchorCtl.announced = true;
1345
+ try { scroller.dispatchEvent(new Event("scroll", { bubbles: true })); } catch { /* ignore */ }
1346
+ }
1347
+ return true;
1348
+ } catch (err) { ioErrors.anchor += 1; ioErrors.last = "anchor: " + String((err && err.message) || err); return false; }
1242
1349
  }
1243
1350
  function anchorDuring(node, before, ms) {
1244
1351
  const scroller = mainScroller();
1245
1352
  if (!scroller) return;
1246
- glide = null; // 点开/收起 = "我要读这一处":一次性滑行必须让位(否则它每帧把视角拽向旧目标)
1247
- releaseSettleHold();
1248
1353
  anchorStop();
1249
1354
  anchorCtl = { el: node, top: before, until: performance.now() + ms, scroller, prevAnchor: scroller.style.overflowAnchor, announced: false };
1250
1355
  try { scroller.style.overflowAnchor = "none"; } catch { /* ignore */ }
@@ -1271,22 +1376,50 @@ function anchorDuring(node, before, ms) {
1271
1376
  * 我们只在自己写过的位置上继续贴底,位置被人动过(你上滚)就立刻撒手。
1272
1377
  */
1273
1378
  const ANIM_BUDGET = 3; // 一次扫描最多播几个"高度动画":加载/切会话/收尾这类批量场景直接切,播放反而看不清
1379
+ const FOLD_CHUNK = 3; // 节奏:一组 3 行(收起从最下往上一组;展开从最上往下一组,用户指定对称)
1380
+ const FOLD_CHUNK_MS = 70; // 每组之间的错峰间隔:让"滚着收/滚着展"看得出来
1381
+ const FOLD_STAGGER_BUDGET_MS = 2000; // **护栏**(只对单轮):ceil(rows/3)*FOLD_CHUNK_MS 超过它就瞬时 —— 防极端大单轮拖太久,不是主判据
1382
+ /** task-22:批量收/展的"瞬时"判据从行数改为**场景** —— 统计本批覆盖的不同轮次(data-chat-turn 去重):
1383
+ * 跨 ≥2 轮 ⇒ 加载/切会话/批量场景(错峰看不清)⇒ 瞬时;单轮 ⇒ 正常收起/展开 ⇒ 一律错峰(哪怕 40+ 行)。
1384
+ * 单轮只在超过时长护栏时瞬时。think 摘要批用 closest(ROW) 归属到所属轮次,同一规则。 */
1385
+ function foldIsInstant(targets) {
1386
+ const turns = new Set();
1387
+ for (const el of targets) {
1388
+ const owner = el.closest && el.closest(ROW) ? el.closest(ROW) : el;
1389
+ const t = owner.getAttribute ? owner.getAttribute("data-chat-turn") : null;
1390
+ turns.add(t === null || t === undefined ? "0" : t);
1391
+ if (turns.size >= 2) return true;
1392
+ }
1393
+ return Math.ceil(targets.length / FOLD_CHUNK) * FOLD_CHUNK_MS > FOLD_STAGGER_BUDGET_MS;
1394
+ }
1395
+ /** task-22:批量展开 —— 与 foldBatch 对称,但**从上往下一组 3 行**(用户指定方向,与收起相反)。
1396
+ * 错峰路径每行走 unfoldRowAnimated(自写起始/终值),**不消耗 animBudget**:否则一波展开会被
1397
+ * "一次扫描最多 3 个动画"的配额截断(foldBatch 的错峰路径同样不消耗)。新一轮决策由调用方 clearFoldTimers() 作废。 */
1398
+ function unfoldBatch(rows, animate) {
1399
+ const targets = rows.filter((row) => row.hasAttribute("data-dshsf-folded"));
1400
+ if (targets.length === 0) return;
1401
+ if (!animate || foldIsInstant(targets)) { for (const row of targets) applyFold(row, false, animate); return; }
1402
+ for (let start = 0; start < targets.length; start += FOLD_CHUNK) { // 从上往下一组组展
1403
+ const chunk = targets.slice(start, start + FOLD_CHUNK);
1404
+ const delay = Math.floor(start / FOLD_CHUNK) * FOLD_CHUNK_MS;
1405
+ const run = () => { for (const row of chunk) unfoldRowAnimated(row); };
1406
+ if (delay === 0) run();
1407
+ else foldTimers.push(setTimeout(run, delay));
1408
+ }
1409
+ }
1410
+ let foldTimers = []; // 未播完的错峰收起定时器(新一轮决策时作废)
1411
+ let scanMsSinceFrame = 0; // 自上一帧以来扫描累计耗时:dt 尖峰归因(是我们在扫,还是官方在渲染)
1412
+ let scanCountSinceFrame = 0;
1274
1413
  const scanSamples = []; // 最近 N 次扫描的墙钟耗时(算分位数用;max 会被外部卡顿污染)
1275
1414
  const SCAN_SAMPLES = 40;
1276
1415
  let animBudget = ANIM_BUDGET;
1277
- // 本次扫描是否属于「用户正看着的折叠」(点折叠条 / 跑完后的收尾折叠):这类即使目标多也要播动画;
1278
- // 加载/切会话那种批量收尾仍走瞬时(见 ANIM_BUDGET 的注释)。
1279
- let scanAnimateAll = false;
1280
1416
  const autoThinks = new Set(); // 我方自动展开过的思考根(只有它们需要"被取代后折回")
1281
1417
  const autoTools = new Set(); // 同上,工具卡
1282
1418
  let lastAnnounceAt = 0; // 上次补发 scroll 事件的时刻(压官方吸底用)
1283
1419
  let announcedHeight = -1; // 上次补发时的内容高度:长高那一帧必须补,等不了 400ms
1284
1420
  let announcedTop = -1; // 上次补发时的位置:位移太小时补发会踩到官方"读者没动过"那条分支
1285
- let stealCount = 0; // 诊断:官方吸底抢跑、把我们写下的位置一把抹平的次数(正常应为 0)
1286
- let undoCount = 0; // 诊断:抢跑在帧循环里被回补的次数(提交阶段直接贴底那条路)
1287
- let guardCount = 0; // 诊断:抢跑在自家 ResizeObserver 里被回补的次数(官方吸底那条路)
1288
1421
  let moveCount = 0; // 诊断:帧循环真正推进过多少帧(流式期间应持续增长)
1289
- const followFlips = []; // 诊断:followOn 最近几次翻转(按钮闪 = 跟随在抖 → 看这里)
1422
+ const followFlips = []; // 诊断:写权最近几次翻转(按钮闪 = 跟随在抖 → 看这里)
1290
1423
  let lastFollowSeen = true;
1291
1424
  /** 记一次跟随翻转。why=谁翻的、top/wrote=翻转瞬间的位置与我方最后写入值(不相等 = 有别人动过)。 */
1292
1425
  function noteFlip(on, gap, info) {
@@ -1294,178 +1427,394 @@ function noteFlip(on, gap, info) {
1294
1427
  followFlips.push({ t: Math.round(performance.now()), on, gap: Math.round(gap), ...info });
1295
1428
  if (followFlips.length > 10) followFlips.shift();
1296
1429
  }
1297
- let stealMax = 0; // 诊断:单次抢跑最多把我们往前推了多少像素
1298
- const stealTail = []; // 诊断:最近几次抢跑的像素数(判断是"小幅抖动"还是"整段抹平")
1299
1430
  let layoutReads = 0; // 扫描里"读布局"的次数:DOM 脏的时候每读一次就强制一遍重排
1300
1431
  let tickCount = 0; // 帧循环跑过多少次(诊断:看优化前后量级)
1301
- let announceCount = 0; // 补发过多少次 scroll 事件(诊断用)
1302
- const ANNOUNCE_MS = 400;
1303
- let followOn = true; // 主窗口是否跟随底部:真实输入关掉,回到贴底自动恢复
1304
- let followWrote = null; // 我方最后一次写入的位置:用来分辨这个 scroll 是不是我们造成的
1432
+ let announceCount = 0; // 已废弃:合成 scroll 已删除(task-22),恒 0(保留字段给 probe/审计 hook 兼容)
1433
+ const ANNOUNCE_MS = 400; // 已废弃(合成 scroll 已删除):保留常量仅为审计 hook/probe 字段兼容
1434
+ /* ---- 写权 / 目标:两个独立状态量(本次重建的核心)--------------------------------------
1435
+ * writer = 谁写 scrollTop:us = 我方(官方贴底写被当"意图"吸收);reader = 读者接管(官方写原样放行,我方零程序化写)。
1436
+ * goal = 我方目标:tail = 每帧重算为 floor(跟内容增长)| element | position | none。
1437
+ * 我方导航(置顶条 / 回底 / 跑完回提问 / 展开收起 / Ctrl+F / 折叠条)只换目标、**绝不交出写权**;
1438
+ * 交出写权只有两个入口:读者真实输入、别的程序导航(非贴底写);再武装(取回写权、目标=尾)只有三个入口:
1439
+ * 读者落到末端 / 点回底 / 会话边界。空闲值全部显式:writer=us、goal=tail。 */
1440
+ const WRITER_US = "us";
1441
+ const WRITER_READER = "reader";
1442
+ let writer = WRITER_US;
1443
+ let goalKind = "tail"; // "tail" | "element" | "position" | "none"
1444
+ let goalEl = null; // element 目标(节点)
1445
+ let goalPos = 0; // position 目标(px)
1446
+ const mainVel = newVel(); // 主容器的速度状态(vFeed/vChase/prev 各一份,唯一状态量)
1447
+ let navActive = false; // 一次性导航进行中:不设追赶速度上限,SETTLE_MAX_MS 兜底
1448
+ let navUntil = 0;
1449
+ let navT0 = 0;
1450
+ let navWatchdog = 0; // task-12:一次性导航的有界终止看门狗(帧循环已停时也能收口;不新增 rAF 空转)
1451
+ let navProgressAt = 0; // 最近一次"导航确实推进"的时刻(performance.now)
1452
+ let navArmedAt = 0; // 诊断:最近一次导航武装看门狗的时刻 / 预算(区分"卡住终止"与"settle 超时")
1453
+ let navBudgetMs = 0;
1454
+ let navAbortCount = 0; // 诊断:看门狗有界终止次数
1455
+ let lastNavAbort = null; // 诊断:{why, armedAt, at, budgetMs, top}
1456
+ let navRecheck = null; // S1-4:element 到位后的有界观察窗口 { el, until, left, lastAt }
1457
+ const NAV_STALL_MS = 1200; // 看门狗判定"期间零进展"的宽限(有推进就续期;取 1.2s 以免慢帧/GC 误杀正常滑行)
1458
+ // S1-4/task-17:element 到位后的有界观察窗口。取 2s 的理由:覆盖到位后常见的"迟到落版"(图片/代码块、上一轮摘要行、
1459
+ // 官方补行、折叠收尾);窗口内以帧驱动观察(≈120 帧 @60Hz),到点立即退出 ⇒ 之后零 rAF/timer。
1460
+ // 上限 4 次 + 最小间隔 100ms 同时挡住"每帧都在变"的持续漂移;纠正复用既有 navigate({kind:"element"}),不新增 mover。
1461
+ const NAV_WATCH_MS = 2000;
1462
+ const NAV_WATCH_MAX = 4;
1463
+ const NAV_WATCH_MIN_GAP_MS = 100;
1464
+ // task-19:观察降频 —— 窗口期不是每帧都要读一次目标 rect;每 2 帧复算一次(检测延迟 ≤1 帧,min-gap 仍 100ms),
1465
+ // 窗口期 rect 读减半、窗口保活/到期零残留语义不变。
1466
+ const NAV_OBSERVE_EVERY = 2;
1467
+ let followWrote = null; // 我方最后一次写下的位置:吸收时恢复它,也用来分辨 scroll 是不是我造成的
1305
1468
  let followScroller = null;
1306
- let scrollIntentAt = 0; // 最近一次"读者真的要滚动"的输入时刻
1307
- let followHeight = -1; // 上一次 tick 时的内容高度:位置变了但长度没变 = 有人在跳转,不是布局
1308
- /**
1309
- * 只有真实输入才算读者滚动。展开/折叠、浏览器滚动锚定(内容在视口上方长高时自动调 scrollTop)
1310
- * 都会产生 scroll 事件——位置变了不等于人动了,否则点一次展开就会把贴底取消掉。
1311
- */
1312
- const USER_SCROLL_MS = 600;
1313
- function noteScrollIntent(event) {
1314
- if (event && event.type === "keydown") {
1469
+ let releaseReason = null;
1470
+ let lastReleaseAt = 0; // 诊断:最近一次交出写权的时刻
1471
+ let followHeight = -1; // 上一次 tick 时的内容高度(补发判据用)
1472
+ let lastStep = null; // 探针:上一帧 {writer, goal, gap, base, rate, cap, dt, moving, nav}
1473
+ let lastNav = null; // 探针:最近一次一次性导航 {kind, why, to, ms}
1474
+ /* ---- 帧轨迹(task-18 ⑥):最近 240 帧;只在这一帧真的动过/写过/补发过时记一条,空闲零开销 ---- */
1475
+ const TRACE_MAX = 240;
1476
+ const trace = [];
1477
+ const traceFrame = (rec) => { if (trace.length >= TRACE_MAX) trace.shift(); trace.push(rec); };
1478
+ let passedOfficial = 0; // 诊断:reader 态被原样放行的官方贴底写
1479
+ /* ---- navIntent:真实输入给"下一笔外来写"的单笔分类位(task-13)-----------------------
1480
+ * 置位:捕获阶段收到真实输入(click / keydown,我方合成分发、输入控件里的不算)。
1481
+ * 消费:被**下一笔外来写**消费即清除(贴底写也算一笔)。
1482
+ * 过期:未被消费则在**其后两帧内**过期(用帧计数 tickCount + 2,避免久置;设位时叫醒帧循环让它真的走两帧)。
1483
+ * 只用于"给单笔外来写分类"(导航 vs 保位),**不参与"是否跟随"的门控**。 */
1484
+ let navIntentUntil = -1;
1485
+ /* ---- 几何信号(task-14):这笔写是否只是在补偿"同期布局变化"--------------------------------
1486
+ * 一次滚动赋值不可能同步改变 scrollHeight,所以在写点"落地前"现读 sh0 必然是 0 变化(信号退化)。
1487
+ * 因此基线取**上一帧观察到的 (st, sh)**(本任务开始前、布局变化之前的世界):
1488
+ * st0/sh0 = 上一帧基线(没有基线时退化为写点前的值)
1489
+ * st1/sh1 = 落地后
1490
+ * compensated = |(st1-st0) - (sh1-sh0)| <= 2 // 位置变化完全由内容高度变化解释 ⇒ 视口内容没动
1491
+ * 与输入位取或:nav = hasNavIntent() || !compensated。 */
1492
+ let baseSt = -1;
1493
+ let baseSh = -1;
1494
+ const hasNavIntent = () => tickCount <= navIntentUntil;
1495
+ const clearNavIntent = () => { navIntentUntil = -1; };
1496
+ function noteNavIntent() { navIntentUntil = tickCount + 2; ensureTickLoop(); }
1497
+ function onNavIntentInput(event) {
1498
+ try {
1499
+ if (synthetic || !(event && (event.type === "click" || event.type === "keydown"))) return;
1315
1500
  const target = event.target;
1316
1501
  if (target instanceof Element && target.closest("input, textarea, [contenteditable]")) return;
1317
- if (!/^(ArrowUp|ArrowDown|PageUp|PageDown|Home|End| )$/.test(event.key)) return;
1502
+ // 真实点击/按键 ⇒ 搜索闩解除:放在"输入分类"之后,且排除 Ctrl+F 自身(与监听器注册顺序无关)
1503
+ if (!((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F"))) searchHold = false;
1504
+ noteNavIntent();
1505
+ } catch (err) { ioErrors.input += 1; ioErrors.last = "navIntent: " + String((err && err.message) || err); }
1506
+ }
1507
+ const ioErrors = { input: 0, absorb: 0, anchor: 0, last: null }; // 输入/写层/锚定里被吞掉的异常(不许空 catch,要能被 probe 看见)
1508
+ const prefersReduced = () => { try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; } };
1509
+ /** 唯一交出写权出口(读者真实输入 / 会话边界各一个调用点)。 */
1510
+ function release(reason) {
1511
+ navRecheck = null; // S1-4:读者接管 ⇒ 未决复算作废
1512
+ clearSettle("reader"); // 读者接管:协调作废(不许把折叠压在别人的滚动上)
1513
+ clearNavIntent(); // 交权:这笔输入由读者接手,不再算导航意图
1514
+ tailWant = -1; // 跟随目标低通作废(下次从当前 floor 重新对齐)
1515
+ if (releaseBy[reason] === undefined) releaseBy[reason] = 0;
1516
+ releaseBy[reason] += 1; // 计数按"调用"记(已经是读者时也要留痕)
1517
+ if (writer === WRITER_READER) { releaseReason = reason; return; } // 已经是读者的位置,不重复翻
1518
+ writer = WRITER_READER;
1519
+ goalKind = "none"; goalEl = null; navActive = false;
1520
+ releaseReason = reason; lastReleaseAt = performance.now();
1521
+ const sc = mainScroller();
1522
+ noteFlip(false, sc === null ? 0 : Math.round(sc.scrollHeight - sc.clientHeight - sc.scrollTop), { why: reason });
1523
+ }
1524
+ /** 唯一取回写权出口(读者落到末端 / 点回底 / 会话边界):目标回到尾。 */
1525
+ function rearm() {
1526
+ if (writer === WRITER_US && goalKind === "tail") return;
1527
+ clearNavIntent();
1528
+ tailWant = -1;
1529
+ const sc = mainScroller();
1530
+ writer = WRITER_US; goalKind = "tail"; goalEl = null; navActive = false; releaseReason = null;
1531
+ navRecheck = null; // S1-4:接回跟尾 ⇒ 未决复算作废
1532
+ followWrote = sc === null ? null : sc.scrollTop; // 基准 = 当前位置:陈旧坐标不许参与吸收时的恢复(B2)
1533
+ resetVel(mainVel); // 目标换人:前馈/追赶速度复位
1534
+ rearmCount += 1;
1535
+ noteFlip(true, sc === null ? 0 : Math.round(sc.scrollHeight - sc.clientHeight - sc.scrollTop), { why: "rearm" });
1536
+ }
1537
+ /** 我方导航:取回写权 + 只换目标,绝不交出写权。spec = { kind: "tail"|"element"|"position", el?, px?, instant? }。
1538
+ * instant=true 只给"用户发起"的一次性导航(置顶条/回底):命中 prefers-reduced-motion 时真瞬跳(写点仍是 writeTop)。 */
1539
+ function navigate(spec) {
1540
+ navRecheck = null; // S1-4:显式新导航作废未决复算(不抢用户/新导航)
1541
+ writer = WRITER_US;
1542
+ { const sc = mainScroller(); followWrote = sc === null ? null : sc.scrollTop; } // 基准 = 当前位置(与 rearm 同一条不变式:陈旧坐标不许参与吸收恢复)
1543
+ clearNavIntent(); // 我方已自己完成这次导航:输入意图用掉了
1544
+ if (settleTurn !== null) clearSettle("nav"); // task-32:滑行途中被新导航取代 ⇒ 当帧作废协调(该轮立刻折)
1545
+ tailWant = -1; // 一次性导航:目标静止,不走低通
1546
+ resetVel(mainVel); // 目标换人:前馈/追赶速度复位
1547
+ goalKind = spec && spec.kind ? spec.kind : "tail";
1548
+ goalEl = spec && spec.el ? spec.el : null;
1549
+ goalPos = spec && Number.isFinite(spec.px) ? spec.px : 0;
1550
+ navActive = true;
1551
+ navT0 = performance.now();
1552
+ navUntil = 0; // 0 = 还没武装:第一帧按真实剩余距离算预算(见 mainFollowTick)
1553
+ releaseReason = null;
1554
+ if (goalKind === "tail") { try { document.documentElement.removeAttribute("data-dshsf-jumpgate"); } catch { /* ignore */ } }
1555
+ if (spec && spec.instant && prefersReduced()) {
1556
+ const sc = mainScroller();
1557
+ const floor = sc === null ? 0 : Math.max(0, sc.scrollHeight - sc.clientHeight);
1558
+ let want = null;
1559
+ if (goalKind === "tail") want = floor;
1560
+ else if (goalKind === "position") want = Math.max(0, Math.min(floor, goalPos));
1561
+ else if (goalKind === "element" && goalEl !== null && goalEl.isConnected) want = Math.max(0, Math.min(floor, viewTop(sc, goalEl) - SETTLE_PAD));
1562
+ if (sc !== null && want !== null) { // 无障碍:不做多帧运动,直接到位
1563
+ writeTop(sc, want, undefined, "nav-instant");
1564
+ navActive = false;
1565
+ lastNav = { kind: goalKind, why: "reduced-motion", to: Math.round(sc.scrollTop), ms: 0 };
1566
+ if (goalKind === "element" || goalKind === "position") { goalEl = null; goalKind = "none"; }
1567
+ else tailWant = -1; // 一次性回底到位 ⇒ 跟随目标从当前 floor 重新对齐
1568
+ }
1318
1569
  }
1319
- scrollIntentAt = performance.now();
1570
+ if (navActive) { // task-12:给一次性导航装有界看门狗(零步停机时由它收口)
1571
+ navProgressAt = performance.now();
1572
+ const sc = mainScroller();
1573
+ const floor = sc === null ? 0 : Math.max(0, sc.scrollHeight - sc.clientHeight);
1574
+ let dist = 0;
1575
+ if (sc !== null) {
1576
+ if (goalKind === "tail") dist = Math.abs(floor - sc.scrollTop);
1577
+ else if (goalKind === "position") dist = Math.abs(Math.max(0, Math.min(floor, goalPos)) - sc.scrollTop);
1578
+ else if (goalKind === "element" && goalEl !== null && goalEl.isConnected) dist = Math.abs(Math.max(0, Math.min(floor, viewTop(sc, goalEl) - SETTLE_PAD)) - sc.scrollTop);
1579
+ }
1580
+ navArmedAt = performance.now(); navBudgetMs = navBudget(dist) + NAV_STALL_MS;
1581
+ armNavWatchdog(navBudgetMs);
1582
+ } else { clearTimeout(navWatchdog); navWatchdog = 0; }
1583
+ ensureTickLoop();
1584
+ }
1585
+ /** 我方定位、但位置由锚定补偿自己写(展开/收起 / Ctrl+F):取回写权、不设我方目标。 */
1586
+ function hold() {
1587
+ writer = WRITER_US; goalKind = "none"; goalEl = null; navActive = false; releaseReason = null;
1588
+ navRecheck = null; // S1-4:锚定期间未决复算作废
1589
+ { const sc = mainScroller(); followWrote = sc === null ? null : sc.scrollTop; } // 基准 = 当前位置(与 rearm 同一条不变式)
1590
+ clearNavIntent();
1591
+ tailWant = -1;
1592
+ resetVel(mainVel); // 目标换人:速度复位
1593
+ ensureTickLoop();
1594
+ }
1595
+ /** reader 释放的唯一入口:真实输入那一刻(滚轮/触摸/翻页键/滚动条)。没有意图窗、没有超时。
1596
+ * 翻页键不要求事件目标在容器内(焦点常在 body)——只按"焦点不在输入控件 + 键命中"判(M2)。 */
1597
+ function noteScrollIntent(event) {
1598
+ try {
1599
+ if (event && event.type === "keydown") {
1600
+ const target = event.target;
1601
+ if (target instanceof Element && target.closest("input, textarea, [contenteditable]")) return;
1602
+ if (!/^(ArrowUp|ArrowDown|PageUp|PageDown|Home|End| )$/.test(event.key)) return;
1603
+ } else if (event && event.target instanceof Node) {
1604
+ const target = event.target;
1605
+ // delta 全 0 的 wheel 不是滚动(真实 WheelEvent 恒带数值 deltaX/deltaY);字段缺失的合成事件无法判定,沿用旧行为
1606
+ const dy = Number(event.deltaY), dx = Number(event.deltaX);
1607
+ const hasDelta = Number.isFinite(dy) && Number.isFinite(dx);
1608
+ if (event.type === "wheel" && hasDelta && !(dy || dx)) return;
1609
+ // A1(task-12 边界感知):主容器**内部**的嵌套滚动体(小窗/工具正文/上下文注入正文)在滚轮方向上**还能滚** ⇒ 不是主容器意图;
1610
+ // 已到该方向边界 ⇒ 不早退,落到下面的输入层 reader 释放 —— 浏览器会把链式滚动交给父容器,用户得以借此上/下滚主对话。
1611
+ // touch / 无数值 delta / 纯横向 wheel:方向不可知,维持 A1 一律早退。
1612
+ const nested = target instanceof Element ? target.closest(NESTED_SCROLL) : null;
1613
+ if (nested !== null) {
1614
+ const box = nestedScrollBox(target, nested); // task-14:命中的是不可滚滚的包装层 ⇒ 不是主容器意图(维持早退)
1615
+ if (box === null) return;
1616
+ const canScroll = event.type === "wheel" && hasDelta && dy !== 0
1617
+ && (dy < 0 ? box.scrollTop > 0.5 : box.scrollHeight - box.clientHeight - box.scrollTop > 0.5);
1618
+ if (canScroll || event.type !== "wheel" || !hasDelta || dy === 0) return;
1619
+ }
1620
+ const sc = mainScroller();
1621
+ if (sc !== null && target !== sc && !sc.contains(target)) return; // 主容器之外的(侧栏等)不是它
1622
+ }
1623
+ searchHold = false; // 通过了输入分类 ⇒ 真实页面输入(滚轮/触摸/翻页键):搜索闩解除;非翻页键/输入控件/别处滚动都不解
1624
+ if (mainScroller() === null) return;
1625
+ release("reader");
1626
+ ensureTickLoop();
1627
+ } catch (err) { ioErrors.input += 1; ioErrors.last = "input: " + String((err && err.message) || err); }
1320
1628
  }
1321
1629
  /** 点在滚动条上时事件目标就是滚动容器自身(点内容不会)。 */
1322
1630
  function onScrollerPointerDown(event) { if (event.target === event.currentTarget) noteScrollIntent(event); }
1323
- /** 每帧一次的主窗口跟随:官方只在 ResizeObserver 回调且"没在采样"时贴底,靠不住;
1324
- * 我们每帧贴到 floor(同值写入不产生 scroll 事件),读者一动就关、回到贴底再自动恢复。 */
1325
- /** 非折叠模式 = 插件整体撒手:清掉我们加过的标记/自绘控件/每帧清单,并把滚动接管还给官方。 */
1326
- function standDown(flow) {
1327
- if (flow) { unfoldAll(flow); clearChips(flow); }
1328
- for (const el of document.querySelectorAll("[data-dshsf-window]")) {
1329
- el.removeAttribute("data-dshsf-window");
1330
- el.style.maxHeight = ""; el.style.overflow = "";
1331
- }
1332
- for (const el of document.querySelectorAll("[data-dshsf-answer-sep]")) el.removeAttribute("data-dshsf-answer-sep");
1333
- for (const el of document.querySelectorAll("[data-dshsf-search]")) { el.removeAttribute("data-dshsf-search"); clearSkipHidden(el); } // 归属感知:别把官方挂的那份也摘了、也别留僵尸标记
1334
- for (const el of document.querySelectorAll("[data-dshsf-jump]")) { try { el.remove(); } catch { /* ignore */ } }
1335
- liveWindows.clear(); // 不再每帧跟这些小窗(autoThinks/autoTools 留着:切回折叠时还要靠它们收旧窗)
1336
- pinRows = []; pinRow = null; pinPick = -1;
1337
- hidePinBar();
1338
- releaseTakeover();
1339
- sparks.focus(null);
1340
- sparks.forge(null);
1341
- }
1342
- /** 归还滚动接管:官方档位下 scrollTop 归官方(toBottom / followRef),回底按钮也由官方自己管。 */
1343
- function releaseTakeover() {
1344
- snapOwn = false; // 位置所有权一起还回去(否则切回官方档后官方贴底仍被挡)
1345
- if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } snapTaken = null; }
1346
- if (document.documentElement.hasAttribute("data-dshsf-jumpgate")) document.documentElement.removeAttribute("data-dshsf-jumpgate");
1347
- glide = null;
1348
- settleHoldTurn = null; settleHoldUntil = 0; // 上一轮的收尾 hold 一起作废(同「换会话」:轮号各会话都从 1 起,会撞)
1631
+ /** A1/task-14:从 el 到嵌套区域根 root(含)之间,最近的"真能滚动"的元素(computed overflowY ∈ auto|scroll);
1632
+ * 没有这样的元素 ⇒ 命中的只是不可滚的包装层,不按"已到边界"处理。 */
1633
+ function nestedScrollBox(el, root) {
1634
+ for (let node = el; node !== null; node = node.parentElement) {
1635
+ const oy = getComputedStyle(node).overflowY;
1636
+ if (oy === "auto" || oy === "scroll") return node;
1637
+ if (node === root) break;
1638
+ }
1639
+ return null;
1349
1640
  }
1641
+ /** 追赶速度上限(px/s):设置非法就回落默认值(单一真源)。 */
1642
+ function speedValue() {
1643
+ const v = Number(state.followMaxSpeed);
1644
+ if (!Number.isFinite(v) || v <= 0) { capRejected = { raw: String(state.followMaxSpeed), at: Math.round(performance.now()) }; return DEFAULTS.followMaxSpeed; }
1645
+ return v;
1646
+ }
1647
+ /** 加速度上限(px/s²):设置非法就回落默认值(单一真源)。 */
1648
+ function accelValue() {
1649
+ const v = Number(state.followMaxAccel);
1650
+ if (!Number.isFinite(v) || v <= 0) { accelRejected = { raw: String(state.followMaxAccel), at: Math.round(performance.now()) }; return DEFAULTS.followMaxAccel; }
1651
+ return v;
1652
+ }
1653
+ /** task-22:合成 scroll 已停用 —— 原实现用 dispatchEvent 同步重入官方 onScroll(真机 11.9~17.5ms/次,
1654
+ * 160Hz 一帧 6.25ms ⇒ 直接掉 2 帧)。官方贴底写现在由 absorbTop / afterForeignWrite 显式吸收或放行,
1655
+ * 不再依赖官方 atBottom 判定 ⇒ 这笔派发没有理由。保留**空实现**仅为审计 hook / 诊断字段兼容。 */
1656
+ function announce() { /* 无操作(task-22 停用) */ }
1657
+ /* task-22:合成 scroll(announce)已删除 ----------------------------------------------------------
1658
+ * 它当年是为了"压官方吸底"(让官方认为我们在底部)。现在官方贴底写由 absorbTop / afterForeignWrite
1659
+ * 显式吸收或放行,写权交接也不依赖官方的 atBottom 判定 ⇒ 这笔同步 dispatchEvent 只会让我们在自己的
1660
+ * tick 里重入官方 onScroll 处理器(真机实测 11.9~17.5ms/次,160Hz 一帧 6.25ms ⇒ 直接掉 2 帧)。
1661
+ * ANNOUNCE_MS / announceCount / announcedTop 等字段保留为诊断(恒 0),不参与任何行为。 */
1662
+ /** 每帧一次的主窗口推进:唯一的写点在这条路上(吸收器只吸收/放行官方写,不推进)。目标每帧重算。 */
1350
1663
  function mainFollowTick(dt) {
1664
+ const tGate = performance.now();
1665
+ if (!(dt > 0)) dt = 16.667; // 帧循环首帧 dt=0(prevTs 从 0 起算):律在 dt=0 时不推进 ⇒ 一次性导航会被当场判成"到位"。按一个 60Hz 帧算。
1666
+ if (gateOfficialMode()) { phaseAcc("mainGate", tGate); return false; } // 选官方档位:零写、置顶/回底收起、速度复位 —— 滚动完全交给官方
1351
1667
  const scroller = mainScroller();
1352
- if (!scroller) { hidePinBar(); return false; } // 不在会话视图(设置页/别的面板):body 上的固定层不能留着
1353
- // 官方档位 = 插件整体撒手:不跟随、不补发 scroll、不钉提问、不接管官方按钮(帧循环随之自然停)。
1354
- if (state.mode !== "fold") { releaseTakeover(); hidePinBar(); return false; }
1668
+ if (!scroller) { hidePinBar(); phaseAcc("mainGate", tGate); return false; }
1669
+ phaseAcc("mainGate", tGate); // 不在会话视图(设置页/别的面板):body 上的固定层不能留着
1355
1670
  if (scroller !== followScroller) {
1356
1671
  if (followScroller) {
1357
1672
  followScroller.removeEventListener("scroll", onMainScroll);
1358
1673
  followScroller.removeEventListener("pointerdown", onScrollerPointerDown);
1359
1674
  }
1675
+ if (followScroller !== null) gutterSet = false; // G1:换容器(切会话)后重新加 stable(gutterSet 是"本容器"的一次性闩)
1360
1676
  followScroller = scroller;
1361
- guardComposer = undefined; // 首次扫描时 followScroller 还是 null,guardObserve 会把 composer 记成 null 并永久提前返回
1677
+ bootAt = performance.now();
1678
+ takeOverSnap(scroller);
1362
1679
  followScroller.addEventListener("scroll", onMainScroll, { passive: true });
1363
1680
  followScroller.addEventListener("pointerdown", onScrollerPointerDown, { passive: true });
1681
+ followWrote = null;
1682
+ baseSt = -1; baseSh = -1; // 换容器:几何基线作废(下一帧重新同步)
1683
+ tailWant = -1; // 换容器:跟随目标低通作废
1684
+ resetVel(mainVel); // 换容器:速度复位
1685
+ // 换容器 = 会话边界。**无条件语义判定**(不依赖标志、与 syncSession 的先后顺序无关):
1686
+ // 新容器已经在末端 ⇒ rearm()(接回跟尾,本来就在底、不会滑);
1687
+ // 否则 ⇒ 交出写权、只钉不跟(别把新会话从顶部一路滑到底);
1688
+ // 等官方/读者真把它放到末端,再由 onMainScroll 与 tick 的「落末端」规则接回。
1689
+ if (scroller.scrollHeight > scroller.clientHeight && scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 0.5) rearm();
1690
+ else { release("reset"); goalKind = "none"; goalEl = null; navActive = false; }
1364
1691
  }
1365
- takeOverSnap(scroller); // 幂等(同实例直接返回):从官方档切回来时要把 setter 重新装上
1366
1692
 
1367
- const sh = scroller.scrollHeight; // 每帧只读一次:DOM 脏时每次读都强制一次重排
1368
- followHeight = sh; // scroll 事件据此判断"是布局长高了还是有人在跳"
1369
- pinTick(scroller); // 置顶条:读一遍行/容器位置并摆好(固定层,不改会话布局)
1370
- const floor = Math.max(0, sh - scroller.clientHeight);
1371
- // 一次性平滑移动(跑完一轮 → 移到提问处;点置顶条也走这里):**必须在"跟随关掉"的早退之前**推进,
1372
- // 否则 glide 会永久挂着(动效开着就永远不动、动效关了就瞬跳),还会把 guard 拖在激活态拽住视角。
1373
- const gliding = glideTick(scroller, floor, dt);
1374
- // 跟随关掉时:不再推进跟随,但置顶条与回底按钮的显隐仍归帧循环管(否则按钮被 CSS 永久藏着)
1375
- if (state.autoFollow === false) {
1376
- if (!gliding) updateJumpGate(floor - scroller.scrollTop, false); // 滑动期间不改按钮显隐(和跟随时一致)
1377
- return gliding;
1378
- }
1379
- // 官方还有几条不经过采样标记、直接贴底的路:React 提交阶段的 `tipMoved && atBottomRef → toBottom`、
1380
- // 它自己 ResizeObserver 里的 `followRef`、以及 onScroll 里"读者没动过"当场 sample → toBottom。
1381
- // 只要 pending 被官方自己的 sample() 清掉,下一次提交/下一帧就会把我们攒的滞后一次抹平——
1382
- // 用户看到的就是"正文每来一坨字跳一下"。这些写入都发生在绘制之前,所以在同一个绘制周期里
1383
- // 把位置还给追赶点:视觉上只剩正常的逐帧推进,和官方谁先谁后无关。
1384
- const top0 = scroller.scrollTop; // 抢跑检测读一次,下面回补后再重读一次就够
1385
- if (followOn && followWrote !== null && followWrote < floor - 1 && top0 > followWrote + 1) {
1386
- stealCount += 1;
1387
- const px = Math.round(top0 - followWrote);
1388
- if (px > stealMax) stealMax = px;
1389
- stealTail.push(px);
1390
- if (stealTail.length > 8) stealTail.shift();
1391
- writeTop(scroller, followWrote); undoCount += 1; // 跟随中一律回补(原先「离底 ≤600px 才补」会把单帧长高 >600px 的那一下放弃)
1392
- }
1393
- const top = scroller.scrollTop; // 回补可能改过位置 → 重新读一次,后面全部复用
1394
- const gap = floor - top;
1395
- // 恢复跟随要分清"这个位置是谁放上去的":只有真贴底(gap<=2),或位置就是我方上一次写入,
1396
- // 才算"贴底恢复"。读者自己滚到离底 25px 以内时若直接 followOn=true,下一帧就把人拽回底部 ——
1397
- // 那正是"滚动跟触底吸附打架"。不恢复也没关系:滚到真贴底会自动接上,点回底按钮也随时能接。
1398
- const mine = followWrote !== null && Math.abs(top - followWrote) <= 2;
1399
- // 滑行期间(回提问处 / 点置顶条)**不许**把跟随重新打开:滑行第一帧位置还在底部、mine 也成立,
1400
- // 会把刚取消的 followOn 又顶回 true —— 等滑到提问处,跟随立刻把人拽回底部,看着就是「回提问处失效」。
1401
- // 所以「先取消触底吸附」必须连这个再武装口一起关掉。
1402
- if (!gliding && gap <= 25 && (gap <= 2 || mine)) followOn = true;
1403
- if (followOn !== lastFollowSeen) noteFlip(followOn, gap, { why: followOn ? "tick/贴底恢复" : "tick/其他", wrote: followWrote === null ? null : Math.round(followWrote) });
1404
- if (!gliding) updateJumpGate(gap, followOn);
1405
- if (gliding) return true;
1406
- if (!followOn || gap <= 0.5) return false;
1407
- // 主会话也走同一套平滑:每帧吃掉一部分差距,而不是直接跳到 floor
1408
- // 与小窗完全同一套:纯比例追赶(不再有"最大滞后"兜底步,避免一帧跳几十像素)
1409
- const step = Math.min(gap, Math.max(minStep(dt), gap * smoothGrow() * (dt / 16)));
1410
- const next = scroller.scrollTop + step;
1411
- writeTop(scroller, next);
1412
- followWrote = next; // 写后读回会逼一次布局:目标值本来就算好了(step ≤ gap,不会被钳)
1413
- moveCount += 1;
1414
- // 接管官方吸底:它只在"不在滚动采样期"时才写 scrollTop,而采样标记由 scroll 事件置位。
1415
- // 同一帧里 rAF 回调早于 ResizeObserver 回调,所以这里补一个 scroll 事件,它那一帧就会认为自己在采样
1416
- // → 不吸底;位置又始终在它 25px 判定内 → 它的 atBottom 保持为真,自带的"回到底部"按钮也不会冒出来。
1417
- // 它的采样标记由 sample() 在"本批第一个 scroll 事件之后 500ms"清一次(后续事件不续期),所以补发必须
1418
- // **赶上内容长高那一帧**——那是官方 ResizeObserver 唯一会触发的时机,也是唯一会吸底的时机。
1419
- // 固定周期补发会在"被清掉之后、下次补发之前"留一段空档,那几帧的吸底会把我们的滞后一次抹平 = 跳一格。
1420
- // 位移不足 0.6px 时不补:官方会把它当成"读者没动过"而当场 sample(含一次 React 重渲染),
1421
- // 而这点滞后本来也看不见。
1422
- const now = performance.now();
1423
- const moved = Math.abs(scroller.scrollTop - announcedTop) > 0.6;
1424
- if ((followHeight !== announcedHeight && moved) || now - lastAnnounceAt >= ANNOUNCE_MS) {
1425
- lastAnnounceAt = now;
1426
- announcedHeight = followHeight;
1427
- announcedTop = scroller.scrollTop;
1428
- announceCount += 1;
1429
- try { scroller.dispatchEvent(new Event("scroll")); } catch { /* ignore */ }
1693
+ // task-19 ②:主容器几何每帧只读一次(sh/ch/top),此后全帧复用;写后位置用唯一一次回读 top1 兜住浏览器 floor 钳位。
1694
+ const sh0 = scroller.scrollHeight;
1695
+ const ch0 = scroller.clientHeight;
1696
+ const top0 = readTop(scroller);
1697
+ frameSh = sh0; frameCh = ch0; frameTop = top0;
1698
+ followHeight = sh0;
1699
+ { const tPin = performance.now(); pinTick(scroller); phaseAcc("pin", tPin); } // 置顶条:读一遍行/容器位置并摆好(固定层,不改会话布局)
1700
+ const tPlan = performance.now();
1701
+ readCount += 2; // 本帧主容器的两次强制布局读(scrollHeight/clientHeight)
1702
+ // 口径(S2-2):readCount = 插件主动发起的强制布局读。尺寸读 = 上面这行 + 每窗那行;scrollTop 读统一走 readTop 自增
1703
+ // (含写后回读)。getBoundingClientRect 与事件侧 onMainScroll/pinTick 不计(量纲不同),勿据此判断"读很少"。
1704
+ const floor = Math.max(0, sh0 - ch0);
1705
+ frameToken += 1; vtStamp = frameToken; vtRect = null; // task-19 ②:viewTop 帧内 rect 缓存只在本次 tick 内有效
1706
+ const mark = (goal, gap, rateV, capV, moving, baseTop) => {
1707
+ lastStep = { writer, goal, gap: +gap.toFixed(2), base: +(baseTop === undefined ? scroller.scrollTop : baseTop).toFixed(2), rate: +rateV.toFixed(3), vMax: capV,
1708
+ vFeed: +mainVel.vFeed.toFixed(2), vChase: +mainVel.vChase.toFixed(2), a: accelValue(),
1709
+ dt: +dt.toFixed(2), moving, nav: navActive ? 1 : 0, floor: +floor.toFixed(1), releaseReason, rearmCount };
1710
+ };
1711
+ if (writer === WRITER_READER) { // 读者接管 / 会话恢复期:零程序化写
1712
+ // 接回跟尾的唯一语义信号:文档可滚动、且视图确实在末端(官方恢复到位 / 读者滚到底 / 换容器时已在底)
1713
+ if (scroller.scrollHeight > scroller.clientHeight && floor - scroller.scrollTop <= 0.5) rearm(); // 契约:reader 态"落末端"接回(static-check 字面量;仅读者分支,不影响滑行帧)
1714
+ else if (floor === 0 && releaseReason !== "reset") rearm(); // A6:内容不足一屏 ⇒ floor=0 本身就是贴底(短会话滚一次不再永久停跟);刚换容器(reset)仍交给官方恢复
1715
+ navRecheck = null; // 读者接管:未决复算作废(不抢输入)
1716
+ updateJumpGate(floor - top0, false);
1717
+ mark("none", floor - top0, 0, 0, false, top0);
1718
+ return false;
1430
1719
  }
1431
- return true;
1432
- }
1433
- function onMainScroll(event) {
1434
- const el = event.currentTarget;
1435
- if (followWrote === null || Math.abs(el.scrollTop - followWrote) > 2) { // 位置不是我写下的 → 不是我们自己造成的
1436
- // 读者动过滚轮/触摸/翻页键/滚动条 → 撒手;没有输入信号但长度没变却动了位置 = 有人在跳转(导航轨/外部程序)→ 也算读者
1437
- if (performance.now() - scrollIntentAt <= USER_SCROLL_MS || el.scrollHeight === followHeight) {
1438
- if (followOn) noteFlip(false, el.scrollHeight - el.clientHeight - el.scrollTop, {
1439
- why: "scroll", top: Math.round(el.scrollTop), wrote: followWrote === null ? null : Math.round(followWrote),
1440
- h: el.scrollHeight, fh: followHeight, intent: Math.round(performance.now() - scrollIntentAt),
1441
- });
1442
- followOn = false;
1443
- glide = null; // 读者明确接管:一次性滑行(每帧都会写回自己的目标)必须一起取消,否则滚了也被拖走
1444
- releaseSettleHold();
1720
+ // S1-4/task-17:element 到位后的**有界观察窗口**。窗口内只观察,|offset|>2px 才纠正;
1721
+ // 纠正走既有 navigate({kind:"element"})(唯一移动律);两次纠正间隔 ≥ NAV_WATCH_MIN_GAP_MS,上限 NAV_WATCH_MAX。
1722
+ // 退出:窗口到期 / 次数用完 / 目标断开 / writer!==us / 新导航(navigate 清)/ 读者(release)/rearm/hold/换会话/官方档/dispose。
1723
+ if (navRecheck !== null && !navActive) {
1724
+ const rc = navRecheck;
1725
+ if (writer !== WRITER_US || !rc.el.isConnected || disposed) navRecheck = null;
1726
+ else if (performance.now() > rc.until || rc.left <= 0) navRecheck = null; // 窗口结束:不再写、不再排帧(尾部自然停)
1727
+ else {
1728
+ rc.tick = (rc.tick === undefined ? 0 : rc.tick) + 1;
1729
+ if (rc.tick % NAV_OBSERVE_EVERY === 0) { // task-19:观察降频(窗口期 rect 读减到 1/NAV_OBSERVE_EVERY)
1730
+ const wantRe = Math.max(0, Math.min(floor, viewTop(scroller, rc.el) - SETTLE_PAD));
1731
+ const off = Math.abs(wantRe - top0);
1732
+ if (off > 2 && performance.now() - rc.lastAt >= NAV_WATCH_MIN_GAP_MS) {
1733
+ rc.left -= 1; rc.lastAt = performance.now();
1734
+ navigate({ kind: "element", el: rc.el }); // navigate 内部会清 navRecheck —— 放回同一窗口/剩余次数
1735
+ navRecheck = rc;
1736
+ }
1737
+ }
1445
1738
  }
1446
- // 其余:展开/折叠让内容长高、浏览器锚定顺手改 scrollTop —— 不是读者,保持贴底
1447
1739
  }
1448
- ensureTickLoop(); // 回底按钮的显隐在帧循环里定:读者一滚就得排一帧(收敛后循环本来是停的)
1449
- }
1740
+ let want = null; // 每帧重算:tail 跟着 floor 长
1741
+ if (goalKind === "element") {
1742
+ if (goalEl === null || !goalEl.isConnected) { // task-32:目标丢了 ⇒ 协调作废(立刻折)
1743
+ const retry = settleTurn !== null ? lastUserRow() : null; // S1-2:本轮提问行被重挂载 ⇒ 不放弃导航,按 lastUserRow 重试一次
1744
+ goalEl = null; goalKind = "none"; navActive = false;
1745
+ if (settleTurn !== null) clearSettle("lost");
1746
+ if (retry !== null && retry.isConnected) navigate({ kind: "element", el: retry });
1747
+ }
1748
+ else want = Math.max(0, Math.min(floor, viewTop(scroller, goalEl) - SETTLE_PAD));
1749
+ } else if (goalKind === "position") want = Math.max(0, Math.min(floor, goalPos));
1750
+ else if (goalKind === "tail") want = navActive ? floor : smoothTail(floor, dt); // 一次性回底导航不走低通(目标静止);跟随才低通
1751
+ if (want === null) { // goal=none:我方停在一个位置(导航到位/锚定中),不写
1752
+ updateJumpGate(floor - top0, false);
1753
+ mark("none", floor - top0, 0, 0, false, top0);
1754
+ // S1-4/task-17:观察窗口开启时 goal 已是 none,但必须继续排帧(否则窗口只能被动等外部唤醒)
1755
+ return navRecheck !== null;
1756
+ }
1757
+ const gap = want - top0;
1758
+ // 一次性导航的安全网:首帧按真实距离武装;只有真到点才续期(旧版的固定 1400ms 会把远跳当超时 → 中途停/掉回限速)
1759
+ if (navActive) {
1760
+ if (navUntil === 0) navUntil = performance.now() + navBudget(Math.abs(gap));
1761
+ else if (performance.now() > navUntil) {
1762
+ navUntil = performance.now() + navBudget(Math.abs(gap));
1763
+ lastNav = { kind: goalKind, why: "renew", to: Math.round(top0), ms: +(performance.now() - navT0).toFixed(1) };
1764
+ }
1765
+ }
1766
+ const vMax = navActive ? 0 : (goalKind === "tail" ? speedValue() : 0); // 一次性导航不设追赶上限;跟随用 followMaxSpeed(px/s)
1450
1767
 
1768
+ const bootOver = moveCount > 20 || performance.now() - bootAt >= 300;
1769
+ if (bootOver && !gutterSet) { gutterSet = true; try { scroller.style.scrollbarGutter = "stable"; } catch { /* ignore */ } }
1770
+ const tSlide = performance.now();
1771
+ const moving = slide(scroller, want, dt, vMax, mainVel, top0, "main", floor); // task-19:cur 用本帧已读值;maxHint=floor 让"未写成"的预测不再回读
1772
+ if (navActive && moving) navProgressAt = performance.now(); // task-12:有推进就给看门狗续期
1773
+ phaseAcc("mainSlide", tSlide);
1774
+ phaseAcc("mainPlan", tPlan);
1775
+ if (performance.now() > settleUntil) settleTimeout(); // 兜底:超时即清(滑行没到位也不许把折叠永久压住)
1776
+ const top1 = readTop(scroller); // task-19:全帧唯一一次写后回读(浏览器按 floor 钳位,预测值不足以还原真实落点)
1777
+ frameTop = top1;
1778
+ if (navActive && Math.abs(want - top1) <= 0.5) { // 一次性导航到位(按落点判,不看 moving:dt=0 那帧不推进):tail 保留目标(转为跟随),element/position 撤目标
1779
+ const arrivedEl = goalKind === "element" ? goalEl : null;
1780
+ navActive = false;
1781
+ lastNav = { kind: goalKind, why: "done", to: Math.round(top1), ms: +(performance.now() - navT0).toFixed(1) };
1782
+ if (goalKind === "element" || goalKind === "position") { goalEl = null; goalKind = "none"; }
1783
+ clearTimeout(navWatchdog); navWatchdog = 0; // task-12:到位 ⇒ 看门狗撤掉
1784
+ // S1-4/task-17:element 到位 ⇒ 开启/延续有界观察窗口(同一目标延续 until/left/lastAt,不被自己的纠正重置)
1785
+ navRecheck = arrivedEl !== null && arrivedEl.isConnected
1786
+ ? (navRecheck !== null && navRecheck.el === arrivedEl
1787
+ ? { el: arrivedEl, until: navRecheck.until, left: navRecheck.left, lastAt: navRecheck.lastAt, tick: navRecheck.tick }
1788
+ : { el: arrivedEl, until: performance.now() + NAV_WATCH_MS, left: NAV_WATCH_MAX, lastAt: 0, tick: 0 })
1789
+ : null;
1790
+ if (settleTurn !== null) clearSettle("done"); // 滑到提问处 ⇒ 收工:该轮折叠交给下一次扫描(task-21)
1791
+ }
1792
+ mark(goalKind, gap, expStep(gap, dt), vMax, moving, top1);
1793
+ updateJumpGate(floor - top1, goalKind === "tail");
1794
+ if (moving) ensureTickLoop(); // task-22:不再派发合成 scroll(原 announce 每帧重入官方 handler 11~17ms)
1795
+ // task-12:不得在这里用 if (moving || navActive) 续帧 —— 此刻 ticking 仍为 true,ensureTickLoop 会早退(空操作);
1796
+ // 真正续帧会变成不可达目标下的永久 rAF 空转。卡住的一次性导航改由上面的看门狗有界终止(清 navActive/goal + clearSettle)。
1797
+ // S1-4/task-17:窗口开启期间由帧驱动观察(有界 2s);窗口到期或退出条件命中后 navRecheck=null,循环自然停、零残留。
1798
+ return moving || navRecheck !== null;
1799
+ }
1800
+ /** scroll 事件只做两件事:reader 态下"位置真的落到末端"(事实 ⇒ 取回写权),以及叫醒帧循环维护按钮显隐。
1801
+ * 释放判定不在这里:在输入层(真实输入)与写层(非贴底写 = 别的程序在导航)。 */
1802
+ function onMainScroll(event) {
1803
+ const el = event.currentTarget;
1804
+ const floor = Math.max(0, el.scrollHeight - el.clientHeight);
1805
+ if (writer === WRITER_READER && floor - el.scrollTop <= 0.5) rearm(); // 规则 4:读者落到末端 ⇒ 取回写权、目标=尾
1806
+ ensureTickLoop();
1807
+ }
1451
1808
  let scrollerCache = null; // 滚动容器几乎不变,但找它要逐级 getComputedStyle(每帧都做就是每秒上百次样式解析)
1452
1809
  let flowCache = null; // 会话根([data-chat-flow])与最后一行:帧路径每帧都要用,不能每帧全文档查
1453
1810
  let lastRowSeen = null;
1454
1811
  function chatFlow() {
1455
1812
  if (flowCache && flowCache.isConnected) return flowCache;
1456
- // 右栏子代理(embedded)会话挂在 [data-sidebar-chat] 里(内核 dsh-client-ui-subagent 的 SidebarChatTab)。
1457
- // 文档序第一个 [data-chat-flow] 未必是主会话 —— 优先取不在 sidebar 里的那一个,一个都没有才退回第一个。
1458
- const all = document.querySelectorAll(CHAT_FLOW);
1459
- flowCache = null;
1460
- for (const el of all) {
1461
- if (el.closest("[data-sidebar-chat]") === null) { flowCache = el; break; }
1462
- }
1463
- if (flowCache === null) flowCache = all.length ? all[0] : null;
1813
+ flowCache = document.querySelector(CHAT_FLOW);
1464
1814
  return flowCache;
1465
1815
  }
1466
1816
  function mainScroller() {
1467
1817
  const flow = chatFlow();
1468
- guardObserve(flow); // 顺带:这个函数每帧被调一次,是唯一稳定拿到 flow 的地方,guard 的 observer 就在这儿挂(缓存后不额外查 DOM)
1469
1818
  const probe = lastRowSeen && lastRowSeen.isConnected ? lastRowSeen : flow;
1470
1819
  if (scrollerCache && scrollerCache.isConnected && probe && scrollerCache.contains(probe)) return scrollerCache;
1471
1820
  // 取「最外层」可滚动祖先:0.1.7 每个过程组自带一个内滚动体,从行往上第一个撞到的不是会话滚动条
@@ -1484,87 +1833,211 @@ function mainScroller() {
1484
1833
  function chatScope() { return mainScroller() || document; }
1485
1834
 
1486
1835
  /* ------------------------------------------------- 跑完一轮 → 移到提问处 -- */
1836
+ const TAIL_TAU_MS = 150; // 跟随目标(floor)的一阶低通时间常数:floor 是 React 按块提交的阶梯,低通后再喂唯一律
1837
+ let tailWant = -1; // 低通状态(仅 goalKind==="tail" 用;换目标/交权/换容器时作废)
1838
+ /** 跟随目标的低通:消掉"块间 gap→0 停住、块到跳一下"的目标锯齿。收缩/首次/换目标时直接对齐,不慢慢爬。 */
1839
+ function smoothTail(floor, dt) {
1840
+ if (tailWant < 0 || tailWant > floor + 1) { tailWant = floor; return floor; }
1841
+ tailWant += (floor - tailWant) * (1 - Math.exp(-dt / TAIL_TAU_MS));
1842
+ return tailWant;
1843
+ }
1487
1844
  const SETTLE_PAD = 12; // 提问行停在视口顶端往下多少像素
1488
- const SETTLE_MAX_MS = 1400; // 一次性滑动的兜底时长(正常 300~800ms 收敛)
1489
- let glide = null; // { el, until }:正在做的一次性平滑移动(目标元素每帧重算:折叠/展开改了布局也跟得上)
1490
- const settleLog = []; // 诊断:每次「跑完回到提问处」的判定(为什么没滑 / 滑到哪),probe().settleLog 可读
1491
- const foldLog = []; // 诊断:每次批量折叠的目标数与走的是动画还是瞬时,probe().foldLog 可读
1492
- function noteFold(entry) { foldLog.push(entry); if (foldLog.length > 10) foldLog.shift(); }
1493
- function noteSettle(entry) { settleLog.push(entry); if (settleLog.length > 8) settleLog.shift(); }
1494
- let settleHoldTurn = null; // 刚跑完的这一轮先别折:等「回到提问处」的滑行走完再折(折叠会改布局,边走边折会把人带偏)
1495
- let justFinishedTurn = null; // 本次扫描里「刚跑完」的那一轮:它的收尾折叠哪怕在视野外也要播动画
1496
- let settleHoldUntil = 0; // 兜底时限:滑行若被别处取消/丢失,最多拖到这时刻
1497
- /** 放行被压住的那一轮折叠,并排一次扫描去折。 */
1498
- function releaseSettleHold() {
1499
- if (settleHoldTurn === null) return;
1500
- settleHoldTurn = null; settleHoldUntil = 0;
1501
- scanAnimateAll = true; // 跑完那轮的收尾折叠是用户正看着的:放行时让它播动画
1502
- schedule();
1845
+ const SETTLE_MAX_MS = 1400; // 一次性导航预算的下限(安全网,不是正常终止条件)
1846
+ /** 一次性导航的"安全网"预算(ms):新律受加速度上限约束,位移近似三角形 ⇒ 覆盖 x 约需 2·sqrt(2x/a)。
1847
+ * 取 2× 三角形时间 + 600ms 余量,且不小于 SETTLE_MAX_MS。到点只**按剩余距离续期**,绝不把它当终止条件
1848
+ * (正常终止只有两个:到位 |dy|≤0.5、目标节点消失)。 */
1849
+ function navBudget(dist) {
1850
+ const a = accelValue();
1851
+ const x = Math.max(0, Number(dist) || 0);
1852
+ return Math.max(SETTLE_MAX_MS, Math.ceil(2 * Math.sqrt(2 * x / a) * 1000) + 600);
1853
+ }
1854
+ /** task-12:有界终止卡住的一次性导航。目标不可达时 slide 零步 ⇒ 帧循环自行停(无 rAF),
1855
+ * 所以不能靠"每帧零进展计数";用一次性看门狗:预算到期且期间零进展 ⇒ 清 navActive/目标并走既有 timeout 清理。
1856
+ * 复用 "timeout" 而不新增 reason:static-check 钉死 clearSettle 恰好 9 个清理点(多一个即视为新闩锁)。 */
1857
+ function abortStuckNav() {
1858
+ navActive = false; navUntil = 0; goalEl = null; goalKind = "none"; navProgressAt = 0;
1859
+ clearTimeout(navWatchdog); navWatchdog = 0;
1860
+ const sc = mainScroller();
1861
+ navAbortCount += 1;
1862
+ lastNavAbort = { why: "stuck", armedAt: Math.round(navArmedAt), at: Math.round(performance.now()), budgetMs: Math.round(navBudgetMs), top: sc === null ? 0 : Math.round(sc.scrollTop) };
1863
+ settleTimeout(); // 复用既有 timeout 清理(不新增 clearSettle 字面量)
1864
+ updateJumpGate(sc === null ? 0 : Math.max(0, sc.scrollHeight - sc.clientHeight) - sc.scrollTop, false);
1865
+ }
1866
+ /** 超时/卡住兜底清理的共享出口:timeout 清理只保留这一处调用点(static-check 钉死恰好 9 个清理点)。 */
1867
+ function settleTimeout() { if (settleTurn !== null) clearSettle("timeout"); }
1868
+ function armNavWatchdog(delay) {
1869
+ clearTimeout(navWatchdog);
1870
+ navWatchdog = setTimeout(() => {
1871
+ navWatchdog = 0;
1872
+ if (disposed || !navActive) return;
1873
+ if (performance.now() - navProgressAt < NAV_STALL_MS) { armNavWatchdog(NAV_STALL_MS); return; } // 仍在推进:续期,不终止
1874
+ abortStuckNav();
1875
+ }, Math.max(NAV_STALL_MS, delay));
1503
1876
  }
1504
1877
  let wasRunning = false; // 上一次扫描时这一轮是否在跑(true→false = 刚跑完)
1505
- /** 元素顶端在滚动容器里的位置(绝对 scrollTop 坐标)。 */
1878
+ let lastUserKey = null; // 最近一次见到的"你最新那条消息"的行 key(回尾判据:key 变了 = 你刚发了新消息)
1879
+ /* ---- 一轮跑完的「先滑后折」协调(task-21):一次性标记,不是历史闩锁 ----------------
1880
+ * 起效时记下这一轮的轮号 ⇒ syncTurns 只压住**该轮**的折叠;滑行到位/超时/交权/会话边界/官方档位/dispose 即清。 */
1881
+ let settleTurn = null; // 正在为哪一轮协调(null = 没在协调)
1882
+ let settleUntil = 0; // 兜底超时(performance.now() 时刻)
1883
+ let lastSettle = null; // 诊断:最近一次协调 {turn, why, at}
1884
+ /** 元素顶端在滚动容器里的位置(绝对 scrollTop 坐标)。
1885
+ * task-19 ②:tick 帧内对主容器复用 frameTop + 一次容器 rect(vtRect),同一帧多次 viewTop 不再重复强制布局读;
1886
+ * 帧外(scan/navigate 等)走原式,语义与缓存无关。 */
1506
1887
  function viewTop(scroller, el) {
1888
+ if (scroller === followScroller && vtStamp === frameToken) {
1889
+ if (vtRect === null) vtRect = scroller.getBoundingClientRect();
1890
+ return frameTop + el.getBoundingClientRect().top - vtRect.top;
1891
+ }
1507
1892
  return scroller.scrollTop + el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
1508
1893
  }
1894
+ /** 复刻 Element.scrollIntoView 的**纵向**目标(主容器坐标):block = start(默认)|center|end|nearest。
1895
+ * 只做主容器一个滚动上下文的对齐(祖先链由调用处保证:node 已在容器内);返回未 clamp 的目标,由调用方按 floor 收口。 */
1896
+ function scrollIntoViewTarget(sc, node, block) {
1897
+ const scRect = sc.getBoundingClientRect();
1898
+ const rect = node.getBoundingClientRect();
1899
+ const height = rect.height || (rect.bottom - rect.top);
1900
+ const top = sc.scrollTop + rect.top - scRect.top;
1901
+ const view = sc.clientHeight;
1902
+ const mode = block === "center" || block === "end" || block === "nearest" ? block : "start";
1903
+ if (mode === "center") return top - (view - height) / 2;
1904
+ if (mode === "end") return top - (view - height);
1905
+ if (mode === "nearest") {
1906
+ const cur = sc.scrollTop;
1907
+ if (top < cur) return top;
1908
+ if (top + height > cur + view) return top + height - view;
1909
+ return cur;
1910
+ }
1911
+ return top;
1912
+ }
1509
1913
  /** 会话里所有用户发言行(文档顺序):置顶条与「跑完回到提问处」共用。 */
1510
1914
  function userRows() {
1511
1915
  return chatRows().filter((el) => el.getAttribute("data-chat-flow-kind") === "user");
1512
1916
  }
1513
- function lastUserRow(rows) {
1514
- // 传进来的 collected.rows 是**全部**行:必须自己筛出用户行。
1515
- // (0.5.2 那批「复用行快照」优化里漏了这一步,直接把最后一行当提问行 —— 于是滑行目标是回答行,
1516
- // 表现就是「跑完回到提问处」失效。)
1517
- const list = rows ? rows.filter((el) => el.getAttribute("data-chat-flow-kind") === "user") : userRows();
1518
- return list.length === 0 ? null : list[list.length - 1];
1519
- }
1520
- /** 本轮跑完:平滑移到本轮提问处。关掉开关、读者已接管滚动、或找不到提问行时都不动。 */
1521
- function settleToPrompt(flow, rows) {
1522
- // 每个出口都记一条:probe().settleLog 直接说明「跑完」这次为什么没滑(设置关 / 读者接管 / 没容器 / 没提问行)
1523
- const mark = (why, extra) => noteSettle(Object.assign({ at: Math.round(performance.now()), why, followOn, autoFollow: state.autoFollow !== false, mode: state.mode }, extra || {}));
1524
- if (state.settleToPrompt === false) return mark("setting-off");
1525
- const scroller = mainScroller();
1526
- if (scroller === null) return mark("no-scroller");
1527
- // 中途滚过不管:只看「结束这一刻」是不是还在触底跟随状态(跟随开着,或本来就贴着底)。
1528
- const gap = Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop);
1529
- if (!(followOn || gap <= 25)) return mark("not-at-bottom", { gap: Math.round(gap) });
1530
- const row = lastUserRow(rows);
1531
- if (row === null) return mark("no-user-row", { rows: rows ? rows.length : -1 });
1532
- const from = Math.round(scroller.scrollTop);
1533
- const floor = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
1534
- const want = Math.min(floor, Math.max(0, viewTop(scroller, row) - SETTLE_PAD));
1535
- followOn = false; // 主动离开底部:别让跟随再把我们拽回底部
1536
- const turn = row.getAttribute("data-chat-turn");
1537
- if (state.animations === false) { writeTop(scroller, want); return mark("jump", { from, want, floor, turn }); } // 动效关掉 → 直接到位
1538
- glide = { el: row, until: performance.now() + SETTLE_MAX_MS, t0: performance.now(), from, want };
1539
- if (turn !== null && turn !== "") { settleHoldTurn = String(turn); settleHoldUntil = performance.now() + SETTLE_MAX_MS + 200; } // 先回提问处,滑完再折
1540
- ensureTickLoop();
1541
- mark("glide", { from, want, floor, turn });
1917
+ const Q_TOOL_SEL = '[data-tool="ask_user_question"], [data-tool="request_user_input"]'; // 提问工具卡(当场回答渲染在卡里)
1918
+ const Q_ANSWER_SEL = "[data-answer], [data-question-answer], [data-tool-result], [class*='_answer'], [class*='_result']";
1919
+ const isQuestionCard = (el) => el.matches(Q_TOOL_SEL) || el.querySelector(Q_TOOL_SEL) !== null;
1920
+ /** 提问卡文案:卡里能取到**用户的回答** ⇒ "答: …";取不到 ⇒ 问题本身 "问: …";两条都取不到 ⇒ null(不进候选)。 */
1921
+ function questionCardText(row) {
1922
+ let answer = row.getAttribute("data-answer") || "";
1923
+ if (!answer) { const el = row.querySelector(Q_ANSWER_SEL); if (el !== null) answer = rowText(el, undefined, 200); }
1924
+ answer = String(answer).replace(/\s+/g, " ").trim();
1925
+ if (answer) return ("答: " + answer).slice(0, 200);
1926
+ const q = String(rowText(row, (n) => n.matches("[class*='_time'],[class*='_actions']"), 400)).replace(/\s+/g, " ").trim();
1927
+ return q ? ("问: " + q).slice(0, 200) : null;
1928
+ }
1929
+ /** H19f:置顶条数据源唯一定义(fold 与 native 共用) —— 提问行 ∪ 迟到回答行 ∪ **带提问工具卡的行**(当场回答)。 */
1930
+ function pinSource(rows) {
1931
+ return rows.filter((el) => {
1932
+ const k = el.getAttribute("data-chat-flow-kind");
1933
+ if (k === "user") return true;
1934
+ if (state.keepUserQuestions !== true) return false;
1935
+ if (k === "question-reply" || k === "user-question-reply" || el.querySelector("[data-question-reply]") !== null) return true;
1936
+ return isQuestionCard(el) && questionCardText(el) !== null; // 当场回答永远没有 question-reply 行,只能从工具卡取
1937
+ });
1542
1938
  }
1543
-
1544
- /** 推进一次性平滑移动:目标每帧重算(折叠/展开改了布局也追得上),和跟随同一条追赶公式。
1545
- * 返回"这一帧动了吗"——调用方据此跳过跟随并让帧循环继续。与「触底自动跟随」开关无关。 */
1546
- function glideTick(scroller, floor, dt) {
1547
- if (glide === null) return false;
1548
- // 注意:滑行结束时要把 followWrote 一起清掉。滑行每帧都写 followWrote,收尾那帧 gliding 已经是 false,
1549
- // 于是「贴底恢复跟随」判据里的 mine 仍为真 —— 目标离底 ≤25px 时滑完会被重新武装跟随、又吸回底部。
1550
- if (!glide.el.isConnected || performance.now() > glide.until) {
1551
- noteSettle({ at: Math.round(performance.now()), why: glide.el.isConnected ? "glide-timeout" : "glide-el-gone", from: glide.from, want: glide.want, now: Math.round(scroller.scrollTop), ms: Math.round(performance.now() - (glide.t0 || 0)) });
1552
- glide = null; followWrote = null; releaseSettleHold(); return false; // 滑行结束不再认领跟随写入点,见下
1553
- }
1554
- const want = Math.min(floor, Math.max(0, viewTop(scroller, glide.el) - SETTLE_PAD));
1555
- const dy = want - scroller.scrollTop;
1556
- if (Math.abs(dy) <= 0.5) { noteSettle({ at: Math.round(performance.now()), why: "glide-done", from: glide.from, want, now: Math.round(scroller.scrollTop), ms: Math.round(performance.now() - (glide.t0 || 0)) }); glide = null; followWrote = null; releaseSettleHold(); return false; }
1557
- const step = Math.min(Math.abs(dy), Math.max(minStep(dt), Math.abs(dy) * smoothGrow() * (dt / 16)));
1558
- const next = scroller.scrollTop + Math.sign(dy) * step;
1559
- writeTop(scroller, next);
1560
- followWrote = next; // 同上:写完不读回
1561
- moveCount += 1;
1562
- ensureTickLoop();
1939
+ function lastUserRow() {
1940
+ const rows = userRows();
1941
+ return rows.length === 0 ? null : rows[rows.length - 1];
1942
+ }
1943
+ /** 结束「先滑后折」协调(一次性):清掉标记后立刻排一次扫描,让该轮按正常规则折叠(不留历史闩锁)。 */
1944
+ /** 记录"用户当场操作过的轮次"(manualState 的**唯一**写入口):值真的变了就立刻取消 settle 协调。
1945
+ * task-23:滑行途中用户手动折叠/展开该轮 ⇒ 当场按用户操作折,不再等滑行到位。 */
1946
+ function setManual(turn, value) {
1947
+ if (turn === null || turn === undefined) return;
1948
+ if (manualState.get(turn) === value) return;
1949
+ manualState.set(turn, value);
1950
+ clearSettle("manual");
1951
+ }
1952
+ /** 结束「先滑后折」协调(一次性):清掉标记后立刻排一次扫描,让该轮按正常规则折叠(不留历史闩锁)。 */
1953
+ function clearSettle(reason) {
1954
+ if (settleTurn === null) return;
1955
+ lastSettle = { turn: String(settleTurn), why: reason, at: Math.round(performance.now()) };
1956
+ settleTurn = null; settleUntil = 0;
1957
+ if (reason !== "dispose") { try { schedule(); } catch { /* ignore */ } } // 折叠由扫描执行:清完立刻排一次
1958
+ }
1959
+ /** 本轮跑完:滑到本轮提问处(我方导航)。**只在触底吸附**(writer=us ∧ goal=tail ∧ 非一次性导航)时起效;
1960
+ * 读者接管 / 停在中途(goal=none|element)/ 官方档位 / 开关关闭 ⇒ 一律不动。 */
1961
+ function settleToPrompt(flow) {
1962
+ if (state.settleToPrompt === false) return false;
1963
+ if (state.mode !== "fold") return false; // 官方档位:完全不接管
1964
+ if (writer !== WRITER_US || goalKind !== "tail" || navActive) return false; // 只在"视图钉在末端"时起效
1965
+ const row = lastUserRow();
1966
+ if (row === null) return false;
1967
+ navigate({ kind: "element", el: row });
1563
1968
  return true;
1564
1969
  }
1565
1970
 
1566
1971
  /* ----------------------------------------------------- 置顶显示提问 -- */
1567
1972
  let pinRows = []; // 所有用户发言行(扫描时更新,文档顺序)
1973
+ /* H19b 提问卡自动展开:登记与点击分离 —— 点击挪出扫描帧(不再把 React 同步提交的 ~30ms 压在我们的扫描上)。
1974
+ 去重键 = 轮次(data-chat-turn);没有轮号的卡片退回按元素去重。 */
1975
+ const qCardTurns = new Set(); // 已自动展开过的提问卡所在轮次
1976
+ const qCardEls = new WeakSet(); // 没有轮号时的兜底
1977
+ const qCardQueued = new WeakSet(); // 已登记待点击(跨扫描不重复入队)
1978
+ let qCardQueue = [];
1979
+ let qCardRaf = 0;
1980
+ function qCardKey(card) {
1981
+ const row = card && typeof card.closest === "function" ? card.closest(ROW) : null;
1982
+ const turn = row === null ? null : row.getAttribute("data-chat-turn");
1983
+ return turn === null || turn === "" ? null : turn;
1984
+ }
1985
+ function qCardHandled(card) {
1986
+ const turn = qCardKey(card);
1987
+ return turn === null ? qCardEls.has(card) : qCardTurns.has(turn);
1988
+ }
1989
+ function markQCard(card) {
1990
+ const turn = qCardKey(card);
1991
+ if (turn === null) qCardEls.add(card); else qCardTurns.add(turn);
1992
+ try { card.dataset.dshsfKeepOpen = "1"; } catch { /* ignore */ }
1993
+ }
1994
+ /** 下一帧再点:扫描帧里不再背 React 提交的成本。 */
1995
+ function runQCardQueue() {
1996
+ qCardRaf = 0;
1997
+ const batch = qCardQueue; qCardQueue = [];
1998
+ for (const card of batch) {
1999
+ if (!card.isConnected || qCardHandled(card)) continue;
2000
+ const target = disclosureTarget(card);
2001
+ if (!target || target.getAttribute("aria-expanded") === "true") { markQCard(card); continue; }
2002
+ if (clickOnce(target)) markQCard(card);
2003
+ }
2004
+ }
2005
+ /* 思考/工具自动展开:与提问卡同一手法 —— 扫描只登记,合成点击挪到下一帧。
2006
+ 它们的合成点击同样会触发 React 同步提交(真机 cost 里 openThink 单次最高 18ms),不该算进扫描成本;
2007
+ 打标(tagThinkWindow)仍在点击后同一个 rAF 里做,避免"先闪一下没有小窗的样子"。 */
2008
+ const autoOpenQueued = new WeakSet();
2009
+ let autoOpenQueue = [];
2010
+ let autoOpenRaf = 0;
2011
+ function queueAutoOpen(kind, root) {
2012
+ if (autoOpenQueued.has(root)) return;
2013
+ autoOpenQueued.add(root);
2014
+ autoOpenQueue.push({ kind, root });
2015
+ if (autoOpenRaf === 0 && typeof requestAnimationFrame === "function") autoOpenRaf = requestAnimationFrame(runAutoOpenQueue);
2016
+ }
2017
+ function runAutoOpenQueue() {
2018
+ autoOpenRaf = 0;
2019
+ const batch = autoOpenQueue; autoOpenQueue = [];
2020
+ for (const item of batch) {
2021
+ const root = item.root;
2022
+ autoOpenQueued.delete(root);
2023
+ if (!root.isConnected || root.closest("[data-dshsf-folded]") !== null) continue;
2024
+ if (item.kind === "think") {
2025
+ if (state.autoExpandReasoning === false || thinkOpen(root)) continue;
2026
+ if (openThink(root)) sparks.focus(root.querySelector(THINK_BODY)); // 展开成功:火星马上挂到新窗
2027
+ } else {
2028
+ if (state.autoExpandTools === false || toolOpen(root)) continue;
2029
+ openTool(root);
2030
+ }
2031
+ }
2032
+ }
2033
+ let capRejected = null;
2034
+ let accelRejected = null; // 跟随加速度上限非法时的 {raw, at}
2035
+ let foreignLanded = 0;
2036
+ const releaseBy = { reader: 0, external: 0, reset: 0 }; // 仪器:按 reason 分组的释放计数;external 为死字段(该释放入口已在 task-12 取消),仅兼容保留
2037
+ let rearmCount = 0; // 仪器:取回写权次数
2038
+ let lastForeign = null; // 仪器:最近一笔放行的官方非贴底写(别的程序在导航)
2039
+ let scanErrors = 0; // F4:扫描期异常计数(诊断)
2040
+ let lastScanError = null; // H19b:已经由我们展开过的提问卡(WeakSet;用户之后手动折回不再抢开)
1568
2041
  let pinPick = -1; // 上一帧钉住的下标(下一帧先验它还成不成立,省掉二分)
1569
2042
  let pinRow = null; // 当前置顶的那一行(帧里按"正在看哪一轮"选出来)
1570
2043
  let pinBar = null; // 固定层上的置顶条
@@ -1578,11 +2051,8 @@ function ensurePinBar() {
1578
2051
  bar.setAttribute("data-dshsf-pin", "");
1579
2052
  bar.title = "回到本轮提问";
1580
2053
  bar.addEventListener("click", () => {
1581
- const el = mainScroller();
1582
- if (el === null || pinRow === null || !pinRow.isConnected) return;
1583
- followOn = false; // 主动离开底部:和「跑完回到提问处」同一套滑行
1584
- glide = { el: pinRow, until: performance.now() + SETTLE_MAX_MS, t0: performance.now(), from: Math.round(el.scrollTop), want: null };
1585
- ensureTickLoop();
2054
+ if (pinRow === null || !pinRow.isConnected) return;
2055
+ navigate({ kind: "element", el: pinRow, instant: true }); // 我方导航:取回写权、只换目标(用户发起 ⇒ reduce 下瞬跳);触底时点它也不会被官方贴底写拉回底部
1586
2056
  });
1587
2057
  (document.body || document.documentElement).appendChild(bar);
1588
2058
  pinBar = bar;
@@ -1600,7 +2070,27 @@ function pinTick(scroller) {
1600
2070
  }
1601
2071
  }
1602
2072
  function hidePinBar() { if (pinBar !== null && pinBar.hasAttribute("data-dshsf-visible")) pinBar.removeAttribute("data-dshsf-visible"); }
2073
+ /** 选官方档位(mode != fold):整个运动层退出滚动 —— 置顶条与回底按钮收起、零写、速度态复位。返回"是否已交出"。 */
2074
+ function gateOfficialMode() {
2075
+ if (state.mode === "fold") return false;
2076
+ clearSettle("official"); // 官方档位:协调作废(这一档我们完全不接管)
2077
+ clearNavIntent();
2078
+ tailWant = -1;
2079
+ hidePinBar(); // 置顶条收起
2080
+ try { document.documentElement.removeAttribute("data-dshsf-jumpgate"); } catch { /* ignore */ }
2081
+ navActive = false; // 一次性导航作废
2082
+ navRecheck = null; // S1-4:官方档位 ⇒ 观察窗口立即退出
2083
+ if (goalKind === "element" || goalKind === "position") { goalEl = null; goalKind = "none"; }
2084
+ resetVel(mainVel); // 速度态复位
2085
+ for (const el of liveWindows) { // 小窗回底按钮也收起
2086
+ const jump = el.querySelector(":scope > [data-dshsf-jump]");
2087
+ if (jump !== null) jump.removeAttribute("data-dshsf-visible");
2088
+ }
2089
+ lastStep = { writer, goal: "off", gap: 0, base: 0, rate: 0, vMax: 0, vFeed: 0, vChase: 0, a: 0, dt: 0, moving: false, nav: 0, floor: 0, releaseReason: "mode", rearmCount };
2090
+ return true;
2091
+ }
1603
2092
  function pinTickInner(scroller) {
2093
+ pinRects += 2; // 该函数每帧固定两次 getBoundingClientRect 级别测量(行 + 容器)
1604
2094
  const hide = hidePinBar;
1605
2095
  if (state.pinPrompt !== true || pinRows.length === 0) { hide(); return; }
1606
2096
  const bar = ensurePinBar();
@@ -1630,8 +2120,20 @@ function pinTickInner(scroller) {
1630
2120
  if (bar.style.top !== top) bar.style.top = top;
1631
2121
  if (bar.style.left !== left) bar.style.left = left;
1632
2122
  if (bar.style.width !== width) bar.style.width = width;
1633
- let text = pinTextOf.get(row);
1634
- if (text === undefined) { text = promptText(row); pinTextOf.set(row, text); }
2123
+ const cachedPin = pinTextOf.get(row); // A13/task-20:observer 定向失效为主(按 record 回溯到行删除);
2124
+ const rawPin = String(row.textContent || ""); // 原始文本变化做兜底(不是长度)⇒ 等长改写同样刷新,不再依赖 len
2125
+ let text;
2126
+ if (cachedPin !== undefined && cachedPin.raw === rawPin) text = cachedPin.text;
2127
+ else {
2128
+ if (isQuestionCard(row)) text = questionCardText(row) || ""; // task-23:提问卡 ⇒ "答: …"(取不到回答则 "问: …")
2129
+ else {
2130
+ text = promptText(row);
2131
+ // H19:回答行加「答:」前缀,与用户提问行区分(只描述作用)
2132
+ const rk = row.getAttribute("data-chat-flow-kind");
2133
+ if (rk === "question-reply" || rk === "user-question-reply" || row.querySelector("[data-question-reply]") !== null) text = "答: " + text;
2134
+ }
2135
+ pinTextOf.set(row, { text, raw: rawPin });
2136
+ }
1635
2137
  if (bar.textContent !== text) bar.textContent = text;
1636
2138
  if (show !== bar.hasAttribute("data-dshsf-visible")) {
1637
2139
  if (show) bar.setAttribute("data-dshsf-visible", "");
@@ -1639,36 +2141,21 @@ function pinTickInner(scroller) {
1639
2141
  }
1640
2142
  }
1641
2143
 
1642
- /**
1643
- * 官方那颗回底按钮的**点击**:官方是 `toBottom → el.scrollTop = el.scrollHeight`(瞬跳,容器也没有
1644
- * `scroll-behavior:smooth`)。捕获阶段截下这次点击,改成置 followOn + 排帧 —— 回底就交给与我们跟随完全
1645
- * 同一条追赶公式,平滑滑下去。跟随/动效关掉时放行,退回官方那条瞬跳(否则点了没反应)。
1646
- */
2144
+ /** 官方那颗回底按钮的**点击**:不阻止官方那笔贴底写(会被吸收器吞掉),只发我方导航意图。 */
1647
2145
  function onToBottomClick(event) {
1648
2146
  if (synthetic || !(event.target instanceof Element)) return;
1649
2147
  const btn = event.target.closest("button[class*='_toBottom']"); // 图标在按钮里,取最近的那个按钮
1650
- if (btn === null || btn.closest("[data-dshsf-window]") !== null) return; // 小窗里的按钮不是它
1651
- // 官方档位 / 关动效 / 关跟随:这颗按钮完全归官方。先交还位置所有权,否则它自己的瞬跳会被我们的接管挡掉(点了没反应)
1652
- if (state.mode !== "fold" || state.autoFollow === false || state.animations === false) { snapOwn = false; return; }
1653
- event.preventDefault();
1654
- event.stopPropagation(); // React 的 onClick 在根上冒泡阶段,捕获阶段拦得住
1655
- followOn = true;
1656
- glide = null; // 点了回底就是要到底:别让没跑完的滑行继续往旧目标带
1657
- releaseSettleHold();
1658
- document.documentElement.removeAttribute("data-dshsf-jumpgate"); // 按钮立刻收起:我们已经在往回走了
1659
- ensureTickLoop();
2148
+ if (btn === null || btn.closest("[data-dshsf-window], [data-dshsf-textwindow]") !== null) return; // 小窗里的按钮不是它
2149
+ // 不阻止官方那笔贴底写(会被吸收器吞掉):取回写权 + 目标=尾,一次性滑行不设 cap。
2150
+ // 不能按 animations 早退:writer=us / goal=none(停在中途)时官方那笔写会被吸收,点了会"没反应"。
2151
+ navigate({ kind: "tail", instant: true }); // 用户发起 ⇒ reduce 下瞬跳(跟随不看 reduced-motion)
1660
2152
  }
1661
2153
 
1662
2154
  /**
1663
- * 主窗口的回底按钮**不自绘**:外观、位置、点击行为都用官方那颗(`[class*='_toBottomSlot']` 里的按钮),
1664
- * 我们只接管"它什么时候出现"——CSS 默认把它藏起来,判定要显示时才给 html 挂 `data-dshsf-jumpgate`。
1665
- * 官方自己是"gap>25 就渲染"(无滞回),跟随中的正常落后会让它一开一关地闪;我们按带滞回的规则给:
1666
- * 点亮 = 读者接管(!followOn)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)/ 关掉开关。
1667
- * 元素在不在 DOM 里仍由官方决定,但那只影响"能不能显示";闪不闪由我们说了算。
2155
+ * 主窗口的回底按钮**不自绘**:外观、位置、点击行为都用官方那颗([class*='_toBottomSlot'] 里的按钮),
2156
+ * 我们只接管"它什么时候出现"——CSS 默认把它藏起来,判定要显示时才给 html 挂 data-dshsf-jumpgate。
2157
+ * 点亮 = 我方目标不是尾(读者接管 / 停在中途)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)或关掉开关。
1668
2158
  */
1669
- /** 官方那颗按钮的显隐(带滞回;状态只有一处=html 上的 data-dshsf-jumpgate)。
1670
- * 点亮 = 读者接管(!followOn)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)或关掉开关。
1671
- * 单独抽出来是因为"跟随关掉"那条早退路径也必须维护它——否则 CSS 默认藏着的官方按钮永远不会露面。 */
1672
2159
  function updateJumpGate(gap, follow) {
1673
2160
  const root = document.documentElement;
1674
2161
  if (state.showJumpButton === false) { root.removeAttribute("data-dshsf-jumpgate"); return; }
@@ -1677,98 +2164,204 @@ function updateJumpGate(gap, follow) {
1677
2164
  }
1678
2165
 
1679
2166
  /**
1680
- * 官方吸底写在它自己的 ResizeObserver 回调里(observe 的正是 [data-chat-flow]),一帧就把我们的滞后抹平。
1681
- * 我们的 observer 注册得晚 → 同一轮投递里排在它后面、仍在 layout 之后 paint 之前:在这一刻把位置改回
1682
- * 我方追赶点,那一跳根本画不出去(与 anchorRO 同一条路子)。只回补"本来就在贴底、且只被抢走一小段"的:
1683
- * 换会话 / 导航跳转这类大位移不碰(距离上限),读者自己滚(followOn 已翻假)也不碰。
1684
- * 附带好处:官方记录的 observedTop 停在 floor,我们回补后的位置与它差着一段追赶距离,
1685
- * 它下一次 onScroll 会判成"读者动过"→ 不再立刻 sample,pending 也就守住了。
1686
- */
1687
- const SNAP_UNDO_MAX = 600; // 一次最多回补多少像素:超过这个数就不是吸底抢跑,是有人在跳转
1688
- let guardRO = null;
1689
- let guardFlow = null;
1690
- let guardComposer; // 官方那条 RO 还 observe 了 composer:它一变也会吸底,所以我们也得盯(undefined=还没查过,null=查过没有)
1691
- /**
1692
- * 官方吸底的最后一条路是**直接赋值** `el.scrollTop = el.scrollHeight`(toBottom 与 followRef 都这么写),
1693
- * 采样标记压不住它,只能事后回补——而回补总有"晚一帧"的窗口(官方在别的绘制阶段落地那一跳就画出去了,
1694
- * 看着就是闪)。所以在滚动容器实例上装一个 setter:**只要位置还归我们管(snapOwn),就把这种"一步贴到底"
1695
- * 的写入直接丢掉**,位置从此只有我们一个写入者;读者/导航/换会话的写入照旧放行。
1696
- * 判据必须是"状态"而不是"我方最近写过":停在底部时帧循环本来就停了 —— 正是这段没写的空档里,输出一恢复,
1697
- * 官方那条 toBottom 就把一整块新内容瞬移到位(用户看到的就是"新内容一出现就被吸到底")。
1698
- * 撒手(读者接管 / 切官方档 / 换会话 / 放行官方按钮)时所有权立刻归还,官方行为原样恢复。
2167
+ * 写层:官方/第三方对 scrollTop 的写入只有两种意图(不再有旧的"距离门槛"、不再有 800ms 让路窗)——
2168
+ * 目标 == floor ⇒ 贴底写:writer=us 时吸收(恢复我方位置,由我们的节奏落屏);writer=reader 时原样放行。
2169
+ * 目标 ≠ floor ⇒ 原样放行 + 重设基准(followWrote = 落地值;导航中同步重设兜底窗):**不交权、不改 goal**。
2170
+ * (Lead 裁决的规则 2 改写:release 只剩"读者真实输入"与"会话边界"两个入口,release("external") 取消。)
1699
2171
  */
1700
2172
  let ownWrite = false; // 我方写入:setter 直接放行
1701
- let snapOwn = false; // 贴底位置归不归我们管。不能用"最近 800ms 写过"当判据:停在底部时帧循环本来就停了,
1702
- // 这一停就把接管判据晃没了——输出一停顿,下一个 chunk 到达时官方的瞬跳正好从空档漏过去。
1703
2173
  let snapTaken = null; // 已接管的容器:{ el }
1704
- let snapBlocked = 0; // 诊断:被丢掉的官方贴底写入次数
1705
- function writeTop(el, value) {
2174
+ let snapBlocked = 0; // 诊断:被吸收(丢掉)的官方贴底写入次数
2175
+ let extWrites = 0; // 诊断:观测到的 scrollBy/scrollIntoView 写入次数
2176
+ let lastExtWrite = null; // 诊断:最近一笔外部写 {which, from, landed, floor, stack}
2177
+ let protoIntoView = null; // Element.prototype.scrollIntoView 的原始实现(装一次;dispose 还原)
2178
+ let bootAt = 0; // 启动豁免起点(接管时置为 performance.now())
2179
+ const backfillLanded = (sc, from) => { requestAnimationFrame(() => { if (lastExtWrite !== null && lastExtWrite.from === from) lastExtWrite.landed = Math.round(sc.scrollTop); }); };
2180
+ /** 物理像素对齐(Windows 150% 缩放 dpr=1.5:1 物理像素 = 1/dpr 布局像素;浏览器把 scrollTop 吸附到物理像素)。 */
2181
+ const pxDpr = () => { const v = Number(window.devicePixelRatio); return Number.isFinite(v) && v > 0 ? v : 1; };
2182
+ const snapPx = (v) => { const d = pxDpr(); return d === 1 ? v : Math.round(v * d) / d; };
2183
+ let writeCount = 0; // 诊断:真实落地的 scrollTop 写次数(trace 的 writes 用它,不再用"推进帧数")
2184
+ /** 唯一写点:只有它改 scrollTop;followWrote 只在写主容器时更新。
2185
+ * task-19:写入值先按物理像素对齐;对齐后与当前物理像素相同 ⇒ **不写**(余量留在 want-cur 上,下一帧自然累积)。 */
2186
+ const writeTally = {}; // task-22 诊断:本帧每笔写是谁发的(trace/probe 用)
2187
+ function writeTop(el, value, curHint, src) {
2188
+ const cur = curHint === undefined ? readTop(el) : curHint; // task-22:调用方已知当前值就别再读(每次读都会强制布局)
2189
+ const snapped = snapPx(value);
2190
+ if (src !== undefined) writeTally[src] = (writeTally[src] || 0) + 1;
2191
+ if (snapped === snapPx(cur)) return false; // 物理像素没变:空帧零成本(读回值可能未对齐,不能直接比数值)
1706
2192
  ownWrite = true;
1707
- try { el.scrollTop = value; } finally { ownWrite = false; }
1708
- if (followOn || glide !== null) snapOwn = true; // 我们在带这段滚动 → 所有权跟着跟随状态走
2193
+ try { el.scrollTop = snapped; } finally { ownWrite = false; }
2194
+ writeCount += 1;
2195
+ moveCount += 1; // 只统计真的落地过的写(bootOver 判据同源)
2196
+ if (el === followScroller) followWrote = readTop(el); // 浏览器可能钳位:以落地值为准
2197
+ return true;
1709
2198
  }
1710
- const snapSetters = new WeakMap(); // el -> 我们装上去的那个访问器(认亲:旧实例 dispose 迟到会把它删掉)
1711
2199
  function takeOverSnap(el) {
1712
- // 只在"当前装着的确实还是我们自己那个访问器"时早退:旧实例的 disposer 迟到会 delete 掉它,而 snapTaken
1713
- // 还指着同一个元素 —— 原来会永远不再重装(吸底过滤静默失效,症状正是"新内容一出现就被吸到底")。
1714
- if (snapTaken !== null && snapTaken.el === el) {
1715
- const cur = Object.getOwnPropertyDescriptor(el, "scrollTop");
1716
- if (cur !== undefined && cur.set === snapSetters.get(el)) return;
1717
- }
2200
+ if (snapTaken !== null && snapTaken.el === el) return;
1718
2201
  const desc = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop");
1719
2202
  if (!desc || typeof desc.get !== "function" || typeof desc.set !== "function") return;
1720
- if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } }
2203
+ if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; delete snapTaken.el.scrollTo; delete snapTaken.el.scrollBy; } catch { /* ignore */ } }
1721
2204
  const taken = { el };
1722
- const setter = function (value) {
1723
- const own = snapOwn && state.autoFollow !== false && (followOn || glide !== null);
1724
- if (!own) snapOwn = false; // 撒手(读者接管/切档/换会话)→ 位置立刻还给官方
1725
- // 判据一:只挡「落到(夹住后的)底部」的写入 —— 而且目标值必须按 **floor 认,不能按 scrollHeight**:0.1.7 官方 toBottom 走的是
1726
- // `ScrollFollow.jump → toBottom → ChatViewport.scrollToBottom`,写进来的是 `scrollHeight - clientHeight`
1727
- // (一手调用栈 + 实测值 4474 = 5703 - 1229)。旧判据 `value >= scrollHeight - 1` 因此永不命中
1728
- // (blocked 恒 0):官方每来一坨字把我们的追赶滞后一次抹平,帧循环下一帧再回补,位置一上一下 ——
1729
- // 就是「切会话后正文流式输出时整段对话上下跳」(steals 与 undo 成对增长)。
1730
- const floor = Math.max(0, el.scrollHeight - el.clientHeight);
1731
- // 判据二:接管期间(跟随中 / 滑行中)**一律**不许官方落底,不再受「离底 ≤600px」窗口限制 ——
1732
- // 那个窗口本意是区分「官方小步追赶」与「有人真在跳转」,但本会话内的导航/换会话/切档已由 snapOwn=false
1733
- // 覆盖;留着它,「单帧长高 >600px」和「滑行超过 600px」都会被当成跳转放行(一帧吸到底 / 半路被拽回底部)。
1734
- if (!ownWrite && own && value >= floor - 1
1735
- && (followOn || glide !== null || floor - el.scrollTop <= SNAP_UNDO_MAX)) { snapBlocked += 1; return; }
1736
- desc.set.call(el, value);
2205
+ /** 贴底写(目标 == floor)的唯一判据:writer=us ⇒ 吸收(恢复我方位置);writer=reader ⇒ 放行。
2206
+ * 非贴底 JS 写统一交给 afterForeignWrite()(task-12:先落地 → 微任务倒回 → 设为幂等目标)。 */
2207
+ const absorbTop = (next) => {
2208
+ const tAbs = performance.now();
2209
+ try {
2210
+ if (ownWrite) return false;
2211
+ if (state.mode !== "fold") return false; // 官方档位:吸收器停手,官方吸附原样生效(task-15 追加)
2212
+ const fl = Math.max(0, el.scrollHeight - el.clientHeight);
2213
+ if (fl - next > 1) return false; // 非贴底:交给 afterForeignWrite(它负责消费 navIntent)
2214
+ clearNavIntent(); // 贴底写也是一笔外来写:消费掉这个位
2215
+ // 除官方档外一律接管:读者态**吞掉不落地**(读者在哪就停在哪;写压根没落地 ⇒ 不需要回写 ⇒ 不破坏"读者态零程序化写")。
2216
+ // 例外:会话切换/容器变更(releaseReason === "reset")那一次是官方在"恢复位置",必须放行。
2217
+ if (writer === WRITER_READER && releaseReason !== "reset") { snapBlocked += 1; ensureTickLoop(); return true; }
2218
+ if (writer === WRITER_READER) return false;
2219
+ // goal=none(停在中途):官方吸附也不落地 —— "回尾"改由我们自己的判据(出现新 user 消息)负责。
2220
+ snapBlocked += 1;
2221
+ const keep = followWrote === null ? desc.get.call(el) : followWrote;
2222
+ if (desc.get.call(el) !== keep) { ownWrite = true; try { desc.set.call(el, keep); } finally { ownWrite = false; } }
2223
+ ensureTickLoop();
2224
+ return true; // 吸收:恢复我方位置,不让它落地
2225
+ } catch (err) { ioErrors.absorb += 1; ioErrors.last = "absorb: " + String((err && err.message) || err); return false; }
2226
+ finally { phaseAcc("absorb", tAbs); }
2227
+ };
2228
+ /** 落地后的统一处置(task-12:接管主容器**全部** JS 写)。规则:
2229
+ * ① 落点 == floor ⇒ 贴底写,维持现状(us 吸收 / reader 放行);
2230
+ * ② 落点 ≠ floor 且 writer===us ⇒ 这笔写算**我方目标**:先让它落地(官方写完立刻读回,必须读到真值),
2231
+ * 再在 queueMicrotask(paint 之前)把位置倒回旧值 + 设 goal={position, px},由唯一律平滑滑过去;
2232
+ * ③ writer===reader ⇒ 一律原样放行(已知限制:读者态下不接管外来定位,写进报告)。 */
2233
+ let claimSeq = 0; // 最近一次接管的序号:被更新的接管取代时不回倒
2234
+ const afterForeignWrite = (from, claimPx) => {
2235
+ const tAbs = performance.now();
2236
+ try {
2237
+ if (ownWrite) return;
2238
+ if (state.mode !== "fold") return; // 官方档位:不接管、不重设基准,全部原样放行(task-15 追加)
2239
+ const intent = hasNavIntent(); // 输入位:真实输入 ⇒ 导航
2240
+ clearNavIntent();
2241
+ const fl = Math.max(0, el.scrollHeight - el.clientHeight);
2242
+ const landed = desc.get.call(el);
2243
+ const sh1 = el.scrollHeight;
2244
+ const st0 = baseSt < 0 ? from : baseSt; // 帧基线(没有则退化为写点前的值)
2245
+ const sh0 = baseSh < 0 ? sh1 : baseSh;
2246
+ const compensated = Math.abs((landed - st0) - (sh1 - sh0)) <= 2; // 几何位:位移能否被同期内容高度变化解释
2247
+ const nav = intent || !compensated; // 两者取或
2248
+ const px = Number.isFinite(claimPx) ? claimPx : landed;
2249
+ baseSt = landed; baseSh = sh1; // 收尾:这笔写之后的观察基线
2250
+ if (fl - px <= 1) { // ① 贴底写:只有 scrollTo/scrollBy 落到 floor 的路径会走到这里
2251
+ // (赋值型贴底写在 absorbTop 就被吞了)。全接管后这里也不再"采纳为回尾"——
2252
+ // 回尾只认一条:**出现你新发的一条消息**(见 scanStructureInner 的边沿判据)。
2253
+ if (writer === WRITER_READER && releaseReason !== "reset") {
2254
+ snapBlocked += 1;
2255
+ if (from !== landed) { ownWrite = true; try { desc.set.call(el, from); } finally { ownWrite = false; } } // 落到 floor 也吞:恢复到读者位置
2256
+ return;
2257
+ }
2258
+ if (writer === WRITER_READER) { passedOfficial += 1; return; } // reset:让官方恢复到位(计数照旧记"放行")
2259
+ snapBlocked += 1;
2260
+ const keep = followWrote === null ? landed : followWrote;
2261
+ if (landed !== keep) { ownWrite = true; try { desc.set.call(el, keep); } finally { ownWrite = false; } }
2262
+ ensureTickLoop();
2263
+ return;
2264
+ }
2265
+ foreignLanded += 1;
2266
+ lastForeign = { which: "write", from: followWrote === null ? null : Math.round(followWrote), to: Math.round(px), floor: Math.round(fl), at: Math.round(performance.now()) };
2267
+ if (writer === WRITER_READER) { // ③ 读者:原样放行
2268
+ if (el === followScroller) followWrote = landed;
2269
+ return;
2270
+ }
2271
+ if (!nav) { // ④ 保位/对齐(无输入且位移可由布局解释):task-6 裁决 —— 放行 + 重设基准,不接管、不改 goal
2272
+ if (el === followScroller) followWrote = landed;
2273
+ if (navActive && goalKind === "position") goalPos += (landed - from); // 远跳期间官方分页 align:目标随视口一起平移,避免拉锯
2274
+ return;
2275
+ }
2276
+ if (Math.abs(px - from) <= 0.5) return; // 有导航意图但没动(如 scrollBy(0,0)):无可接管
2277
+ if (navActive && goalKind === "element") { // S1-1:元素滑行(回到提问处/置顶条)期间,外来写不得改目标
2278
+ if (el === followScroller) followWrote = landed; // 放行 + 上面已重设基线:目标不动,滑行继续
2279
+ resetVel(mainVel); tailWant = -1; // task-11:落点是外来跳变 ⇒ 作废旧方向的 approach 速度态(否则方向翻转后会零步停机)
2280
+ return;
2281
+ }
2282
+ const seq = ++claimSeq; // ② writer=us:已落地 ⇒ 微任务倒回 + 设我方目标
2283
+ queueMicrotask(() => {
2284
+ try {
2285
+ if (disposed || snapTaken === null || snapTaken.el !== el) return; // 收摊/换容器:不接管
2286
+ if (seq !== claimSeq) return; // 被更新的一笔接管取代
2287
+ if (writer !== WRITER_US) return; // 期间读者接管 / 官方档位:不接管
2288
+ ownWrite = true;
2289
+ try { desc.set.call(el, from); } finally { ownWrite = false; } // 同帧(paint 前)倒回旧值
2290
+ if (el === followScroller) followWrote = from;
2291
+ navigate({ kind: "position", px }); // 取回写权 + 一次性导航到落点
2292
+ } catch (err) { ioErrors.absorb += 1; ioErrors.last = "claim: " + String((err && err.message) || err); }
2293
+ });
2294
+ } catch (err) { ioErrors.absorb += 1; ioErrors.last = "settle: " + String((err && err.message) || err); }
2295
+ finally { phaseAcc("absorb", tAbs); }
1737
2296
  };
2297
+ const nativeScrollTo = el.scrollTo;
1738
2298
  Object.defineProperty(el, "scrollTop", {
1739
2299
  configurable: true,
1740
2300
  get() { return desc.get.call(el); },
1741
- set: setter,
2301
+ set(value) {
2302
+ if (ownWrite) { desc.set.call(el, value); return; } // 我方写入:直接落地(writeTop 随后回读基准)
2303
+ const from = desc.get.call(el);
2304
+ if (absorbTop(value)) return; // 贴底写 + us:吸收,不落地
2305
+ desc.set.call(el, value); // 先落地:官方写完立刻读回,必须读到真值(不许吞写)
2306
+ afterForeignWrite(from);
2307
+ },
1742
2308
  });
1743
- snapSetters.set(el, setter);
2309
+ try {
2310
+ // 关键:**不要**用固定形参 (a,b) —— 那会把 1 参的 scrollTo(options) 变成 2 参调用,
2311
+ // WebIDL 会因此选中 scrollTo(x,y) 重载、把 options 对象转成数字 0(Y 的「滑到 0」回归根因)。
2312
+ el.scrollTo = function () {
2313
+ if (arguments.length) {
2314
+ const a = arguments[0];
2315
+ const next = (a && typeof a === "object") ? Number(a.top) : Number(arguments[1]); // (x,y) 形态里纵向是 y
2316
+ if (Number.isFinite(next)) {
2317
+ const from = desc.get.call(el);
2318
+ if (absorbTop(next)) return;
2319
+ const r = Reflect.apply(nativeScrollTo, el, arguments);
2320
+ if (!ownWrite) afterForeignWrite(from, next);
2321
+ return r;
2322
+ }
2323
+ }
2324
+ const from = desc.get.call(el);
2325
+ const r = Reflect.apply(nativeScrollTo, el, arguments);
2326
+ if (!ownWrite) afterForeignWrite(from);
2327
+ return r;
2328
+ };
2329
+ const noteExternal = (which, before, claimPx) => {
2330
+ if (ownWrite) return;
2331
+ extWrites += 1;
2332
+ lastExtWrite = { which: which, from: before === null ? null : Math.round(before), landed: null, floor: Math.round(Math.max(0, el.scrollHeight - el.clientHeight)), stack: (() => { try { return String(new Error().stack || "").split("\n")[2] || ""; } catch { return ""; } })() };
2333
+ afterForeignWrite(before, claimPx);
2334
+ };
2335
+ const nativeScrollBy = el.scrollBy;
2336
+ try {
2337
+ el.scrollBy = function () {
2338
+ const before = desc.get.call(el);
2339
+ const r = Reflect.apply(nativeScrollBy, el, arguments);
2340
+ noteExternal("scrollBy", before);
2341
+ backfillLanded(el, before === null ? null : Math.round(before));
2342
+ return r;
2343
+ };
2344
+ } catch { /* ignore */ }
2345
+ if (protoIntoView === null) { // 只装一次:重复接管不叠加
2346
+ protoIntoView = Element.prototype.scrollIntoView;
2347
+ try {
2348
+ Element.prototype.scrollIntoView = function () {
2349
+ const sc = mainScroller();
2350
+ if (sc === null || !sc.contains(this) || ownWrite) return Reflect.apply(protoIntoView, this, arguments); // 非主容器内的调用:原样转发、不干预
2351
+ const arg = arguments.length ? arguments[0] : undefined;
2352
+ const block = arg && typeof arg === "object" ? arg.block : "start"; // boolean/缺省 = start(与浏览器一致)
2353
+ const target = scrollIntoViewTarget(sc, this, block); // 先自己算目标(要害 2),再走"落地→接管"
2354
+ const before = sc.scrollTop;
2355
+ const r = Reflect.apply(protoIntoView, this, arguments);
2356
+ noteExternal("scrollIntoView", before, target);
2357
+ backfillLanded(sc, Math.round(before));
2358
+ return r;
2359
+ };
2360
+ } catch { protoIntoView = null; }
2361
+ }
2362
+ } catch { /* ignore */ }
1744
2363
  snapTaken = taken;
1745
2364
  }
1746
- function guardSnap() {
1747
- // 官方档 = 插件整体撒手:observer 还挂着,没有这道门就会在官方自己吸底之后把位置拽回我们旧的 followWrote。
1748
- if (state.mode !== "fold") return;
1749
- const el = followScroller;
1750
- if (!el || (!followOn && glide === null) || followWrote === null) return;
1751
- const floor = Math.max(0, el.scrollHeight - el.clientHeight);
1752
- const back = floor - followWrote;
1753
- if (back <= 1 || back > SNAP_UNDO_MAX) return;
1754
- if (el.scrollTop < floor - 1) return; // 没被推到贴底:不是吸底
1755
- writeTop(el, followWrote); // 撤销,仍在 paint 之前
1756
- guardCount += 1;
1757
- }
1758
- function guardObserve(flow) {
1759
- if (!flow) return;
1760
- // 缓存:这个函数每帧都会被 mainScroller() 调到,querySelector 在长会话里要走完整棵子树,不能每帧查
1761
- if (flow === guardFlow && guardComposer !== undefined && (guardComposer === null || guardComposer.isConnected)) return;
1762
- const composer = followScroller === null ? null : followScroller.querySelector("[data-composer-seat]");
1763
- if (flow === guardFlow && composer === guardComposer) return;
1764
- if (guardRO === null) { try { guardRO = new ResizeObserver(guardSnap); } catch { guardRO = false; return; } }
1765
- if (guardRO === false) return;
1766
- guardRO.disconnect();
1767
- guardFlow = flow;
1768
- guardComposer = composer;
1769
- guardRO.observe(flow);
1770
- if (composer !== null) guardRO.observe(composer); // 官方那条 RO 也 observe 它:composer 一变它就会吸底
1771
- }
1772
2365
 
1773
2366
  /* -------------------------------------------------------------- 小窗打标 -- */
1774
2367
  /** 某一轮里最后一个匹配元素(运行中"最新那一个"就是它)。 */
@@ -1785,6 +2378,17 @@ function syncWindows(flow, running, collected, bodies, heights) {
1785
2378
  const now = Date.now();
1786
2379
  // ① 只有"正文已挂载"的块才需要小窗打标 / 揭示动画 / 收起拦截(收起只可能发生在展开着的块上)。
1787
2380
  // 扫描开始时一次遍历就拿到这些块,而不是对每个卡片各查一次子树——会话越长,这笔账越贵。
2381
+ // H19b:提问卡(LLM 提问)小窗强制展开 —— 仅"我们第一次看到它"时点一次;尊重用户之后的手动折叠。
2382
+ // 成本契约:合成点击会触发 React 同步提交(真机实测一次扫描 +30.4ms)⇒ **扫描只登记,点击挪到下一帧**;
2383
+ // 去重按「轮次」而不是卡片元素(React 重建卡片时元素变了,按元素记会重复点)。
2384
+ try {
2385
+ for (const card of flow.querySelectorAll('[data-tool="ask_user_question"],[data-tool="request_user_input"]')) {
2386
+ if (qCardHandled(card) || card.dataset.dshsfKeepOpen === "1" || qCardQueued.has(card)) continue;
2387
+ qCardQueued.add(card);
2388
+ qCardQueue.push(card);
2389
+ }
2390
+ if (qCardQueue.length > 0 && qCardRaf === 0 && typeof requestAnimationFrame === "function") qCardRaf = requestAnimationFrame(runQCardQueue);
2391
+ } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 120); } // F4:异常计数进诊断(不空吞)
1788
2392
  const wiredTools = new Set();
1789
2393
  const windowQueue = []; // 先只读、后集中写:量一个写一个会让下一次量强制重排(布局抖动)
1790
2394
  for (const body of bodies || flow.querySelectorAll(BODY_ANY)) {
@@ -1806,10 +2410,7 @@ function syncWindows(flow, running, collected, bodies, heights) {
1806
2410
  wireDisclosure(root, toolOpen, TOOL_BODY, (el) => { el.dataset.dshsfKeepClosed = "1"; delete el.dataset.dshsfToolAuto; });
1807
2411
  }
1808
2412
  for (const [body, height] of windowQueue) tagThinkWindow(body, height); // 集中写:这一轮里不再夹读取
1809
- // 已卸载的登记先清掉(与运行态无关):否则空闲期这些强引用会跨会话一直累积
1810
- for (const root of autoThinks) if (!root.isConnected) autoThinks.delete(root);
1811
- for (const root of autoTools) if (!root.isConnected) autoTools.delete(root);
1812
- if (!running) { sparks.focus(null); return; } // 跑完就不再磨金属:火星自然熄灭后画布一并摘掉
2413
+ if (!running) { activeWindow = null; sparks.focus(null); return; } // 跑完就不再磨金属:火星熄灭 + 跟尾对象清空(旧窗不再吸附)
1813
2414
  // ② 运行中只处理"本轮":自动展开最新那一个、被取代的我方窗口延迟折回。
1814
2415
  // 原来是扫整个会话的思考/工具根,会话一长就是几百次 closest + 子树查询。
1815
2416
  const lastTurn = collected ? collected.lastTurn : collectTurns().lastTurn;
@@ -1827,21 +2428,21 @@ function syncWindows(flow, running, collected, bodies, heights) {
1827
2428
  if (hit.length) newestTool = hit[hit.length - 1];
1828
2429
  }
1829
2430
  }
1830
- let budget = 3; // 每次扫描最多展开 3 个,避免加载时重渲染风暴
2431
+ // 合成点击挪到下一帧(与提问卡同一手法):React 的同步提交不再算进扫描成本。
1831
2432
  if (newestThink !== null && !newestThink.closest("[data-dshsf-folded]") && state.autoExpandReasoning !== false
1832
- && !thinkOpen(newestThink) && budget > 0 && inViewport(newestThink)) {
1833
- if (openThink(newestThink)) budget -= 1;
1834
- // 只有"还会变"的失败才值得续扫:用户收起过(keepOpen)/重试次数用满 = 永久不可达,
1835
- // 再排下去就是"屏幕上什么都没变也每 300ms 全量重扫"。
1836
- else if (newestThink.dataset.dshsfKeepOpen !== "1" && Number(newestThink.dataset.dshsfTries || "0") < 8) busyWork = true;
2433
+ && !thinkOpen(newestThink) && inViewport(newestThink)) {
2434
+ queueAutoOpen("think", newestThink);
2435
+ busyWork = true; // 还没真的打开:让扫描继续推进(重试节流由 openThink 自己的 260ms 管)
1837
2436
  }
1838
2437
  if (newestTool !== null && !newestTool.closest("[data-dshsf-folded]") && state.autoExpandTools !== false
1839
- && !toolOpen(newestTool) && budget > 0 && inViewport(newestTool)) {
1840
- if (openTool(newestTool)) budget -= 1;
1841
- else if (newestTool.dataset.dshsfKeepClosed !== "1" && Number(newestTool.dataset.dshsfToolTries || "0") < 6) busyWork = true;
1842
- }
1843
- // 火星只跟着"正在被磨的那一个窗":本轮最新的思考小窗(没展开就没有火星)
1844
- sparks.focus(newestThink ? newestThink.querySelector(THINK_BODY) : null);
2438
+ && !toolOpen(newestTool) && inViewport(newestTool)) {
2439
+ queueAutoOpen("tool", newestTool);
2440
+ busyWork = true;
2441
+ }
2442
+ // 火星与跟尾同源:本轮最新的思考正文(没展开就是 null)。activeWindow 由这里维护,与 sparks 开关无关。
2443
+ const newestBody = newestThink ? newestThink.querySelector(THINK_BODY) : null;
2444
+ if (activeWindow !== newestBody) { activeWindow = newestBody; if (newestBody !== null) ensureTickLoop(); } // 活动窗刚出现:排一帧做首屏贴底
2445
+ sparks.focus(newestBody);
1845
2446
  // 被取代的我方窗口:只遍历"我们自己开过的那几个"(通常 1~3 个),不再遍历本轮所有思考/工具根
1846
2447
  for (const root of autoThinks) {
1847
2448
  if (!root.isConnected) { autoThinks.delete(root); continue; }
@@ -1870,69 +2471,36 @@ function syncContextRows(flow, bodies, heights) {
1870
2471
  }
1871
2472
  }
1872
2473
 
1873
- /* -------------------------------------------------------- 穿插正文小窗 -- */
1874
- function clearTextWindows(row) {
1875
- if (!row.hasAttribute("data-dshsf-tw")) return; // 绝大多数行没有穿插正文小窗:省掉一次子树查询
1876
- row.removeAttribute("data-dshsf-tw");
1877
- for (const el of row.querySelectorAll("[data-dshsf-textwindow]")) {
2474
+ /* ------------------------------------------------ 穿插正文(保留 + 分隔线) -- */
2475
+ /** 升级/热重载残留:容器级一次清扫旧的「穿插正文限高小窗」(已不再套窗)。 */
2476
+ function clearTextWindows(scope) {
2477
+ for (const el of scope.querySelectorAll("[data-dshsf-textwindow]")) {
1878
2478
  el.removeAttribute("data-dshsf-textwindow");
1879
2479
  el.style.maxHeight = "";
1880
2480
  el.style.overflow = "";
1881
2481
  liveWindows.delete(el);
1882
2482
  }
2483
+ for (const row of scope.querySelectorAll("[data-dshsf-tw]")) row.removeAttribute("data-dshsf-tw");
1883
2484
  }
1884
- /** 过程块:思考、工具卡(含 bash 卡)、上下文注入、内联过程。它们有自己的折叠/内联滚动,
1885
- * 只用于诊断(probe().textWindowDetail):确认某个残留的正文壳里是不是过程块。 */
1886
- const PROCESS_BLOCK = THINK + ", " + TOOL_CARD + ", [data-context-injection-body], [data-turn-process-inline]";
1887
- /** 撤销一个块的窗标记:短块不套壳时用(阈值由紧到松变化后,旧壳也要拆掉)。 */
1888
- function unwindowText(el) {
1889
- if (el.dataset.dshsfTextwindow !== "1") return;
1890
- el.removeAttribute("data-dshsf-textwindow");
1891
- el.style.maxHeight = "";
1892
- el.style.overflow = "";
1893
- liveWindows.delete(el);
1894
- }
1895
- /** 折叠后仍保留的穿插正文行进限高小窗(答案行正文不套窗,只用一条线跟它分开)。 */
2485
+ /** 折叠后仍保留的穿插正文行:不套窗,只用答案行上的一条横线跟正式回答分开。 */
1896
2486
  function syncTextWindows(flow, collected) {
2487
+ clearTextWindows(flow || document); // 一次容器级清扫:旧的每行早退依赖 data-dshsf-tw,而它已无写入点 ⇒ 已失效
1897
2488
  const keepText = state.keepInterleavedText !== false;
1898
2489
  for (const turnRows of (collected ? collected.turns : collectTurns().turns).values()) {
1899
- const assistantRows = turnRows.filter((row) => rowKind(row) === "assistant-step");
1900
- const answerRow = answerRowOf(turnRows);
2490
+ const answerRow = answerRowOf(turnRows); // task-20 A11:复用 syncTurns 已算好的按轮 memo(answerRowCache)
2491
+ if (!keepText || answerRow === null) { // 原语义保持:不保留穿插正文 ⇒ 该轮 answer-sep 一律清掉
2492
+ if (answerRow) answerRow.removeAttribute("data-dshsf-answer-sep");
2493
+ continue;
2494
+ }
1901
2495
  let keptText = false;
1902
- for (const row of assistantRows) {
1903
- if (!keepText || row === answerRow || row.hasAttribute("data-dshsf-folded") || !hasText(row)) {
1904
- clearTextWindows(row);
1905
- continue;
1906
- }
1907
- const body = assistantBody(row);
1908
- if (!body) continue;
1909
- keptText = true;
1910
- // 穿插正文**不套窗**:给官方 markdown 容器外套一层限高/内滚,会把它内部的布局压坏 —— 实测表现为
1911
- // "文字被切一半"和"整块空白玻璃壳"。保留正文、直接自然排版才是最紧凑的。
1912
- // 这里同时把历史版本留下的壳拆掉(unwindowText 幂等)。
1913
- for (const child of Array.from(body.children)) unwindowText(child);
1914
- row.removeAttribute("data-dshsf-tw");
2496
+ for (const row of assistantRowsOf(turnRows)) { // task-20 A11:复用同一份 assistant 行列表;只判断"有没有"
2497
+ if (row === answerRow || row.hasAttribute("data-dshsf-folded") || !hasText(row)) continue;
2498
+ if (assistantBody(row)) { keptText = true; break; } // 穿插正文不再套限高小窗:只靠 answer-sep 横线与正式回答分开
1915
2499
  }
1916
- if (answerRow) answerRow.toggleAttribute("data-dshsf-answer-sep", keptText); // 有穿插正文才画线
2500
+ answerRow.toggleAttribute("data-dshsf-answer-sep", keptText); // 有穿插正文才画线
1917
2501
  }
1918
2502
  }
1919
2503
 
1920
- /* ---------------------------------------------------- 官方「对话显示」行 -- */
1921
- let nativeRowSeen = null; // 上次标记过的那条官方行:还连着且标记还在,就不必每扫描全文档查按钮
1922
- function syncNativeRow() {
1923
- const ours = document.querySelector(".dshsf-select");
1924
- if (ours === null && document.querySelector("[data-dshsf-native-row]") === null) { nativeRowSeen = null; return; }
1925
- if (ours !== null && nativeRowSeen !== null && nativeRowSeen.isConnected && nativeRowSeen.hasAttribute("data-dshsf-native-row")) return;
1926
- for (const button of document.querySelectorAll('button[aria-haspopup="menu"]')) {
1927
- if (ours !== null && ours.contains(button)) continue; // 别把自己的下拉当成官方那一行
1928
- const text = (button.textContent || "").trim();
1929
- if (!/^(简洁|标准|详细|完全展开|紧凑|Normal|Compact|Standard|Detailed|Verbose)(\s*[▾⌄v])?$/.test(text)) continue;
1930
- const row = button.closest('[class*="_row"]');
1931
- if (!row) continue;
1932
- if (ours !== null) { row.setAttribute("data-dshsf-native-row", ""); nativeRowSeen = row; }
1933
- else { row.removeAttribute("data-dshsf-native-row"); nativeRowSeen = null; }
1934
- }
1935
- }
1936
2504
 
1937
2505
  /* ------------------------------------------------------ 火星特效(独立) -- */
1938
2506
  /**
@@ -1957,7 +2525,7 @@ const SPARK_LIT_MAX = 2.4; // 亮度曲线满值(画的时候映射到描
1957
2525
  const SPARK_LIT_LO = 0.35; // 曲线起点:u(速度/基准)低于它基本不发光
1958
2526
  const SPARK_LIT_HI = 5; // 曲线顶端:u 到这里算拉满
1959
2527
  const SPARK_LIT_CURVE = 0.6; // 曲线形状:<1 = 低段抬起来(压缩),1 = 线性
1960
- const SPARK_EMIT_MAX = 1.8; // 满强度发射率倍率(与旧版那个恒定值一致,密度观感不变)
2528
+ const SPARK_EMIT_MAX = 1.8; // 满强度发射率倍率(恒定发射率:密度观感不随输出速度变化)
1961
2529
  const SPARK_EMIT_BASE = 0.55; // 最慢时的发射率占比:速度只做温和的疏密变化,慢输出也不至于没火星
1962
2530
  const SPARK_REF_MIN = 0.35; // 基准速度下限(px/16ms):输出很慢时也保留灵敏度
1963
2531
  const SPARK_REF_MAX = 2.5; // 基准速度上限:到顶后不再按输出速度归一化 → 亮度继续随绝对速度涨
@@ -1990,7 +2558,6 @@ function createSparkFx() {
1990
2558
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
1991
2559
  const tone = () => { core = rgb.map((c) => Math.round(c + (255 - c) * 0.75)).join(","); };
1992
2560
  tone();
1993
- // 注意:这一句**不能**缓存成一次性结果 —— 系统"减弱动态效果"中途变化必须立刻生效(test/spark.mjs 有断言)。
1994
2561
  const reduced = () => { try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; } };
1995
2562
  const enabled = () => opt.on && !reduced();
1996
2563
  const enabledForge = () => enabled() && opt.forge;
@@ -2070,7 +2637,7 @@ function stats() {
2070
2637
  * flow = EMA(|ΔscrollHeight| / dt16) 当前输出速度(内容长得多快,τ=200ms)
2071
2638
  * ref = clamp(flow*0.6, 0.35, 2.5) **基准速度只在小速度侧跟着输出速度走**:
2072
2639
  * 输出慢 → 基准小,一点点滚动就看得出;输出快到 ref 封顶后就不再归一化,
2073
- * 亮度继续随绝对速度上涨(快输出更烫)。旧版 ref 与输出速度等比(上限 6),等于把速度
2640
+ * 亮度继续随绝对速度上涨(快输出更烫)。若 ref 与输出速度等比(上限 6),等于把速度
2074
2641
  * 信息除掉了 —— 快慢都稳稳顶在天花板上,这才是"看不出随速度变化"的原因。
2075
2642
  * u = speed / ref 1 ≈ 恰好跟上输出
2076
2643
  * k = clamp((u-0.35)/(5-0.35),0,1)^0.6 压缩曲线:低段抬起来、高段放缓,单调、无台阶、无硬顶
@@ -2411,10 +2978,8 @@ const sparks = createSparkFx();
2411
2978
  /* ----------------------------------------------------------- 控制器 -- */
2412
2979
  let scheduled = false;
2413
2980
  let scanTimer = 0; // schedule 里那个补扫定时器
2414
- let readyTimer = 0; // [streamfold] ready 那条日志的句柄:卸载后不该再打
2415
2981
  let lastScan = 0;
2416
2982
  let allowAnim = false; // 首屏不做动效(几十个过渡是加载卡顿主因)
2417
- let stoodDown = false; // 官方档位下是否已撒手(重活只做一次)
2418
2983
  let observer = null;
2419
2984
  const SCAN_INTERVAL = 300;
2420
2985
 
@@ -2422,7 +2987,7 @@ function scanStructure() {
2422
2987
  if (disposed) return;
2423
2988
  try { scanStructureInner(); }
2424
2989
  catch (error) { cost.lastError = String((error && error.message) || error); throw error; } // 扫描里抛错要看得见,而不是静默少记一次
2425
- finally { scanAnimateAll = false; justFinishedTurn = null; } // 官方档早退 / 抛错都不许把这个「本次扫描」标志留给下一次(否则切回折叠档整批历史行会播动画)
2990
+ finally { scanRows = null; scanRunningMark = null; scanRunningKnown = false; } // task-19 A9:扫描作用域结束(含抛错)
2426
2991
  }
2427
2992
  function scanStructureInner() {
2428
2993
  const t0 = performance.now();
@@ -2435,32 +3000,42 @@ function scanStructureInner() {
2435
3000
  cost.phases.hasText = 0;
2436
3001
  cost.phases.cards = 0;
2437
3002
  answerRowCache = new Map(); // 每次扫描重算:行还在变(流式),不能跨扫描复用
3003
+ // task-19 A9:扫描开头把行列表与 RUNNING 标记各查一次;之后 collectTurns/userRows/pinSource 全复用这一份
3004
+ scanRows = null; scanRunningKnown = true; scanRunningMark = null;
3005
+ scanRows = chatRows();
3006
+ scanRunningMark = (chatScope() || document).querySelector(RUNNING);
2438
3007
  // eslint-disable-next-line no-use-before-define
2439
3008
  rootVars();
2440
3009
  syncSession(); // 换会话先把串号状态清掉(轮号复用会让运行中的轮次被折)
2441
3010
  const flow = chatScope();
2442
3011
  if (state.mode !== "fold") {
2443
- // 官方档位 = 插件整体撒手:不留任何我们的标记/自绘控件/跟随接管/置顶条(重活只在切过来的那一次做)。
2444
- if (!stoodDown) { stoodDown = true; standDown(flow); }
2445
- // busyWork 留着会让 schedule 每 300ms 自续(永久空转);wasRunning 留着会在切回折叠时误触发"跑完回到提问处"。
2446
- busyWork = false;
2447
- wasRunning = false;
2448
- syncNativeRow(); // 设置页那一行仍由我们接管:两种模式都只显示一个下拉
3012
+ // 顺序契约:先展开/删条,再放手。unfoldAll / clearChips 是按 data-dshsf-row / data-dshsf-chip 找目标的,
3013
+ // 先 releaseOurVisuals 会把这两个标记先摘掉 ⇒ 折叠行展不开、折叠条删不掉,只有刷新页面(React 重渲染)才恢复。
3014
+ if (flow) { unfoldAll(flow); clearChips(flow); }
3015
+ releaseOurVisuals(); // task-30:完全放手 —— 连小窗正文的内联限高/变量/数据集与残余标记一起清
3016
+ activeWindow = null; // 官方档:跟尾对象同样清空
3017
+ sparks.focus(null);
3018
+ sparks.forge(null);
3019
+ pinRows = pinSource(chatRows()); // H19f:native 分支此前只收 user 行(缺回答)
3020
+ if (pinPick >= pinRows.length) pinPick = -1;
2449
3021
  return;
2450
3022
  }
2451
- stoodDown = false;
2452
3023
  if (!flow) return;
2453
3024
  // 运行态带粘性:标记一消失就当成跑完,会在步骤/工具交接的空档里折一次(用户看到"运行中全折起来")。
2454
3025
  // 只有连续 RUN_GRACE_MS 看不到任何运行标记,才算这一轮结束 —— 收尾的折叠只做这一次。
2455
3026
  const stamp = performance.now();
2456
3027
  const collected = collectTurns(); // 提前取一次:运行判定要用最后一轮
2457
- // 契约自检:官方每一轮都会渲染一个 [data-turn-tail](收尾标记就挂在它上面)。已经有 2 轮以上却一个都没有,
2458
- // 说明是内核换了 DOM(而不是"首轮还没跑完")——此时 openLastTurn 恒真、最后一轮永不折叠,只报警不猜。
2459
- if (!contractWarned && collected.turns.size >= 2 && flow.querySelector("[data-turn-tail]") === null) {
2460
- contractWarned = true;
2461
- console.warn("[streamfold] 找不到官方的 [data-turn-tail] 收尾标记:运行态判定可能与内核不符,请跑 __dshStreamfold.rows() 核对");
2462
- }
2463
- const markRunning = flow.querySelector(RUNNING) !== null;
3028
+ // 回尾:**出现你新发的一条消息** ⇒ 滑回尾部(内容侧边沿判据,不是时间窗)。
3029
+ // 官方自己就是这么干的(ui-chat:4961 ownInput ⇒ followTail),我们接管吸附后这一条必须自己认。
3030
+ {
3031
+ let newestUser = null;
3032
+ for (let i = collected.rows.length - 1; i >= 0; i -= 1) { if (rowKind(collected.rows[i]) === "user") { newestUser = collected.rows[i]; break; } }
3033
+ const uKey = newestUser === null ? null : (newestUser.getAttribute("data-chat-flow-key") || (newestUser.getAttribute("data-chat-turn") || "") + ":" + String(newestUser.textContent || "").length);
3034
+ if (uKey !== null && lastUserKey !== null && uKey !== lastUserKey && state.returnToTail !== false
3035
+ && !(writer === WRITER_US && goalKind === "tail")) navigate({ kind: "tail" });
3036
+ if (uKey !== null) lastUserKey = uKey;
3037
+ }
3038
+ const markRunning = scanRunningMark !== null; // task-19 A9:复用扫描开头那次 RUNNING 查询(flow === chatScope())
2464
3039
  if (markRunning) lastRunningAt = stamp;
2465
3040
  else if (runningSticky) {
2466
3041
  // 标记已经没了、粘性还在:跑完那一刻之后没有 DOM 变化就不会再有扫描,
@@ -2473,10 +3048,19 @@ function scanStructureInner() {
2473
3048
  // (模型在两步之间、工具在跑、刚提交还没出字,这些空档都不该被当成"跑完")
2474
3049
  const openLastTurn = collected.lastTurn !== null && !turnClosed(collected.lastTurn);
2475
3050
  const running = markRunning || runningSticky || openLastTurn;
2476
- if (settleHoldTurn !== null && stamp > settleHoldUntil) { settleHoldTurn = null; settleHoldUntil = 0; scanAnimateAll = true; } // 兜底放行:滑行被别处取消时别把折叠无限期拖住(这仍是收尾折叠,照样播动画)
2477
- const justFinished = wasRunning && !running;
2478
- justFinishedTurn = justFinished && collected.lastTurn !== null ? String(collected.lastTurn) : null; // 收尾折叠的强制动画对象
2479
- if (justFinished) settleToPrompt(flow, collected.rows); // 刚跑完这一轮(「移到提问处」在这里排一次)
3051
+ if (wasRunning && !running) { // 刚跑完这一轮:先起滑,本轮折叠压到滑完(settleTurn)
3052
+ const turn = collected.runningTurn === null || collected.runningTurn === undefined ? collected.lastTurn : collected.runningTurn;
3053
+ if (settleToPrompt(flow)) { settleTurn = turn; settleUntil = performance.now() + SETTLE_MAX_MS; }
3054
+ if (settleTurn !== null && settleTurn === turn) { // S2-1:刚起滑 ⇒ 把兜底预算换成与 navBudget 同源(长滑行不得被 1400ms 切断)
3055
+ const sc = mainScroller();
3056
+ let dist = 0;
3057
+ if (sc !== null && goalEl !== null && goalEl.isConnected) {
3058
+ const floor = Math.max(0, sc.scrollHeight - sc.clientHeight);
3059
+ dist = Math.abs(Math.max(0, Math.min(floor, viewTop(sc, goalEl) - SETTLE_PAD)) - sc.scrollTop);
3060
+ }
3061
+ settleUntil = performance.now() + Math.max(SETTLE_MAX_MS, navBudget(dist));
3062
+ }
3063
+ }
2480
3064
  wasRunning = running;
2481
3065
 
2482
3066
  busyWork = false;
@@ -2494,12 +3078,18 @@ function scanStructureInner() {
2494
3078
  heights.set(body, body.getBoundingClientRect().height);
2495
3079
  }
2496
3080
  }
2497
- pinRows = collected.rows.filter((el) => el.getAttribute("data-chat-flow-kind") === "user"); // 置顶条数据源:复用这份行快照,不再自己遍历一遍
3081
+ // H19c:置顶条 = 用户提问行(照旧,不替换)∪ ——仅当「提问不入折叠」开启时—— 提问的**用户回答**
3082
+ // 按**真实 flow kind** 匹配:reply 行的行级标志是 data-chat-flow-kind="question-reply"(本文件 976 行既有判定用的就是它);
3083
+ // [data-question-reply] 只是提问卡**内部**的气泡,卡本身不是 collected.rows 里的行 ⇒ 之前用它匹配必然为空。
3084
+ pinRows = pinSource(collected.rows); // H19f:与 native 分支共用同一个 pinSource()
2498
3085
  if (pinPick >= pinRows.length) pinPick = -1;
2499
3086
  cost.phases.collect = +(performance.now() - tC).toFixed(2);
2500
3087
  const turnKeys = Array.from(collected.turns.keys());
2501
3088
  hotRows = new WeakSet();
2502
- for (const key of turnKeys.slice(-2)) for (const row of collected.turns.get(key)) hotRows.add(row);
3089
+ // A8:热集 = 最近 3 轮 ∪ 运行中的轮(更老的轮次靠 observer 定向失效,不靠热集)
3090
+ for (const key of new Set([...turnKeys.slice(-3), collected.runningTurn].filter((k) => k !== null && k !== undefined))) {
3091
+ for (const row of collected.turns.get(key) || []) hotRows.add(row);
3092
+ }
2503
3093
  let t1 = performance.now();
2504
3094
  syncWindows(flow, running, collected, bodies, heights);
2505
3095
  cost.phases.windows = +(performance.now() - t1).toFixed(2);
@@ -2514,9 +3104,9 @@ function scanStructureInner() {
2514
3104
  cost.phases.ctx = +(performance.now() - t1).toFixed(2);
2515
3105
  const ms = performance.now() - t0;
2516
3106
  cost.lastMs = +ms.toFixed(2);
2517
- cost.counts.layouts = layoutReads;
2518
3107
  cost.scanMs += ms;
2519
3108
  scanSamples.push(ms);
3109
+ scanMsSinceFrame += ms; scanCountSinceFrame += 1;
2520
3110
  if (scanSamples.length > SCAN_SAMPLES) scanSamples.shift();
2521
3111
  if (ms > cost.maxMs) {
2522
3112
  cost.maxMs = +ms.toFixed(2);
@@ -2529,7 +3119,6 @@ function scanStructureInner() {
2529
3119
  dom: { rows: collected.rows.length, bodies: bodies.length, nodes: flow.getElementsByTagName("*").length },
2530
3120
  };
2531
3121
  }
2532
- syncNativeRow();
2533
3122
  }
2534
3123
  function scan() {
2535
3124
  lastScan = performance.now();
@@ -2545,10 +3134,9 @@ function schedule() {
2545
3134
  const now = performance.now();
2546
3135
  const wait = SCAN_INTERVAL - (now - lastScan);
2547
3136
  if (disposed) return;
2548
- if (scanTimer) { clearTimeout(scanTimer); scanTimer = 0; } // 同一个节流窗口会被排很多次:先撤旧的,只留一个待触发
2549
3137
  if (wait <= 0) { lastScan = now; scanStructure(); }
2550
3138
  else scanTimer = setTimeout(schedule, wait); // 被节流挡下的这次变化,到点补扫一遍(别丢)
2551
- if (busyWork) { if (scanTimer) clearTimeout(scanTimer); scanTimer = setTimeout(schedule, SCAN_INTERVAL); } // 还有没做完的展开/收起,继续排队
3139
+ if (busyWork) scanTimer = setTimeout(schedule, SCAN_INTERVAL); // 还有没做完的展开/收起,继续排队
2552
3140
  ensureTickLoop(); // 跟随统一走帧循环,一帧只推进一次
2553
3141
  });
2554
3142
  }
@@ -2562,7 +3150,6 @@ function rowsReport() {
2562
3150
  runningTurn,
2563
3151
  lastTurn,
2564
3152
  lastTurnClosed: turnClosed(lastTurn),
2565
- tailNodes: flow.querySelectorAll("[data-turn-tail]").length, // 官方收尾标记节点数(本会话,≥2 轮时不该是 0)
2566
3153
  turns: [...turns.entries()].slice(-6).map(([turn, list]) => ({
2567
3154
  turn,
2568
3155
  rows: list.length,
@@ -2630,8 +3217,6 @@ function probe() {
2630
3217
  lastTurn,
2631
3218
  runningMark: flow.querySelector(RUNNING) ? "yes" : "no",
2632
3219
  officialView: nativeValue, // 折叠模式下应为官方「不折叠」档(旧内核 normal,0.1.7 起 verbose),不能是 compact
2633
- bound: lowerBound(), // 小窗底边不许越过的下边界(composer 上沿,视口坐标);null = 没认到 composer 座位
2634
-
2635
3220
  // 回底按钮自检:n=我方注入的按钮数、vis=当前可见数,detail=每个小窗的状态
2636
3221
  jumps: {
2637
3222
  n: document.querySelectorAll("[data-dshsf-jump]").length,
@@ -2641,16 +3226,21 @@ function probe() {
2641
3226
  has: el.querySelector(":scope > [data-dshsf-jump]") ? 1 : 0,
2642
3227
  sub: el.querySelectorAll("[data-dshsf-jump]").length,
2643
3228
  gap: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop),
2644
- bottom: Math.round(el.getBoundingClientRect().bottom), // 应与 probe().bound 对齐(不许超过它)
2645
- follow: el.dataset.dshsfFollow === "1" ? 1 : 0,
3229
+ follow: winState.get(el) !== undefined && winState.get(el).writer === "us" ? 1 : 0,
3230
+ active: el === activeWindow ? 1 : 0,
2646
3231
  vis: el.querySelector(":scope > [data-dshsf-jump][data-dshsf-visible]") ? 1 : 0,
2647
3232
  })),
2648
3233
  },
2649
- perf: { ticks: tickCount, announces: announceCount }, // 帧循环次数 / 补发 scroll 次数
3234
+ perf: { ticks: tickCount, announces: announceCount, tickErrors, lastTickError }, // 帧循环次数 / 补发 scroll 次数 / 循环内异常
3235
+ trace: trace.slice(), // 最近 240 帧的帧轨迹 {dt,writes,announces,tickMs,scans,step,gap,writer,goal}(直接 JSON.stringify 粘回来;空闲不记)
2650
3236
  // 我们自己的 JS 成本:帧循环平均每帧多少毫秒、结构扫描平均每次多少毫秒(不含浏览器布局/绘制)
2651
3237
  cost: {
2652
3238
  frames: cost.frames,
2653
3239
  tickAvgMs: +(cost.tickMs / Math.max(1, cost.frames)).toFixed(3),
3240
+ tickP50: percentile(tickSamples, 0.5), tickP95: percentile(tickSamples, 0.95),
3241
+ tickMax: tickSamples.length ? Math.max.apply(null, tickSamples) : 0,
3242
+ pinRects,
3243
+ tickPhases: (() => { const n = Math.max(1, tickPhase.n); return { plan: +(tickPhase.plan / n).toFixed(2), main: +(tickPhase.main / n).toFixed(2), mainPlan: +(tickPhase.mainPlan / n).toFixed(2), mainSlide: +(tickPhase.mainSlide / n).toFixed(2), mainGate: +(tickPhase.mainGate / n).toFixed(2), pin: +(tickPhase.pin / n).toFixed(2), win: +(tickPhase.win / n).toFixed(2), winStyle: +(tickPhase.winStyle / n).toFixed(2), winSlide: +(tickPhase.winSlide / n).toFixed(2), winJump: +(tickPhase.winJump / n).toFixed(2), sparks: +(tickPhase.sparks / n).toFixed(2), announce: +(tickPhase.announce / n).toFixed(2), absorb: +(tickPhase.absorb / n).toFixed(2), other: +(tickPhase.other / n).toFixed(2) }; })(),
2654
3244
  scans: cost.scans,
2655
3245
  scanAvgMs: +(cost.scanMs / Math.max(1, cost.scans)).toFixed(2), // 含加载时那一次全量折叠,会被它拉高
2656
3246
  scanLastMs: cost.lastMs, // 最近一次扫描
@@ -2668,32 +3258,29 @@ function probe() {
2668
3258
  skipped: document.querySelectorAll('[hidden="until-found"]').length, // 已跳过渲染的折叠行(应能 Ctrl+F 搜到)
2669
3259
  searchBodies: document.querySelectorAll("[data-dshsf-search]").length, // 为搜索而挂载并藏起的思考正文数
2670
3260
  heapMb: (() => { const m = performance.memory; return m ? Math.round(m.usedJSHeapSize / 1048576) : null; })(),
2671
- // 主窗口贴底自检:on=是否跟随、gap=离底像素、intentAgo=最近一次读者输入距今毫秒
2672
- settleLog: settleLog.slice(-8),
2673
- foldLog: foldLog.slice(-8),
2674
- settleHold: settleHoldTurn,
2675
- animAll: scanAnimateAll, // 本次扫描是否被标记为「用户正看着的折叠」(诊断:跨扫描残留会看得见)
3261
+ // 主窗口运动自检:writer=谁写、goal=我方目标、nav=一次性导航中、gap=离底像素
2676
3262
  mainFollow: (() => {
2677
3263
  const el = mainScroller();
2678
3264
  if (!el) return null;
2679
- return { on: followOn, gap: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop), intentAgo: Math.round(performance.now() - scrollIntentAt), height: followHeight, wrote: followWrote === null ? null : Math.round(followWrote), steals: stealCount, undo: undoCount, undoRO: guardCount, moves: moveCount, stealMax, stealTail, blocked: snapBlocked, flips: followFlips.slice(-6),
3265
+ const f = Math.max(0, el.scrollHeight - el.clientHeight);
3266
+ let goalWant = null;
3267
+ if (goalKind === "tail") goalWant = f;
3268
+ else if (goalKind === "position") goalWant = Math.max(0, Math.min(f, goalPos));
3269
+ else if (goalKind === "element" && goalEl !== null && goalEl.isConnected) goalWant = Math.max(0, Math.min(f, viewTop(el, goalEl) - SETTLE_PAD));
3270
+ return { writer, goal: goalKind, navActive: navActive ? 1 : 0, gap: Math.round(f - el.scrollTop), height: followHeight, wrote: followWrote === null ? null : Math.round(followWrote), moves: moveCount, blocked: snapBlocked, passed: passedOfficial, foreign: foreignLanded, extWrites: extWrites, lastExtWrite: lastExtWrite, lastForeign: lastForeign, flips: followFlips.slice(-6),
3271
+ step: lastStep, // 上一帧 {writer, goal, gap, base, rate(px/帧), vMax(px/s), vFeed, vChase, a, dt, moving, nav}
3272
+ settle: { turn: settleTurn === null ? null : String(settleTurn), until: Math.round(settleUntil), last: lastSettle }, // A14:真机才能看到 lastSettle.why
3273
+ abort: { count: navAbortCount, last: lastNavAbort }, // task-14:区分"卡住终止"(why=stuck)与"settle 超时"(lastSettle.why=timeout)
3274
+ nav: lastNav, // 最近一次一次性导航:kind / why=done|timeout|reduced-motion / to / ms
3275
+ releaseReason, rearmCount, releaseBy: { ...releaseBy }, ioErrors: { ...ioErrors }, capRejected, accelRejected,
3276
+ want: goalWant === null ? null : Math.round(goalWant), left: goalWant === null ? null : Math.round(goalWant - el.scrollTop),
2680
3277
  officialJump: document.querySelector("[class*='_toBottomSlot']") === null ? 0 : 1, // 官方那颗在不在 DOM(由官方 state 决定)
2681
3278
  jumpGate: document.documentElement.hasAttribute("data-dshsf-jumpgate") ? 1 : 0, // 我们的放行开关(= 我们判定该显示)
2682
3279
  pin: pinBar !== null && pinBar.hasAttribute("data-dshsf-visible") ? 1 : 0,
2683
- pinTurn: pinRow === null ? null : pinRow.getAttribute("data-chat-turn"),
2684
- settle: glide === null ? null : { top: Math.round(viewTop(el, glide.el) - SETTLE_PAD), left: Math.round(Math.max(0, viewTop(el, glide.el) - SETTLE_PAD) - el.scrollTop) } };
3280
+ pinTurn: pinRow === null ? null : pinRow.getAttribute("data-chat-turn") };
2685
3281
  })(),
2686
3282
  keepText: state.keepInterleavedText !== false ? 1 : 0,
2687
3283
  textWindows: document.querySelectorAll("[data-dshsf-textwindow]").length,
2688
- // 每个正文小窗的现场:cls=class、len=可见文本长度、h=实测高度、sh=内容高度、process=是否含过程块
2689
- // (len 小、h 大 = 空壳/被官方折叠的内容;确认"多出来的空白窗体"是什么就看这里)
2690
- textWindowDetail: Array.from(document.querySelectorAll("[data-dshsf-textwindow]")).slice(-14).map((el) => ({
2691
- cls: String(el.className || el.tagName).slice(0, 44),
2692
- len: (el.textContent || "").trim().length,
2693
- h: Math.round(el.getBoundingClientRect().height),
2694
- sh: el.scrollHeight,
2695
- process: el.querySelector(PROCESS_BLOCK) !== null ? 1 : 0,
2696
- })),
2697
3284
  ctxBodies: document.querySelectorAll(CTX_BODY).length,
2698
3285
  // 各轮所有"带正文"的行:answer=1 是答案行(设计上不套窗);wins 是套上的正文窗数
2699
3286
  textRows: rows.filter((row) => rowKind(row) === "assistant-step" && hasText(row)).slice(-12).map((row) => ({
@@ -2753,7 +3340,6 @@ function probe() {
2753
3340
  }).filter((item) => item.vis > 1),
2754
3341
  anim: {
2755
3342
  attr: document.documentElement.hasAttribute("data-dshsf-anim"), // = 动效开关 && (交互或 4 秒后)
2756
- lastClose, // 最近一次收起:anim=是否播了动画、以及每个判据当时的值
2757
3343
  lastReveal,
2758
3344
  lastWindow, // 最近一次思考小窗:h=实测高度 cap=动画目标 now=40ms 时高度 anim=计算动画名
2759
3345
  setting: state.animations !== false,
@@ -2782,13 +3368,9 @@ function startController() {
2782
3368
  // 热重载/重复注入时,上一个实例可能还活着(disposer 没被调到)——那样按钮、帧循环、画布都会翻倍。
2783
3369
  // 先把上一个实例收掉,保证同一时刻只有一个控制器在前台。
2784
3370
  try { const prev = window.__dshStreamfold; if (prev && typeof prev.dispose === "function") prev.dispose(); } catch { /* ignore */ }
2785
- // disposed 是模块级的:同一个模块被二次 apply(重复注入/双 fiber)时,上面那句 prev.dispose()
2786
- // 会把新实例一起判死(scan/schedule/帧循环全部早退)——这里必须复位。
2787
- disposed = false;
2788
- ticking = false;
2789
3371
  for (const old of document.querySelectorAll("[data-dshsf-pin]")) { try { old.remove(); } catch { /* ignore */ } } // 同上:置顶条只留本实例那一个
2790
3372
  sparks.setOptions({ on: state.sparks !== false, color: state.sparkColor, density: state.sparkDensity, forge: state.forgeSparks !== false, forgeSpeed: state.forgeSpeed, forgeLife: state.forgeLife }); // localStorage 里的设置先落地(不等宿主)
2791
- scan();
3373
+ try { scan(); } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 200); } // F4:首次 scan 不得让插件半死
2792
3374
  // 开动效 = 标志位 + 立刻把 html[data-dshsf-anim] 置位(只改标志位要等下一次扫描,首次点开卡片常常赶不上)
2793
3375
  const wakeAnim = () => { if (!allowAnim) { allowAnim = true; rootVars(); } };
2794
3376
  const timer = setTimeout(wakeAnim, 4000);
@@ -2801,7 +3383,7 @@ function startController() {
2801
3383
  // 用户自己点开工具卡时,React 提交后给"新出现的正文"补播揭示动画。
2802
3384
  // 不能按 [data-tool] 找:bash 卡由官方 BashRow 渲染,根上没有 data-tool,只能按正文类名认。
2803
3385
  const onUserActivate = (event) => {
2804
- if (state.mode !== "fold" || synthetic || !(event.target instanceof Element)) return; // 官方档位:不拦点击、不播揭示动效
3386
+ if (synthetic || !(event.target instanceof Element)) return;
2805
3387
  const flow = chatScope();
2806
3388
  if (!flow.contains(event.target)) return; // 点的不在对话流里(侧栏/设置)→ 不扫
2807
3389
  if (event.target.closest(TOOL_BODY)) return; // 点的是正文内部(复制/选字/开文件),不是展开动作
@@ -2814,7 +3396,7 @@ function startController() {
2814
3396
  const prepped = thinkRoot !== null && thinkRoot.querySelector("[data-dshsf-search]") !== null;
2815
3397
  clearSearchHidden(thinkRoot || anchorEl);
2816
3398
  if (prepped) { event.preventDefault(); event.stopPropagation(); } // 放行 = 官方收起 = 正文被卸载,搜索又白做
2817
- followOn = false;
3399
+ hold(); // 用户点击展开/收起 = 我方定位:取回写权,官方贴底写不再拖我们,位置交给 anchorDuring
2818
3400
  anchorDuring(anchorEl, anchorEl.getBoundingClientRect().top, 500);
2819
3401
  queueMicrotask(anchorSync); // React 在同一个 click 里提交,微任务阶段补一次(仍在绘制之前)
2820
3402
  }
@@ -2831,58 +3413,63 @@ function startController() {
2831
3413
  // 在这里把那一轮瞬时展开——搜索照样能命中,折叠省下的布局也不用还回去。
2832
3414
  const onBeforeMatch = (event) => {
2833
3415
  const target = event.target instanceof Element ? event.target : null;
2834
- const skipped = state.mode === "fold" && target && (target.closest('[hidden="until-found"]') || target.closest("[data-dshsf-folded]"));
3416
+ const skipped = target && (target.closest('[hidden="until-found"]') || target.closest("[data-dshsf-folded]"));
2835
3417
  if (!skipped) return;
2836
3418
  const row = skipped.closest(ROW) || skipped;
2837
3419
  clearSearchHidden(row); // 命中:把"为搜索而藏"的正文亮出来
2838
3420
  const turn = row.getAttribute("data-chat-turn");
2839
- if (turn) manualState.set(turn, "open");
2840
- followOn = false; // 搜到哪就看哪,别被贴底拖回底部
3421
+ if (turn) setManual(turn, "open");
3422
+ if (writer !== WRITER_READER) hold(); // Ctrl+F 已交权给读者 ⇒ 不抢回来;其它 beforematch(如锚点)维持旧行为
2841
3423
  const prev = allowAnim;
2842
3424
  allowAnim = false; // 瞬时展开:高度动画会把"滚到匹配处"带偏
2843
- try { scan(); } finally { allowAnim = prev; }
3425
+ try { scan(); } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 200); } finally { allowAnim = prev; } // F4:调用点兜底
3426
+ };
3427
+ const onFindKey = (event) => {
3428
+ if (!((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F"))) return;
3429
+ release("reader"); // 用户要求:Ctrl+F 一按就彻底撒手 —— 浏览匹配期间零程序化写,绝不会被拽回底部
3430
+ releaseWindows(); // 小窗同一语义:所有 liveWindows 撒手;搜索期间新登记/重挂载的窗也不贴底
3431
+ searchPrep();
2844
3432
  };
2845
- const onFindKey = (event) => { if ((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F")) searchPrep(); };
2846
3433
  document.addEventListener("click", onUserActivate, true);
2847
3434
  document.addEventListener("click", onToBottomClick, true);
3435
+ document.addEventListener("click", onNavIntentInput, true); // 真实输入 ⇒ 给下一笔外来写打"导航"位(task-13)
3436
+ window.addEventListener("keydown", onNavIntentInput, true);
2848
3437
  document.addEventListener("keydown", onUserKey, true);
2849
3438
  document.addEventListener("keydown", onFindKey, true);
2850
3439
  document.addEventListener("beforematch", onBeforeMatch, true);
2851
- // 行子树一变就把该行的「有没有正文」缓存作废:React 复用同一行节点换内容时,轮号可能不变、
2852
- // 只认轮号挡不住 —— 缓存说「没正文」会让承载正式回答的行被当过程行折掉(B3)。
3440
+ // 会话 id 就地更新不触发 childList:侦听该属性,微任务里立刻复位(不再等 SCAN_INTERVAL=300ms 的扫描)
2853
3441
  observer = new MutationObserver((records) => {
2854
- for (const rec of records) {
2855
- const node = rec.target;
2856
- const el = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
2857
- const row = el && el.closest ? el.closest(ROW) : null;
2858
- if (row) textCache.delete(row);
2859
- // 新挂上来的思考正文**当场**打小窗(MutationObserver 回调是微任务,赶在这一帧绘制之前):等 300ms 的节流扫描的话,
2860
- // 这段时间它会按原始高度铺开 —— 底边先掉到输入框下面,下一帧再"啪"地收成小窗(用户看到的"生成时掉一下")。
2861
- if (rec.type === "childList" && state.mode === "fold") {
2862
- for (const added of rec.addedNodes || []) {
2863
- if (!added || added.nodeType !== 1) continue;
2864
- const bodies = added.matches && added.matches(THINK_BODY) ? [added] : Array.from(added.querySelectorAll ? added.querySelectorAll(THINK_BODY) : []);
2865
- for (const body of bodies) {
2866
- const root = body.closest(THINK);
2867
- if (root && root.closest("[data-dshsf-folded]")) continue; // 折起来的行不打(与扫描同一判据)
2868
- tagThinkWindow(body);
2869
- }
3442
+ // A8:定向失效 —— 任意行(含很老的轮次)的正文在 DOM 里变了,清掉该行及其祖先行的 hasText 缓存;
3443
+ // 新增子树里若带着行(React 换节点)也同样失效。避免"已定轮正文不再变"的假设失效后拿到陈旧 hasText。
3444
+ for (const r of records) {
3445
+ const t = r.target;
3446
+ const node = t === null || t === undefined ? null : (t.nodeType === 1 ? t : (t.parentElement || null));
3447
+ for (let n = node; n !== null; n = n.parentElement) {
3448
+ if (n.nodeType === 1 && n.matches && n.matches(ROW)) { textCache.delete(n); cardCountCache.delete(n); pinTextOf.delete(n); }
3449
+ }
3450
+ if (r.type === "childList") {
3451
+ for (const add of r.addedNodes || []) {
3452
+ const el = add && add.nodeType === 1 ? add : null;
3453
+ if (el !== null && el.closest) { const row = el.closest(ROW); if (row !== null) { textCache.delete(row); cardCountCache.delete(row); pinTextOf.delete(row); } }
2870
3454
  }
2871
3455
  }
2872
3456
  }
3457
+ if (records.some((r) => r.type === "attribute")) syncSession();
2873
3458
  schedule();
2874
3459
  });
2875
- observer.observe(document.body, { childList: true, subtree: true, characterData: true });
3460
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["data-conversation-session"] });
2876
3461
  const unsubscribe = subscribe(schedule);
2877
3462
  const dispose = () => {
3463
+ if (disposed) return; // A3:幂等门 —— 旧 fiber 的 effect 清理可能在新实例装配之后再跑一次
2878
3464
  disposed = true;
3465
+ const ownsGlobals = window.__dshStreamfold !== undefined && window.__dshStreamfold.dispose === dispose; // A3:全局副作用所有权
3466
+ clearSettle("dispose"); // 收摊:协调作废(不再排扫描)
2879
3467
  ticking = false;
2880
3468
  if (rafId) { try { cancelAnimationFrame(rafId); } catch { /* ignore */ } rafId = 0; }
2881
3469
  clearTimeout(scanTimer);
2882
- clearTimeout(readyTimer);
2883
3470
  clearTimeout(runGraceTimer);
2884
- clearTimeout(searchPrepTimer); searchPrepTimer = 0; searchPrepRunning = false;
2885
- liveWindows.clear();
3471
+ clearTimeout(navWatchdog); navWatchdog = 0;
3472
+ navRecheck = null; // S1-4:收摊 ⇒ 观察窗口立即退出(无 rAF/timer 残留)
2886
3473
  unsubscribe();
2887
3474
  clearTimeout(timer);
2888
3475
  window.removeEventListener("pointerdown", wakeAnim, true);
@@ -2892,40 +3479,42 @@ function startController() {
2892
3479
  window.removeEventListener("resize", ensureTickLoop);
2893
3480
  document.removeEventListener("click", onUserActivate, true);
2894
3481
  document.removeEventListener("click", onToBottomClick, true);
3482
+ document.removeEventListener("click", onNavIntentInput, true);
3483
+ window.removeEventListener("keydown", onNavIntentInput, true);
2895
3484
  document.removeEventListener("keydown", onUserKey, true);
2896
3485
  document.removeEventListener("keydown", onFindKey, true);
2897
3486
  document.removeEventListener("beforematch", onBeforeMatch, true);
2898
3487
  if (followScroller) { followScroller.removeEventListener("scroll", onMainScroll); followScroller.removeEventListener("pointerdown", onScrollerPointerDown); followScroller = null; }
2899
- try { anchorStop(); } catch { /* ignore */ } // 锚定接管(anchorRO + overflowAnchor)也必须回收
2900
- try { if (guardRO && guardRO !== false) guardRO.disconnect(); } catch { /* ignore */ }
2901
- guardRO = null; guardFlow = null; guardComposer = undefined;
2902
- if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } snapTaken = null; }
3488
+ anchorStop(); // A2:收锚定补偿(还原 overflow-anchor / 断开 RO,否则卸载后还在补写)
3489
+ clearFoldTimers(); // A2:清错峰折叠定时器(否则卸载后还会被折叠)
3490
+ for (const el of document.querySelectorAll("[data-dshsf-chip]")) { try { el.remove(); } catch { /* ignore */ } } // chip 先摘:releaseOurVisuals 只摘属性、之后认不出它
3491
+ unfoldAll(document); // A2:摘行级折叠标记 + 行内 max-height/overflow(必须在 releaseOurVisuals 摘 data-dshsf-row 之前,否则查不到)
3492
+ releaseOurVisuals(); // A2:摘掉我方全部内联视觉痕迹(行/正文/小窗/折叠条/置顶条/动画标记)
3493
+ for (const el of document.querySelectorAll("[data-dshsf-jump]")) { try { el.remove(); } catch { /* ignore */ } } // 回底按钮:releaseOurVisuals 只收显隐
3494
+ liveWindows.clear(); // 放在 releaseOurVisuals 之后:它会经 clearSearchHidden→wireWindow 重新登记
3495
+ activeWindow = null;
3496
+ if (snapTaken !== null) { // A3:实例属性/原型补丁是全局副作用 —— 不属本实例就不还原(否则拆掉新实例的吸收器)
3497
+ if (ownsGlobals) { try { delete snapTaken.el.scrollTop; delete snapTaken.el.scrollTo; delete snapTaken.el.scrollBy; } catch { /* ignore */ } }
3498
+ snapTaken = null;
3499
+ }
3500
+ if (protoIntoView !== null && ownsGlobals) { // H4:原型补丁还原(幂等)。不属本实例:原型已被新实例接管,
3501
+ try { Element.prototype.scrollIntoView = protoIntoView; } catch { /* ignore */ } // 保留 protoIntoView 让本实例的包装层
3502
+ protoIntoView = null; // 仍可调用(否则新包装链 apply 到 null)
3503
+ }
2903
3504
  document.documentElement.removeAttribute("data-dshsf-jumpgate");
2904
- document.documentElement.removeAttribute("data-dshsf-mode"); // 卸载后别把「折叠档」标记留在 <html> 上
2905
- document.documentElement.removeAttribute("data-dshsf-anim");
2906
- for (const old of document.querySelectorAll("[data-dshsf-mainjump]")) { try { old.remove(); } catch { /* ignore */ } } // 旧版本自绘的容器:清掉
2907
3505
  if (pinBar !== null) { try { pinBar.remove(); } catch { /* ignore */ } pinBar = null; }
2908
3506
  pinRows = []; pinRow = null;
2909
3507
  if (observer) observer.disconnect();
2910
3508
  observer = null;
2911
- // 把自己改过的 DOM 还原:样式表已随 styles effect 移除,折叠行会停在 hidden="until-found"
2912
- // (不渲染)、折叠条成了没样式的死按钮 —— 关插件/卸载后只能刷新页面才恢复。
2913
- try {
2914
- unfoldAll(document);
2915
- clearChips(document);
2916
- for (const el of document.querySelectorAll("[data-dshsf-search]")) { el.removeAttribute("data-dshsf-search"); clearSkipHidden(el); } // 归属感知:别把官方挂的那份也摘了、也别留僵尸标记
2917
- for (const el of document.querySelectorAll("[data-dshsf-window], [data-dshsf-answer-sep]")) {
2918
- el.removeAttribute("data-dshsf-window"); el.removeAttribute("data-dshsf-answer-sep");
2919
- el.style.maxHeight = ""; el.style.overflow = "";
2920
- }
2921
- for (const el of document.querySelectorAll("[data-dshsf-jump]")) { try { el.remove(); } catch { /* ignore */ } }
2922
- } catch { /* ignore */ }
2923
3509
  sparks.reset();
2924
- if (window.__dshStreamfold && window.__dshStreamfold.dispose === dispose) delete window.__dshStreamfold;
3510
+ if (ownsGlobals) delete window.__dshStreamfold;
2925
3511
  };
3512
+ // task-57 H10:v11 契约的固定出口名(按优先级第一个)+ 兼容别名
3513
+ const diagFn = () => ({ lastStep: lastStep, writer, goal: goalKind, navActive: navActive ? 1 : 0, nav: lastNav, navAbort: navAbortCount, lastNavAbort, blocked: snapBlocked, passed: passedOfficial, foreign: foreignLanded, extWrites: extWrites, lastExtWrite: lastExtWrite, releaseBy: { ...releaseBy }, rearmCount, ioErrors: { ...ioErrors }, capRejected, accelRejected });
3514
+ try { window.__streamfoldDiag = diagFn; window.__dshStreamfoldDiag = diagFn; window.__sfDiag = diagFn; window.__dshsfProbe = diagFn; } catch { /* ignore */ }
2926
3515
  window.__dshStreamfold = { stats, rows: rowsReport, probe, scan, set: setSettings, get: snapshot, chooseDisplay, resetCost, state: () => ({ ...state, nativeValue }),
2927
3516
  fx: sparks, dispose }; // 调参/排障入口:__dshStreamfold.fx 可实时调火花参数
2928
- readyTimer = setTimeout(() => { if (disposed) return; try { console.info("[streamfold] ready", JSON.stringify(stats())); } catch { /* ignore */ } }, 2500);
3517
+ setTimeout(() => { try { console.info("[streamfold] ready", JSON.stringify(stats())); } catch { /* ignore */ } }, 2500);
2929
3518
  return dispose;
2930
3519
  }
2931
3520
 
@@ -2982,6 +3571,67 @@ function Select(props) {
2982
3571
  onClick: () => { setOpen(false); props.onChange(option.value); },
2983
3572
  }, option.label))) : null);
2984
3573
  }
3574
+ /* -------------------------------------------- 设置页:只留我们这一行 -- */
3575
+ /** T9:官方 slot item(id transcript-view)DOM 里没有 id ⇒ 按结构识别;我们自己的行加标记用于排除。 */
3576
+ const OWN_ROW_ATTR = "data-dshsf-own-row"; // 只打在我们自己的行上(排除用,避免误伤别的设置行)
3577
+ const HIDE_OFFICIAL_ATTR = "data-dshsf-hide-official"; // 只打在官方「工作步骤展示」行上(唯一痕迹:隐藏)
3578
+ let hideObserver = null;
3579
+ /** 官方标题文案(tChat 取不到就退回中文),只用于确认这一行的身份。 */
3580
+ const transcriptTitle = () => {
3581
+ if (!tChat) return "工作步骤展示";
3582
+ try { const t = tChat("settings.transcript.title"); return typeof t === "string" && t !== "settings.transcript.title" ? t : "工作步骤展示"; } catch { return "工作步骤展示"; }
3583
+ };
3584
+ /** 我们自己的行:`.dshsf-select` 所在的那条设置行。 */
3585
+ function ownRowEl() {
3586
+ const sel = document.querySelector(".dshsf-select");
3587
+ if (sel === null) return null;
3588
+ return sel.closest(".dshsf-row") ?? sel.closest('[class*="_row"]'); // 我方行类名是 dshsf-row(不含 _row);不能只靠官方那套 CSS-module 命名猜
3589
+ }
3590
+ /** 官方「工作步骤展示」行:触发按钮(aria-haspopup=menu)+ 四个官方档位标签之一 + 标题文本,并排除我方下拉/我方行。 */
3591
+ function findOfficialRow(own) {
3592
+ const title = transcriptTitle();
3593
+ for (const button of document.querySelectorAll('button[aria-haspopup="menu"]')) {
3594
+ if (button.closest(".dshsf-select") !== null) continue; // 我方下拉:永不隐藏
3595
+ const row = button.closest('[class*="_row"]');
3596
+ if (row === null) continue;
3597
+ if (own !== null && (row === own || own.contains(button))) continue; // 我方行:排除
3598
+ const text = String(button.textContent || "").trim();
3599
+ if (!NATIVE_MODES.some((m) => modeLabel(m) === text)) continue; // 必须是四个官方档位之一
3600
+ if (String(row.textContent || "").indexOf(title) < 0) continue; // 必须带官方标题
3601
+ return row;
3602
+ }
3603
+ return null;
3604
+ }
3605
+ /** 幂等:我方行渲染成功 ⇒ 隐藏官方行;我方行缺席/卸载 ⇒ 立即恢复。绝不允许两行都没了。 */
3606
+ function syncOfficialRowHidden() {
3607
+ const own = ownRowEl();
3608
+ if (own !== null && !own.hasAttribute(OWN_ROW_ATTR)) own.setAttribute(OWN_ROW_ATTR, "");
3609
+ const official = findOfficialRow(own);
3610
+ if (own !== null && official !== null) {
3611
+ if (!official.hasAttribute(HIDE_OFFICIAL_ATTR)) official.setAttribute(HIDE_OFFICIAL_ATTR, "");
3612
+ return;
3613
+ }
3614
+ for (const el of document.querySelectorAll("[" + HIDE_OFFICIAL_ATTR + "]")) el.removeAttribute(HIDE_OFFICIAL_ATTR);
3615
+ }
3616
+ /** 观察设置页 DOM(childList):我方行出现/消失、官方行被 React 重建 ⇒ 重贴。不进帧路径。 */
3617
+ function startOfficialRowHide() {
3618
+ if (hideObserver === null && typeof MutationObserver === "function" && document.body !== null) {
3619
+ hideObserver = new MutationObserver(() => {
3620
+ if (document.querySelector(".dshsf-select") === null && document.querySelector("[" + HIDE_OFFICIAL_ATTR + "]") === null) return;
3621
+ syncOfficialRowHidden();
3622
+ });
3623
+ hideObserver.observe(document.body, { childList: true, subtree: true });
3624
+ }
3625
+ syncOfficialRowHidden();
3626
+ }
3627
+ /** 收摊:断开观察并摘掉两个标记 ⇒ 官方行恢复。 */
3628
+ function stopOfficialRowHide() {
3629
+ if (hideObserver !== null) { try { hideObserver.disconnect(); } catch { /* ignore */ } hideObserver = null; }
3630
+ for (const el of document.querySelectorAll("[" + OWN_ROW_ATTR + "], [" + HIDE_OFFICIAL_ATTR + "]")) {
3631
+ el.removeAttribute(OWN_ROW_ATTR);
3632
+ el.removeAttribute(HIDE_OFFICIAL_ATTR);
3633
+ }
3634
+ }
2985
3635
  function DisplayRow() {
2986
3636
  const [choice, setChoice] = React.useState(displayChoice);
2987
3637
  React.useEffect(() => subscribe(() => setChoice(displayChoice())), []);
@@ -2990,7 +3640,7 @@ function DisplayRow() {
2990
3640
  try { return nativeScope.subscribe(() => setChoice(displayChoice())); } catch { return undefined; }
2991
3641
  }, []);
2992
3642
  const options = NATIVE_MODES.map((mode) => ({ value: mode, label: modeLabel(mode) })).concat([{ value: "fold", label: modeLabel("fold") }]);
2993
- return h(Row, { title: uiText("工作步骤展示", "Work details"), desc: uiText("选择希望看到多少工具调用细节;「折叠」是「流式折叠」提供的模式", "How much tool-call detail to show. Fold is provided by Streamfold.") },
3643
+ return h(Row, { title: "工作步骤展示", desc: "选择希望看到多少工具调用细节;「折叠」=由流式折叠插件提供的模式" },
2994
3644
  h(Select, { value: choice, options, onChange: (value) => { chooseDisplay(value); setChoice(displayChoice()); } }));
2995
3645
  }
2996
3646
  function SettingsPage() {
@@ -3000,10 +3650,16 @@ function SettingsPage() {
3000
3650
  h("div", { className: "dshsf-group" },
3001
3651
  h(Row, { title: "折叠历史轮次", desc: "关闭后:加载页面不再自动折叠历史轮次(只在跑动时折前面的)" },
3002
3652
  h(Toggle, { value: s.foldHistory !== false, onChange: (v) => setSettings({ foldHistory: v }) })),
3003
- h(Row, { title: "保留穿插正文", desc: "折叠时保留带正文的行(直接自然排版,不套窗;正式回答本身也不套窗)" },
3004
- h(Toggle, { value: s.keepInterleavedText !== false, onChange: (v) => setSettings({ keepInterleavedText: v }) }))),
3653
+ h(Row, { title: "保留穿插正文", desc: "折叠时保留带正文的行,用横线与正式回答分开(正文不再套限高小窗)" },
3654
+ h(Toggle, { value: s.keepInterleavedText !== false, onChange: (v) => setSettings({ keepInterleavedText: v }) })),
3655
+ h(Row, { title: "提问不入折叠", desc: "LLM 的提问工具与其回复(ask_user_question / question-reply):开启则两者都始终保留、不折进「已折叠 N 行」;关闭则随其余内容一起折叠" },
3656
+ h(Toggle, { value: s.keepUserQuestions === true, onChange: (v) => setSettings({ keepUserQuestions: v }) }))),
3005
3657
  h("div", { className: "dshsf-group" },
3006
- h(Row, { title: "窗口高度", desc: "运行中思考小窗的最大高度(px)" },
3658
+ h(Row, { title: "跟随速度上限(px/s)", desc: "我们主动追赶时的速度上限(px/s,默认 " + DEFAULTS.followMaxSpeed + " = 旧 8px/帧@60Hz 的手感)。越小越柔、高速输出时落后越多;前馈(内容自己的增长速度)不受它限制" },
3659
+ h(NumberInput, { value: s.followMaxSpeed, min: 60, max: 2000, step: 10, onChange: (v) => setSettings({ followMaxSpeed: v }) })),
3660
+ h(Row, { title: "跟随加速度上限(px/s²)", desc: "速度变化的上限(px/s²,默认 " + DEFAULTS.followMaxAccel + ")。人对加速度突变更敏感:调小起步/刹车更柔,调大更跟手;2000–120000 为实用区间" },
3661
+ h(NumberInput, { value: s.followMaxAccel, min: 2000, max: 120000, step: 1000, onChange: (v) => setSettings({ followMaxAccel: v }) })),
3662
+ h(Row, { title: "窗口高度", desc: "单个思考小窗的最大高度(px)" },
3007
3663
  h(NumberInput, { value: s.windowHeight, min: 80, max: 1200, onChange: (v) => setSettings({ windowHeight: v }) })),
3008
3664
  h(Row, { title: "运行中自动展开思考", desc: "只自动展开本轮最新的那一个;被取代的旧窗按下面的宽限折回一行摘要" },
3009
3665
  h(Toggle, { value: s.autoExpandReasoning !== false, onChange: (v) => setSettings({ autoExpandReasoning: v }) })),
@@ -3012,12 +3668,10 @@ function SettingsPage() {
3012
3668
  h(Row, { title: "运行中自动展开工具", desc: "开:运行中最新的工具卡自动展开(用官方卡片自己的折叠/内滚,不套我们的限高窗)" },
3013
3669
  h(Toggle, { value: s.autoExpandTools !== false, onChange: (v) => setSettings({ autoExpandTools: v }) }))),
3014
3670
  h("div", { className: "dshsf-group" },
3015
- h(Row, { title: "触底自动跟随", desc: "内容增长时自动贴底;你上滚即停跟" },
3016
- h(Toggle, { value: s.autoFollow !== false, onChange: (v) => setSettings({ autoFollow: v }) })),
3671
+ h(Row, { title: "发消息回到尾部", desc: "你停在中途(看过置顶、跳转过轮次)时,自己再发一条消息 ⇒ 视图滑回尾部;关掉后一律停在原地,只有滚到底或点「回到底部」才恢复跟随" },
3672
+ h(Toggle, { value: s.returnToTail !== false, onChange: (v) => setSettings({ returnToTail: v }) })),
3017
3673
  h(Row, { title: "平滑系数", desc: "跟随底部时每帧吃掉多少差距,越小越柔(默认 0.15)" },
3018
3674
  h(NumberInput, { value: s.smoothGrow, min: 0.05, max: 0.9, step: 0.05, onChange: (v) => setSettings({ smoothGrow: v }) })),
3019
- h(Row, { title: "平滑最小步长", desc: "每 16ms 至少推进多少像素(与屏幕刷新率无关)" },
3020
- h(NumberInput, { value: s.smoothMin, min: 0, max: 12, step: 1, onChange: (v) => setSettings({ smoothMin: v }) })),
3021
3675
  h(Row, { title: "离底显示「回到底部」" }, h(Toggle, { value: s.showJumpButton !== false, onChange: (v) => setSettings({ showJumpButton: v }) })),
3022
3676
  h(Row, { title: "跑完回到提问处", desc: "一轮结束后平滑移到本轮你那句话的位置;你已经自己上滚过就不动你。动效过渡关掉时直接到位" },
3023
3677
  h(Toggle, { value: s.settleToPrompt !== false, onChange: (v) => setSettings({ settleToPrompt: v }) })),
@@ -3038,7 +3692,7 @@ function SettingsPage() {
3038
3692
  h(Row, { title: "火花密度", desc: "火星数量倍率(0.2~3):越高越密,绘制开销也越大" },
3039
3693
  h(Range, { value: s.sparkDensity, min: 0.2, max: 3, step: 0.1, unit: "×", onChange: (v) => setSettings({ sparkDensity: v }) }))),
3040
3694
  h("div", { className: "dshsf-actions" },
3041
- h("button", { type: "button", className: "dshsf-btn", onClick: () => setSettings({ ...DEFAULTS }) }, "恢复默认")));
3695
+ h("button", { type: "button", className: "dshsf-btn", onClick: () => { setSettings({ ...DEFAULTS }); chooseDisplay("fold"); } }, "恢复默认"))); // A5:恢复默认也要强制官方档(否则官方仍自折 ⇒ 双重折叠)
3042
3696
  }
3043
3697
 
3044
3698
  /* ------------------------------------------------------------- apply -- */
@@ -3068,16 +3722,29 @@ function apply(ctx) {
3068
3722
  };
3069
3723
  adopt();
3070
3724
  enforceNativeBaseline().then(schedule, () => {});
3071
- // 订阅必须能退订:否则每次重载/卸载都留一个常驻回调(闭包持有整个模块实例),
3072
- // 且旧实例卸载后还会继续把用户的官方档位写回去。
3073
- const offNative = nativeScope.subscribe(() => { if (disposed) return; adopt(); enforceNativeBaseline(); schedule(); });
3074
- ctx.effect(() => () => { try { if (offNative) offNative(); } catch { /* ignore */ } }, "dsh-streamfold: native settings scope");
3725
+ nativeScope.subscribe(() => { adopt(); enforceNativeBaseline(); schedule(); });
3075
3726
  } catch (error) { console.warn("[streamfold] native settings scope unavailable", error); }
3076
3727
  });
3077
- if (ctx.slots) {
3078
- ctx.slots.inject("settings.general.item", () => ctx.slots.register({ name: "settings.general.item", id: "streamfold", order: 12 }, DisplayRow));
3079
- ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "streamfold", order: 42, label: () => uiText("流式折叠", "Streamfold") }, SettingsPage));
3080
- }
3728
+ if (ctx.slots) ctx.effect(() => applyPageSlots(ctx), "dsh-streamfold: page slots");
3729
+ }
3730
+ /** 页面级 slot 注册做成**幂等**:disposer 挂在 window 上;apply 时先把上一次的逐个 dispose 再注册。
3731
+ * 热重载时旧模块实例的 effect disposer 不会被调到(那就是"工作步骤展示"重复两行的根因),
3732
+ * 所以清理由 window 上的列表负责;正常卸载时由 ctx.effect 返回的 disposer 清理。 */
3733
+ function applyPageSlots(ctx) {
3734
+ const prev = window.__dshsfPageSlots;
3735
+ if (Array.isArray(prev)) { for (const d of prev) { try { d(); } catch { /* ignore */ } } }
3736
+ const list = [];
3737
+ window.__dshsfPageSlots = list;
3738
+ const keep = (d) => { if (typeof d === "function") list.push(d); return d; };
3739
+ // 方案A:折叠回到我们自己的设置行(唯一入口)。
3740
+ keep(ctx.slots.inject("settings.general.item", () => keep(ctx.slots.register({ name: "settings.general.item", id: "streamfold", order: 13 }, DisplayRow))));
3741
+ // T9:我方行渲染成功时隐藏官方「工作步骤展示」行(只改可见性;我方行不在 ⇒ 立即恢复)。
3742
+ if (ctx.effect) keep(ctx.effect(() => { startOfficialRowHide(); return () => stopOfficialRowHide(); }, "dsh-streamfold: hide official row"));
3743
+ keep(ctx.slots.inject("settings.section", () => keep(ctx.slots.register({ name: "settings.section", id: "streamfold", order: 42, label: () => "流式折叠" }, SettingsPage))));
3744
+ return () => {
3745
+ for (const d of list) { try { d(); } catch { /* ignore */ } }
3746
+ if (window.__dshsfPageSlots === list) { try { delete window.__dshsfPageSlots; } catch { /* ignore */ } }
3747
+ };
3081
3748
  }
3082
3749
  exports.apply = apply;
3083
3750
  exports.inject = ["slots"];