fix(mothership): keep scroll position when user scrolls up during streaming (#5639)

* fix(mothership): keep scroll position when user scrolls up during streaming

* chore(mothership): trim scroll fix comments
This commit is contained in:
Theodore Li
2026-07-13 18:24:37 -04:00
committed by GitHub
parent f80162d9b7
commit 8853b0c21b
3 changed files with 25 additions and 6 deletions
@@ -176,19 +176,23 @@ function BoundedViewport({ children, isStreaming }: BoundedViewportProps) {
const ref = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const stickToBottomRef = useRef(true)
const prevScrollTopRef = useRef(0)
const [hasOverflow, setHasOverflow] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
// (wheel back down or dragging scrollbar) re-attaches it.
// Upward user input detaches auto-stick; a downward scroll reaching the
// bottom re-attaches it (a small upward flick can't re-stick itself).
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) stickToBottomRef.current = false
}
const handleScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) {
stickToBottomRef.current = true
}
prevScrollTopRef.current = el.scrollTop
}
el.addEventListener('wheel', handleWheel, { passive: true })
el.addEventListener('scroll', handleScroll, { passive: true })
@@ -339,6 +339,16 @@ export function MothershipChat({
rangeExtractor,
})
/**
* Instance property — silently ignored if passed as a `useVirtualizer`
* option. Skips scroll compensation for the streaming last row: it starts
* above the viewport but grows at its bottom edge, so the default dragged
* the viewport down in lockstep with growth even after the user scrolled
* away. Other rows keep the library default.
*/
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
item.index !== lastIndex && item.start < (instance.scrollElement?.scrollTop ?? 0)
const scrolledChatRef = useRef<string | undefined | typeof UNSCROLLED>(UNSCROLLED)
const userInputRef = useRef<UserInputHandle>(null)
const messageQueueRef = useRef(messageQueue)
+8 -3
View File
@@ -49,8 +49,8 @@ interface UseAutoScrollOptions {
*
* Stays pinned to the bottom while content streams in. Detaches immediately
* on any upward user gesture (wheel, touch, scrollbar drag, keyboard). Once
* detached, the user must scroll back to within {@link REATTACH_THRESHOLD} of
* the bottom to re-engage. Each streaming start re-seeds stickiness from the
* detached, the user must scroll back down to within {@link REATTACH_THRESHOLD}
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
* current scroll position, so a user who scrolled up beforehand stays put.
*
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
@@ -164,6 +164,10 @@ export function useAutoScroll(
* (pointer held) or a recent keyboard scroll. A programmatic upward scroll, e.g.
* a virtualizer re-pinning content on a row-size shrink, has neither and must not
* be mistaken for the user scrolling away.
*
* Re-attach also requires a downward move once detached — a small upward
* flick still lands within {@link REATTACH_THRESHOLD}, and would otherwise
* re-stick on its own scroll event.
*/
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
@@ -172,8 +176,9 @@ export function useAutoScroll(
const userDriven =
pointerDownRef.current ||
performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW
const movedDown = scrollTop > prevScrollTopRef.current
if (distanceFromBottom <= threshold) {
if (distanceFromBottom <= threshold && (!userDetachedRef.current || movedDown)) {
stickyRef.current = true
userDetachedRef.current = false
} else if (