ChatView: smoother stick-to-bottom during streaming (#8999)

This commit is contained in:
Hannes Rudolph 2025-12-04 12:38:17 -07:00 committed by GitHub
parent ffff38be2c
commit 61aabe715c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -150,7 +150,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const prevExpandedRowsRef = useRef<Record<number, boolean>>()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const disableAutoScrollRef = useRef(false)
const stickyFollowRef = useRef<boolean>(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const lastTtsRef = useRef<string>("")
@ -480,9 +480,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}
// Expanding a row indicates the user is browsing; disable sticky follow
if (wasAnyRowExpandedByUser) {
disableAutoScrollRef.current = true
stickyFollowRef.current = false
}
prevExpandedRowsRef.current = expandedRows // Store current state for next comparison
}, [expandedRows])
@ -555,7 +557,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Do not reset mode here as it should persist.
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
disableAutoScrollRef.current = false
}, [])
/**
@ -1112,7 +1113,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
if (!disableAutoScrollRef.current) {
if (isAtBottom) {
if (isTaller) {
scrollToBottomSmooth()
} else {
@ -1120,33 +1121,34 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}
},
[scrollToBottomSmooth, scrollToBottomAuto],
[scrollToBottomSmooth, scrollToBottomAuto, isAtBottom],
)
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined
if (!disableAutoScrollRef.current) {
timer = setTimeout(() => scrollToBottomSmooth(), 50)
}
return () => {
if (timer) {
clearTimeout(timer)
}
}
}, [groupedMessages.length, scrollToBottomSmooth])
// Disable sticky follow when user scrolls up inside the chat container
const handleWheel = useCallback((event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
// User scrolled up
disableAutoScrollRef.current = true
}
if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
stickyFollowRef.current = false
}
}, [])
useEvent("wheel", handleWheel, window, { passive: true })
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
// 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(() => {
@ -1443,26 +1445,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{task && (
<>
<div className="grow flex flex-col min-h-0" ref={scrollContainerRef}>
<div className="flex-auto min-h-0">
<Virtuoso
ref={virtuosoRef}
key={task.ts}
className="h-full overflow-y-auto mb-1"
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
itemContent={itemContent}
atBottomStateChange={(isAtBottom: boolean) => {
setIsAtBottom(isAtBottom)
if (isAtBottom) {
disableAutoScrollRef.current = false
}
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
}}
atBottomThreshold={10}
initialTopMostItemIndex={groupedMessages.length - 1}
/>
</div>
<div className="grow flex" ref={scrollContainerRef}>
<Virtuoso
ref={virtuosoRef}
key={task.ts}
className="scrollable grow overflow-y-scroll mb-1"
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
itemContent={itemContent}
followOutput={(isAtBottom: boolean) => isAtBottom || stickyFollowRef.current}
atBottomStateChange={(isAtBottom: boolean) => {
setIsAtBottom(isAtBottom)
// Only show the scroll-to-bottom button if not at bottom
setShowScrollToBottom(!isAtBottom)
}}
atBottomThreshold={10}
initialTopMostItemIndex={groupedMessages.length - 1}
/>
</div>
{areButtonsVisible && (
<div
@ -1479,8 +1478,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
variant="secondary"
className="flex-[2]"
onClick={() => {
scrollToBottomSmooth()
disableAutoScrollRef.current = false
// Engage sticky follow until user scrolls up
stickyFollowRef.current = true
// Pin immediately to avoid lag during fast streaming
scrollToBottomAuto()
// Hide button immediately to prevent flash
setShowScrollToBottom(false)
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>