Skip to content

refactor(chat): input-event-driven scroll-follow redesign#145

Draft
SsparKluo wants to merge 2 commits into
lehhair:mainfrom
SsparKluo:feat/auto-scroll-input-driven
Draft

refactor(chat): input-event-driven scroll-follow redesign#145
SsparKluo wants to merge 2 commits into
lehhair:mainfrom
SsparKluo:feat/auto-scroll-input-driven

Conversation

@SsparKluo

Copy link
Copy Markdown
Contributor

背景

这是 OpenCodeUI chat 滚动跟随系统的一次完整重写尝试。基于 v0.6.34 (main HEAD c49fb49f)。原实现基于 markAuto / isAuto 时间戳 token,几类反复出现的 bug 猜测都是因为这套机制:

  • 流式输出中无故退出贴底
  • 展开工具调用 / thinking block 后被拉回底部
  • 小幅 wheel-up 退不出跟随
  • 选中文字 → 取消选中 → 状态被错误改变

scroll 事件没有 JS 可见的输入层前驱 —— layout clamp、viewport resize、find-in-page、history restoration 都会 fire scroll 事件但没有任何用户手势。感觉这类场景里反推机制不可能 100% 准确。

三种实现并列对比

A. OpenCodeUI 当前实现(useAutoScroll.ts

核心:markAuto / isAuto 时间戳。

el.scrollTop = max
markAuto(el)              // 记录 top + now()

scroll 事件 → handleScroll:
  isAuto(el)?              // (Date.now() - token.time < 1500ms) && (|scrollTop - token.top| < 2)
    yes → ignore           // 程序触发,不动
    no  → stop()           // 用户滚动,停跟随

5 处显式 markAuto 调用点(scrollToFn、resizeItem 微任务、pinToBottom、scrollToLastMessage、imperative handle)。4 套阈值(10/60/150/80)。双门控(userScrolledRef && prevState.bottom)。

B. OpenCode 上游实现(packages/ui/src/hooks/create-auto-scroll.ts

OpenCode 同时保留了 markAuto 系统并叠加了 scrollGesture 系统:

scrollGestureWindowMs = 250

用户输入(wheel / touch / pointer / key):
  markScrollGesture(target)
    ↓
  最近的 scrollGesture 时间戳

scroll 事件 → handleScroll:
  if hasScrollGesture(): stop()
  else if isAuto(): scrollToBottom()
  else: stop()

OpenCode 的 markBoundaryGesture 是个有意思的设计:嵌套块内滚动是私有的,只有溢出时才把意图上传给外层 chat:

shouldMarkBoundaryGesture(delta, scrollTop, scrollHeight, clientHeight):
  if delta < 0: return scrollTop + delta <= 0    // 上滚到顶
  return delta > max - scrollTop                  // 下滚到底

OpenCode 配套的 data-scrollable 静态属性标记嵌套块,normalizeWheelDelta 处理 pixel/line/page 三种 deltaMode。

C. 这个 PR 的尝试

input-event-driven 单系统:用户意图在事件源头直接声明,scroll 事件根本不参与状态判定。

userScrolled (single ref + state)
  ↓ ↑ 由用户输入直接设置
  ↓
输入事件(capture phase, native listener):
  wheel / touchstart / touchmove / touchend /
  pointerdown / pointerup / keydown /
  OS_DRAG_START / OS_DRAG_END / selectionchange

scroll 事件:
  只负责 drift pin(用户在跟随时排 rAF 写回底部)
  + recovery window 内的 atBottom 完成恢复

scroll 事件永远不直接清 userScrolled,除非在用户显式向下输入打开的 500ms 恢复窗口内(解决 iOS momentum / 触屏边界偏差)。

三方对比

维度 OpenCodeUI 当前 OpenCode 本 PR
意图来源 scroll 事件反推 输入 + scroll 事件反推 仅输入
嵌套块 不处理 markBoundaryGesture markBoundaryGesture(沿用)
嵌套块检测 运行时 getComputedStyle data-scrollable 静态 data-scrollable 静态(沿用)
wheel delta 原值 normalize normalize(沿用)
wheel down tryRecover 即时 tryRecover 即时 mark + handleScroll 窗口
selection onClick 检查 onClick 检查 selectionchange 监听
触屏 简单 stopFollow React handler 方向感知
状态机 userScrolled + markAuto + prevState userScrolled + scrollGesture + markAuto userScrolled + recovery window

与 OpenCode 上游的差异(保留 vs 改动)

沿用 OpenCode

  • data-scrollable 静态属性(加在 ScrollArea / CodeBlock / DiffViewer / Markdown 代码预览)
  • shouldMarkBoundaryGesture 嵌套块边界溢出检测
  • normalizeWheelDelta 处理 pixel/line/page
  • withScrollLock(action, expanding) 显式声明 expand/collapse
  • AutoScrollContext 跨组件树连接 pause 信号

和 OpenCode 不同

  1. 删掉 markAuto:OpenCode 双系统(markAuto + scrollGesture)我们只用一个。原因:markAuto 存在的意义是「程序写的 scroll 事件不该被当作用户输入」。但状态由输入事件直接设置,scroll 事件根本不参与状态判定,markAuto 没有存在的必要。

  2. wheel-down 开恢复窗口:OpenCode 立即调 tryRecover 检查 atBottom。我们开 500ms 窗口让 iOS momentum 和触屏松手时差几 px 的情况也能完成恢复。

  3. selection 用 selectionchange 而非 onClick:OpenCode 的 handleInteraction 只在 click 时检查(要等 mouseup)。selectionchange 能更早捕获「开始选」的意图。但加了判断:只对 empty → non-empty 起反应,clear 不动。

  4. 触屏方向感知(OpenCode 没有):touchmove 跟踪 touchMaxDownRef,touchend 只在确实向下滚过 >10px 时才调 tryRecover。否则 tap 一下立即恢复,再松手就被 drift 自愈拉回去。

  5. toBottom 按钮由 userScrolled 驱动(OpenCode 没有):OpenCode 用位置阈值(60/150px)。我们用 userScrolled 状态反转。小幅 wheel-up 不动 scrollTop 时按钮仍要显示,因为 userScrolled=true(用户表达了停止意图)。

  6. OS_DRAG_START/END 自定义事件(OpenCode 没有):OpenCodeUI 自绘滚动条(overlayScrollbar)的 thumb 在父元素上 + stopPropagation,pointerdown 检测不到。我们让 overlayScrollbar 在 thumb 拖拽时广播 OS_DRAG_START/END 自定义事件。

为什么用单系统

OpenCode 双系统有两个 clock 协调:

  • autoTimer: 程序 scroll 标记,1500ms TTL
  • ui.scrollGesture: 用户输入标记,250ms 窗口

我们只有一个:recovery window(500ms)。理由:

  • markAuto 想识别「程序写的 scroll 不该被当作用户输入」。但状态机根本不读 scroll 事件做判断,所以 markAuto 没有目标。
  • scrollGesture 想用「最近 250ms 内有过用户手势」作为一种信号。我们每次输入事件直接调用 stopFollow / tryRecover,不需要中间时间戳。

代价:markAuto 的程序写入识别能力没了。比如程序调 el.scrollTop = max 后立即 fire scroll —— OpenCode 用 markAuto 忽略;我们直接信任 handleScroll 的 atBottom 判断(atBottom=true 就排 rAF pin 回去)。这是等效的,但路径不同。

修复的 bug

Bug 旧实现根因 新实现处理
流式中无故退出贴底 scroll 事件无 JS 前驱场景误判 这些场景不匹配任何输入事件
展开工具/thinking 被拉回底 lockScrollAroundAnchor 写 scrollTop 被当用户滚动 disclosure 显式 expanding=true 才 stop
小幅 wheel-up 退不出 scroll 事件看到 atBottom=true 立刻清 userScrolled scroll 事件不参与状态判定
选中/取消选中状态变化 handleInteraction 笼统处理 只对 empty→non-empty 起反应,clear 不动
自绘滚动条拖拽没用 pointerdown 检测不到 thumb OS_DRAG_START/END 自定义事件
嵌套代码块内 wheel 误停 markAuto 不理解嵌套容器边界 boundary 检查(OpenCode 沿用)

未解决的边界

  • recovery window 是启发式:500ms 对慢设备可能不够,对快设备可能太长
  • iOS momentum 极端情况:超慢 momentum 可能在窗口外完成;fallback 是点 toBottom 按钮
  • data-scrollable 漏标记:新增嵌套滚动组件必须记得加,否则 fallback 到「嵌套块内 wheel 也算 chat 跟随」行为
  • OpenCode 的「scrollGesture 不区分方向」:我们也用相同思路(markRecoverGesture 不区分方向)。但 OpenCode 还在边界外用 hasScrollGesture() 通用判定,我们是显式 stopFollow / tryRecover 两条路径。哪种更可扩展不确定。
  • 测试覆盖度:29 个集成测试覆盖核心状态转换。真实浏览器行为(capture phase 边界、native momentum、real getComputedStyle)仍需手测。

兼容性

  • 删掉了 5 处 markAuto 调用点
  • 删掉了 prevState.bottom 二次门控
  • 删除了 useAutoScroll.handleInteraction / handleWheel / markAuto / isAuto API
  • useDisclosureScrollLock.withScrollLock 是破坏性变更(加了第 2 个参数),所有 8 个调用点已更新
  • AutoScrollContext 是新增,不破坏现有组件

文件

20 个文件改动(+1181/-139 行):

新增:
  src/hooks/AutoScrollContext.ts                    (context + useAutoScrollIntent hook)
  src/features/chat/DESIGN.md                       (设计文档 + mermaid 序列图)
  src/features/chat/virtual/useAutoScroll.test.tsx  (29 集成测试)

重写:
  src/features/chat/virtual/useAutoScroll.ts        (320 行)

修改:
  src/hooks/useDisclosureScrollLock.ts              (withScrollLock 加 expanding 参数)
  src/features/chat/ChatArea.tsx                    (onFollowingChange callback)
  src/features/chat/ChatPane.tsx                    (isFollowing state)
  src/lib/overlayScrollbar.ts                       (OS_DRAG_START/END 事件)
  8 个 disclosure widget 调用点                      (ToolPartView / ReasoningPartView / ...)
  4 个嵌套滚动组件                                  (CodeBlock / DiffViewer / ...)

测试

  • 602/602 passing(其中 29 个新的 useAutoScroll.test.tsx)
  • tsc -b clean
  • eslint clean
  • 手测覆盖 11 个场景(见 DESIGN.md)

起点

基于 main (c49fb49f, v0.6.34)。单一 commit refactor(chat): input-event-driven scroll-follow redesign

Replace the markAuto/isAuto timestamp token system with input-event-
driven attribution: userScrolled is now set ONLY by direct user input
events, NEVER by scroll events alone. Align nested-scroll handling with
the upstream OpenCode boundary model so scrolling inside a code block
or diff stays private to that block.

Core architecture
----------------
- userScrolled is a single ref + state. scroll events never clear it.
- Recovery only via (a) forceScrollToBottom button, (b) explicit down
  input + atBottom check, or (c) a 500ms recovery window opened by an
  explicit down input that handleScroll closes on atBottom (iOS
  momentum / touch-with-px-error edge cases).
- handleScroll only schedules a recoverPin rAF when the user is
  following and scrollTop has drifted off bottom.
- stopFollow explicitly closes any active recovery window so a re-stop
  can't be silently undone by a lingering window.

Input attribution
-----------------
Inputs are captured at the event source, not reverse-engineered from
scroll events:

  wheel-up       target in chat root, not editable, no nested boundary -> stopFollow
  wheel-down     target in chat root, not editable, no nested boundary -> open recovery window
  wheel          target inside marked nested scrollable, not at boundary -> no-op for chat follow
  wheel          nested scrollable already at top (up) / bottom (down) -> escalate to chat gesture
  touchstart     target in chat root, not editable -> stopFollow + reset touchMaxDownRef
  touchmove      update touchMaxDownRef (max downward displacement)
  touchend       touchMaxDownRef > 10 -> tryRecover, else no-op
  pointerdown    on scrollbar region -> stopFollow
  pointerup      no-op (mouse-up is not a "scroll down" gesture)
  keydown        PageUp/Home/ArrowUp -> stopFollow; PageDown/End/ArrowDown -> tryRecover
  selectionchange  empty to non-empty (chat-root anchor) -> stopFollow; clear -> no-op
  OS_DRAG_START  overlayScrollbar custom event -> stopFollow
  OS_DRAG_END    overlayScrollbar custom event -> tryRecover

Nested scroll boundaries (upstream alignment)
---------------------------------------------
Nested vertical scroll viewports (ScrollArea, CodeBlock, DiffViewer,
Markdown code preview) are explicitly marked with data-scrollable.
shouldMarkBoundaryGesture() lets a wheel intent escape to the chat only
when the nested viewport would overflow its top or bottom, matching
OpenCode's markBoundaryGesture. This keeps scrolling inside a code
block private to that block; only an overflow attempt becomes an outer
chat gesture. normalizeWheelDelta handles pixel / line / page delta
modes uniformly.

Disclosure widget semantics
----------------------------
useDisclosureScrollLock.withScrollLock(action, expanding) takes an
explicit second arg: expanding=true stops the follow (user wants to
read expanded content); expanding=false is a no-op (collapsing already-
seen content should not change follow state). All 8 callsites updated.
AutoScrollContext wires disclosure widgets deep in the message tree to
useAutoScroll.pause via context.

Implementation details
-----------------------
- Listeners attached via AbortController for one-line cleanup.
- Handlers + attach split into module-level createInputHandlers /
  attachInputListeners for readability.
- setScrolled short-circuits when the value is unchanged.
- scrollEl.style.overflowAnchor='none' set on attach.
- toBottom button visibility is driven by userScrolled (via
  onFollowingChange callback from ChatArea to ChatPane), NOT by a
  positional threshold, so a tiny wheel-up that doesn't move scroll
  position still shows the recovery affordance.

Files
-----
- useAutoScroll.ts rewritten (320 lines): core state machine + handler factory
- AutoScrollContext.ts (new): provider + useAutoScrollIntent hook
- useDisclosureScrollLock.ts: withScrollLock gains 'expanding' arg
- ChatArea.tsx, ChatPane.tsx: integrate new system, expose isFollowing
- CodeBlock.tsx, DiffViewer.tsx, MarkdownRenderer.tsx, ScrollArea.tsx:
  mark vertical scroll viewports with data-scrollable
- 8 disclosure widget files (ToolPartView, ReasoningPartView, SubtaskPartView,
  SystemPartViews, MessageErrorView, TaskRenderer, TodoRenderer,
  MessageRenderer): pass expanding arg
- overlayScrollbar.ts: emit OS_DRAG_START/END custom events from thumb drag
- DESIGN.md (new): state machine, input mapping table, recovery rules,
  sequence diagrams for key scenarios
- useAutoScroll.test.tsx (new, 29 tests): wheel, touch, keyboard,
  pointer, OS_DRAG, recovery window, nested scrollable boundary,
  rapid events, alternating sequence, editables, handleScroll invariants

Behavioral fixes (vs previous markAuto design)
-----------------------------------------------
- Layout clamp / viewport resize / find-in-page / history restoration
  no longer mis-detected as user scroll (no input event matches).
- Disclosure toggle no longer causes spurious follow break
  (lockScrollAroundAnchor scroll writes no longer collide with
  state inference).
- Tiny wheel-up persists stop instead of being immediately undone.
- Selection clear no longer changes follow state.
- Touch tap does not snap-recover; only real downward drag releases.
- Custom scrollbar thumb drag works (OS_DRAG events).
- Wheel inside a nested code block stays private until boundary.

Testing
-------
608/608 tests pass (29 new in useAutoScroll.test.tsx).
tsc -b clean. ESLint clean for the touched areas.
@lehhair

lehhair commented Jul 21, 2026

Copy link
Copy Markdown
Owner

这么大的更改吗,我抽空看一下,看看能不能学习一下

@SsparKluo

Copy link
Copy Markdown
Contributor Author

这么大的更改吗,我抽空看一下,看看能不能学习一下

我也不知道这么做好不好😂

selectionchange is now a no-op when the session is not streaming.
This means selecting text to copy/reference while idle doesn't:
  - set userScrolled=true
  - show the toBottom button
  - break follow for the next message

During streaming, selection still stops follow as before — the user
is choosing to read instead of watch the stream.

Implementation:
- useAutoScroll: add isStreamingRef + setStreaming() method
- onSelectionChange: early return if !isStreamingRef.current
- ChatArea: useEffect syncs isStreaming prop → auto.setStreaming()
- 2 new integration tests (selection during streaming stops, during
  idle doesn't)
@SsparKluo
SsparKluo force-pushed the feat/auto-scroll-input-driven branch from ee23bba to c56183f Compare July 22, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants