From bc73cf825fb047826ab8fe73adf6401475f71ef3 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 10 Feb 2026 16:00:28 -0700 Subject: [PATCH 1/2] fix: resolve chat scroll anchoring and task-switch scroll race conditions The chat view was losing bottom anchoring due to several interacting issues in the scroll tracking system: - A manual scroll listener was attached to the Virtuoso wrapper div which doesn't scroll (Virtuoso's internal scroller does), causing conflicting state updates with Virtuoso's atBottomStateChange. Removed the dead listener and consolidated scroll tracking into Virtuoso's callback. - handleRowHeightChange captured isAtBottom as React state in its closure. Since it was in the dependency chain of itemContent, every bottom-state change forced Virtuoso to re-render all rows, creating timing windows for stale callbacks. Replaced with a ref to break the cascade. - Entering an existing task didn't anchor to bottom because stickyFollowRef was never reset on task switch, and initialTopMostItemIndex could race with async message loading. Now resets stickyFollowRef and explicitly scrolls after a frame. - CodeBlock directly manipulated the Virtuoso scroller's scrollTop via document.querySelector, fighting Virtuoso's own scroll management. Removed the direct DOM manipulation in favor of Virtuoso's followOutput and handleRowHeightChange mechanisms. --- webview-ui/src/components/chat/ChatView.tsx | 43 +++++++++---------- .../src/components/common/CodeBlock.tsx | 35 +-------------- 2 files changed, 22 insertions(+), 56 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 3df10e4e996..bf5c21819d7 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -163,7 +163,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const stickyFollowRef = useRef(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const [isAtBottom, setIsAtBottom] = useState(false) + const isAtBottomRef = useRef(false) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -520,6 +520,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) + }) + } }, [task?.ts]) const taskTs = task?.ts @@ -1393,7 +1404,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottom) { + if (isAtBottomRef.current) { if (isTaller) { scrollToBottomSmooth() } else { @@ -1401,7 +1412,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const el = scrollContainerRef.current - if (!el) return - const onScroll = () => { - // Consider near-bottom within a small threshold consistent with Virtuoso settings - const nearBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 10 - if (!nearBottom) { - stickyFollowRef.current = false - } - // Keep UI button state in sync with scroll position - setShowScrollToBottom(!nearBottom) - } - el.addEventListener("scroll", onScroll, { passive: true }) - return () => el.removeEventListener("scroll", onScroll) - }, []) - // Effect to clear checkpoint warning when messages appear or task changes useEffect(() => { if (isHidden || !task) { @@ -1767,9 +1761,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} atBottomStateChange={(isAtBottom: boolean) => { - setIsAtBottom(isAtBottom) - // Only show the scroll-to-bottom button if not at bottom + isAtBottomRef.current = isAtBottom setShowScrollToBottom(!isAtBottom) + // Clear sticky follow when user scrolls away from bottom + if (!isAtBottom) { + stickyFollowRef.current = false + } }} atBottomThreshold={10} initialTopMostItemIndex={groupedMessages.length - 1} @@ -1898,7 +1895,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottom) { + if (isAtBottomRef.current) { scrollToBottomAuto() } }} diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index b13a6ec24d8..042b764a9a8 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -299,9 +299,6 @@ const CodeBlock = memo( // potentially changes scrollHeight const wasScrolledUpRef = useRef(false) - // Ref to track if outer container was near bottom - const outerContainerNearBottomRef = useRef(false) - // Effect to listen to scroll events and update the ref useEffect(() => { const preElement = preRef.current @@ -323,28 +320,6 @@ const CodeBlock = memo( } }, []) // Empty dependency array: runs once on mount - // Effect to track outer container scroll position - useEffect(() => { - const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (!scrollContainer) return - - const handleOuterScroll = () => { - const isAtBottom = - Math.abs(scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight) < - SCROLL_SNAP_TOLERANCE - outerContainerNearBottomRef.current = isAtBottom - } - - scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true }) - - // Initial check - handleOuterScroll() - - return () => { - scrollContainer.removeEventListener("scroll", handleOuterScroll) - } - }, []) - // Store whether we should scroll after highlighting completes const shouldScrollAfterHighlightRef = useRef(false) @@ -471,14 +446,8 @@ const CodeBlock = memo( wasScrolledUpRef.current = false } - // Also scroll outer container if it was near bottom - if (outerContainerNearBottomRef.current) { - const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (scrollContainer) { - scrollContainer.scrollTop = scrollContainer.scrollHeight - outerContainerNearBottomRef.current = true - } - } + // Outer container scrolling is handled by Virtuoso's followOutput + // and ChatView's handleRowHeightChange — no direct DOM manipulation needed. // Reset the flag shouldScrollAfterHighlightRef.current = false From ff515accb665db7ae499ae418e16ce981d81b3a8 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 10 Feb 2026 16:24:56 -0700 Subject: [PATCH 2/2] fix: add cancelAnimationFrame cleanup for task-switch scroll RAF Cancel the requestAnimationFrame in the useEffect cleanup to prevent stale callbacks from accumulating during rapid task switches. --- webview-ui/src/components/chat/ChatView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index bf5c21819d7..89fb168f1a7 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -525,12 +525,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + rafId = requestAnimationFrame(() => { virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) }) } + return () => { + if (rafId !== undefined) { + cancelAnimationFrame(rafId) + } + } }, [task?.ts]) const taskTs = task?.ts