基于 OpenCode 重构滚动系统(useAutoScroll + @tanstack/react-virtual)#129
Closed
SsparKluo wants to merge 18 commits into
Closed
基于 OpenCode 重构滚动系统(useAutoScroll + @tanstack/react-virtual)#129SsparKluo wants to merge 18 commits into
SsparKluo wants to merge 18 commits into
Conversation
Open
Author
|
问题还有点多 我应该还要修一会。主要是要让输入框悬浮比较复杂 |
8c0f2ea to
ac86030
Compare
…tual
Replace ChatArea's hand-rolled scroll management and page virtualization
with a port of OpenCode's createAutoScroll hook and @tanstack/react-virtual.
Three key changes:
1. useAutoScroll hook (src/hooks/useAutoScroll.ts)
- React port of OpenCode's SolidJS createAutoScroll
- Tracks userScrolled state, markAuto/isAuto debounce (1500ms),
300ms settle period after streaming, overflow-anchor management
- Supports both normal-flow and flex-col-reverse containers
- 17 unit tests covering all scroll-following edge cases
2. Normal flow + @tanstack/react-virtual
- Switched scroll container from flex-col-reverse to flex-col
- Replaced hand-rolled page expansion/premeasure infrastructure with
useVirtualizer (anchorTo: 'end', measureElement, overscan: 20)
- shouldAdjustScrollPositionOnItemSizeChange ensures items above the
viewport growing don't shift visible content (core jitter fix)
- Removed ~330 lines of manual virtualization code
3. InputBox clearance via virtualizer resizeItem
- Clearance virtual item at the end of the virtualizer provides
bottom space so messages aren't hidden behind the InputBox
- ResizeObserver detects InputBox height changes, synced to the
clearance item via pageVirtualizer.resizeItem() (bypasses the
virtualizer's item-size cache which estimateSize alone cannot
update after initial measurement)
…bottom) buildChatPages was returning pages in newest-first order, causing the virtual list to render the newest content at the top and oldest content at the bottom. This made the chat feel inverted — scrolling down showed older messages instead of newer ones. - buildChatPages: reverse the result so pages[0] = oldest (top) - flattenPagesMessageIdsChronological: no longer needs .reverse() - reconcileStableChatPages: update indices — newest page is now last, older pages prepend to the front - Fix tests to match oldest-first ordering
…ttom shouldAdjustScrollPositionOnItemSizeChange blocked all size-change scroll adjustments for items below the viewport. When the last page (newest content) expanded — from streaming or block expansion — the content grew below the viewport without scroll compensation, hiding it behind the input box. The fix allows scroll adjustment when the viewport is near the bottom (distanceToBottom < 2x threshold), so end-anchored content stays visible when it grows.
…ollow content height changes 1. Deferred snap: when user stops scrolling within the snap zone (bottomThreshold), smoothly scroll to the very bottom after 150ms delay so the latest content is fully visible. 2. Removed !active() gate from scrollToBottom and ResizeObserver: content height changes (block expansion, tool results rendering) always trigger scroll-to-bottom when user is following, not just during streaming or the 300ms settle window.
…follow mode - Reset snap timer on each scroll event (debounced), not just once, so the snap only fires 150ms after the user truly stops scrolling - Use 'auto' (instant) instead of 'smooth' for the snap — prevents intermediate scroll events during animation from triggering stop()
…x escape valve stuck on textarea focus - Replace CollapsedCapsule (centered pill) with full-width CollapsedBar - Use grid-rows transition for smooth height + opacity crossfade (same pattern as attachments) - Remove expandedHeight placeholder (overlay legacy; no longer needed in flex flow) - Fix escape valve: allow clearing isFocused when tapping chat area even if textarea focused - Allow collapse when hasContent (text preserved in always-mounted DOM)
ac86030 to
d89d815
Compare
Author
。。。搞不定,现在这个实现没有 overlay 的输入框。。。 |
…vent clipping The CSS grid-rows transition wrapper added overflow-hidden which clipped the absolute-positioned MentionMenu and SlashCommandMenu (bottom: 100%), causing / and @ commands to appear unresponsive.
…aryGesture Port opencode's markBoundaryGesture algorithm + message-gesture helpers to distinguish user-driven scroll from virtualizer-driven scroll events: - scrollGesture.ts: pure functions (normalizeWheelDelta, shouldMarkBoundaryGesture, boundaryTarget, markBoundaryGesture, markPointerGesture) — direct port from opencode's message-gesture.ts and message-timeline.tsx - useScrollGestureDetector: React hook exposing wheel/touch/pointer/keyboard handlers and a 250ms hasGesture() window — replaces lastUserGestureAtRef + SCROLL_GESTURE_WINDOW_MS timestamp pair - ChatArea: wires the detector on the scroll root, removes the old 250ms gate Improvements over the timestamp window: - Touch Y tracking — previously opencodeUI did not track touch gestures at all - Nested scroller opt-out via data-scrollable — wheel inside a code block with room no longer marks the outer gesture - Wheel deltaMode normalization (line × 40, page × rootHeight) - Keyboard scroll keys (PageUp/PageDown/Home/End) mark the gesture - Scrollbar drag (pointerdown on root) is detected via target === root
useAutoScroll's ResizeObserver only watched contentEl (the virtualized page wrapper). When the InputBox textarea auto-resized, the ChatArea above shrank by one line height, but the content's total scrollHeight didn't change — so the observer never fired and the user was stranded at the old max scrollTop, with the latest message clipped off the bottom. Add scrollEl to the same observer so container resize (textarea grow, window resize, layout shifts) re-snaps the bottom when the user is following, gated by userScrolled so an intentional scroll-up is preserved. Drive-by fix: scrollToBottomNow wrote scrollHeight instead of scrollHeight - clientHeight, relying on the browser to clamp the overshoot. Use the existing bottomScrollTop helper for the correct target in any host (works in jsdom / headless envs too).
This reverts commit fce3f67.
InputBox auto-resize set style.height = 'auto' before reading scrollHeight. This briefly collapsed the textarea to 1 line on every text change, even when the line count didn't change (e.g. typing a character after pressing Enter). The spurious collapse caused a layout shift in the chat area above, triggering unnecessary virtualizer/auto-scroll recalculations that scrolled the chat to the wrong position. scrollHeight is the content height regardless of the element's current style.height, so the auto reset is unnecessary. Removing it eliminates the layout shift on character-only edits.
Remove the auto-collapse behavior that folded the input box when the user scrolled away from the bottom. The content-height reset on textarea resize (removed in preceding commit) broke the layout transition, leaving the collapsed bar at the top of the panel with empty space below. Switch to a manual toggle: - useMobileCollapse: track a user-controlled manuallyCollapsed state instead of deriving from isAtBottom / isFocused - InputToolbar: add a collapse button on mobile (isCompact) that calls toggleCollapse - InputBox: wire collapse state to InputToolbar, drop unused refs from hook call The collapsed bar is still shown when collapsed; clicking it expands and focuses the textarea automatically.
The previous auto-collapse used the same CSS pattern but the grid item (relative z-30) lacked overflow-hidden, so 0fr track never collapsed to 0 height on manual collapse — the expanded input area left blank space below the CollapsedBar. Move the relative z-30 positioning anchor outside the grid, add overflow-hidden to the grid element itself. This way 0fr properly clips the grid items to 0 height. Menus (MentionMenu, SlashCommandMenu) remain outside the grid overflow so they're not affected. Drive-by: remove onNavigate prop from SlashCommandMenu — the prop doesn't exist in its interface and referenced an undefined function (updateSlashQuery).
The auto-resize effect previously set style.height = 'auto' to read accurate scrollHeight, but this briefly collapsed the textarea to 1 line, causing a layout shift that propagated to the ChatArea (scroll jumping on typing). Removing the auto reset fixed the scroll jumping but broke shrinking: when the textarea had a fixed style.height larger than the content, scrollHeight could return the element height instead of the content height, so deleting lines never reduced the height. Replace with a hidden mirror div that copies the textarea's computed styles and current width. Since the mirror is off-screen (position: absolute, top/left: -9999px), no visible layout shift occurs. The mirror's scrollHeight gives the correct content height.
…isible when collapsed - Replace div mirror with off-screen textarea mirror to match native scrollHeight behavior exactly (fixes 'first Enter not growing' bug) - Replace useEffect with useLayoutEffect for synchronous resize before paint - Unify bottomDockPadding formula (Footer always visible, no split) - Move InputFooter outside full-input grid so it stays visible when collapsed - Wire onDockResize callback for explicit scroll-to-bottom-on-resize safety - Batch useAutoScroll RO via rAF and observe scrollEl for layout shifts
Author
后续新增改动 (since PR created)1. 输入框测高优化(3 commits)
2. 折叠态 footer 常驻
3. useAutoScroll RO 增强
4. ChatPane 保险回调
改动文件
|
…r gestures on mobile
Remove dependency on touch-pan-y propagation which could not reliably forward horizontal gestures to the pager. Instead, add useMobileChatPagerGestureBridge that explicitly tracks touch axis on the scroll root and drives pager.scrollLeft when horizontal-dominant.
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.
背景
原有滚动系统存在两个核心问题:
flex-col-reverse下overflow-anchor失效,JS 锚定补偿晚一帧userScrolled误判方案
1.
useAutoScrollHook(src/hooks/useAutoScroll.ts)React 移植 OpenCode 的
createAutoScroll:markAuto/isAuto(1500ms 防抖)区分程序 scrollTo 与用户手势overflowAnchor: 'dynamic'管理[data-scrollable]区域轮滚不触发暂停2. 正常流 +
@tanstack/react-virtualflex-col-reverse→flex-col,overflow-anchor原生生效useVirtualizer,净减 ~330 行anchorTo: 'end'+shouldAdjustScrollPositionOnItemSizeChange同步锚定,消除 jitter3. InputBox 布局优化
bottomPaddingprop、inputBoxHeight追踪4. 移动端输入框紧凑栏
grid-template-rows过渡做高度 + 交叉淡入淡出改动(12 文件,+1119 -521)
src/hooks/useAutoScroll.tssrc/hooks/useAutoScroll.test.tssrc/features/chat/ChatArea.tsxsrc/features/chat/ChatPane.tsxsrc/features/chat/chatPageModel.tssrc/features/chat/ChatArea.test.tssrc/features/chat/InputBox.tsxsrc/features/chat/input/InputActions.tsxsrc/features/chat/input/useMobileCollapse.tssrc/main.tsxsrc/constants/ui.tspackage.json@tanstack/react-virtual