cc-viewer 1.6.348 → 1.7.1

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.
Files changed (222) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-gUSmU6Ny.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
  5. package/dist/assets/Mobile-qfRsRth0.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-B51Ci0GL.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +3 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +62 -274
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1124 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +558 -0
  99. package/server/lib/v2/meta-rows.js +360 -0
  100. package/server/lib/v2/migrate-prompt.js +54 -0
  101. package/server/lib/v2/replay.js +191 -0
  102. package/server/lib/v2/session-select.js +222 -0
  103. package/server/lib/v2/singleflight.js +104 -0
  104. package/server/lib/v2/v2-writer.js +528 -0
  105. package/server/lib/v2/verify.js +233 -0
  106. package/server/lib/wire-compress.js +167 -0
  107. package/server/routes/events.js +120 -150
  108. package/server/routes/files-fs.js +4 -2
  109. package/server/routes/im.js +11 -2
  110. package/server/routes/logs.js +209 -164
  111. package/server/routes/preferences.js +0 -3
  112. package/server/routes/project-meta.js +2 -3
  113. package/server/routes/session-pin.js +7 -8
  114. package/server/routes/v2.js +60 -0
  115. package/server/routes/workspaces.js +50 -14
  116. package/server/server.js +70 -66
  117. package/server/workspace-registry.js +24 -4
  118. package/src/utils/apiUrl.js +26 -0
  119. package/src/utils/askFallback.js +21 -0
  120. package/src/utils/askOptionDesc.js +55 -0
  121. package/src/utils/askPortalMatcher.js +54 -0
  122. package/src/utils/autoApproveOptions.js +27 -0
  123. package/src/utils/avatarAnimationPostPass.js +49 -0
  124. package/src/utils/builtinPresets.js +18 -0
  125. package/src/utils/clearCheckpoint.js +7 -0
  126. package/src/utils/commandValidator.js +45 -0
  127. package/src/utils/contentFilter.js +620 -0
  128. package/src/utils/contextRaw.js +18 -0
  129. package/src/utils/contextSidebarNavigation.js +16 -0
  130. package/src/utils/contextTurns.js +117 -0
  131. package/src/utils/displayScaleHelper.js +47 -0
  132. package/src/utils/effectiveModel.js +13 -0
  133. package/src/utils/entry-slim.js +563 -0
  134. package/src/utils/entryCache.js +144 -0
  135. package/src/utils/errorReport.js +6 -0
  136. package/src/utils/fileExpandedPathsStorage.js +70 -0
  137. package/src/utils/fileIcons.jsx +36 -0
  138. package/src/utils/fileOpen.js +37 -0
  139. package/src/utils/formatters.js +48 -0
  140. package/src/utils/gitApi.js +50 -0
  141. package/src/utils/gitTreeBuilder.js +19 -0
  142. package/src/utils/helpers.js +846 -0
  143. package/src/utils/identityHeal.js +99 -0
  144. package/src/utils/imConnState.js +59 -0
  145. package/src/utils/imOrigin.js +24 -0
  146. package/src/utils/imTr.js +7 -0
  147. package/src/utils/imageCompress.js +83 -0
  148. package/src/utils/imageDownscale.js +28 -0
  149. package/src/utils/imageResize.js +112 -0
  150. package/src/utils/ingestPipeline.js +47 -0
  151. package/src/utils/lazyWithReload.js +53 -0
  152. package/src/utils/markdown.js +30 -0
  153. package/src/utils/markdownIncremental.js +39 -0
  154. package/src/utils/markdownProfiler.js +113 -0
  155. package/src/utils/mdExtensionDetect.js +64 -0
  156. package/src/utils/memoryLinkParser.js +44 -0
  157. package/src/utils/modalMask.js +15 -0
  158. package/src/utils/monotime.js +6 -0
  159. package/src/utils/pinnedMenu.js +55 -0
  160. package/src/utils/presetShortcuts.js +14 -0
  161. package/src/utils/projectAlias.js +135 -0
  162. package/src/utils/promptClassifier.js +112 -0
  163. package/src/utils/promptDetect.js +196 -0
  164. package/src/utils/promptNav.js +60 -0
  165. package/src/utils/ptyChunkBuilder.js +251 -0
  166. package/src/utils/quickMenuHoverIntent.js +35 -0
  167. package/src/utils/rateLimitParser.js +119 -0
  168. package/src/utils/readResultPool.js +106 -0
  169. package/src/utils/refreshCachedItemProp.js +30 -0
  170. package/src/utils/requestType.js +208 -0
  171. package/src/utils/resizeCalc.js +21 -0
  172. package/src/utils/resolveLocalized.js +17 -0
  173. package/src/utils/searchApi.js +47 -0
  174. package/src/utils/searchReplace.js +51 -0
  175. package/src/utils/seqResourceLoaders.js +79 -0
  176. package/src/utils/sessionManager.js +471 -0
  177. package/src/utils/sessionMerge.js +180 -0
  178. package/src/utils/skillModalController.js +111 -0
  179. package/src/utils/skillsParser.js +116 -0
  180. package/src/utils/slashCommandLabels.js +80 -0
  181. package/src/utils/splitDragCalc.js +73 -0
  182. package/src/utils/stickyBottomController.js +643 -0
  183. package/src/utils/svgSanitize.js +48 -0
  184. package/src/utils/systemTags.js +47 -0
  185. package/src/utils/tClaude.js +20 -0
  186. package/src/utils/teamModalBuilder.js +351 -0
  187. package/src/utils/teamSessionParser.js +190 -0
  188. package/src/utils/teammateAvatars.js +194 -0
  189. package/src/utils/teammateDetector.js +121 -0
  190. package/src/utils/termDiag.js +114 -0
  191. package/src/utils/terminalClipboard.js +117 -0
  192. package/src/utils/terminalWriteQueue.js +398 -0
  193. package/src/utils/thinkingMerge.js +49 -0
  194. package/src/utils/toolCatalog.js +29 -0
  195. package/src/utils/toolResultBuilder.js +393 -0
  196. package/src/utils/toolResultClassifier.js +16 -0
  197. package/src/utils/toolResultCore.js +167 -0
  198. package/src/utils/toolsDiff.js +44 -0
  199. package/src/utils/toolsXmlFormatter.js +3 -0
  200. package/src/utils/ultraplanController.js +109 -0
  201. package/src/utils/ultraplanExperts.js +86 -0
  202. package/src/utils/ultraplanTemplates.js +154 -0
  203. package/src/utils/userImageRefs.js +49 -0
  204. package/src/utils/v3Assembler.js +112 -0
  205. package/src/utils/v3Rows.js +48 -0
  206. package/src/utils/voicePackPlayer.js +233 -0
  207. package/src/utils/webSearchGrouping.js +111 -0
  208. package/src/utils/workflowFormat.js +52 -0
  209. package/src/utils/workflowRuns.js +118 -0
  210. package/src/utils/workflowStore.js +94 -0
  211. package/dist/assets/App-VTo4NVdo.js +0 -2
  212. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  213. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  214. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  215. package/dist/assets/clone-md-Mc1-T.js +0 -1
  216. package/dist/assets/index-DCo650PG.js +0 -2
  217. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  218. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  219. package/server/lib/instance-registry.js +0 -57
  220. package/server/lib/jsonl-archive.js +0 -180
  221. package/server/lib/teammate-detect.js +0 -32
  222. package/server/lib/zip-safety.js +0 -68
