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

This commit is contained in:
Hannes Rudolph 2026-02-10 16:40:13 -07:00 committed by GitHub
parent 8d57da8bc8
commit 097f648349
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 28 additions and 56 deletions

View file

@ -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()
}
}}

View file

@ -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