Skip to content

基于 OpenCode 重构滚动系统(useAutoScroll + @tanstack/react-virtual)#129

Closed
SsparKluo wants to merge 18 commits into
lehhair:mainfrom
SsparKluo:feat/use-auto-scroll
Closed

基于 OpenCode 重构滚动系统(useAutoScroll + @tanstack/react-virtual)#129
SsparKluo wants to merge 18 commits into
lehhair:mainfrom
SsparKluo:feat/use-auto-scroll

Conversation

@SsparKluo

@SsparKluo SsparKluo commented Jun 25, 2026

Copy link
Copy Markdown

背景

原有滚动系统存在两个核心问题:

  1. 流式输出时视口抖动flex-col-reverseoverflow-anchor 失效,JS 锚定补偿晚一帧
  2. 自动滚动状态不可靠 — 异步 scroll 事件竞争导致 userScrolled 误判

方案

1. useAutoScroll Hook(src/hooks/useAutoScroll.ts

React 移植 OpenCode 的 createAutoScroll

  • markAuto / isAuto(1500ms 防抖)区分程序 scrollTo 与用户手势
  • 300ms settle 期缓冲流式结束后的布局抖动
  • overflowAnchor: 'dynamic' 管理
  • 嵌套 [data-scrollable] 区域轮滚不触发暂停

2. 正常流 + @tanstack/react-virtual

  • flex-col-reverseflex-coloverflow-anchor 原生生效
  • 手写虚拟化(分页/预测量/折叠)→ useVirtualizer,净减 ~330 行
  • anchorTo: 'end' + shouldAdjustScrollPositionOnItemSizeChange 同步锚定,消除 jitter

3. InputBox 布局优化

  • 移除 overlay 输入框:InputBox 从绝对定位改为正常 flex 流,ChatArea 自然撑满剩余空间
  • 移除 clearance virtual itembottomPadding prop、inputBoxHeight 追踪

4. 移动端输入框紧凑栏

  • 收起态从居中胶囊改为全宽玻璃栏,点击展开
  • CSS grid-template-rows 过渡做高度 + 交叉淡入淡出
  • 修复逃逸阀卡死:滑动聊天区后仍可再次收起
  • 允许有内容时收起(文字在始终挂载的 DOM 中保留)

改动(12 文件,+1119 -521)

文件 说明
src/hooks/useAutoScroll.ts 新增全文,17 个测试
src/hooks/useAutoScroll.test.ts 新增全文
src/features/chat/ChatArea.tsx 重构(-330 行旧代码)
src/features/chat/ChatPane.tsx 移除 clearance/inputBoxHeight;移除 overlay 布局
src/features/chat/chatPageModel.ts 修复页面顺序(top to bottom)
src/features/chat/ChatArea.test.ts 适配新分页逻辑
src/features/chat/InputBox.tsx 移动端紧凑栏 + grid-rows 动画;移除 overlay 占位
src/features/chat/input/InputActions.tsx CollapsedCapsule → CollapsedBar
src/features/chat/input/useMobileCollapse.ts 移除 expandedHeight;修复逃逸阀
src/main.tsx 引入 useViewportHeight
src/constants/ui.ts 滚动阈值常量
package.json 添加 @tanstack/react-virtual

@SsparKluo SsparKluo mentioned this pull request Jun 25, 2026
@SsparKluo

Copy link
Copy Markdown
Author

问题还有点多 我应该还要修一会。主要是要让输入框悬浮比较复杂

@SsparKluo SsparKluo force-pushed the feat/use-auto-scroll branch 2 times, most recently from 8c0f2ea to ac86030 Compare June 25, 2026 10:15
…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)
@SsparKluo SsparKluo force-pushed the feat/use-auto-scroll branch from ac86030 to d89d815 Compare June 25, 2026 10:19
@SsparKluo

Copy link
Copy Markdown
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).
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
@SsparKluo

Copy link
Copy Markdown
Author

后续新增改动 (since PR created)

1. 输入框测高优化(3 commits)

  • 离屏 textarea mirror 替代 height:auto 瞬态塌缩:解决因 style.height = 'auto' 中间态导致 ChatArea 视口 layout shift、scroll 跳动的问题(改用隐藏 textarea mirror 同步宽度/样式测高,可见 textarea 全程不缩)。
  • useLayoutEffect 同步设高:替换 useEffect,减少一帧错误布局。
  • 尾随换行处理:content 以 \n 结尾时在 mirror 追加零宽空格,确保最后一行空行计入 scrollHeight,修复「第一次 Enter 后不涨高」的 bug。

2. 折叠态 footer 常驻

  • InputFooter("请验证AI" / todo 进度)移出全输入区 grid,折叠时仍可见。
  • bottomDockPadding 统一:不再区分折叠/展开两套公式(Footer h-8 已提供缓冲)。
  • 权限/问答胶囊(FloatingActions)折叠时也在正常文档流中可见。

3. useAutoScroll RO 增强

  • 同时观察 contentEl + scrollEl:当仅 scrollEl clientHeight 变化(如输入框变高导致的视口重分配)时,RO 仍能触发贴底校正。
  • rAF 批处理:合并同帧内多次 RO 回调,防止中间态高度覆盖最终贴底位置。

4. ChatPane 保险回调

  • onDockResize -> scrollToBottomIfAtBottom():输入框高度稳定后,若仍处在贴底状态,显式补一次滚动校正。

改动文件

文件 说明
useTextareaAutoHeight.ts 新,mirror 测高 hook
measureTextareaContentHeight.ts 新,离屏 textarea mirror
InputBox.tsx 接入 mirror 测高 + footer 常驻
useAutoScroll.ts scrollEl RO + rAF
ChatPane.tsx onDockResize

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.
@SsparKluo SsparKluo closed this Jun 27, 2026
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.

1 participant