fix(chat): redesign rehydration scroll lifecycle

Redesigns chat rehydration scroll lifecycle so existing chats
consistently converge to the newest message on open while preserving
explicit user control.

- Introduce an explicit phase model in ChatView for hydration, anchored
  following, and user-history browsing states
- Replace ad-hoc settle loops with a bounded initial settle window
  using frame and time caps
- Re-anchoring now uses deterministic phase transitions instead of
  race-prone sticky toggles
- Upward user intent (wheel, keyboard navigation, or pointer/manual
  upward scroll) immediately disengages automatic follow
- Scroll-to-bottom CTA re-anchors in one interaction
- Settle loop respects user scroll intent via stickyFollowRef gating
- Explicit escape hatches cover keyboard nav, pointer drag, wheel,
  and row expansion scroll-away methods

Squashed from scroll-fix branch (PR #11483).
This commit is contained in:
Roo Code 2026-02-16 18:16:21 +00:00
parent 3e24e21c3d
commit 573da7f2dd
3 changed files with 818 additions and 47 deletions

View file

@ -143,11 +143,12 @@ const ChatRow = memo(
)
useEffect(() => {
const isHeightValid = height !== 0 && height !== Infinity
// used for partials, command output, etc.
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
// height starts off at Infinity
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
if (isLast && isHeightValid && height !== prevHeightRef.current) {
if (!isInitialRender) {
onHeightChange(height > prevHeightRef.current)
}

View file

@ -62,8 +62,30 @@ export interface ChatViewRef {
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
const INITIAL_LOAD_SETTLE_TIMEOUT_MS = 2500
const INITIAL_LOAD_SETTLE_HARD_CAP_MS = 10000
const INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET = 3
const INITIAL_LOAD_SETTLE_MAX_FRAMES = Math.ceil(INITIAL_LOAD_SETTLE_HARD_CAP_MS / (1000 / 60))
type ScrollPhase = "HYDRATING_PINNED_TO_BOTTOM" | "ANCHORED_FOLLOWING" | "USER_BROWSING_HISTORY"
type ScrollFollowDisengageSource = "wheel-up" | "row-expansion" | "keyboard-nav-up" | "pointer-scroll-up"
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
const isEditableKeyboardTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false
}
if (target.isContentEditable) {
return true
}
const tagName = target.tagName
return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
}
const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewProps> = (
{ isHidden, showAnnouncement, hideAnnouncement },
ref,
@ -156,9 +178,24 @@ 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 stickyFollowRef = useRef<boolean>(false)
const [scrollPhase, setScrollPhase] = useState<ScrollPhase>("USER_BROWSING_HISTORY")
const scrollPhaseRef = useRef<ScrollPhase>("USER_BROWSING_HISTORY")
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const isAtBottomRef = useRef(false)
const isSettlingRef = useRef(false)
const settleTaskTsRef = useRef<number | null>(null)
const settleDeadlineMsRef = useRef<number | null>(null)
const settleHardDeadlineMsRef = useRef<number | null>(null)
const settleAnimationFrameRef = useRef<number | null>(null)
const settleStableFramesRef = useRef(0)
const settleFrameCountRef = useRef(0)
const settleBottomConfirmedRef = useRef(false)
const settleMutationVersionRef = useRef(0)
const settleObservedMutationVersionRef = useRef(0)
const groupedMessagesLengthRef = useRef(0)
const pointerScrollActiveRef = useRef(false)
const pointerScrollLastTopRef = useRef<number | null>(null)
const reanchorAnimationFrameRef = useRef<number | null>(null)
const lastTtsRef = useRef<string>("")
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [checkpointWarning, setCheckpointWarning] = useState<
@ -191,6 +228,174 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
clineAskRef.current = clineAsk
}, [clineAsk])
useEffect(() => {
scrollPhaseRef.current = scrollPhase
}, [scrollPhase])
const transitionScrollPhase = useCallback((nextPhase: ScrollPhase) => {
if (scrollPhaseRef.current === nextPhase) {
return
}
scrollPhaseRef.current = nextPhase
setScrollPhase(nextPhase)
}, [])
const beginHydrationPinnedToBottom = useCallback(() => {
isAtBottomRef.current = false
settleBottomConfirmedRef.current = false
settleFrameCountRef.current = 0
transitionScrollPhase("HYDRATING_PINNED_TO_BOTTOM")
setShowScrollToBottom(false)
}, [transitionScrollPhase])
const enterAnchoredFollowing = useCallback(() => {
transitionScrollPhase("ANCHORED_FOLLOWING")
setShowScrollToBottom(false)
}, [transitionScrollPhase])
const enterUserBrowsingHistory = useCallback(
(_source: ScrollFollowDisengageSource) => {
transitionScrollPhase("USER_BROWSING_HISTORY")
setShowScrollToBottom(!isAtBottomRef.current)
},
[transitionScrollPhase],
)
const isSettleWindowOpen = useCallback((taskTs: number): boolean => {
if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") {
return false
}
if (settleTaskTsRef.current !== taskTs) {
return false
}
const nowMs = Date.now()
const deadlineMs = settleDeadlineMsRef.current
if (deadlineMs === null || nowMs > deadlineMs) {
return false
}
const hardDeadlineMs = settleHardDeadlineMsRef.current
return hardDeadlineMs === null || nowMs <= hardDeadlineMs
}, [])
const extendInitialSettleWindow = useCallback((taskTs: number): boolean => {
if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") {
return false
}
if (settleTaskTsRef.current !== taskTs) {
return false
}
const nowMs = Date.now()
const hardDeadlineMs = settleHardDeadlineMsRef.current
if (hardDeadlineMs !== null && nowMs > hardDeadlineMs) {
return false
}
settleDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS
if (hardDeadlineMs === null) {
settleHardDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS
}
return true
}, [])
const cancelInitialSettleFrame = useCallback(() => {
if (settleAnimationFrameRef.current !== null) {
cancelAnimationFrame(settleAnimationFrameRef.current)
settleAnimationFrameRef.current = null
}
}, [])
const cancelReanchorFrame = useCallback(() => {
if (reanchorAnimationFrameRef.current !== null) {
cancelAnimationFrame(reanchorAnimationFrameRef.current)
reanchorAnimationFrameRef.current = null
}
}, [])
const completeInitialSettle = useCallback(() => {
cancelInitialSettleFrame()
isSettlingRef.current = false
if (isAtBottomRef.current && settleBottomConfirmedRef.current) {
enterAnchoredFollowing()
return
}
transitionScrollPhase("USER_BROWSING_HISTORY")
setShowScrollToBottom(true)
}, [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase])
const runInitialSettleFrame = useCallback(
(taskTs: number) => {
if (!isMountedRef.current) {
return
}
if (!isSettlingRef.current || settleTaskTsRef.current !== taskTs) {
return
}
settleFrameCountRef.current += 1
if (settleFrameCountRef.current > INITIAL_LOAD_SETTLE_MAX_FRAMES) {
completeInitialSettle()
return
}
if (!isSettleWindowOpen(taskTs)) {
completeInitialSettle()
return
}
const mutationVersion = settleMutationVersionRef.current
const isTailStable = mutationVersion === settleObservedMutationVersionRef.current
settleObservedMutationVersionRef.current = mutationVersion
if (isAtBottomRef.current && settleBottomConfirmedRef.current && isTailStable) {
settleStableFramesRef.current += 1
} else {
settleStableFramesRef.current = 0
}
virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
if (settleStableFramesRef.current >= INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET) {
completeInitialSettle()
return
}
settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs))
},
[completeInitialSettle, isSettleWindowOpen],
)
const startInitialSettle = useCallback(
(taskTs: number) => {
if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") {
return
}
if (!isSettleWindowOpen(taskTs)) {
return
}
if (isSettlingRef.current && settleTaskTsRef.current === taskTs) {
return
}
cancelInitialSettleFrame()
settleTaskTsRef.current = taskTs
isSettlingRef.current = true
settleStableFramesRef.current = 0
settleFrameCountRef.current = 0
settleObservedMutationVersionRef.current = settleMutationVersionRef.current
settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs))
},
[cancelInitialSettleFrame, isSettleWindowOpen, runInitialSettleFrame],
)
const {
isOpen: isUpsellOpen,
openUpsell,
@ -223,8 +428,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
isMountedRef.current = true
return () => {
isMountedRef.current = false
cancelReanchorFrame()
}
}, [])
}, [cancelReanchorFrame])
const isProfileDisabled = useMemo(
() => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList),
@ -492,6 +698,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}, [messages.length])
useEffect(() => {
const taskSwitchMs = Date.now()
settleStableFramesRef.current = 0
settleFrameCountRef.current = 0
settleBottomConfirmedRef.current = false
settleMutationVersionRef.current = 0
settleObservedMutationVersionRef.current = 0
isAtBottomRef.current = false
cancelInitialSettleFrame()
cancelReanchorFrame()
settleTaskTsRef.current = task?.ts ?? null
settleDeadlineMsRef.current = null
settleHardDeadlineMsRef.current = null
// Reset UI states only when task changes
setExpandedRows({})
everVisibleMessagesTsRef.current.clear() // Clear for new task
@ -507,23 +726,35 @@ 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
// Ensure new task starts in deterministic hydration mode, pinned to
// the bottom until the message tail has settled.
if (task?.ts) {
stickyFollowRef.current = true
rafId = requestAnimationFrame(() => {
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
})
beginHydrationPinnedToBottom()
isSettlingRef.current = false
settleDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS
settleHardDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS
startInitialSettle(task.ts)
} else {
transitionScrollPhase("USER_BROWSING_HISTORY")
setShowScrollToBottom(false)
}
return () => {
if (rafId !== undefined) {
cancelAnimationFrame(rafId)
}
cancelInitialSettleFrame()
cancelReanchorFrame()
settleTaskTsRef.current = null
settleDeadlineMsRef.current = null
settleHardDeadlineMsRef.current = null
isSettlingRef.current = false
settleBottomConfirmedRef.current = false
}
}, [task?.ts])
}, [
beginHydrationPinnedToBottom,
cancelInitialSettleFrame,
cancelReanchorFrame,
startInitialSettle,
task?.ts,
transitionScrollPhase,
])
const taskTs = task?.ts
@ -566,11 +797,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Expanding a row indicates the user is browsing; disable sticky follow
if (wasAnyRowExpandedByUser) {
stickyFollowRef.current = false
enterUserBrowsingHistory("row-expansion")
}
prevExpandedRowsRef.current = expandedRows // Store current state for next comparison
}, [expandedRows])
}, [enterUserBrowsingHistory, expandedRows])
const isStreaming = useMemo(() => {
// Checking clineAsk isn't enough since messages effect may be called
@ -1313,13 +1544,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return result
}, [isCondensing, visibleMessages])
useEffect(() => {
const previousLength = groupedMessagesLengthRef.current
groupedMessagesLengthRef.current = groupedMessages.length
if (previousLength === groupedMessages.length) {
return
}
settleMutationVersionRef.current += 1
const settleTaskTs = settleTaskTsRef.current
if (settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) {
startInitialSettle(settleTaskTs)
}
}, [groupedMessages.length, extendInitialSettleWindow, startInitialSettle])
// scrolling
const scrollToBottomSmooth = useMemo(
() =>
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
immediate: true,
}),
debounce(
() => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }),
10,
{
immediate: true,
},
),
[],
)
@ -1330,8 +1581,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}, [scrollToBottomSmooth])
const scrollToBottomAuto = useCallback(() => {
virtuosoRef.current?.scrollTo({
top: Number.MAX_SAFE_INTEGER,
virtuosoRef.current?.scrollToIndex({
index: "LAST",
align: "end",
behavior: "auto", // Instant causes crash.
})
}, [])
@ -1358,26 +1610,109 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
if (isAtBottomRef.current) {
settleMutationVersionRef.current += 1
const settleTaskTs = settleTaskTsRef.current
if (isTaller && settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) {
startInitialSettle(settleTaskTs)
}
const shouldAutoFollowBottom = scrollPhaseRef.current !== "USER_BROWSING_HISTORY"
const shouldForcePinForAnchoredStreaming = scrollPhaseRef.current === "ANCHORED_FOLLOWING" && isStreaming
if ((isAtBottomRef.current || shouldForcePinForAnchoredStreaming) && shouldAutoFollowBottom) {
if (isTaller) {
scrollToBottomSmooth()
} else {
setTimeout(() => scrollToBottomAuto(), 0)
scrollToBottomAuto()
}
}
},
[scrollToBottomSmooth, scrollToBottomAuto],
[extendInitialSettleWindow, isStreaming, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle],
)
// Disable sticky follow when user scrolls up inside the chat container
const handleWheel = useCallback((event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
stickyFollowRef.current = false
}
}, [])
const handleWheel = useCallback(
(event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
enterUserBrowsingHistory("wheel-up")
}
},
[enterUserBrowsingHistory],
)
useEvent("wheel", handleWheel, window, { passive: true })
const handlePointerDown = useCallback((event: Event) => {
const pointerEvent = event as PointerEvent
const pointerTarget = pointerEvent.target
if (!(pointerTarget instanceof HTMLElement)) {
pointerScrollActiveRef.current = false
pointerScrollLastTopRef.current = null
return
}
if (!scrollContainerRef.current?.contains(pointerTarget)) {
pointerScrollActiveRef.current = false
pointerScrollLastTopRef.current = null
return
}
const scroller =
(pointerTarget.closest(".scrollable") as HTMLElement | null) ??
(pointerTarget.scrollHeight > pointerTarget.clientHeight ? pointerTarget : null)
pointerScrollActiveRef.current = true
pointerScrollLastTopRef.current = scroller?.scrollTop ?? 0
}, [])
const handlePointerEnd = useCallback(() => {
pointerScrollActiveRef.current = false
pointerScrollLastTopRef.current = null
}, [])
const handlePointerActiveScroll = useCallback(
(event: Event) => {
if (!pointerScrollActiveRef.current) {
return
}
const scrollTarget = event.target
if (!(scrollTarget instanceof HTMLElement)) {
return
}
if (!scrollContainerRef.current?.contains(scrollTarget)) {
return
}
const previousTop = pointerScrollLastTopRef.current
const currentTop = scrollTarget.scrollTop
pointerScrollLastTopRef.current = currentTop
if (previousTop !== null && currentTop < previousTop) {
enterUserBrowsingHistory("pointer-scroll-up")
}
},
[enterUserBrowsingHistory],
)
useEvent("pointerdown", handlePointerDown, window, { passive: true })
useEvent("pointerup", handlePointerEnd, window, { passive: true })
useEvent("pointercancel", handlePointerEnd, window, { passive: true })
useEvent("scroll", handlePointerActiveScroll, window, { passive: true, capture: true })
const handleScrollToBottomClick = useCallback(() => {
enterAnchoredFollowing()
scrollToBottomAuto()
cancelReanchorFrame()
reanchorAnimationFrameRef.current = requestAnimationFrame(() => {
reanchorAnimationFrameRef.current = null
if (scrollPhaseRef.current === "ANCHORED_FOLLOWING") {
scrollToBottomAuto()
}
})
}, [cancelReanchorFrame, enterAnchoredFollowing, scrollToBottomAuto])
// Effect to clear checkpoint warning when messages appear or task changes
useEffect(() => {
if (isHidden || !task) {
@ -1537,9 +1872,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Just Period = Next mode
switchToNextMode()
}
return
}
if (!task || isHidden) {
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key !== "PageUp" && event.key !== "Home" && event.key !== "ArrowUp") {
return
}
if (isEditableKeyboardTarget(event.target)) {
return
}
const activeElement = document.activeElement
const focusInsideChat =
activeElement instanceof HTMLElement && !!scrollContainerRef.current?.contains(activeElement)
const eventTargetInsideChat =
event.target instanceof Node && !!scrollContainerRef.current?.contains(event.target)
if (focusInsideChat || eventTargetInsideChat || activeElement === document.body) {
enterUserBrowsingHistory("keyboard-nav-up")
}
},
[switchToNextMode, switchToPreviousMode],
[enterUserBrowsingHistory, isHidden, switchToNextMode, switchToPreviousMode, task],
)
useEffect(() => {
@ -1687,14 +2049,43 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
itemContent={itemContent}
followOutput={(isAtBottom: boolean) => isAtBottom || stickyFollowRef.current}
followOutput={() => (scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto")}
atBottomStateChange={(isAtBottom: boolean) => {
isAtBottomRef.current = isAtBottom
setShowScrollToBottom(!isAtBottom)
// Clear sticky follow when user scrolls away from bottom
if (!isAtBottom) {
stickyFollowRef.current = false
const currentPhase = scrollPhaseRef.current
if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && isAtBottom) {
settleBottomConfirmedRef.current = true
}
if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && !isAtBottom) {
return
}
if (
currentPhase === "ANCHORED_FOLLOWING" &&
!isAtBottom &&
pointerScrollActiveRef.current
) {
enterUserBrowsingHistory("pointer-scroll-up")
return
}
if (isAtBottom) {
setShowScrollToBottom(false)
if (currentPhase === "USER_BROWSING_HISTORY") {
enterAnchoredFollowing()
}
return
}
if (currentPhase === "ANCHORED_FOLLOWING" && isStreaming) {
scrollToBottomAuto()
setShowScrollToBottom(false)
return
}
setShowScrollToBottom(currentPhase === "USER_BROWSING_HISTORY")
}}
atBottomThreshold={10}
initialTopMostItemIndex={groupedMessages.length - 1}
@ -1710,14 +2101,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
<Button
variant="secondary"
className="flex-[2]"
onClick={() => {
// 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)
}}>
onClick={handleScrollToBottomClick}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
@ -1823,7 +2207,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages}
onHeightChange={() => {
if (isAtBottomRef.current) {
if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") {
scrollToBottomAuto()
}
}}

View file

@ -0,0 +1,386 @@
import React, { useEffect, useImperativeHandle, useRef } from "react"
import { act, fireEvent, render, waitFor } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ClineMessage } from "@roo-code/types"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import ChatView, { type ChatViewProps } from "../ChatView"
type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false
interface ExtensionStateMessage {
type: "state"
state: {
version: string
clineMessages: ClineMessage[]
taskHistory: unknown[]
shouldShowAnnouncement: boolean
allowedCommands: string[]
alwaysAllowExecute: boolean
cloudIsAuthenticated: boolean
telemetrySetting: "enabled" | "disabled" | "unset"
}
}
interface MockVirtuosoHandle {
scrollToIndex: (options: {
index: number | "LAST"
align?: "end" | "start" | "center"
behavior?: "auto" | "smooth"
}) => void
}
interface MockVirtuosoProps {
data: ClineMessage[]
itemContent: (index: number, item: ClineMessage) => React.ReactNode
atBottomStateChange?: (isAtBottom: boolean) => void
followOutput?: FollowOutput
className?: string
}
interface VirtuosoHarnessState {
scrollCalls: number
atBottomAfterCalls: number
signalDelayMs: number
emitFalseOnDataChange: boolean
followOutput: FollowOutput | undefined
}
const harness = vi.hoisted<VirtuosoHarnessState>(() => ({
scrollCalls: 0,
atBottomAfterCalls: Number.POSITIVE_INFINITY,
signalDelayMs: 20,
emitFalseOnDataChange: true,
followOutput: undefined,
}))
function nullDefaultModule() {
return { default: () => null }
}
vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } }))
vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => [vi.fn()]) }))
vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ CloudUpsellDialog: () => null }))
vi.mock("@src/hooks/useCloudUpsell", () => ({
useCloudUpsell: () => ({
isOpen: false,
openUpsell: vi.fn(),
closeUpsell: vi.fn(),
handleConnect: vi.fn(),
}),
}))
vi.mock("../common/TelemetryBanner", nullDefaultModule)
vi.mock("../common/VersionIndicator", nullDefaultModule)
vi.mock("../history/HistoryPreview", nullDefaultModule)
vi.mock("@src/components/welcome/RooHero", nullDefaultModule)
vi.mock("@src/components/welcome/RooTips", nullDefaultModule)
vi.mock("../Announcement", nullDefaultModule)
vi.mock("./TaskHeader", () => ({ default: () => <div data-testid="task-header" /> }))
vi.mock("./ProfileViolationWarning", nullDefaultModule)
vi.mock("../common/DismissibleUpsell", nullDefaultModule)
vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null }))
vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null }))
vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null }))
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock("@/components/ui", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/ui")>()
return {
...actual,
StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
vi.mock("../ChatTextArea", () => {
const MockTextArea = React.forwardRef(function MockTextArea(
props: {
inputValue?: string
setInputValue?: (value: string) => void
onSend: () => void
sendingDisabled?: boolean
},
ref: React.ForwardedRef<{ focus: () => void }>,
) {
useImperativeHandle(ref, () => ({ focus: () => {} }))
return (
<input
value={props.inputValue ?? ""}
onChange={(event) => props.setInputValue?.(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !props.sendingDisabled) {
props.onSend()
}
}}
/>
)
})
return { default: MockTextArea, ChatTextArea: MockTextArea }
})
vi.mock("../ChatRow", () => ({
default: ({ message }: { message: ClineMessage }) => <div data-testid="chat-row">{message.ts}</div>,
}))
vi.mock("react-virtuoso", () => {
const MockVirtuoso = React.forwardRef<MockVirtuosoHandle, MockVirtuosoProps>(function MockVirtuoso(
{ data, itemContent, atBottomStateChange, followOutput, className },
ref,
) {
const atBottomRef = useRef(atBottomStateChange)
const timeoutIdsRef = useRef<number[]>([])
harness.followOutput = followOutput
useImperativeHandle(ref, () => ({
scrollToIndex: () => {
harness.scrollCalls += 1
const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls
const timeoutId = window.setTimeout(() => {
atBottomRef.current?.(reachedBottom)
}, harness.signalDelayMs)
timeoutIdsRef.current.push(timeoutId)
},
}))
useEffect(() => {
atBottomRef.current = atBottomStateChange
}, [atBottomStateChange])
useEffect(() => {
if (harness.emitFalseOnDataChange) {
atBottomStateChange?.(false)
}
}, [data.length, atBottomStateChange])
useEffect(
() => () => {
timeoutIdsRef.current.forEach((id) => window.clearTimeout(id))
timeoutIdsRef.current = []
},
[],
)
return (
<div data-testid="virtuoso-item-list" className={className} data-count={data.length}>
{data.map((item, index) => (
<div key={item.ts} data-testid={`virtuoso-item-${index}`}>
{itemContent(index, item)}
</div>
))}
</div>
)
})
return { Virtuoso: MockVirtuoso }
})
const props: ChatViewProps = {
isHidden: false,
showAnnouncement: false,
hideAnnouncement: () => {},
}
const sleep = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms))
const buildMessages = (baseTs: number): ClineMessage[] => [
{ type: "say", say: "text", ts: baseTs, text: "task" },
{ type: "say", say: "text", ts: baseTs + 1, text: "row-1" },
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
]
const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => {
const followOutput = harness.followOutput
if (typeof followOutput === "function") {
return followOutput(isAtBottom)
}
return followOutput === "auto" ? "auto" : false
}
const postState = (clineMessages: ClineMessage[]) => {
const message: ExtensionStateMessage = {
type: "state",
state: {
version: "1.0.0",
clineMessages,
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
cloudIsAuthenticated: false,
telemetrySetting: "enabled",
},
}
window.postMessage(message, "*")
}
const renderView = () =>
render(
<ExtensionStateContextProvider>
<QueryClientProvider client={new QueryClient()}>
<ChatView {...props} />
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
const hydrate = async (atBottomAfterCalls: number) => {
harness.atBottomAfterCalls = atBottomAfterCalls
renderView()
await act(async () => {
postState(buildMessages(Date.now() - 3_000))
})
}
const waitForCalls = async (min: number, timeout = 1_500) => {
await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(min), { timeout })
}
const expectCallsStable = async (ms = 120) => {
await sleep(ms)
const snapshot = harness.scrollCalls
await sleep(ms)
expect(harness.scrollCalls).toBe(snapshot)
}
const getScrollable = (): HTMLElement => {
const scrollable = document.querySelector(".scrollable")
if (!(scrollable instanceof HTMLElement)) {
throw new Error("Expected ChatView scrollable container")
}
return scrollable
}
const getScrollToBottomButton = (): HTMLButtonElement => {
const icon = document.querySelector(".codicon-chevron-down")
if (!(icon instanceof HTMLElement)) {
throw new Error("Expected scroll-to-bottom icon")
}
const button = icon.closest("button")
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Expected scroll-to-bottom button")
}
return button
}
describe("ChatView scroll behavior regression coverage", () => {
beforeEach(() => {
harness.scrollCalls = 0
harness.atBottomAfterCalls = Number.POSITIVE_INFINITY
harness.signalDelayMs = 20
harness.emitFalseOnDataChange = true
harness.followOutput = undefined
})
it("rehydration converges to bottom", async () => {
await hydrate(6)
await waitForCalls(6, 2_000)
await expectCallsStable()
expect(document.querySelector(".codicon-chevron-down")).toBeNull()
})
it("transient settle-time not-at-bottom signals do not disable sticky follow", async () => {
await hydrate(8)
await waitForCalls(2, 1_200)
expect(resolveFollowOutput(false)).toBe("auto")
expect(document.querySelector(".codicon-chevron-down")).toBeNull()
await waitForCalls(8, 2_000)
await expectCallsStable()
expect(resolveFollowOutput(false)).toBe("auto")
})
it("user escape hatch during settle stops forced follow", async () => {
await hydrate(Number.POSITIVE_INFINITY)
await waitForCalls(3, 1_200)
await act(async () => {
fireEvent.keyDown(window, { key: "PageUp" })
})
expect(resolveFollowOutput(false)).toBe(false)
const callsAfterEscape = harness.scrollCalls
await sleep(260)
expect(harness.scrollCalls).toBe(callsAfterEscape)
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
timeout: 1_200,
})
})
it("non-wheel upward intent disengages sticky follow", async () => {
await hydrate(4)
await waitForCalls(4)
await expectCallsStable()
expect(resolveFollowOutput(false)).toBe("auto")
const scrollable = getScrollable()
scrollable.scrollTop = 240
await act(async () => {
fireEvent.pointerDown(scrollable)
scrollable.scrollTop = 120
fireEvent.scroll(scrollable)
fireEvent.pointerUp(window)
})
expect(resolveFollowOutput(false)).toBe(false)
})
it("wheel-up intent disengages sticky follow", async () => {
await hydrate(4)
await waitForCalls(4)
await expectCallsStable()
expect(resolveFollowOutput(false)).toBe("auto")
const scrollable = getScrollable()
await act(async () => {
fireEvent.wheel(scrollable, { deltaY: -120 })
})
expect(resolveFollowOutput(false)).toBe(false)
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
timeout: 1_200,
})
})
it("scroll-to-bottom CTA re-anchors with one interaction", async () => {
await hydrate(4)
await waitForCalls(4)
await expectCallsStable()
expect(resolveFollowOutput(false)).toBe("auto")
await act(async () => {
fireEvent.keyDown(window, { key: "PageUp" })
})
expect(resolveFollowOutput(false)).toBe(false)
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
timeout: 1_200,
})
const callsBeforeClick = harness.scrollCalls
harness.atBottomAfterCalls = callsBeforeClick + 2
await act(async () => {
getScrollToBottomButton().click()
})
expect(resolveFollowOutput(false)).toBe("auto")
await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(callsBeforeClick + 2), {
timeout: 1_200,
})
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 })
})
})