refactor(chat): input-event-driven scroll-follow redesign#145
Draft
SsparKluo wants to merge 2 commits into
Draft
refactor(chat): input-event-driven scroll-follow redesign#145SsparKluo wants to merge 2 commits into
SsparKluo wants to merge 2 commits into
Conversation
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.
Owner
|
这么大的更改吗,我抽空看一下,看看能不能学习一下 |
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
force-pushed
the
feat/auto-scroll-input-driven
branch
from
July 22, 2026 02:48
ee23bba to
c56183f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
这是 OpenCodeUI chat 滚动跟随系统的一次完整重写尝试。基于 v0.6.34 (
mainHEADc49fb49f)。原实现基于markAuto/isAuto时间戳 token,几类反复出现的 bug 猜测都是因为这套机制:scroll事件没有 JS 可见的输入层前驱 —— layout clamp、viewport resize、find-in-page、history restoration 都会 fire scroll 事件但没有任何用户手势。感觉这类场景里反推机制不可能 100% 准确。三种实现并列对比
A. OpenCodeUI 当前实现(
useAutoScroll.ts)核心:markAuto / isAuto 时间戳。
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系统:OpenCode 的
markBoundaryGesture是个有意思的设计:嵌套块内滚动是私有的,只有溢出时才把意图上传给外层 chat:OpenCode 配套的
data-scrollable静态属性标记嵌套块,normalizeWheelDelta处理 pixel/line/page 三种 deltaMode。C. 这个 PR 的尝试
input-event-driven 单系统:用户意图在事件源头直接声明,scroll 事件根本不参与状态判定。
scroll事件永远不直接清 userScrolled,除非在用户显式向下输入打开的 500ms 恢复窗口内(解决 iOS momentum / 触屏边界偏差)。三方对比
data-scrollable静态data-scrollable静态(沿用)与 OpenCode 上游的差异(保留 vs 改动)
沿用 OpenCode:
data-scrollable静态属性(加在 ScrollArea / CodeBlock / DiffViewer / Markdown 代码预览)shouldMarkBoundaryGesture嵌套块边界溢出检测normalizeWheelDelta处理 pixel/line/pagewithScrollLock(action, expanding)显式声明 expand/collapse和 OpenCode 不同:
删掉 markAuto:OpenCode 双系统(markAuto + scrollGesture)我们只用一个。原因:markAuto 存在的意义是「程序写的 scroll 事件不该被当作用户输入」。但状态由输入事件直接设置,scroll 事件根本不参与状态判定,markAuto 没有存在的必要。
wheel-down 开恢复窗口:OpenCode 立即调 tryRecover 检查 atBottom。我们开 500ms 窗口让 iOS momentum 和触屏松手时差几 px 的情况也能完成恢复。
selection 用 selectionchange 而非 onClick:OpenCode 的
handleInteraction只在 click 时检查(要等 mouseup)。selectionchange能更早捕获「开始选」的意图。但加了判断:只对 empty → non-empty 起反应,clear 不动。触屏方向感知(OpenCode 没有):touchmove 跟踪
touchMaxDownRef,touchend 只在确实向下滚过 >10px 时才调 tryRecover。否则 tap 一下立即恢复,再松手就被 drift 自愈拉回去。toBottom 按钮由 userScrolled 驱动(OpenCode 没有):OpenCode 用位置阈值(60/150px)。我们用 userScrolled 状态反转。小幅 wheel-up 不动 scrollTop 时按钮仍要显示,因为 userScrolled=true(用户表达了停止意图)。
OS_DRAG_START/END 自定义事件(OpenCode 没有):OpenCodeUI 自绘滚动条(overlayScrollbar)的 thumb 在父元素上 + stopPropagation,pointerdown 检测不到。我们让 overlayScrollbar 在 thumb 拖拽时广播 OS_DRAG_START/END 自定义事件。
为什么用单系统
OpenCode 双系统有两个 clock 协调:
autoTimer: 程序 scroll 标记,1500ms TTLui.scrollGesture: 用户输入标记,250ms 窗口我们只有一个:recovery window(500ms)。理由:
代价:markAuto 的程序写入识别能力没了。比如程序调
el.scrollTop = max后立即 fire scroll —— OpenCode 用 markAuto 忽略;我们直接信任 handleScroll 的 atBottom 判断(atBottom=true 就排 rAF pin 回去)。这是等效的,但路径不同。修复的 bug
未解决的边界
data-scrollable漏标记:新增嵌套滚动组件必须记得加,否则 fallback 到「嵌套块内 wheel 也算 chat 跟随」行为hasScrollGesture()通用判定,我们是显式 stopFollow / tryRecover 两条路径。哪种更可扩展不确定。兼容性
useAutoScroll.handleInteraction/handleWheel/markAuto/isAutoAPIuseDisclosureScrollLock.withScrollLock是破坏性变更(加了第 2 个参数),所有 8 个调用点已更新AutoScrollContext是新增,不破坏现有组件文件
20 个文件改动(+1181/-139 行):
测试
tsc -bcleaneslintclean起点
基于
main(c49fb49f, v0.6.34)。单一 commitrefactor(chat): input-event-driven scroll-follow redesign。