From 01e184e891b0a46a84a6caf23360f7d3be7c27b4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 13 Jul 2026 11:41:05 -0700 Subject: [PATCH 1/2] fix(mothership): keep scroll position when user scrolls up during streaming --- .../components/agent-group/agent-group.tsx | 12 +++++++++--- .../mothership-chat/mothership-chat.tsx | 17 +++++++++++++++++ apps/sim/hooks/use-auto-scroll.ts | 13 ++++++++++--- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 0314907eefe..251e932681c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -176,19 +176,25 @@ function BoundedViewport({ children, isStreaming }: BoundedViewportProps) { const ref = useRef(null) const rafRef = useRef(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. + // Any upward user input detaches auto-stick. A subsequent downward scroll + // reaching the bottom re-attaches it — the move must be downward, else the + // scroll event a small upward flick produces (still within the threshold) + // would instantly undo the detach. 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 }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 841538b2e17..00e7702c1ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -339,6 +339,23 @@ export function MothershipChat({ rangeExtractor, }) + /** + * Never compensate scroll position for size changes of the last row (an + * instance property, not a `useVirtualizer` option — options are ignored for + * this hook point). The default compensation shifts `scrollTop` by the delta + * whenever a resized row starts above the viewport — right for rows above, + * but the streaming last row is tall enough to start above the viewport + * while growing at its BOTTOM edge, so each token's ResizeObserver delta + * scrolled the viewport down in lockstep with growth. That glued the view to + * a fixed distance-from-bottom and dragged the user down even after + * useAutoScroll detached. Sticking to the bottom during streaming is + * useAutoScroll's job, not the virtualizer's. For all other rows this + * mirrors the library default (live `scrollTop` stands in for the private + * `getScrollOffset() + scrollAdjustments`). + */ + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => + item.index !== lastIndex && item.start < (instance.scrollElement?.scrollTop ?? 0) + const scrolledChatRef = useRef(UNSCROLLED) const userInputRef = useRef(null) const messageQueueRef = useRef(messageQueue) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index 19b3a47a9ed..f601970d000 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -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,12 @@ 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 additionally requires a *downward* move once detached. A small + * upward wheel/touch flick from the pinned position lands still within + * {@link REATTACH_THRESHOLD} of the bottom, so a proximity-only check would + * re-attach on the very scroll event the flick produced — undoing the detach + * one event after it happened and letting the chase drag the user back down. */ const onScroll = () => { const { scrollTop, scrollHeight, clientHeight } = el @@ -172,8 +178,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 ( From edca7bfbae83025e92f742a5246b47f92a0e7d86 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 13 Jul 2026 11:45:24 -0700 Subject: [PATCH 2/2] chore(mothership): trim scroll fix comments --- .../components/agent-group/agent-group.tsx | 6 ++---- .../mothership-chat/mothership-chat.tsx | 17 +++++------------ apps/sim/hooks/use-auto-scroll.ts | 8 +++----- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 251e932681c..74317dab9de 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -182,10 +182,8 @@ function BoundedViewport({ children, isStreaming }: BoundedViewportProps) { useEffect(() => { const el = ref.current if (!el) return - // Any upward user input detaches auto-stick. A subsequent downward scroll - // reaching the bottom re-attaches it — the move must be downward, else the - // scroll event a small upward flick produces (still within the threshold) - // would instantly undo the detach. + // 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 } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 00e7702c1ec..951eb17da98 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -340,18 +340,11 @@ export function MothershipChat({ }) /** - * Never compensate scroll position for size changes of the last row (an - * instance property, not a `useVirtualizer` option — options are ignored for - * this hook point). The default compensation shifts `scrollTop` by the delta - * whenever a resized row starts above the viewport — right for rows above, - * but the streaming last row is tall enough to start above the viewport - * while growing at its BOTTOM edge, so each token's ResizeObserver delta - * scrolled the viewport down in lockstep with growth. That glued the view to - * a fixed distance-from-bottom and dragged the user down even after - * useAutoScroll detached. Sticking to the bottom during streaming is - * useAutoScroll's job, not the virtualizer's. For all other rows this - * mirrors the library default (live `scrollTop` stands in for the private - * `getScrollOffset() + scrollAdjustments`). + * 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) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index f601970d000..7e824746baf 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -165,11 +165,9 @@ export function useAutoScroll( * a virtualizer re-pinning content on a row-size shrink, has neither and must not * be mistaken for the user scrolling away. * - * Re-attach additionally requires a *downward* move once detached. A small - * upward wheel/touch flick from the pinned position lands still within - * {@link REATTACH_THRESHOLD} of the bottom, so a proximity-only check would - * re-attach on the very scroll event the flick produced — undoing the detach - * one event after it happened and letting the chase drag the user back down. + * 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