Просмотр исходного кода

fix: resolve chat scroll anchoring and task-switch scroll race condit… (#11385)

Hannes Rudolph 2 дней назад
Родитель
Сommit
097f648349

+ 26 - 23
webview-ui/src/components/chat/ChatView.tsx

@@ -163,7 +163,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	const scrollContainerRef = useRef<HTMLDivElement>(null)
 	const stickyFollowRef = useRef<boolean>(false)
 	const [showScrollToBottom, setShowScrollToBottom] = useState(false)
-	const [isAtBottom, setIsAtBottom] = useState(false)
+	const isAtBottomRef = useRef(false)
 	const lastTtsRef = useRef<string>("")
 	const [wasStreaming, setWasStreaming] = useState<boolean>(false)
 	const [checkpointWarning, setCheckpointWarning] = useState<
@@ -520,6 +520,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		}
 		// Reset user response flag for new task
 		userRespondedRef.current = false
+
+		// Ensure new task starts anchored to the bottom. Virtuoso's
+		// initialTopMostItemIndex fires at mount but the message data may
+		// arrive asynchronously, so we also engage sticky follow and
+		// explicitly scroll after a frame to handle the race.
+		let rafId: number | undefined
+		if (task?.ts) {
+			stickyFollowRef.current = true
+			rafId = requestAnimationFrame(() => {
+				virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
+			})
+		}
+		return () => {
+			if (rafId !== undefined) {
+				cancelAnimationFrame(rafId)
+			}
+		}
 	}, [task?.ts])
 
 	const taskTs = task?.ts
@@ -1393,7 +1410,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	const handleRowHeightChange = useCallback(
 		(isTaller: boolean) => {
-			if (isAtBottom) {
+			if (isAtBottomRef.current) {
 				if (isTaller) {
 					scrollToBottomSmooth()
 				} else {
@@ -1401,7 +1418,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				}
 			}
 		},
-		[scrollToBottomSmooth, scrollToBottomAuto, isAtBottom],
+		[scrollToBottomSmooth, scrollToBottomAuto],
 	)
 
 	// Disable sticky follow when user scrolls up inside the chat container
@@ -1413,23 +1430,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	}, [])
 	useEvent("wheel", handleWheel, window, { passive: true })
 
-	// Also disable sticky follow when the chat container is scrolled away from bottom
-	useEffect(() => {
-		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 +1767,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 							itemContent={itemContent}
 							followOutput={(isAtBottom: boolean) => 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 +1901,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				onSelectImages={selectImages}
 				shouldDisableImages={shouldDisableImages}
 				onHeightChange={() => {
-					if (isAtBottom) {
+					if (isAtBottomRef.current) {
 						scrollToBottomAuto()
 					}
 				}}

+ 2 - 33
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