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..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 @@ -176,19 +176,23 @@ 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. + // 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 }) 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..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 @@ -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(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..7e824746baf 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,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 (