Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 26 additions & 23 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1393,15 +1410,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
if (isAtBottom) {
if (isAtBottomRef.current) {
if (isTaller) {
scrollToBottomSmooth()
} else {
setTimeout(() => scrollToBottomAuto(), 0)
}
}
},
[scrollToBottomSmooth, scrollToBottomAuto, isAtBottom],
[scrollToBottomSmooth, scrollToBottomAuto],
)

// Disable sticky follow when user scrolls up inside the chat container
Expand All @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1898,7 +1901,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages}
onHeightChange={() => {
if (isAtBottom) {
if (isAtBottomRef.current) {
scrollToBottomAuto()
}
}}
Expand Down
35 changes: 2 additions & 33 deletions webview-ui/src/components/common/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Loading