Skip to content

Commit 8853b0c

Browse files
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
1 parent f80162d commit 8853b0c

3 files changed

Lines changed: 25 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,19 +176,23 @@ function BoundedViewport({ children, isStreaming }: BoundedViewportProps) {
176176
const ref = useRef<HTMLDivElement>(null)
177177
const rafRef = useRef<number | null>(null)
178178
const stickToBottomRef = useRef(true)
179+
const prevScrollTopRef = useRef(0)
179180
const [hasOverflow, setHasOverflow] = useState(false)
180181

181182
useEffect(() => {
182183
const el = ref.current
183184
if (!el) return
184-
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
185-
// (wheel back down or dragging scrollbar) re-attaches it.
185+
// Upward user input detaches auto-stick; a downward scroll reaching the
186+
// bottom re-attaches it (a small upward flick can't re-stick itself).
186187
const handleWheel = (e: WheelEvent) => {
187188
if (e.deltaY < 0) stickToBottomRef.current = false
188189
}
189190
const handleScroll = () => {
190191
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
191-
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
192+
if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) {
193+
stickToBottomRef.current = true
194+
}
195+
prevScrollTopRef.current = el.scrollTop
192196
}
193197
el.addEventListener('wheel', handleWheel, { passive: true })
194198
el.addEventListener('scroll', handleScroll, { passive: true })

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,16 @@ export function MothershipChat({
339339
rangeExtractor,
340340
})
341341

342+
/**
343+
* Instance property — silently ignored if passed as a `useVirtualizer`
344+
* option. Skips scroll compensation for the streaming last row: it starts
345+
* above the viewport but grows at its bottom edge, so the default dragged
346+
* the viewport down in lockstep with growth even after the user scrolled
347+
* away. Other rows keep the library default.
348+
*/
349+
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
350+
item.index !== lastIndex && item.start < (instance.scrollElement?.scrollTop ?? 0)
351+
342352
const scrolledChatRef = useRef<string | undefined | typeof UNSCROLLED>(UNSCROLLED)
343353
const userInputRef = useRef<UserInputHandle>(null)
344354
const messageQueueRef = useRef(messageQueue)

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ interface UseAutoScrollOptions {
4949
*
5050
* Stays pinned to the bottom while content streams in. Detaches immediately
5151
* on any upward user gesture (wheel, touch, scrollbar drag, keyboard). Once
52-
* detached, the user must scroll back to within {@link REATTACH_THRESHOLD} of
53-
* the bottom to re-engage. Each streaming start re-seeds stickiness from the
52+
* detached, the user must scroll back down to within {@link REATTACH_THRESHOLD}
53+
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
5454
* current scroll position, so a user who scrolled up beforehand stays put.
5555
*
5656
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
@@ -164,6 +164,10 @@ export function useAutoScroll(
164164
* (pointer held) or a recent keyboard scroll. A programmatic upward scroll, e.g.
165165
* a virtualizer re-pinning content on a row-size shrink, has neither and must not
166166
* be mistaken for the user scrolling away.
167+
*
168+
* Re-attach also requires a downward move once detached — a small upward
169+
* flick still lands within {@link REATTACH_THRESHOLD}, and would otherwise
170+
* re-stick on its own scroll event.
167171
*/
168172
const onScroll = () => {
169173
const { scrollTop, scrollHeight, clientHeight } = el
@@ -172,8 +176,9 @@ export function useAutoScroll(
172176
const userDriven =
173177
pointerDownRef.current ||
174178
performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW
179+
const movedDown = scrollTop > prevScrollTopRef.current
175180

176-
if (distanceFromBottom <= threshold) {
181+
if (distanceFromBottom <= threshold && (!userDetachedRef.current || movedDown)) {
177182
stickyRef.current = true
178183
userDetachedRef.current = false
179184
} else if (

0 commit comments

Comments
 (0)