@@ -0,0 +1,643 @@
1
+ // StickyBottomController — vanilla JS controller for ChatView 流式吸底状态机
2
+ //
3
+ // 收敛 7 处独立 scrollTop 写入与 3 套并行吸底机制到单一权威路径。
4
+ // 关键不变量:_lockDepth >= 0;dispose 后 _lockDepth === 0 且不再变化。
5
+ // 详见 /Users/sky/.claude/plans/modular-floating-hopper.md (v2.1)。
6
+ //
7
+ // 用户滚动优先(user-scroll intent 暂停窗口):
8
+ // scroll 事件分不清「用户滚」还是「程序滚」,只能靠 _lockDepth 屏蔽——但 SSE 流式期间
9
+ // startSmoothFollow 的缓动链被高频重启、锁几乎常驻,用户上滑信号全被吃掉,困死在吸底态。
10
+ // 解法:直接监听不可伪造的用户输入(wheel / touch / pointer 拖动),进入「用户滚动中」
11
+ // 暂停窗口:窗口内一切自动追底(缓动 / RO 跟底 / 新消息硬贴底)停摆,sticky 随阈值实时
12
+ // 翻转;最后一次输入后 userScrollIdleMs(默认 300ms)判定停手,做一次终判并恢复追底。
13
+ // 详见 /Users/sky/.claude/plans/snug-purring-wadler.md。
14
+
15
+ const NOOP = () => {};
16
+ const DEFAULT_THRESHOLD_ENTER = 10;
17
+ const DEFAULT_THRESHOLD_LEAVE = 50;
18
+ // Virtuoso 路径下 footer 子树(lastResponse / spinner / streamingLiveItem 三段)高度抖动会让
19
+ // Virtuoso 内部的 atBottom 误判翻转。匹配 Virtuoso atBottomThreshold:60,notifyAtBottom 用此值
20
+ // 兜底:仅当真实 DOM 距离 > 60px 才信任 atBottom=false 翻 sticky。
21
+ const DEFAULT_AT_BOTTOM_PX = 60;
22
+ // _setSticky 决策去重窗口(ms):同 rAF tick 内 RO + Virtuoso atBottomStateChange 双发时合并。
23
+ // 选 16ms 对齐 60Hz 一帧;高刷屏(120Hz/144Hz)下相当于 2 帧,可能压制合法翻转——若实测有手感
24
+ // 问题可改 8ms 或动态读 screen.refreshRate(P2 backlog,待手动验证)。
25
+ const STICKY_DECISION_DEDUP_MS = 16;
26
+
27
+ // 平滑追底(startSmoothFollow → step)的帧率节流间隔(ms)。
28
+ // step 缓动本会跟随 rAF 跑满显示器刷新率——120Hz ProMotion 屏上 ~120fps,每帧读写 scrollTop
29
+ // 触发一次 forced reflow(trace 实测 get/set scrollTop 是 #1 JS 热点 + 229 Layout/s)。
30
+ // 流式追底肉眼无需如此密集:门控到 ~30fps(33ms)即与刷新率解耦、视觉等效,主线程 layout/paint
31
+ // 负载降 ~4×。可经 opts.smoothFollowMinFrameMs 调整(设 0 = 关闭节流,恢复每帧)。
32
+ const DEFAULT_SMOOTH_FOLLOW_MIN_FRAME_MS = 33;
33
+
34
+ // 用户停手判定空窗(ms):最后一次用户输入(wheel/touch/pointer 拖动)后超过此时长视为停手,
35
+ // 触发终判并恢复自动追底。触控板惯性 wheel 事件流与 momentum scroll 事件会持续刷新窗口。
36
+ const DEFAULT_USER_SCROLL_IDLE_MS = 300;
37
+ // pointer 拖动资格判定(slop):pointerdown 后位移超过此值才算「拖动」开窗。纯点击(展开
38
+ // 工具结果 / 复制 / 加载更早)零位移,不开窗——否则每次点击都暂停追底 300ms,且点击展开
39
+ // 导致内容长高 >thresholdLeave 时会被终判误降级踢出吸底。
40
+ const POINTER_DRAG_SLOP_PX = 5;
41
+
42
+ export class StickyBottomController {
43
+ constructor(opts = {}) {
44
+ this._getSticky = opts.getSticky || (() => false);
45
+ this._setStickyExternal = opts.setSticky || NOOP;
46
+ this._getMode = opts.getMode || (() => 'desktop');
47
+ this._thresholdEnter = opts.thresholdEnter ?? DEFAULT_THRESHOLD_ENTER;
48
+ this._thresholdLeave = opts.thresholdLeave ?? DEFAULT_THRESHOLD_LEAVE;
49
+ // 兼容映射:旧 touchSuppressMs 语义被统一空窗超集覆盖;显式传入且未传 userScrollIdleMs
50
+ // 时沿用其值(?? 保证 0 这种合法值不被默认值吞掉)。
51
+ this._userScrollIdleMs = opts.userScrollIdleMs ?? opts.touchSuppressMs ?? DEFAULT_USER_SCROLL_IDLE_MS;
52
+ this._atBottomPx = opts.atBottomPx ?? DEFAULT_AT_BOTTOM_PX;
53
+ this._smoothFollowMinFrameMs = opts.smoothFollowMinFrameMs ?? DEFAULT_SMOOTH_FOLLOW_MIN_FRAME_MS;
54
+ this._now = opts.now || (() => Date.now());
55
+ // 定时器可注入(与 opts.now 对称):测试单一时钟源,避免 fake-timer 双钟漂移
56
+ this._setTimeout = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
57
+ this._clearTimeout = opts.clearTimeout || ((id) => clearTimeout(id));
58
+ this._onUserScrollChange = opts.onUserScrollChange || NOOP;
59
+
60
+ this._lockDepth = 0;
61
+ this._smoothLockHeld = false;
62
+ this._followTarget = 0;
63
+ this._smoothFollowRafId = null;
64
+ this._scrollHandlerRafId = null;
65
+ // Set 而非 Array:流式高频 writeUnderLock 时 add/delete 都 O(1);Array.filter 是 O(n)
66
+ // 累积成 O(n²) 帧成本,是 perf-auditor 找出的 P0 内存泄漏路径。
67
+ this._writeLockRafIds = new Set();
68
+ this._resizeObserver = null;
69
+ this._boundEl = null;
70
+ this._touchListenersAttached = false;
71
+ // 决策去重快照:拆为两字段而非 { value, ts } 对象,避免 _setSticky 高频调用时重复 GC
72
+ this._lastStickyValue = null; // boolean | null(null = 尚无决策)
73
+ this._lastStickyTs = 0;
74
+ this._disposed = false;
75
+
76
+ // ── 用户滚动意图状态 ──
77
+ // _pointerHold / _touchHold 必须是两个独立布尔:触摸设备浏览器接管滚动时会发
78
+ // pointercancel,单布尔会被它误清掉 touch 的 hold → 手指按住不动 >空窗 即被终判拽底。
79
+ this._pointerHold = false;
80
+ this._touchHold = false;
81
+ this._pointerTracking = false; // pointerdown 已发生、等待 slop 升级
82
+ this._pointerDownX = 0;
83
+ this._pointerDownY = 0;
84
+ this._touchSession = false; // touchstart 已发生(区分测试直调 _onTouchEnd 的旧路径)
85
+ this._touchMoved = false; // touchstart 后是否有 touchmove(tap 判定)
86
+ this._touchIgnored = false; // touchstart 落在容器外(横滑代码块/DiffView 等),整段序列不开窗
87
+ this._lastUserIntentTs = 0; // 0 = 从未有过意图
88
+ this._userIdleTimer = null;
89
+ this._userScrollActive = false; // 窗口开/关沿(onUserScrollChange 去抖)
90
+
91
+ this._onScroll = () => {
92
+ if (this._disposed) return;
93
+ if (this._lockDepth > 0) return;
94
+ this._extendMomentumWindow();
95
+ this._scheduleStickyDecision();
96
+ };
97
+
98
+ // virtuoso 模式的轻量 scroll 监听:仅做 momentum 延展(安卓 fling 惯性常超空窗,无延展
99
+ // 会在惯性中被终判补追杀掉 fling),不做 sticky 决策——决策权威仍是 notifyAtBottom。
100
+ this._onVirtuosoScroll = () => {
101
+ if (this._disposed) return;
102
+ if (this._lockDepth > 0) return;
103
+ this._extendMomentumWindow();
104
+ };
105
+
106
+ this._onWheel = (e) => {
107
+ if (this._disposed) return;
108
+ const dy = (e && typeof e.deltaY === 'number') ? e.deltaY : 0;
109
+ // 上滚恒为意图;下滚仅在未吸底时算意图(贴底下滚无位移可言,不为此打断追底)
110
+ if (dy < 0 || !this._getSticky()) this._noteUserIntent();
111
+ };
112
+
113
+ this._onPointerDown = (e) => {
114
+ if (this._disposed) return;
115
+ if (e && e.pointerType === 'touch') return; // 触摸由 touch 通道负责
116
+ const el = this._boundEl;
117
+ // 纵向滚动条区域按下(offsetX 超出 clientWidth):滚动条拖动期间浏览器不派发
118
+ // pointermove 给页面,slop 升级不可用 → 直接按时间戳开窗,拖动产生的 scroll 事件
119
+ // 经 momentum 延展续窗。
120
+ if (el && e && e.target === el && typeof e.offsetX === 'number'
121
+ && typeof el.clientWidth === 'number' && e.offsetX >= el.clientWidth) {
122
+ this._noteUserIntent();
123
+ return;
124
+ }
125
+ this._pointerTracking = true;
126
+ this._pointerDownX = (e && typeof e.clientX === 'number') ? e.clientX : 0;
127
+ this._pointerDownY = (e && typeof e.clientY === 'number') ? e.clientY : 0;
128
+ };
129
+
130
+ this._onPointerMove = (e) => {
131
+ if (this._disposed) return;
132
+ if (e && e.pointerType === 'touch') return;
133
+ if (!this._pointerTracking || this._pointerHold) return;
134
+ const dx = ((e && typeof e.clientX === 'number') ? e.clientX : 0) - this._pointerDownX;
135
+ const dy = ((e && typeof e.clientY === 'number') ? e.clientY : 0) - this._pointerDownY;
136
+ if ((dx * dx + dy * dy) > POINTER_DRAG_SLOP_PX * POINTER_DRAG_SLOP_PX) {
137
+ this._pointerHold = true; // slop 达标:拖滚动条外的内容拖动(选文本等)
138
+ this._noteUserIntent({ setTs: false }); // hold 本身即意图,时间戳等释放时再记
139
+ }
140
+ };
141
+
142
+ this._onPointerUp = (e) => {
143
+ if (this._disposed) return;
144
+ if (e && e.pointerType === 'touch') return;
145
+ this._pointerTracking = false;
146
+ if (!this._pointerHold) return; // 纯点击从未开窗,无事可做
147
+ this._pointerHold = false;
148
+ this._lastUserIntentTs = this._now();
149
+ this._armUserIdleTimer();
150
+ };
151
+
152
+ this._onTouchStart = (ev) => {
153
+ if (this._disposed) return;
154
+ // 容器子树过滤:touch 监听挂在 document,横滑代码块/DiffView、滑 RoleFilterBar 等
155
+ // 「非滚 chat」的触摸不应暂停追底。事件无 target(测试直调)时跳过过滤,保持旧语义。
156
+ const el = this._boundEl;
157
+ if (el && ev && ev.target && typeof el.contains === 'function' && !el.contains(ev.target)) {
158
+ this._touchIgnored = true;
159
+ return;
160
+ }
161
+ this._touchIgnored = false;
162
+ this._touchSession = true;
163
+ this._touchMoved = false;
164
+ this._touchHold = true;
165
+ this._noteUserIntent({ setTs: false });
166
+ };
167
+
168
+ this._onTouchMove = () => {
169
+ if (this._touchHold) this._touchMoved = true;
170
+ };
171
+
172
+ this._onTouchEnd = () => {
173
+ if (this._disposed) return;
174
+ if (this._touchIgnored) { this._touchIgnored = false; return; } // 容器外序列整段忽略
175
+ const wasSession = this._touchSession;
176
+ const moved = this._touchMoved;
177
+ this._touchSession = false;
178
+ this._touchHold = false;
179
+ if (wasSession && !moved) {
180
+ // 纯 tap(点展开/复制等):不起空窗,立即关窗,RO 跟底不中断
181
+ this._lastUserIntentTs = 0;
182
+ this._cancelUserIdleTimer();
183
+ if (!this.isUserScrolling()) this._setUserScrollActive(false);
184
+ return;
185
+ }
186
+ // 拖动结束(或测试直调 _onTouchEnd 的旧 touch 抑制路径):起空窗计时
187
+ this._lastUserIntentTs = this._now();
188
+ this._armUserIdleTimer();
189
+ };
190
+
191
+ // pointerup 丢失兜底(alt-tab、原生右键菜单等场景收不到 up):hold 永挂 = 追底永久死亡
192
+ this._onWindowBlur = () => {
193
+ if (this._disposed) return;
194
+ if (!this._pointerHold && !this._touchHold && !this._pointerTracking) return;
195
+ this._pointerHold = false;
196
+ this._touchHold = false;
197
+ this._pointerTracking = false;
198
+ this._touchSession = false;
199
+ this._lastUserIntentTs = this._now();
200
+ this._armUserIdleTimer();
201
+ };
202
+ }
203
+
204
+ _raf(fn) {
205
+ if (typeof globalThis.requestAnimationFrame === 'function') {
206
+ return globalThis.requestAnimationFrame(fn);
207
+ }
208
+ // SSR / 缺失 rAF 环境:setTimeout 0 兜底(同步语义不可恢复,但至少不挂死)
209
+ return setTimeout(fn, 0);
210
+ }
211
+
212
+ _cancelRaf(id) {
213
+ if (id == null) return;
214
+ if (typeof globalThis.cancelAnimationFrame === 'function') {
215
+ try { globalThis.cancelAnimationFrame(id); } catch {}
216
+ } else {
217
+ try { clearTimeout(id); } catch {}
218
+ }
219
+ }
220
+
221
+ _setSticky(value) {
222
+ if (this._disposed) return;
223
+ const ts = this._now();
224
+ if (this._lastStickyValue === value && (ts - this._lastStickyTs) < STICKY_DECISION_DEDUP_MS) {
225
+ return;
226
+ }
227
+ this._lastStickyValue = value;
228
+ this._lastStickyTs = ts;
229
+ try { this._setStickyExternal(value); } catch {}
230
+ }
231
+
232
+ // ── 用户滚动意图机制 ──────────────────────────────────────────────────────
233
+
234
+ // 统一意图入口:wheel / touchstart / pointer 拖动达标 / 滚动条按下 都汇到这里。
235
+ // setTs=false 用于 hold 型意图(按住期间 isUserScrolling 由 hold 撑着,时间戳等释放再记,
236
+ // 这样纯 tap 可以无痕撤销、不留 300ms 尾巴)。
237
+ _noteUserIntent({ setTs = true } = {}) {
238
+ if (this._disposed) return;
239
+ if (setTs) this._lastUserIntentTs = this._now();
240
+ // 逃逸关键步:同步释放缓动锁,让紧随其后的 scroll / atBottomStateChange 信号不再被锁吃掉
241
+ this.cancelSmoothFollow();
242
+ this._setUserScrollActive(true);
243
+ this._armUserIdleTimer();
244
+ // 主动调度一次决策 rAF(绕过事件层锁检查):意图可能恰落在 writeUnderLock 双 rAF 残留
245
+ // 锁(≤2 帧)内,首批 scroll 事件被吞——主动决策保证「上滑立刻显示按钮」真正立刻。
246
+ this._scheduleStickyDecision();
247
+ }
248
+
249
+ // 窗口开/关沿(去抖:仅翻转沿发回调)。故意不查 _disposed:dispose 路径也要把关沿发出去,
250
+ // 保证不变量「dispose 后 _userScrollActive === false 且关沿回调已发」(宿主有 _unmounted 兜底)。
251
+ _setUserScrollActive(active) {
252
+ if (this._userScrollActive === active) return;
253
+ this._userScrollActive = active;
254
+ try { this._onUserScrollChange(active); } catch {}
255
+ }
256
+
257
+ // 自检重排定时器:armed 即不动(高频 wheel 只刷时间戳零 churn),到点时不满空窗按剩余重排
258
+ _armUserIdleTimer() {
259
+ if (this._userIdleTimer != null) return;
260
+ this._userIdleTimer = this._setTimeout(() => {
261
+ this._userIdleTimer = null;
262
+ this._onUserIdle();
263
+ }, this._userScrollIdleMs);
264
+ }
265
+
266
+ _cancelUserIdleTimer() {
267
+ if (this._userIdleTimer == null) return;
268
+ try { this._clearTimeout(this._userIdleTimer); } catch {}
269
+ this._userIdleTimer = null;
270
+ }
271
+
272
+ // 停手终判:用户停止手动滚动后按位置重新判定 sticky,并在吸底时恢复追底补欠距
273
+ _onUserIdle() {
274
+ if (this._disposed) return;
275
+ // hold 中到点:不重排(剩余时间为负会 setTimeout(0) 自旋),靠 pointerup/touchend 接力 re-arm
276
+ if (this._pointerHold || this._touchHold) return;
277
+ const elapsed = this._now() - this._lastUserIntentTs;
278
+ if (this._lastUserIntentTs > 0 && elapsed < this._userScrollIdleMs) {
279
+ this._userIdleTimer = this._setTimeout(() => {
280
+ this._userIdleTimer = null;
281
+ this._onUserIdle();
282
+ }, Math.max(0, this._userScrollIdleMs - elapsed));
283
+ return;
284
+ }
285
+ const el = this._boundEl;
286
+ if (!el) {
287
+ // 无绑定元素也必须关沿,否则宿主 userScrolling 永真、followOutput 永久残废
288
+ this._setUserScrollActive(false);
289
+ return;
290
+ }
291
+ this.refreshFollowTarget(el);
292
+ // 终判按模式分流:desktop 用 enter/leave 滞回;virtuoso 用 atBottomPx 单边界——
293
+ // 否则 gap∈(thresholdLeave, atBottomPx] 区间终判翻 false 会被下一次 Virtuoso
294
+ // atBottomStateChange(true) 强行推翻、用户被拽回。
295
+ let sticky = this._getSticky();
296
+ if (this._getMode() === 'virtuoso') {
297
+ const realGap = (el.scrollHeight ?? 0) - (el.scrollTop ?? 0) - (el.clientHeight ?? 0);
298
+ if (Number.isFinite(realGap)) sticky = realGap <= this._atBottomPx;
299
+ } else {
300
+ const gap = this._followTarget - (el.scrollTop ?? 0);
301
+ if (gap <= this._thresholdEnter) sticky = true;
302
+ else if (gap > this._thresholdLeave) sticky = false;
303
+ // 中间带:维持现状
304
+ }
305
+ this._setSticky(sticky);
306
+ this._setUserScrollActive(false);
307
+ if (sticky) this.startSmoothFollow(el);
308
+ }
309
+
310
+ // sticky 决策(rAF 防抖统一通道):_onScroll 事件路径与 _noteUserIntent 主动路径共用
311
+ _scheduleStickyDecision() {
312
+ if (this._scrollHandlerRafId !== null) return;
313
+ this._scrollHandlerRafId = this._raf(() => {
314
+ this._scrollHandlerRafId = null;
315
+ if (this._disposed) return;
316
+ const el = this._boundEl;
317
+ if (!el) return;
318
+ // 窗口内缓动停摆、_followTarget 无人保鲜,内容长高时用冻结值会造成「滚回旧底部
319
+ // sticky 闪现又被终判翻回」的按钮闪烁——决策前刷新(成本与 30fps 缓动同阶,仅交互期)
320
+ if (this.isUserScrolling()) this.refreshFollowTarget(el);
321
+ const sticky = this._getSticky();
322
+ if (this._getMode() === 'virtuoso') {
323
+ // virtuoso 用与 notifyAtBottom 真值修正层一致的 60px 单边界,避免双权威打架
324
+ const realGap = (el.scrollHeight ?? 0) - (el.scrollTop ?? 0) - (el.clientHeight ?? 0);
325
+ if (!Number.isFinite(realGap)) return;
326
+ if (sticky && realGap > this._atBottomPx) this._setSticky(false);
327
+ else if (!sticky && realGap <= this._atBottomPx) this._setSticky(true);
328
+ return;
329
+ }
330
+ const gap = this._followTarget - el.scrollTop;
331
+ if (sticky && gap > this._thresholdLeave) {
332
+ this._setSticky(false);
333
+ } else if (!sticky && gap <= this._thresholdEnter) {
334
+ this._setSticky(true);
335
+ }
336
+ });
337
+ }
338
+
339
+ // 松手惯性期续窗:窗口活跃且非按住时,scroll 事件刷新意图时间戳(hold 期由 hold 自己撑窗)。
340
+ // 调用方必须先过 _lockDepth 检查——程序滚在锁内短路,不会误延展。
341
+ _extendMomentumWindow() {
342
+ if (this._pointerHold || this._touchHold) return;
343
+ if (this._lastUserIntentTs <= 0) return;
344
+ if ((this._now() - this._lastUserIntentTs) >= this._userScrollIdleMs) return;
345
+ this._lastUserIntentTs = this._now();
346
+ }
347
+
348
+ // 是否处于「用户滚动中」窗口:hold 撑着,或最后一次意图在空窗内。
349
+ // _lastUserIntentTs > 0 臂必须保留(「从未有过意图」→ false,小时钟注入下依赖)。
350
+ isUserScrolling() {
351
+ if (this._pointerHold || this._touchHold) return true;
352
+ if (this._lastUserIntentTs <= 0) return false;
353
+ return (this._now() - this._lastUserIntentTs) < this._userScrollIdleMs;
354
+ }
355
+
356
+ // 旧名薄别名(touch 抑制语义已被统一空窗超集覆盖;既有测试直调此名)
357
+ _isWithinTouchSuppress() { return this.isUserScrolling(); }
358
+
359
+ // 显式动作(「回到底部」按钮、移动端面板切换)清除用户滚动窗口:用户明确要贴底,
360
+ // 残留窗口不应再抑制任何自动追底
361
+ resetUserScrollState() {
362
+ this._pointerHold = false;
363
+ this._touchHold = false;
364
+ this._pointerTracking = false;
365
+ this._touchSession = false;
366
+ this._touchMoved = false;
367
+ this._touchIgnored = false;
368
+ this._lastUserIntentTs = 0;
369
+ this._cancelUserIdleTimer();
370
+ this._setUserScrollActive(false);
371
+ }
372
+
373
+ // ── 生命周期 ──────────────────────────────────────────────────────────────
374
+
375
+ _attachTouchListenersOnce() {
376
+ if (this._touchListenersAttached) return;
377
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return;
378
+ this._touchListenersAttached = true;
379
+ try { document.addEventListener('touchstart', this._onTouchStart, { passive: true }); } catch {}
380
+ try { document.addEventListener('touchmove', this._onTouchMove, { passive: true }); } catch {}
381
+ try { document.addEventListener('touchend', this._onTouchEnd, { passive: true }); } catch {}
382
+ try { document.addEventListener('touchcancel', this._onTouchEnd, { passive: true }); } catch {}
383
+ try { document.addEventListener('pointerup', this._onPointerUp, { passive: true }); } catch {}
384
+ try { document.addEventListener('pointercancel', this._onPointerUp, { passive: true }); } catch {}
385
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
386
+ try { window.addEventListener('blur', this._onWindowBlur); } catch {}
387
+ }
388
+ }
389
+
390
+ _detachTouchListeners() {
391
+ if (!this._touchListenersAttached) return;
392
+ if (typeof document === 'undefined' || typeof document.removeEventListener !== 'function') return;
393
+ try { document.removeEventListener('touchstart', this._onTouchStart); } catch {}
394
+ try { document.removeEventListener('touchmove', this._onTouchMove); } catch {}
395
+ try { document.removeEventListener('touchend', this._onTouchEnd); } catch {}
396
+ try { document.removeEventListener('touchcancel', this._onTouchEnd); } catch {}
397
+ try { document.removeEventListener('pointerup', this._onPointerUp); } catch {}
398
+ try { document.removeEventListener('pointercancel', this._onPointerUp); } catch {}
399
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
400
+ try { window.removeEventListener('blur', this._onWindowBlur); } catch {}
401
+ }
402
+ this._touchListenersAttached = false;
403
+ }
404
+
405
+ bind(el) {
406
+ if (this._disposed) return;
407
+ if (el === this._boundEl) return; // idempotent
408
+ if (this._boundEl) this._detachFromBoundEl();
409
+ this._boundEl = el || null;
410
+ if (!el) return;
411
+ const mode = this._getMode();
412
+ if (mode !== 'virtuoso') {
413
+ try { el.addEventListener('scroll', this._onScroll, { passive: true }); } catch {}
414
+ } else {
415
+ try { el.addEventListener('scroll', this._onVirtuosoScroll, { passive: true }); } catch {}
416
+ }
417
+ try { el.addEventListener('wheel', this._onWheel, { passive: true }); } catch {}
418
+ try { el.addEventListener('pointerdown', this._onPointerDown, { passive: true }); } catch {}
419
+ try { el.addEventListener('pointermove', this._onPointerMove, { passive: true }); } catch {}
420
+ if (typeof ResizeObserver !== 'undefined') {
421
+ try {
422
+ this._resizeObserver = new ResizeObserver(() => this.handleScrollerResize(el));
423
+ this._resizeObserver.observe(el);
424
+ } catch {}
425
+ }
426
+ this.refreshFollowTarget(el);
427
+ this._attachTouchListenersOnce();
428
+ }
429
+
430
+ _detachFromBoundEl() {
431
+ const el = this._boundEl;
432
+ if (!el) return;
433
+ try { el.removeEventListener('scroll', this._onScroll); } catch {}
434
+ try { el.removeEventListener('scroll', this._onVirtuosoScroll); } catch {}
435
+ try { el.removeEventListener('wheel', this._onWheel); } catch {}
436
+ try { el.removeEventListener('pointerdown', this._onPointerDown); } catch {}
437
+ try { el.removeEventListener('pointermove', this._onPointerMove); } catch {}
438
+ if (this._resizeObserver) {
439
+ try { this._resizeObserver.disconnect(); } catch {}
440
+ this._resizeObserver = null;
441
+ }
442
+ this._boundEl = null;
443
+ // 换绑/解绑(Virtuoso 卸载走 bind(null),不经 unbind)必须:
444
+ // ① 取消缓动链并释放其锁——只 cancel rAF 不 release 会留下孤儿锁(_lockDepth 卡 1),
445
+ // 在重新 bind 前堵死 _onScroll/notifyAtBottom/RO 三条决策入口;
446
+ // ② 复位用户滚动窗口——否则定时器带着 null el 到点、userScrolling 开沿永不关闭。
447
+ this.cancelSmoothFollow();
448
+ this.resetUserScrollState();
449
+ }
450
+
451
+ unbind() {
452
+ this.cancelSmoothFollow(); // _boundEl 已空时 _detachFromBoundEl 早退,孤儿锁在此兜底释放
453
+ this._detachFromBoundEl();
454
+ this.resetUserScrollState();
455
+ if (this._scrollHandlerRafId !== null) {
456
+ this._cancelRaf(this._scrollHandlerRafId);
457
+ this._scrollHandlerRafId = null;
458
+ }
459
+ if (this._smoothFollowRafId !== null) {
460
+ this._cancelRaf(this._smoothFollowRafId);
461
+ this._smoothFollowRafId = null;
462
+ }
463
+ for (const id of this._writeLockRafIds) this._cancelRaf(id);
464
+ this._writeLockRafIds.clear();
465
+ }
466
+
467
+ dispose() {
468
+ if (this._disposed) return;
469
+ // 先关沿再置 _disposed:保证关沿回调发得出去(不变量:dispose 后 _userScrollActive===false)
470
+ this.resetUserScrollState();
471
+ this._disposed = true;
472
+ this.unbind();
473
+ this._detachTouchListeners();
474
+ this._lockDepth = 0;
475
+ this._smoothLockHeld = false;
476
+ }
477
+
478
+ isLocked() { return this._lockDepth > 0; }
479
+
480
+ refreshFollowTarget(el) {
481
+ if (this._disposed) return;
482
+ const target = el || this._boundEl;
483
+ if (!target) return;
484
+ const sh = target.scrollHeight ?? 0;
485
+ const ch = target.clientHeight ?? 0;
486
+ this._followTarget = Math.max(0, sh - ch);
487
+ }
488
+
489
+ // Single authoritative scrollTop write entry.
490
+ // 双 rAF 后 _lockDepth--。期间 _onScroll / RO writeUnderLock 都会被锁短路。
491
+ // 所有入参守卫(disposed / el / 类型 / 数值有限性)必须在 _lockDepth++ 之前完成,
492
+ // 防极端值(NaN/Infinity)/非法元素让 lock 白占一个 rAF 周期(防 DoS + 防御)。
493
+ // 注意:不查 isUserScrolling——这是显式动作(回到底部按钮等)的通道,必须保持畅通;
494
+ // 自动追底调用方各自把关。
495
+ writeUnderLock(el, target) {
496
+ if (this._disposed) return;
497
+ if (!el) return;
498
+ if (typeof el.scrollTop !== 'number') return;
499
+ if (!Number.isFinite(target)) return;
500
+ this._lockDepth++;
501
+ try { el.scrollTop = target; } catch {}
502
+ let inner = null;
503
+ const outer = this._raf(() => {
504
+ this._writeLockRafIds.delete(outer);
505
+ if (this._disposed) return;
506
+ inner = this._raf(() => {
507
+ this._writeLockRafIds.delete(inner);
508
+ if (this._disposed) return;
509
+ this._lockDepth = Math.max(0, this._lockDepth - 1);
510
+ });
511
+ if (inner != null) this._writeLockRafIds.add(inner);
512
+ });
513
+ if (outer != null) this._writeLockRafIds.add(outer);
514
+ }
515
+
516
+ // 单次锁短路一帧(handleLoadMore 桌面分支用:裸写 scrollTop 后让 RO fire 被吃掉)
517
+ suppressOnce() {
518
+ if (this._disposed) return;
519
+ this._lockDepth++;
520
+ const id = this._raf(() => {
521
+ this._writeLockRafIds.delete(id);
522
+ if (this._disposed) return;
523
+ this._lockDepth = Math.max(0, this._lockDepth - 1);
524
+ });
525
+ if (id != null) this._writeLockRafIds.add(id);
526
+ }
527
+
528
+ // 缓动追底:双 rAF 等 layout 完,然后 step easeOut(35% gap,min 1px max 120px)
529
+ // _smoothLockHeld 是 owner 标记,整条 step 链占 1 个 lock 引用;嵌套 startSmoothFollow
530
+ // 不重复 increment(已持有 owner 的 lock)。
531
+ // 帧率节流:step 用 _smoothFollowMinFrameMs(默认 33ms≈30fps)门控——未到间隔的帧只重排
532
+ // rAF、不读写 scrollTop,避免每帧 forced reflow(与显示器刷新率解耦,可经 opts 调整)。
533
+ // 用户滚动窗口内整体停摆(入口 + step 双守卫):SSE 高频重启在窗口内全部 no-op,
534
+ // 锁不会被重新抓走,用户的 scroll 信号得以进入决策通道。
535
+ startSmoothFollow(el) {
536
+ if (this._disposed) return;
537
+ if (this.isUserScrolling()) return;
538
+ const scroller = el || this._boundEl;
539
+ if (!scroller) return;
540
+ if (!this._smoothLockHeld) {
541
+ this._lockDepth++;
542
+ this._smoothLockHeld = true;
543
+ }
544
+ if (this._smoothFollowRafId !== null) {
545
+ this._cancelRaf(this._smoothFollowRafId);
546
+ this._smoothFollowRafId = null;
547
+ }
548
+ // 上一次实际移动 scrollTop 的时间戳(闭包内,每条 step 链独立);0 = 首帧立即移动,不被节流
549
+ let lastMoveTs = 0;
550
+ const release = () => {
551
+ if (!this._smoothLockHeld) return;
552
+ this._smoothLockHeld = false;
553
+ this._lockDepth = Math.max(0, this._lockDepth - 1);
554
+ };
555
+ const step = () => {
556
+ this._smoothFollowRafId = null;
557
+ if (this._disposed) { release(); return; }
558
+ if (!this._getSticky()) { release(); return; }
559
+ // 竞态臂:意图事件经 cancelSmoothFollow 同步取消链,这里兜两帧之间事件未及取消的极端情况
560
+ if (this.isUserScrolling()) { release(); return; }
561
+ // 帧率门控:距上次移动不足 _smoothFollowMinFrameMs 则跳过本帧(不触碰 layout),仅重排 rAF。
562
+ // disposed / sticky 守卫放在门控之前,保证取消语义在节流期间仍即时生效。
563
+ if (this._smoothFollowMinFrameMs > 0 && (this._now() - lastMoveTs) < this._smoothFollowMinFrameMs) {
564
+ this._smoothFollowRafId = this._raf(step);
565
+ return;
566
+ }
567
+ const target = this._followTarget;
568
+ const current = scroller.scrollTop ?? 0;
569
+ const gap = target - current;
570
+ // 非有限数(scroller 异常 / target 缓存被污染)防御:直接 release 避免死循环 step
571
+ if (!Number.isFinite(gap)) { release(); return; }
572
+ if (gap <= 0.5) {
573
+ try { scroller.scrollTop = target; } catch {}
574
+ release();
575
+ return;
576
+ }
577
+ lastMoveTs = this._now();
578
+ const delta = Math.max(1, Math.min(gap * 0.35, 120));
579
+ try { scroller.scrollTop = current + delta; } catch {}
580
+ this._smoothFollowRafId = this._raf(step);
581
+ };
582
+ // 双 rAF:先让新内容 layout 完成再测量 target
583
+ this._smoothFollowRafId = this._raf(() => {
584
+ if (this._disposed) { release(); return; }
585
+ this._smoothFollowRafId = this._raf(() => {
586
+ if (this._disposed) { release(); return; }
587
+ this.refreshFollowTarget(scroller);
588
+ step();
589
+ });
590
+ });
591
+ }
592
+
593
+ cancelSmoothFollow() {
594
+ if (this._disposed) return;
595
+ if (this._smoothFollowRafId !== null) {
596
+ this._cancelRaf(this._smoothFollowRafId);
597
+ this._smoothFollowRafId = null;
598
+ }
599
+ if (this._smoothLockHeld) {
600
+ this._smoothLockHeld = false;
601
+ this._lockDepth = Math.max(0, this._lockDepth - 1);
602
+ }
603
+ }
604
+
605
+ // RO 回调统一入口:尺寸变 → 刷缓存 → sticky 时跟到底(受 lock + 用户滚动窗口守卫)
606
+ handleScrollerResize(el) {
607
+ if (this._disposed) return;
608
+ const target = el || this._boundEl;
609
+ if (!target) return;
610
+ this.refreshFollowTarget(target);
611
+ if (!this._getSticky()) return;
612
+ if (this._lockDepth > 0) return;
613
+ if (this.isUserScrolling()) return;
614
+ this.writeUnderLock(target, this._followTarget);
615
+ }
616
+
617
+ // Virtuoso atBottomStateChange 接管入口。
618
+ //
619
+ // 这个方法整合两层职责(按下到上):
620
+ // ── (A) Virtuoso 真值修正层 ──
621
+ // Virtuoso footer 子树(lastResponse / spinner / streamingLiveItem 三段)高度抖动
622
+ // 会让内部 atBottom 误判翻转。用真实 DOM 距离 ≤/> _atBottomPx 兜底,过滤掉抖动。
623
+ // ── (B) 状态翻转决策层 ──
624
+ // 走 _setSticky 享受 16ms 决策去重(合并同 tick 内 RO + Virtuoso 双发)。
625
+ //
626
+ // 锁期间统一短路(保留 Virtuoso 真实 atBottom 不可靠)。未来若有 iPad 等新 scroller 出
627
+ // 类似 atBottom 不可靠场景,可考虑把 (A) 抽成可插拔的 correctionFn。
628
+ notifyAtBottom(isAtBottom) {
629
+ if (this._disposed) return;
630
+ if (this._lockDepth > 0) return;
631
+ // (A) 真值修正:DOM 实测距离 vs _atBottomPx 兜底
632
+ const el = this._boundEl;
633
+ if (el && typeof el.scrollHeight === 'number') {
634
+ const realGap = (el.scrollHeight ?? 0) - (el.scrollTop ?? 0) - (el.clientHeight ?? 0);
635
+ if (!isAtBottom && this._getSticky() && realGap <= this._atBottomPx) return;
636
+ if (isAtBottom && !this._getSticky() && realGap > this._atBottomPx) return;
637
+ }
638
+ // (B) 翻转决策:走 _setSticky 决策去重
639
+ this._setSticky(!!isAtBottom);
640
+ }
641
+ }
642
+
643
+ export default StickyBottomController;