From 965bc892d8a9a3c68dc2983aa62a0e041eb90851 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 15 Feb 2026 15:42:11 -0700 Subject: [PATCH 1/9] fix(chat): stabilize rehydration scroll-to-bottom convergence --- webview-ui/src/components/chat/ChatRow.tsx | 3 +- webview-ui/src/components/chat/ChatView.tsx | 450 +++++++++- .../ChatView.scroll-debug-repro.spec.tsx | 806 ++++++++++++++++++ webview-ui/src/utils/chatScrollDebug.ts | 45 + 4 files changed, 1285 insertions(+), 19 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx create mode 100644 webview-ui/src/utils/chatScrollDebug.ts diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 5dab93d0086..96bfb280a9a 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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) } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fbd7db07436..b8cf71336d8 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -49,6 +49,7 @@ import { WorktreeSelector } from "./WorktreeSelector" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" +import { emitChatScrollDebug, isChatScrollDebugEnabled } from "@src/utils/chatScrollDebug" export interface ChatViewProps { isHidden: boolean @@ -62,6 +63,10 @@ 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 isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 const ChatViewComponent: React.ForwardRefRenderFunction = ( @@ -94,6 +99,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const isAtBottomRef = useRef(false) + const isSettlingRef = useRef(false) + const settleBudgetAnchorMsRef = useRef(null) + const settleHardCapDeadlineMsRef = useRef(null) + const settleLifecycleTaskTsRef = useRef(null) + const settleAnimationFrameRef = useRef(null) + const settleStableFramesRef = useRef(0) + const settleMutationVersionRef = useRef(0) + const settleObservedMutationVersionRef = useRef(0) + const settlingTaskTsRef = useRef(null) + const chatScrollDebugEnabledRef = useRef(false) + const settleStartMsRef = useRef(null) + const settleAttemptRef = useRef(0) + const groupedMessagesLengthRef = useRef(0) + const rawMessagesLengthRef = useRef(0) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -191,6 +211,244 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + chatScrollDebugEnabledRef.current = isChatScrollDebugEnabled(debug) + }, [debug]) + + type InitialSettleWindowState = { + nowMs: number + taskEligible: boolean + windowOpen: boolean + deadlineMs: number | null + hardCapDeadlineMs: number | null + hardCapReached: boolean + } + + const getInitialSettleWindowState = useCallback((taskTs: number | null): InitialSettleWindowState => { + const nowMs = Date.now() + const taskEligible = taskTs !== null && settleLifecycleTaskTsRef.current === taskTs + const deadlineMs = + settleBudgetAnchorMsRef.current === null + ? null + : settleBudgetAnchorMsRef.current + INITIAL_LOAD_SETTLE_TIMEOUT_MS + const hardCapDeadlineMs = settleHardCapDeadlineMsRef.current + const hardCapReached = hardCapDeadlineMs !== null && nowMs > hardCapDeadlineMs + const windowOpen = taskEligible && deadlineMs !== null && nowMs <= deadlineMs && !hardCapReached + + return { + nowMs, + taskEligible, + windowOpen, + deadlineMs, + hardCapDeadlineMs, + hardCapReached, + } + }, []) + + const extendInitialSettleBudgetForMutation = useCallback( + (taskTs: number): { budgetExtended: boolean; windowState: InitialSettleWindowState } => { + if (settleLifecycleTaskTsRef.current !== taskTs) { + return { + budgetExtended: false, + windowState: getInitialSettleWindowState(taskTs), + } + } + + const nowMs = Date.now() + const hardCapDeadlineMs = settleHardCapDeadlineMsRef.current + if (hardCapDeadlineMs !== null && nowMs > hardCapDeadlineMs) { + return { + budgetExtended: false, + windowState: getInitialSettleWindowState(taskTs), + } + } + + settleBudgetAnchorMsRef.current = nowMs + if (settleHardCapDeadlineMsRef.current === null) { + settleHardCapDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS + } + + return { + budgetExtended: true, + windowState: getInitialSettleWindowState(taskTs), + } + }, + [getInitialSettleWindowState], + ) + + const cancelInitialSettleFrame = useCallback(() => { + if (settleAnimationFrameRef.current !== null) { + cancelAnimationFrame(settleAnimationFrameRef.current) + settleAnimationFrameRef.current = null + } + }, []) + + const completeInitialSettle = useCallback( + (taskTs: number, reason: "stable" | "timeout") => { + cancelInitialSettleFrame() + isSettlingRef.current = false + + const windowState = getInitialSettleWindowState(taskTs) + + const nowMs = Date.now() + const settleStartMs = settleStartMsRef.current ?? nowMs + const elapsedMs = nowMs - settleStartMs + + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-complete", + taskTs, + reason, + attempts: settleAttemptRef.current, + elapsedMs, + isAtBottom: isAtBottomRef.current, + stableFrames: settleStableFramesRef.current, + windowOpen: windowState.windowOpen, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + }) + + if (!isAtBottomRef.current) { + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "show-scroll-to-bottom-set", + value: true, + source: "settle-complete", + reason, + taskTs, + windowOpen: windowState.windowOpen, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + }) + setShowScrollToBottom(true) + } + }, + [cancelInitialSettleFrame, getInitialSettleWindowState], + ) + + const runInitialSettleFrame = useCallback( + (taskTs: number) => { + if (!isMountedRef.current) { + return + } + if (!isSettlingRef.current || settlingTaskTsRef.current !== taskTs) { + return + } + + settleAttemptRef.current += 1 + const nowMs = Date.now() + const settleStartMs = settleStartMsRef.current ?? nowMs + const elapsedMs = nowMs - settleStartMs + + const mutationVersion = settleMutationVersionRef.current + const isTailStable = mutationVersion === settleObservedMutationVersionRef.current + settleObservedMutationVersionRef.current = mutationVersion + + if (isAtBottomRef.current && isTailStable) { + settleStableFramesRef.current += 1 + } else { + settleStableFramesRef.current = 0 + } + + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-attempt", + taskTs, + attempt: settleAttemptRef.current, + elapsedMs, + isAtBottom: isAtBottomRef.current, + isTailStable, + stableFrames: settleStableFramesRef.current, + stableFrameTarget: INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET, + }) + + virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" }) + + if (settleStableFramesRef.current >= INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET) { + completeInitialSettle(taskTs, "stable") + return + } + + const windowState = getInitialSettleWindowState(taskTs) + if (!windowState.windowOpen) { + completeInitialSettle(taskTs, "timeout") + return + } + + settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs)) + }, + [completeInitialSettle, getInitialSettleWindowState], + ) + + const startInitialSettle = useCallback( + (taskTs: number, source: string) => { + const windowState = getInitialSettleWindowState(taskTs) + + if (!windowState.taskEligible) { + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-start-rejected", + taskTs, + source, + reason: "task-ineligible", + nowMs: windowState.nowMs, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + lifecycleTaskTs: settleLifecycleTaskTsRef.current, + }) + return + } + + if (!windowState.windowOpen) { + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-start-rejected", + taskTs, + source, + reason: "window-closed", + nowMs: windowState.nowMs, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + }) + return + } + + if (isSettlingRef.current && settlingTaskTsRef.current === taskTs) { + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-start-rejected", + taskTs, + source, + reason: "already-settling", + nowMs: windowState.nowMs, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + }) + return + } + + cancelInitialSettleFrame() + settlingTaskTsRef.current = taskTs + isSettlingRef.current = true + if (settleStartMsRef.current === null) { + settleStartMsRef.current = windowState.nowMs + } + settleStableFramesRef.current = 0 + settleObservedMutationVersionRef.current = settleMutationVersionRef.current + + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-start", + taskTs, + source, + isAtBottom: isAtBottomRef.current, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + }) + + settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs)) + }, + [cancelInitialSettleFrame, getInitialSettleWindowState, runInitialSettleFrame], + ) + const { isOpen: isUpsellOpen, openUpsell, @@ -492,6 +750,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const taskSwitchMs = Date.now() + settleAttemptRef.current = 0 + settleStartMsRef.current = null + settleStableFramesRef.current = 0 + settleMutationVersionRef.current = 0 + settleObservedMutationVersionRef.current = 0 + cancelInitialSettleFrame() + settleBudgetAnchorMsRef.current = null + settleHardCapDeadlineMsRef.current = null + settleLifecycleTaskTsRef.current = task?.ts ?? null + settlingTaskTsRef.current = task?.ts ?? null + // Reset UI states only when task changes setExpandedRows({}) everVisibleMessagesTsRef.current.clear() // Clear for new task @@ -511,22 +781,52 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) + isSettlingRef.current = false + setShowScrollToBottom(false) + settleBudgetAnchorMsRef.current = taskSwitchMs + settleStartMsRef.current = taskSwitchMs + const deadlineMs = taskSwitchMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "settle-window-opened", + taskTs: task.ts, + taskSwitchMs, + deadlineMs, + hardCapDeadlineMs: null, + timeoutMs: INITIAL_LOAD_SETTLE_TIMEOUT_MS, + hardCapTimeoutMs: INITIAL_LOAD_SETTLE_HARD_CAP_MS, }) + startInitialSettle(task.ts, "task-switch") } return () => { - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - } + cancelInitialSettleFrame() + settleBudgetAnchorMsRef.current = null + settleHardCapDeadlineMsRef.current = null + settleLifecycleTaskTsRef.current = null + settlingTaskTsRef.current = null + isSettlingRef.current = false } - }, [task?.ts]) + }, [cancelInitialSettleFrame, startInitialSettle, task?.ts]) const taskTs = task?.ts + useEffect(() => { + const previousLength = rawMessagesLengthRef.current + rawMessagesLengthRef.current = messages.length + + if (previousLength === messages.length) { + return + } + + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "raw-messages-length-change", + previousLength, + nextLength: messages.length, + taskTs, + }) + }, [messages.length, taskTs]) + // Request aggregated costs when task changes and has childIds useEffect(() => { if (taskTs && currentTaskItem?.childIds && currentTaskItem.childIds.length > 0) { @@ -1313,13 +1613,55 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const previousLength = groupedMessagesLengthRef.current + groupedMessagesLengthRef.current = groupedMessages.length + + if (previousLength === groupedMessages.length) { + return + } + + settleMutationVersionRef.current += 1 + const mutationVersion = settleMutationVersionRef.current + + const taskTs = settleLifecycleTaskTsRef.current + const budgetExtension = + taskTs !== null + ? extendInitialSettleBudgetForMutation(taskTs) + : { budgetExtended: false, windowState: getInitialSettleWindowState(taskTs) } + const shouldRearmSettle = taskTs !== null && !isSettlingRef.current && budgetExtension.windowState.windowOpen + + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "grouped-messages-length-change", + previousLength, + nextLength: groupedMessages.length, + mutationVersion, + taskTs, + windowOpen: budgetExtension.windowState.windowOpen, + deadlineMs: budgetExtension.windowState.deadlineMs, + hardCapDeadlineMs: budgetExtension.windowState.hardCapDeadlineMs, + hardCapReached: budgetExtension.windowState.hardCapReached, + budgetExtended: budgetExtension.budgetExtended, + isSettling: isSettlingRef.current, + shouldRearmSettle, + }) + + if (shouldRearmSettle) { + startInitialSettle(taskTs, "grouped-messages-length-change") + } + }, [groupedMessages.length, extendInitialSettleBudgetForMutation, getInitialSettleWindowState, 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 +1672,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, + virtuosoRef.current?.scrollToIndex({ + index: "LAST", + align: "end", behavior: "auto", // Instant causes crash. }) }, []) @@ -1358,6 +1701,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + settleMutationVersionRef.current += 1 + + const taskTs = settleLifecycleTaskTsRef.current + const budgetExtension = + isTaller && taskTs !== null + ? extendInitialSettleBudgetForMutation(taskTs) + : { budgetExtended: false, windowState: getInitialSettleWindowState(taskTs) } + const shouldRearmSettle = + isTaller && taskTs !== null && !isSettlingRef.current && budgetExtension.windowState.windowOpen + const mutationVersion = settleMutationVersionRef.current + + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "row-height-change-callback", + isTaller, + isAtBottom: isAtBottomRef.current, + taskTs, + windowOpen: budgetExtension.windowState.windowOpen, + deadlineMs: budgetExtension.windowState.deadlineMs, + hardCapDeadlineMs: budgetExtension.windowState.hardCapDeadlineMs, + hardCapReached: budgetExtension.windowState.hardCapReached, + budgetExtended: budgetExtension.budgetExtended, + isSettling: isSettlingRef.current, + mutationVersion, + shouldRearmSettle, + }) + + if (shouldRearmSettle) { + startInitialSettle(taskTs, "row-height-growth") + } + if (isAtBottomRef.current) { if (isTaller) { scrollToBottomSmooth() @@ -1366,7 +1739,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} + followOutput={(isAtBottom: boolean) => + isAtBottom || stickyFollowRef.current ? "auto" : false + } atBottomStateChange={(isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom - setShowScrollToBottom(!isAtBottom) - // Clear sticky follow when user scrolls away from bottom - if (!isAtBottom) { - stickyFollowRef.current = false + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "at-bottom-state-change", + isAtBottom, + isSettling: isSettlingRef.current, + }) + if (isSettlingRef.current && !isAtBottom) { + const windowState = getInitialSettleWindowState(settlingTaskTsRef.current) + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "show-scroll-to-bottom-suppressed", + source: "at-bottom-state-change-suppressed", + isAtBottom, + isSettling: true, + windowOpen: windowState.windowOpen, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + taskTs: settlingTaskTsRef.current, + }) + return } + const windowState = getInitialSettleWindowState(settlingTaskTsRef.current) + const nextShowScrollToBottom = !isAtBottom + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "show-scroll-to-bottom-set", + value: nextShowScrollToBottom, + source: "at-bottom-state-change", + isAtBottom, + isSettling: isSettlingRef.current, + windowOpen: windowState.windowOpen, + deadlineMs: windowState.deadlineMs, + hardCapDeadlineMs: windowState.hardCapDeadlineMs, + hardCapReached: windowState.hardCapReached, + taskTs: settlingTaskTsRef.current, + }) + setShowScrollToBottom(nextShowScrollToBottom) + // stickyFollowRef is only cleared by explicit user actions + // (wheel-up, row expansion), not by transient bottom-detection + // state changes which can flicker during layout reflows. }} atBottomThreshold={10} initialTopMostItemIndex={groupedMessages.length - 1} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx new file mode 100644 index 00000000000..a9927561468 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -0,0 +1,806 @@ +import React, { useEffect, useImperativeHandle, useRef } from "react" +import { act, render, waitFor } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { CHAT_SCROLL_DEBUG_EVENT_NAME } from "@src/utils/chatScrollDebug" + +import ChatView, { type ChatViewProps } from "../ChatView" + +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string + partial?: boolean +} + +interface ExtensionStateMessage { + type: "state" + state: { + version: string + clineMessages: ClineMessage[] + taskHistory: unknown[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + cloudIsAuthenticated: boolean + telemetrySetting: "enabled" | "disabled" | "unset" + debug?: boolean + } +} + +interface ChatScrollDebugEventDetail { + ts: number + event: string + [key: string]: unknown +} + +interface VirtuosoHarnessState { + scrollCalls: number + atBottomAfterCalls: number + atBottomSignalDelayMs: number + emitFalseOnDataChange: boolean +} + +const virtuosoHarness = vi.hoisted(() => ({ + scrollCalls: 0, + atBottomAfterCalls: Number.POSITIVE_INFINITY, + atBottomSignalDelayMs: 20, + emitFalseOnDataChange: true, +})) + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock("@src/hooks/useCloudUpsell", () => ({ + useCloudUpsell: () => ({ + isOpen: false, + openUpsell: vi.fn(), + closeUpsell: vi.fn(), + handleConnect: vi.fn(), + }), +})) + +vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ + CloudUpsellDialog: () => null, +})) + +vi.mock("../common/TelemetryBanner", () => ({ + default: () => null, +})) + +vi.mock("../common/VersionIndicator", () => ({ + default: () => null, +})) + +vi.mock("../history/HistoryPreview", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooHero", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooTips", () => ({ + default: () => null, +})) + +vi.mock("../Announcement", () => ({ + default: () => null, +})) + +vi.mock("./TaskHeader", () => ({ + default: () =>
, +})) + +vi.mock("./ProfileViolationWarning", () => ({ + default: () => null, +})) + +vi.mock("./CheckpointWarning", () => ({ + CheckpointWarning: () => null, +})) + +vi.mock("./QueuedMessages", () => ({ + QueuedMessages: () => null, +})) + +vi.mock("./WorktreeSelector", () => ({ + WorktreeSelector: () => null, +})) + +vi.mock("../common/DismissibleUpsell", () => ({ + default: () => null, +})) + +interface MockChatTextAreaProps { + inputValue?: string + setInputValue?: (value: string) => void + onSend: () => void + sendingDisabled?: boolean +} + +vi.mock("../ChatTextArea", () => { + const ChatTextAreaComponent = React.forwardRef(function MockChatTextArea( + props: MockChatTextAreaProps, + ref: React.ForwardedRef<{ focus: () => void }>, + ) { + React.useImperativeHandle(ref, () => ({ + focus: () => {}, + })) + + return ( +
+ props.setInputValue?.(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !props.sendingDisabled) { + props.onSend() + } + }} + /> +
+ ) + }) + + return { + default: ChatTextAreaComponent, + ChatTextArea: ChatTextAreaComponent, + } +}) + +interface MockChatRowProps { + message: ClineMessage + isLast: boolean + onHeightChange: (isTaller: boolean) => void +} + +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message, isLast, onHeightChange }: MockChatRowProps) { + useEffect(() => { + if (!isLast || message.type !== "say" || message.say !== "text") { + return + } + + if (!message.text?.includes("__LATE_GROW__")) { + return + } + + const timeoutA = window.setTimeout(() => onHeightChange(true), 1050) + const timeoutB = window.setTimeout(() => onHeightChange(true), 1250) + + return () => { + window.clearTimeout(timeoutA) + window.clearTimeout(timeoutB) + } + }, [isLast, message, onHeightChange]) + + return
{message.ts}
+ }, +})) + +interface VirtuosoScrollOptions { + index: number | "LAST" + align?: "end" | "start" | "center" + behavior?: "auto" | "smooth" +} + +interface MockVirtuosoHandle { + scrollToIndex: (options: VirtuosoScrollOptions) => void +} + +interface MockVirtuosoProps { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + atBottomStateChange?: (isAtBottom: boolean) => void +} + +vi.mock("react-virtuoso", () => { + const MockVirtuoso = React.forwardRef(function MockVirtuoso( + { data, itemContent, atBottomStateChange }, + ref, + ) { + const atBottomCallbackRef = useRef(atBottomStateChange) + const pendingAtBottomTimeoutsRef = useRef([]) + + useEffect(() => { + atBottomCallbackRef.current = atBottomStateChange + }, [atBottomStateChange]) + + useEffect(() => { + return () => { + for (const timeoutId of pendingAtBottomTimeoutsRef.current) { + window.clearTimeout(timeoutId) + } + pendingAtBottomTimeoutsRef.current = [] + } + }, []) + + useEffect(() => { + if (virtuosoHarness.emitFalseOnDataChange) { + atBottomStateChange?.(false) + } + }, [data.length, atBottomStateChange]) + + useImperativeHandle(ref, () => ({ + scrollToIndex: () => { + virtuosoHarness.scrollCalls += 1 + const shouldReportAtBottom = virtuosoHarness.scrollCalls >= virtuosoHarness.atBottomAfterCalls + + const timeoutId = window.setTimeout(() => { + atBottomCallbackRef.current?.(shouldReportAtBottom) + }, virtuosoHarness.atBottomSignalDelayMs) + pendingAtBottomTimeoutsRef.current.push(timeoutId) + }, + })) + + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }) + + return { Virtuoso: MockVirtuoso } +}) + +function mockPostMessage(state: Partial) { + const message: ExtensionStateMessage = { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + } + + window.postMessage(message, "*") +} + +function mockMessageUpdated(clineMessage: ClineMessage) { + window.postMessage( + { + type: "messageUpdated", + clineMessage, + }, + "*", + ) +} + +function getEventNumber(detail: ChatScrollDebugEventDetail, key: string): number | undefined { + const value = detail[key] + return typeof value === "number" ? value : undefined +} + +function getEventBoolean(detail: ChatScrollDebugEventDetail, key: string): boolean | undefined { + const value = detail[key] + return typeof value === "boolean" ? value : undefined +} + +function buildLongHeterogeneousHistory(baseTs: number, includeLateTailGrowth: boolean): ClineMessage[] { + const messages: ClineMessage[] = [ + { + type: "say", + say: "task", + ts: baseTs, + text: "Investigate existing conversation", + }, + ] + + for (let i = 0; i < 12; i += 1) { + messages.push({ + type: "say", + say: i % 2 === 0 ? "text" : "user_feedback", + ts: baseTs + 10 + i, + text: `history-text-${i}`, + }) + } + + for (let i = 0; i < 8; i += 1) { + messages.push({ + type: "ask", + ask: "tool", + ts: baseTs + 100 + i, + text: JSON.stringify({ tool: "readFile", path: `src/file-${i}.ts`, reason: `line-${i}` }), + }) + } + + for (let i = 0; i < 8; i += 1) { + messages.push({ + type: "ask", + ask: "tool", + ts: baseTs + 200 + i, + text: JSON.stringify({ tool: "listFilesRecursive", path: `src/dir-${i}` }), + }) + } + + for (let i = 0; i < 5; i += 1) { + messages.push({ + type: "ask", + ask: "tool", + ts: baseTs + 300 + i, + text: JSON.stringify({ tool: "editedExistingFile", path: `src/edit-${i}.ts`, diff: `@@ change ${i}` }), + }) + } + + messages.push({ + type: "say", + say: "text", + ts: baseTs + 500, + text: includeLateTailGrowth ? "tail message __LATE_GROW__" : "tail message stable", + }) + + return messages +} + +function createDebugCapture() { + const events: ChatScrollDebugEventDetail[] = [] + + const handler = (event: Event) => { + const customEvent = event as CustomEvent + events.push(customEvent.detail) + } + + window.addEventListener(CHAT_SCROLL_DEBUG_EVENT_NAME, handler) + + return { + events, + stop: () => window.removeEventListener(CHAT_SCROLL_DEBUG_EVENT_NAME, handler), + } +} + +async function sleep(ms: number) { + await new Promise((resolve) => window.setTimeout(resolve, ms)) +} + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +function renderChatView() { + return render( + + + + + , + ) +} + +describe("ChatView scroll debug repro harness", () => { + beforeEach(() => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY + virtuosoHarness.atBottomSignalDelayMs = 20 + virtuosoHarness.emitFalseOnDataChange = true + ;(window as Window & { __ROO_CHAT_SCROLL_DEBUG__?: boolean }).__ROO_CHAT_SCROLL_DEBUG__ = true + window.localStorage.setItem("roo.chatScrollDebug", "1") + }) + + afterEach(() => { + window.localStorage.removeItem("roo.chatScrollDebug") + ;(window as Window & { __ROO_CHAT_SCROLL_DEBUG__?: boolean }).__ROO_CHAT_SCROLL_DEBUG__ = false + }) + + it("converges to bottom after delayed at-bottom measurement cycles", async () => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = 7 + virtuosoHarness.atBottomSignalDelayMs = 18 + + const capture = createDebugCapture() + + try { + renderChatView() + + const baseTs = Date.now() - 20_000 + const initialMessages = buildLongHeterogeneousHistory(baseTs, false) + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: initialMessages }) + }) + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-complete")).toBe(true) + }, + { timeout: 2500 }, + ) + + const firstAtBottomTrue = capture.events.find( + (event) => event.event === "at-bottom-state-change" && event.isAtBottom === true, + ) + const firstTrueTs = + typeof firstAtBottomTrue?.ts === "number" ? firstAtBottomTrue.ts : Number.MAX_SAFE_INTEGER + const settleAttemptsBeforeFirstTrue = capture.events.filter( + (event) => event.event === "settle-attempt" && event.ts <= firstTrueTs, + ) + const stableSettleComplete = capture.events.find( + (event) => event.event === "settle-complete" && event.reason === "stable" && event.isAtBottom === true, + ) + + console.info( + "[chat-scroll-repro][scenario=delayed-measurement-convergence]", + JSON.stringify({ + scrollCalls: virtuosoHarness.scrollCalls, + firstAtBottomTrue, + settleAttemptsBeforeFirstTrue: settleAttemptsBeforeFirstTrue.length, + stableSettleComplete, + }), + ) + + expect(firstAtBottomTrue).toBeTruthy() + expect(settleAttemptsBeforeFirstTrue.length).toBeGreaterThanOrEqual(5) + expect(stableSettleComplete).toBeTruthy() + } finally { + capture.stop() + } + }) + + it("re-arms on late tail growth during initial settle window and still converges", async () => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = 6 + virtuosoHarness.atBottomSignalDelayMs = 20 + + const capture = createDebugCapture() + + try { + renderChatView() + + const baseTs = Date.now() - 10_000 + const initialMessages = buildLongHeterogeneousHistory(baseTs, true) + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: initialMessages }) + }) + + await waitFor( + () => { + expect( + capture.events.some( + (event) => event.event === "row-height-change-callback" && event.shouldRearmSettle === true, + ), + ).toBe(true) + }, + { timeout: 2200 }, + ) + + const rowGrowthRearmEvent = capture.events.find( + (event) => event.event === "row-height-change-callback" && event.shouldRearmSettle === true, + ) + const rowGrowthRearmTs = typeof rowGrowthRearmEvent?.ts === "number" ? rowGrowthRearmEvent.ts : 0 + + await waitFor( + () => { + expect( + capture.events.some( + (event) => + event.event === "settle-start" && + event.source === "row-height-growth" && + event.ts >= rowGrowthRearmTs, + ), + ).toBe(true) + }, + { timeout: 2500 }, + ) + + const rearmSettleStart = capture.events.find( + (event) => + event.event === "settle-start" && + event.source === "row-height-growth" && + event.ts >= rowGrowthRearmTs, + ) + const rearmSettleStartTs = typeof rearmSettleStart?.ts === "number" ? rearmSettleStart.ts : 0 + await waitFor( + () => { + expect( + capture.events.some( + (event) => + event.event === "settle-complete" && + event.reason === "stable" && + event.isAtBottom === true && + event.ts >= rearmSettleStartTs, + ), + ).toBe(true) + }, + { timeout: 1200 }, + ) + + const stableCompleteAfterRearm = capture.events.find( + (event) => + event.event === "settle-complete" && + event.reason === "stable" && + event.isAtBottom === true && + event.ts >= rearmSettleStartTs, + ) + + console.info( + "[chat-scroll-repro][scenario=late-tail-growth-rearm-convergence]", + JSON.stringify({ + rowGrowthRearmEvent, + rearmSettleStart, + stableCompleteAfterRearm, + }), + ) + + expect(rowGrowthRearmEvent).toBeTruthy() + expect(rearmSettleStart).toBeTruthy() + expect(stableCompleteAfterRearm).toBeTruthy() + } finally { + capture.stop() + } + }) + + it("uses the safety cap deterministically when bottom is never reached", async () => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY + virtuosoHarness.atBottomSignalDelayMs = 20 + + const capture = createDebugCapture() + + try { + renderChatView() + + const baseTs = Date.now() - 30_000 + const initialMessages = buildLongHeterogeneousHistory(baseTs, false) + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: initialMessages }) + }) + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-complete")).toBe(true) + }, + { timeout: 3400 }, + ) + + const timeoutSettleComplete = capture.events.find( + (event) => event.event === "settle-complete" && event.reason === "timeout", + ) + const elapsedMs = + timeoutSettleComplete && typeof timeoutSettleComplete.elapsedMs === "number" + ? timeoutSettleComplete.elapsedMs + : -1 + + console.info( + "[chat-scroll-repro][scenario=safety-cap-timeout]", + JSON.stringify({ + timeoutSettleComplete, + elapsedMs, + scrollCalls: virtuosoHarness.scrollCalls, + }), + ) + + expect(timeoutSettleComplete).toBeTruthy() + expect(timeoutSettleComplete?.isAtBottom).toBe(false) + expect(elapsedMs).toBeGreaterThanOrEqual(2400) + expect(elapsedMs).toBeLessThanOrEqual(3300) + expect(virtuosoHarness.scrollCalls).toBeGreaterThan(10) + } finally { + capture.stop() + } + }) + + it("keeps settle lifecycle eligible across late rehydration waves and converges after re-arm", async () => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = 5 + virtuosoHarness.atBottomSignalDelayMs = 18 + + const capture = createDebugCapture() + + try { + renderChatView() + + const baseTs = Date.now() - 45_000 + const initialMessages = buildLongHeterogeneousHistory(baseTs, false) + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: initialMessages }) + }) + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-window-opened")).toBe(true) + }, + { timeout: 1200 }, + ) + + const windowOpened = capture.events.find((event) => event.event === "settle-window-opened") + const deadlineMs = windowOpened ? getEventNumber(windowOpened, "deadlineMs") : undefined + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-complete")).toBe(true) + }, + { timeout: 2500 }, + ) + + const initialSettleComplete = capture.events.find((event) => event.event === "settle-complete") + const initialSettleCompleteTs = typeof initialSettleComplete?.ts === "number" ? initialSettleComplete.ts : 0 + + const waitMs = Math.max(0, (deadlineMs ?? Date.now()) - Date.now() + 220) + await sleep(waitMs) + + const lateMessage: ClineMessage = { + type: "say", + say: "text", + ts: baseTs + 700, + text: "late hydration wave __LATE_GROW__", + } + const lateWaveMessages = [...initialMessages, lateMessage] + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: lateWaveMessages }) + }) + + await act(async () => { + mockMessageUpdated({ ...lateMessage, text: "late hydration wave __LATE_GROW__ updated" }) + }) + + const lateMutationStartTs = Date.now() + + await waitFor( + () => { + expect( + capture.events.some( + (event) => + event.event === "grouped-messages-length-change" && + getEventBoolean(event, "budgetExtended") === true && + getEventBoolean(event, "windowOpen") === true, + ), + ).toBe(true) + }, + { timeout: 1700 }, + ) + + await waitFor( + () => { + expect( + capture.events.some( + (event) => + event.event === "row-height-change-callback" && + getEventBoolean(event, "budgetExtended") === true && + getEventBoolean(event, "windowOpen") === true, + ), + ).toBe(true) + }, + { timeout: 3200 }, + ) + + await waitFor( + () => { + expect( + capture.events.some( + (event) => + event.event === "settle-start" && + (event.source === "grouped-messages-length-change" || + event.source === "row-height-growth") && + event.ts >= lateMutationStartTs, + ), + ).toBe(true) + }, + { timeout: 2200 }, + ) + + await waitFor( + () => { + expect( + capture.events.some( + (event) => + event.event === "settle-complete" && + event.reason === "stable" && + event.isAtBottom === true && + event.ts > initialSettleCompleteTs, + ), + ).toBe(true) + }, + { timeout: 2200 }, + ) + + const firstAtBottomTrue = capture.events.find( + (event) => event.event === "at-bottom-state-change" && event.isAtBottom === true, + ) + + const groupedLengthMutations = capture.events.filter( + (event) => event.event === "grouped-messages-length-change", + ) + const firstGroupedLengthMutation = groupedLengthMutations.at(0) + const lastGroupedLengthMutation = groupedLengthMutations.at(-1) + + const lateRawLengthMutations = capture.events.filter((event) => { + if (event.event !== "raw-messages-length-change") { + return false + } + const eventTs = getEventNumber(event, "ts") + return eventTs !== undefined && deadlineMs !== undefined && eventTs > deadlineMs + }) + + const successfulLateRearms = capture.events.filter((event) => { + if (event.event !== "grouped-messages-length-change" && event.event !== "row-height-change-callback") { + return false + } + const eventTs = getEventNumber(event, "ts") + if (eventTs === undefined || eventTs <= (deadlineMs ?? 0)) { + return false + } + return ( + getEventBoolean(event, "windowOpen") === true && + getEventBoolean(event, "shouldRearmSettle") === true + ) + }) + + const showScrollToBottomSuppressedWhileSettling = capture.events.filter( + (event) => + event.event === "show-scroll-to-bottom-suppressed" && getEventBoolean(event, "windowOpen") === true, + ) + + const stableSettleAfterLateWave = capture.events.find( + (event) => + event.event === "settle-complete" && + event.reason === "stable" && + event.isAtBottom === true && + event.ts > initialSettleCompleteTs, + ) + + console.info( + "[chat-scroll-repro][scenario=hydration-aware-rearm-after-late-wave]", + JSON.stringify({ + windowOpened, + initialSettleComplete, + firstAtBottomTrue, + firstGroupedLengthMutation, + lastGroupedLengthMutation, + lateRawLengthMutations, + successfulLateRearms, + showScrollToBottomSuppressedWhileSettling, + stableSettleAfterLateWave, + }), + ) + + expect(deadlineMs).toBeTruthy() + expect(firstAtBottomTrue).toBeTruthy() + expect(firstGroupedLengthMutation).toBeTruthy() + expect(lastGroupedLengthMutation).toBeTruthy() + expect(lateRawLengthMutations.length).toBeGreaterThan(0) + expect(successfulLateRearms.length).toBeGreaterThan(0) + expect(showScrollToBottomSuppressedWhileSettling.length).toBeGreaterThan(0) + expect(stableSettleAfterLateWave).toBeTruthy() + } finally { + capture.stop() + } + }) +}) diff --git a/webview-ui/src/utils/chatScrollDebug.ts b/webview-ui/src/utils/chatScrollDebug.ts new file mode 100644 index 00000000000..82e9e67d2a4 --- /dev/null +++ b/webview-ui/src/utils/chatScrollDebug.ts @@ -0,0 +1,45 @@ +export const CHAT_SCROLL_DEBUG_EVENT_NAME = "roo:chat-scroll-debug" +const CHAT_SCROLL_DEBUG_STORAGE_KEY = "roo.chatScrollDebug" +const CHAT_SCROLL_DEBUG_QUERY_KEY = "chatScrollDebug" + +export interface ChatScrollDebugDetail { + event: string + [key: string]: unknown +} + +export function isChatScrollDebugEnabled(debugSetting?: boolean): boolean { + if (debugSetting === true) { + return true + } + + if (typeof window === "undefined") { + return false + } + + const runtimeFlag = (window as Window & { __ROO_CHAT_SCROLL_DEBUG__?: boolean }).__ROO_CHAT_SCROLL_DEBUG__ + if (runtimeFlag === true) { + return true + } + + const debugFromQuery = new URLSearchParams(window.location.search).get(CHAT_SCROLL_DEBUG_QUERY_KEY) + if (debugFromQuery === "1" || debugFromQuery === "true") { + return true + } + + return window.localStorage.getItem(CHAT_SCROLL_DEBUG_STORAGE_KEY) === "1" +} + +export function emitChatScrollDebug(enabled: boolean, detail: ChatScrollDebugDetail): void { + if (!enabled) { + return + } + + const payload = { + ts: Date.now(), + ...detail, + } + + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(CHAT_SCROLL_DEBUG_EVENT_NAME, { detail: payload })) + } +} From 4a0d2294c2154068f8b30e60b5573cfcb06593b9 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 15 Feb 2026 16:10:23 -0700 Subject: [PATCH 2/9] fix(chat): preserve user escape hatch during initial settle --- webview-ui/src/components/chat/ChatView.tsx | 156 +++++++++++++++++- .../ChatView.scroll-debug-repro.spec.tsx | 118 ++++++++++++- 2 files changed, 262 insertions(+), 12 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b8cf71336d8..6cb84e81aad 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -67,8 +67,23 @@ const INITIAL_LOAD_SETTLE_TIMEOUT_MS = 2500 const INITIAL_LOAD_SETTLE_HARD_CAP_MS = 10000 const INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET = 3 +type StickyFollowClearSource = "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 = ( { isHidden, showAnnouncement, hideAnnouncement }, ref, @@ -179,6 +194,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -335,6 +352,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (!stickyFollowRef.current) { + return + } + + stickyFollowRef.current = false + emitChatScrollDebug(chatScrollDebugEnabledRef.current, { + event: "sticky-follow-cleared", + source, + isAtBottom: isAtBottomRef.current, + isSettling: isSettlingRef.current, + taskTs: settlingTaskTsRef.current, + }) + }, []) + const startInitialSettle = useCallback( (taskTs: number, source: string) => { const windowState = getInitialSettleWindowState(taskTs) @@ -866,11 +914,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Checking clineAsk isn't enough since messages effect may be called @@ -1749,13 +1797,75 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - 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)) { + clearStickyFollow("wheel-up") + } + }, + [clearStickyFollow], + ) + 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 }, []) - useEvent("wheel", handleWheel, window, { passive: true }) + + 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) { + clearStickyFollow("pointer-scroll-up") + } + }, + [clearStickyFollow], + ) + + 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 }) // Effect to clear checkpoint warning when messages appear or task changes useEffect(() => { @@ -1916,9 +2026,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -2107,7 +2244,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction React.ReactNode atBottomStateChange?: (isAtBottom: boolean) => void + className?: string } vi.mock("react-virtuoso", () => { const MockVirtuoso = React.forwardRef(function MockVirtuoso( - { data, itemContent, atBottomStateChange }, + { data, itemContent, atBottomStateChange, className }, ref, ) { const atBottomCallbackRef = useRef(atBottomStateChange) @@ -257,7 +258,7 @@ vi.mock("react-virtuoso", () => { })) return ( -
+
{data.map((item, index) => (
{itemContent(index, item)} @@ -803,4 +804,115 @@ describe("ChatView scroll debug repro harness", () => { capture.stop() } }) + + it("stops forcing bottom when user interrupts settle via keyboard navigation", async () => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY + virtuosoHarness.atBottomSignalDelayMs = 16 + + const capture = createDebugCapture() + + try { + renderChatView() + + const baseTs = Date.now() - 25_000 + const initialMessages = buildLongHeterogeneousHistory(baseTs, false) + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: initialMessages }) + }) + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-attempt")).toBe(true) + }, + { timeout: 1200 }, + ) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + await waitFor( + () => { + expect( + capture.events.some( + (event) => event.event === "sticky-follow-cleared" && event.source === "keyboard-nav-up", + ), + ).toBe(true) + }, + { timeout: 1000 }, + ) + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-abort-sticky-disabled")).toBe(true) + }, + { timeout: 1200 }, + ) + + const scrollCallsAtAbort = virtuosoHarness.scrollCalls + const settleAttemptsAtAbort = capture.events.filter((event) => event.event === "settle-attempt").length + + await sleep(140) + + const settleAttemptsAfterWait = capture.events.filter((event) => event.event === "settle-attempt").length + expect(virtuosoHarness.scrollCalls).toBe(scrollCallsAtAbort) + expect(settleAttemptsAfterWait).toBe(settleAttemptsAtAbort) + } finally { + capture.stop() + } + }) + + it("disengages sticky follow on pointer drag/manual upward scroll intent", async () => { + virtuosoHarness.scrollCalls = 0 + virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY + virtuosoHarness.atBottomSignalDelayMs = 20 + + const capture = createDebugCapture() + + try { + renderChatView() + + const baseTs = Date.now() - 5_000 + const initialMessages = buildLongHeterogeneousHistory(baseTs, false) + + await act(async () => { + mockPostMessage({ debug: true, clineMessages: initialMessages }) + }) + + await waitFor( + () => { + expect(capture.events.some((event) => event.event === "settle-attempt")).toBe(true) + }, + { timeout: 1200 }, + ) + + const scrollable = document.querySelector(".scrollable") + expect(scrollable).toBeInstanceOf(HTMLElement) + + const scrollableElement = scrollable as HTMLElement + scrollableElement.scrollTop = 220 + + await act(async () => { + fireEvent.pointerDown(scrollableElement) + scrollableElement.scrollTop = 120 + fireEvent.scroll(scrollableElement) + fireEvent.pointerUp(window) + }) + + await waitFor( + () => { + expect( + capture.events.some( + (event) => event.event === "sticky-follow-cleared" && event.source === "pointer-scroll-up", + ), + ).toBe(true) + }, + { timeout: 1200 }, + ) + } finally { + capture.stop() + } + }) }) From 1ed6b7fb9133057290f74b73a7c175546823ed34 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 15 Feb 2026 18:26:15 -0700 Subject: [PATCH 3/9] refactor(chat): reduce scroll fix PR scope and remove debug plumbing --- webview-ui/src/components/chat/ChatView.tsx | 409 ++------ .../ChatView.scroll-debug-repro.spec.tsx | 969 ++++-------------- webview-ui/src/utils/chatScrollDebug.ts | 45 - 3 files changed, 257 insertions(+), 1166 deletions(-) delete mode 100644 webview-ui/src/utils/chatScrollDebug.ts diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 6cb84e81aad..1331300de35 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -49,7 +49,6 @@ import { WorktreeSelector } from "./WorktreeSelector" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" -import { emitChatScrollDebug, isChatScrollDebugEnabled } from "@src/utils/chatScrollDebug" export interface ChatViewProps { isHidden: boolean @@ -114,7 +113,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) - const settleHardCapDeadlineMsRef = useRef(null) - const settleLifecycleTaskTsRef = useRef(null) + const settleTaskTsRef = useRef(null) + const settleDeadlineMsRef = useRef(null) + const settleHardDeadlineMsRef = useRef(null) const settleAnimationFrameRef = useRef(null) const settleStableFramesRef = useRef(0) const settleMutationVersionRef = useRef(0) const settleObservedMutationVersionRef = useRef(0) - const settlingTaskTsRef = useRef(null) - const chatScrollDebugEnabledRef = useRef(false) - const settleStartMsRef = useRef(null) - const settleAttemptRef = useRef(0) const groupedMessagesLengthRef = useRef(0) - const rawMessagesLengthRef = useRef(0) const pointerScrollActiveRef = useRef(false) const pointerScrollLastTopRef = useRef(null) const lastTtsRef = useRef("") @@ -228,70 +221,39 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - chatScrollDebugEnabledRef.current = isChatScrollDebugEnabled(debug) - }, [debug]) - - type InitialSettleWindowState = { - nowMs: number - taskEligible: boolean - windowOpen: boolean - deadlineMs: number | null - hardCapDeadlineMs: number | null - hardCapReached: boolean - } + const isSettleWindowOpen = useCallback((taskTs: number): boolean => { + if (settleTaskTsRef.current !== taskTs) { + return false + } - const getInitialSettleWindowState = useCallback((taskTs: number | null): InitialSettleWindowState => { const nowMs = Date.now() - const taskEligible = taskTs !== null && settleLifecycleTaskTsRef.current === taskTs - const deadlineMs = - settleBudgetAnchorMsRef.current === null - ? null - : settleBudgetAnchorMsRef.current + INITIAL_LOAD_SETTLE_TIMEOUT_MS - const hardCapDeadlineMs = settleHardCapDeadlineMsRef.current - const hardCapReached = hardCapDeadlineMs !== null && nowMs > hardCapDeadlineMs - const windowOpen = taskEligible && deadlineMs !== null && nowMs <= deadlineMs && !hardCapReached - - return { - nowMs, - taskEligible, - windowOpen, - deadlineMs, - hardCapDeadlineMs, - hardCapReached, + const deadlineMs = settleDeadlineMsRef.current + if (deadlineMs === null || nowMs > deadlineMs) { + return false } + + const hardDeadlineMs = settleHardDeadlineMsRef.current + return hardDeadlineMs === null || nowMs <= hardDeadlineMs }, []) - const extendInitialSettleBudgetForMutation = useCallback( - (taskTs: number): { budgetExtended: boolean; windowState: InitialSettleWindowState } => { - if (settleLifecycleTaskTsRef.current !== taskTs) { - return { - budgetExtended: false, - windowState: getInitialSettleWindowState(taskTs), - } - } + const extendInitialSettleWindow = useCallback((taskTs: number): boolean => { + if (settleTaskTsRef.current !== taskTs) { + return false + } - const nowMs = Date.now() - const hardCapDeadlineMs = settleHardCapDeadlineMsRef.current - if (hardCapDeadlineMs !== null && nowMs > hardCapDeadlineMs) { - return { - budgetExtended: false, - windowState: getInitialSettleWindowState(taskTs), - } - } + const nowMs = Date.now() + const hardDeadlineMs = settleHardDeadlineMsRef.current + if (hardDeadlineMs !== null && nowMs > hardDeadlineMs) { + return false + } - settleBudgetAnchorMsRef.current = nowMs - if (settleHardCapDeadlineMsRef.current === null) { - settleHardCapDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS - } + settleDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS + if (hardDeadlineMs === null) { + settleHardDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS + } - return { - budgetExtended: true, - windowState: getInitialSettleWindowState(taskTs), - } - }, - [getInitialSettleWindowState], - ) + return true + }, []) const cancelInitialSettleFrame = useCallback(() => { if (settleAnimationFrameRef.current !== null) { @@ -300,79 +262,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - cancelInitialSettleFrame() - isSettlingRef.current = false - - const windowState = getInitialSettleWindowState(taskTs) - - const nowMs = Date.now() - const settleStartMs = settleStartMsRef.current ?? nowMs - const elapsedMs = nowMs - settleStartMs - - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "settle-complete", - taskTs, - reason, - attempts: settleAttemptRef.current, - elapsedMs, - isAtBottom: isAtBottomRef.current, - stableFrames: settleStableFramesRef.current, - windowOpen: windowState.windowOpen, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - }) - - if (!isAtBottomRef.current) { - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "show-scroll-to-bottom-set", - value: true, - source: "settle-complete", - reason, - taskTs, - windowOpen: windowState.windowOpen, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - }) - setShowScrollToBottom(true) - } - }, - [cancelInitialSettleFrame, getInitialSettleWindowState], - ) + const completeInitialSettle = useCallback(() => { + cancelInitialSettleFrame() + isSettlingRef.current = false + if (!isAtBottomRef.current) { + setShowScrollToBottom(true) + } + }, [cancelInitialSettleFrame]) const runInitialSettleFrame = useCallback( (taskTs: number) => { if (!isMountedRef.current) { return } - if (!isSettlingRef.current || settlingTaskTsRef.current !== taskTs) { + if (!isSettlingRef.current || settleTaskTsRef.current !== taskTs) { return } - if (!stickyFollowRef.current) { - const windowState = getInitialSettleWindowState(taskTs) - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "settle-abort-sticky-disabled", - taskTs, - attempt: settleAttemptRef.current, - isAtBottom: isAtBottomRef.current, - windowOpen: windowState.windowOpen, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - }) - completeInitialSettle(taskTs, "timeout") + if (!stickyFollowRef.current || !isSettleWindowOpen(taskTs)) { + completeInitialSettle() return } - settleAttemptRef.current += 1 - const nowMs = Date.now() - const settleStartMs = settleStartMsRef.current ?? nowMs - const elapsedMs = nowMs - settleStartMs - const mutationVersion = settleMutationVersionRef.current const isTailStable = mutationVersion === settleObservedMutationVersionRef.current settleObservedMutationVersionRef.current = mutationVersion @@ -383,118 +294,43 @@ const ChatViewComponent: React.ForwardRefRenderFunction= INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET) { - completeInitialSettle(taskTs, "stable") - return - } - - const windowState = getInitialSettleWindowState(taskTs) - if (!windowState.windowOpen) { - completeInitialSettle(taskTs, "timeout") + completeInitialSettle() return } settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs)) }, - [completeInitialSettle, getInitialSettleWindowState], + [completeInitialSettle, isSettleWindowOpen], ) - const clearStickyFollow = useCallback((source: StickyFollowClearSource) => { + const clearStickyFollow = useCallback((_source: StickyFollowClearSource) => { if (!stickyFollowRef.current) { return } stickyFollowRef.current = false - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "sticky-follow-cleared", - source, - isAtBottom: isAtBottomRef.current, - isSettling: isSettlingRef.current, - taskTs: settlingTaskTsRef.current, - }) }, []) const startInitialSettle = useCallback( - (taskTs: number, source: string) => { - const windowState = getInitialSettleWindowState(taskTs) - - if (!windowState.taskEligible) { - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "settle-start-rejected", - taskTs, - source, - reason: "task-ineligible", - nowMs: windowState.nowMs, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - lifecycleTaskTs: settleLifecycleTaskTsRef.current, - }) - return - } - - if (!windowState.windowOpen) { - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "settle-start-rejected", - taskTs, - source, - reason: "window-closed", - nowMs: windowState.nowMs, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - }) + (taskTs: number) => { + if (!isSettleWindowOpen(taskTs)) { return } - - if (isSettlingRef.current && settlingTaskTsRef.current === taskTs) { - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "settle-start-rejected", - taskTs, - source, - reason: "already-settling", - nowMs: windowState.nowMs, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - }) + if (isSettlingRef.current && settleTaskTsRef.current === taskTs) { return } cancelInitialSettleFrame() - settlingTaskTsRef.current = taskTs + settleTaskTsRef.current = taskTs isSettlingRef.current = true - if (settleStartMsRef.current === null) { - settleStartMsRef.current = windowState.nowMs - } settleStableFramesRef.current = 0 settleObservedMutationVersionRef.current = settleMutationVersionRef.current - - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "settle-start", - taskTs, - source, - isAtBottom: isAtBottomRef.current, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - }) - settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs)) }, - [cancelInitialSettleFrame, getInitialSettleWindowState, runInitialSettleFrame], + [cancelInitialSettleFrame, isSettleWindowOpen, runInitialSettleFrame], ) const { @@ -799,16 +635,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { const taskSwitchMs = Date.now() - settleAttemptRef.current = 0 - settleStartMsRef.current = null settleStableFramesRef.current = 0 settleMutationVersionRef.current = 0 settleObservedMutationVersionRef.current = 0 cancelInitialSettleFrame() - settleBudgetAnchorMsRef.current = null - settleHardCapDeadlineMsRef.current = null - settleLifecycleTaskTsRef.current = task?.ts ?? null - settlingTaskTsRef.current = task?.ts ?? null + settleTaskTsRef.current = task?.ts ?? null + settleDeadlineMsRef.current = null + settleHardDeadlineMsRef.current = null // Reset UI states only when task changes setExpandedRows({}) @@ -833,48 +666,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction { cancelInitialSettleFrame() - settleBudgetAnchorMsRef.current = null - settleHardCapDeadlineMsRef.current = null - settleLifecycleTaskTsRef.current = null - settlingTaskTsRef.current = null + settleTaskTsRef.current = null + settleDeadlineMsRef.current = null + settleHardDeadlineMsRef.current = null isSettlingRef.current = false } }, [cancelInitialSettleFrame, startInitialSettle, task?.ts]) const taskTs = task?.ts - useEffect(() => { - const previousLength = rawMessagesLengthRef.current - rawMessagesLengthRef.current = messages.length - - if (previousLength === messages.length) { - return - } - - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "raw-messages-length-change", - previousLength, - nextLength: messages.length, - taskTs, - }) - }, [messages.length, taskTs]) - // Request aggregated costs when task changes and has childIds useEffect(() => { if (taskTs && currentTaskItem?.childIds && currentTaskItem.childIds.length > 0) { @@ -1670,34 +1476,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction { settleMutationVersionRef.current += 1 - const taskTs = settleLifecycleTaskTsRef.current - const budgetExtension = - isTaller && taskTs !== null - ? extendInitialSettleBudgetForMutation(taskTs) - : { budgetExtended: false, windowState: getInitialSettleWindowState(taskTs) } - const shouldRearmSettle = - isTaller && taskTs !== null && !isSettlingRef.current && budgetExtension.windowState.windowOpen - const mutationVersion = settleMutationVersionRef.current - - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "row-height-change-callback", - isTaller, - isAtBottom: isAtBottomRef.current, - taskTs, - windowOpen: budgetExtension.windowState.windowOpen, - deadlineMs: budgetExtension.windowState.deadlineMs, - hardCapDeadlineMs: budgetExtension.windowState.hardCapDeadlineMs, - hardCapReached: budgetExtension.windowState.hardCapReached, - budgetExtended: budgetExtension.budgetExtended, - isSettling: isSettlingRef.current, - mutationVersion, - shouldRearmSettle, - }) - - if (shouldRearmSettle) { - startInitialSettle(taskTs, "row-height-growth") + const settleTaskTs = settleTaskTsRef.current + if ( + isTaller && + settleTaskTs !== null && + !isSettlingRef.current && + extendInitialSettleWindow(settleTaskTs) + ) { + startInitialSettle(settleTaskTs) } if (isAtBottomRef.current) { @@ -1787,13 +1553,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { isAtBottomRef.current = isAtBottom - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "at-bottom-state-change", - isAtBottom, - isSettling: isSettlingRef.current, - }) if (isSettlingRef.current && !isAtBottom) { - const windowState = getInitialSettleWindowState(settlingTaskTsRef.current) - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "show-scroll-to-bottom-suppressed", - source: "at-bottom-state-change-suppressed", - isAtBottom, - isSettling: true, - windowOpen: windowState.windowOpen, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - taskTs: settlingTaskTsRef.current, - }) return } - const windowState = getInitialSettleWindowState(settlingTaskTsRef.current) - const nextShowScrollToBottom = !isAtBottom - emitChatScrollDebug(chatScrollDebugEnabledRef.current, { - event: "show-scroll-to-bottom-set", - value: nextShowScrollToBottom, - source: "at-bottom-state-change", - isAtBottom, - isSettling: isSettlingRef.current, - windowOpen: windowState.windowOpen, - deadlineMs: windowState.deadlineMs, - hardCapDeadlineMs: windowState.hardCapDeadlineMs, - hardCapReached: windowState.hardCapReached, - taskTs: settlingTaskTsRef.current, - }) - setShowScrollToBottom(nextShowScrollToBottom) + setShowScrollToBottom(!isAtBottom) // stickyFollowRef is only cleared by explicit user actions // (wheel-up, keyboard navigation up, pointer drag up, row expansion), // not by transient bottom-detection diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index 5699df46b34..ad21fa3d4b6 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -2,19 +2,13 @@ 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 { CHAT_SCROLL_DEBUG_EVENT_NAME } from "@src/utils/chatScrollDebug" import ChatView, { type ChatViewProps } from "../ChatView" -interface ClineMessage { - type: "say" | "ask" - say?: string - ask?: string - ts: number - text?: string - partial?: boolean -} +type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false interface ExtensionStateMessage { type: "state" @@ -27,52 +21,48 @@ interface ExtensionStateMessage { alwaysAllowExecute: boolean cloudIsAuthenticated: boolean telemetrySetting: "enabled" | "disabled" | "unset" - debug?: boolean } } -interface ChatScrollDebugEventDetail { - ts: number - event: string - [key: string]: unknown +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 - atBottomSignalDelayMs: number + signalDelayMs: number emitFalseOnDataChange: boolean + followOutput: FollowOutput | undefined } -const virtuosoHarness = vi.hoisted(() => ({ +const harness = vi.hoisted(() => ({ scrollCalls: 0, atBottomAfterCalls: Number.POSITIVE_INFINITY, - atBottomSignalDelayMs: 20, + signalDelayMs: 20, emitFalseOnDataChange: true, + followOutput: undefined, })) -vi.mock("@src/utils/vscode", () => ({ - vscode: { - postMessage: vi.fn(), - }, -})) - -vi.mock("use-sound", () => ({ - default: vi.fn().mockImplementation(() => [vi.fn()]), -})) - -vi.mock("@/components/ui", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - } -}) - -vi.mock("@vscode/webview-ui-toolkit/react", () => ({ - VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, -})) +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, @@ -82,180 +72,102 @@ vi.mock("@src/hooks/useCloudUpsell", () => ({ }), })) -vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ - CloudUpsellDialog: () => null, -})) - -vi.mock("../common/TelemetryBanner", () => ({ - default: () => null, -})) - -vi.mock("../common/VersionIndicator", () => ({ - default: () => null, -})) - -vi.mock("../history/HistoryPreview", () => ({ - default: () => null, -})) - -vi.mock("@src/components/welcome/RooHero", () => ({ - default: () => null, -})) - -vi.mock("@src/components/welcome/RooTips", () => ({ - default: () => null, -})) - -vi.mock("../Announcement", () => ({ - default: () => null, -})) - -vi.mock("./TaskHeader", () => ({ - default: () =>
, -})) - -vi.mock("./ProfileViolationWarning", () => ({ - default: () => null, -})) - -vi.mock("./CheckpointWarning", () => ({ - CheckpointWarning: () => null, -})) +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: () =>
})) +vi.mock("./ProfileViolationWarning", nullDefaultModule) +vi.mock("../common/DismissibleUpsell", nullDefaultModule) -vi.mock("./QueuedMessages", () => ({ - QueuedMessages: () => null, -})) - -vi.mock("./WorktreeSelector", () => ({ - WorktreeSelector: () => null, -})) +vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null })) +vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null })) +vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) -vi.mock("../common/DismissibleUpsell", () => ({ - default: () => null, +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, })) -interface MockChatTextAreaProps { - inputValue?: string - setInputValue?: (value: string) => void - onSend: () => void - sendingDisabled?: boolean -} +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) vi.mock("../ChatTextArea", () => { - const ChatTextAreaComponent = React.forwardRef(function MockChatTextArea( - props: MockChatTextAreaProps, + const MockTextArea = React.forwardRef(function MockTextArea( + props: { + inputValue?: string + setInputValue?: (value: string) => void + onSend: () => void + sendingDisabled?: boolean + }, ref: React.ForwardedRef<{ focus: () => void }>, ) { - React.useImperativeHandle(ref, () => ({ - focus: () => {}, - })) + useImperativeHandle(ref, () => ({ focus: () => {} })) return ( -
- props.setInputValue?.(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && !props.sendingDisabled) { - props.onSend() - } - }} - /> -
+ props.setInputValue?.(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !props.sendingDisabled) { + props.onSend() + } + }} + /> ) }) - return { - default: ChatTextAreaComponent, - ChatTextArea: ChatTextAreaComponent, - } + return { default: MockTextArea, ChatTextArea: MockTextArea } }) -interface MockChatRowProps { - message: ClineMessage - isLast: boolean - onHeightChange: (isTaller: boolean) => void -} - vi.mock("../ChatRow", () => ({ - default: function MockChatRow({ message, isLast, onHeightChange }: MockChatRowProps) { - useEffect(() => { - if (!isLast || message.type !== "say" || message.say !== "text") { - return - } - - if (!message.text?.includes("__LATE_GROW__")) { - return - } - - const timeoutA = window.setTimeout(() => onHeightChange(true), 1050) - const timeoutB = window.setTimeout(() => onHeightChange(true), 1250) - - return () => { - window.clearTimeout(timeoutA) - window.clearTimeout(timeoutB) - } - }, [isLast, message, onHeightChange]) - - return
{message.ts}
- }, + default: ({ message }: { message: ClineMessage }) =>
{message.ts}
, })) -interface VirtuosoScrollOptions { - index: number | "LAST" - align?: "end" | "start" | "center" - behavior?: "auto" | "smooth" -} - -interface MockVirtuosoHandle { - scrollToIndex: (options: VirtuosoScrollOptions) => void -} - -interface MockVirtuosoProps { - data: ClineMessage[] - itemContent: (index: number, item: ClineMessage) => React.ReactNode - atBottomStateChange?: (isAtBottom: boolean) => void - className?: string -} - vi.mock("react-virtuoso", () => { const MockVirtuoso = React.forwardRef(function MockVirtuoso( - { data, itemContent, atBottomStateChange, className }, + { data, itemContent, atBottomStateChange, followOutput, className }, ref, ) { - const atBottomCallbackRef = useRef(atBottomStateChange) - const pendingAtBottomTimeoutsRef = useRef([]) + const atBottomRef = useRef(atBottomStateChange) + const timeoutIdsRef = useRef([]) - useEffect(() => { - atBottomCallbackRef.current = atBottomStateChange - }, [atBottomStateChange]) + 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(() => { - return () => { - for (const timeoutId of pendingAtBottomTimeoutsRef.current) { - window.clearTimeout(timeoutId) - } - pendingAtBottomTimeoutsRef.current = [] - } - }, []) + atBottomRef.current = atBottomStateChange + }, [atBottomStateChange]) useEffect(() => { - if (virtuosoHarness.emitFalseOnDataChange) { + if (harness.emitFalseOnDataChange) { atBottomStateChange?.(false) } }, [data.length, atBottomStateChange]) - useImperativeHandle(ref, () => ({ - scrollToIndex: () => { - virtuosoHarness.scrollCalls += 1 - const shouldReportAtBottom = virtuosoHarness.scrollCalls >= virtuosoHarness.atBottomAfterCalls - - const timeoutId = window.setTimeout(() => { - atBottomCallbackRef.current?.(shouldReportAtBottom) - }, virtuosoHarness.atBottomSignalDelayMs) - pendingAtBottomTimeoutsRef.current.push(timeoutId) + useEffect( + () => () => { + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)) + timeoutIdsRef.current = [] }, - })) + [], + ) return (
@@ -271,648 +183,143 @@ vi.mock("react-virtuoso", () => { return { Virtuoso: MockVirtuoso } }) -function mockPostMessage(state: Partial) { +const props: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const sleep = (ms: number) => new Promise((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: [], + clineMessages, taskHistory: [], shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, cloudIsAuthenticated: false, telemetrySetting: "enabled", - ...state, }, } window.postMessage(message, "*") } -function mockMessageUpdated(clineMessage: ClineMessage) { - window.postMessage( - { - type: "messageUpdated", - clineMessage, - }, - "*", +const renderView = () => + render( + + + + + , ) -} - -function getEventNumber(detail: ChatScrollDebugEventDetail, key: string): number | undefined { - const value = detail[key] - return typeof value === "number" ? value : undefined -} - -function getEventBoolean(detail: ChatScrollDebugEventDetail, key: string): boolean | undefined { - const value = detail[key] - return typeof value === "boolean" ? value : undefined -} -function buildLongHeterogeneousHistory(baseTs: number, includeLateTailGrowth: boolean): ClineMessage[] { - const messages: ClineMessage[] = [ - { - type: "say", - say: "task", - ts: baseTs, - text: "Investigate existing conversation", - }, - ] - - for (let i = 0; i < 12; i += 1) { - messages.push({ - type: "say", - say: i % 2 === 0 ? "text" : "user_feedback", - ts: baseTs + 10 + i, - text: `history-text-${i}`, - }) - } - - for (let i = 0; i < 8; i += 1) { - messages.push({ - type: "ask", - ask: "tool", - ts: baseTs + 100 + i, - text: JSON.stringify({ tool: "readFile", path: `src/file-${i}.ts`, reason: `line-${i}` }), - }) - } - - for (let i = 0; i < 8; i += 1) { - messages.push({ - type: "ask", - ask: "tool", - ts: baseTs + 200 + i, - text: JSON.stringify({ tool: "listFilesRecursive", path: `src/dir-${i}` }), - }) - } - - for (let i = 0; i < 5; i += 1) { - messages.push({ - type: "ask", - ask: "tool", - ts: baseTs + 300 + i, - text: JSON.stringify({ tool: "editedExistingFile", path: `src/edit-${i}.ts`, diff: `@@ change ${i}` }), - }) - } - - messages.push({ - type: "say", - say: "text", - ts: baseTs + 500, - text: includeLateTailGrowth ? "tail message __LATE_GROW__" : "tail message stable", +const hydrate = async (atBottomAfterCalls: number) => { + harness.atBottomAfterCalls = atBottomAfterCalls + renderView() + await act(async () => { + postState(buildMessages(Date.now() - 3_000)) }) - - return messages } -function createDebugCapture() { - const events: ChatScrollDebugEventDetail[] = [] - - const handler = (event: Event) => { - const customEvent = event as CustomEvent - events.push(customEvent.detail) - } - - window.addEventListener(CHAT_SCROLL_DEBUG_EVENT_NAME, handler) - - return { - events, - stop: () => window.removeEventListener(CHAT_SCROLL_DEBUG_EVENT_NAME, handler), - } -} - -async function sleep(ms: number) { - await new Promise((resolve) => window.setTimeout(resolve, ms)) +const waitForCalls = async (min: number, timeout = 1_500) => { + await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(min), { timeout }) } -const defaultProps: ChatViewProps = { - isHidden: false, - showAnnouncement: false, - hideAnnouncement: () => {}, +const expectCallsStable = async (ms = 120) => { + await sleep(ms) + const snapshot = harness.scrollCalls + await sleep(ms) + expect(harness.scrollCalls).toBe(snapshot) } -function renderChatView() { - return render( - - - - - , - ) +const getScrollable = (): HTMLElement => { + const scrollable = document.querySelector(".scrollable") + if (!(scrollable instanceof HTMLElement)) { + throw new Error("Expected ChatView scrollable container") + } + return scrollable } -describe("ChatView scroll debug repro harness", () => { +describe("ChatView scroll behavior regression coverage", () => { beforeEach(() => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY - virtuosoHarness.atBottomSignalDelayMs = 20 - virtuosoHarness.emitFalseOnDataChange = true - ;(window as Window & { __ROO_CHAT_SCROLL_DEBUG__?: boolean }).__ROO_CHAT_SCROLL_DEBUG__ = true - window.localStorage.setItem("roo.chatScrollDebug", "1") + harness.scrollCalls = 0 + harness.atBottomAfterCalls = Number.POSITIVE_INFINITY + harness.signalDelayMs = 20 + harness.emitFalseOnDataChange = true + harness.followOutput = undefined }) - afterEach(() => { - window.localStorage.removeItem("roo.chatScrollDebug") - ;(window as Window & { __ROO_CHAT_SCROLL_DEBUG__?: boolean }).__ROO_CHAT_SCROLL_DEBUG__ = false + it("rehydration converges to bottom", async () => { + await hydrate(6) + await waitForCalls(6, 2_000) + await expectCallsStable() + expect(document.querySelector(".codicon-chevron-down")).toBeNull() }) - it("converges to bottom after delayed at-bottom measurement cycles", async () => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = 7 - virtuosoHarness.atBottomSignalDelayMs = 18 - - const capture = createDebugCapture() - - try { - renderChatView() - - const baseTs = Date.now() - 20_000 - const initialMessages = buildLongHeterogeneousHistory(baseTs, false) - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: initialMessages }) - }) - - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-complete")).toBe(true) - }, - { timeout: 2500 }, - ) - - const firstAtBottomTrue = capture.events.find( - (event) => event.event === "at-bottom-state-change" && event.isAtBottom === true, - ) - const firstTrueTs = - typeof firstAtBottomTrue?.ts === "number" ? firstAtBottomTrue.ts : Number.MAX_SAFE_INTEGER - const settleAttemptsBeforeFirstTrue = capture.events.filter( - (event) => event.event === "settle-attempt" && event.ts <= firstTrueTs, - ) - const stableSettleComplete = capture.events.find( - (event) => event.event === "settle-complete" && event.reason === "stable" && event.isAtBottom === true, - ) - - console.info( - "[chat-scroll-repro][scenario=delayed-measurement-convergence]", - JSON.stringify({ - scrollCalls: virtuosoHarness.scrollCalls, - firstAtBottomTrue, - settleAttemptsBeforeFirstTrue: settleAttemptsBeforeFirstTrue.length, - stableSettleComplete, - }), - ) - - expect(firstAtBottomTrue).toBeTruthy() - expect(settleAttemptsBeforeFirstTrue.length).toBeGreaterThanOrEqual(5) - expect(stableSettleComplete).toBeTruthy() - } finally { - capture.stop() - } - }) - - it("re-arms on late tail growth during initial settle window and still converges", async () => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = 6 - virtuosoHarness.atBottomSignalDelayMs = 20 - - const capture = createDebugCapture() - - try { - renderChatView() - - const baseTs = Date.now() - 10_000 - const initialMessages = buildLongHeterogeneousHistory(baseTs, true) - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: initialMessages }) - }) - - await waitFor( - () => { - expect( - capture.events.some( - (event) => event.event === "row-height-change-callback" && event.shouldRearmSettle === true, - ), - ).toBe(true) - }, - { timeout: 2200 }, - ) - - const rowGrowthRearmEvent = capture.events.find( - (event) => event.event === "row-height-change-callback" && event.shouldRearmSettle === true, - ) - const rowGrowthRearmTs = typeof rowGrowthRearmEvent?.ts === "number" ? rowGrowthRearmEvent.ts : 0 - - await waitFor( - () => { - expect( - capture.events.some( - (event) => - event.event === "settle-start" && - event.source === "row-height-growth" && - event.ts >= rowGrowthRearmTs, - ), - ).toBe(true) - }, - { timeout: 2500 }, - ) - - const rearmSettleStart = capture.events.find( - (event) => - event.event === "settle-start" && - event.source === "row-height-growth" && - event.ts >= rowGrowthRearmTs, - ) - const rearmSettleStartTs = typeof rearmSettleStart?.ts === "number" ? rearmSettleStart.ts : 0 - await waitFor( - () => { - expect( - capture.events.some( - (event) => - event.event === "settle-complete" && - event.reason === "stable" && - event.isAtBottom === true && - event.ts >= rearmSettleStartTs, - ), - ).toBe(true) - }, - { timeout: 1200 }, - ) - - const stableCompleteAfterRearm = capture.events.find( - (event) => - event.event === "settle-complete" && - event.reason === "stable" && - event.isAtBottom === true && - event.ts >= rearmSettleStartTs, - ) - - console.info( - "[chat-scroll-repro][scenario=late-tail-growth-rearm-convergence]", - JSON.stringify({ - rowGrowthRearmEvent, - rearmSettleStart, - stableCompleteAfterRearm, - }), - ) - - expect(rowGrowthRearmEvent).toBeTruthy() - expect(rearmSettleStart).toBeTruthy() - expect(stableCompleteAfterRearm).toBeTruthy() - } finally { - capture.stop() - } - }) + 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() - it("uses the safety cap deterministically when bottom is never reached", async () => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY - virtuosoHarness.atBottomSignalDelayMs = 20 - - const capture = createDebugCapture() - - try { - renderChatView() - - const baseTs = Date.now() - 30_000 - const initialMessages = buildLongHeterogeneousHistory(baseTs, false) - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: initialMessages }) - }) - - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-complete")).toBe(true) - }, - { timeout: 3400 }, - ) - - const timeoutSettleComplete = capture.events.find( - (event) => event.event === "settle-complete" && event.reason === "timeout", - ) - const elapsedMs = - timeoutSettleComplete && typeof timeoutSettleComplete.elapsedMs === "number" - ? timeoutSettleComplete.elapsedMs - : -1 - - console.info( - "[chat-scroll-repro][scenario=safety-cap-timeout]", - JSON.stringify({ - timeoutSettleComplete, - elapsedMs, - scrollCalls: virtuosoHarness.scrollCalls, - }), - ) - - expect(timeoutSettleComplete).toBeTruthy() - expect(timeoutSettleComplete?.isAtBottom).toBe(false) - expect(elapsedMs).toBeGreaterThanOrEqual(2400) - expect(elapsedMs).toBeLessThanOrEqual(3300) - expect(virtuosoHarness.scrollCalls).toBeGreaterThan(10) - } finally { - capture.stop() - } + await waitForCalls(8, 2_000) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") }) - it("keeps settle lifecycle eligible across late rehydration waves and converges after re-arm", async () => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = 5 - virtuosoHarness.atBottomSignalDelayMs = 18 - - const capture = createDebugCapture() - - try { - renderChatView() + it("user escape hatch during settle stops forced follow", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(3, 1_200) - const baseTs = Date.now() - 45_000 - const initialMessages = buildLongHeterogeneousHistory(baseTs, false) - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: initialMessages }) - }) - - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-window-opened")).toBe(true) - }, - { timeout: 1200 }, - ) - - const windowOpened = capture.events.find((event) => event.event === "settle-window-opened") - const deadlineMs = windowOpened ? getEventNumber(windowOpened, "deadlineMs") : undefined + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-complete")).toBe(true) - }, - { timeout: 2500 }, - ) + expect(resolveFollowOutput(false)).toBe(false) + const callsAfterEscape = harness.scrollCalls + await sleep(260) + expect(harness.scrollCalls).toBe(callsAfterEscape) - const initialSettleComplete = capture.events.find((event) => event.event === "settle-complete") - const initialSettleCompleteTs = typeof initialSettleComplete?.ts === "number" ? initialSettleComplete.ts : 0 + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) - const waitMs = Math.max(0, (deadlineMs ?? Date.now()) - Date.now() + 220) - await sleep(waitMs) + it("non-wheel upward intent disengages sticky follow", async () => { + await hydrate(4) + await waitForCalls(4) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") - const lateMessage: ClineMessage = { - type: "say", - say: "text", - ts: baseTs + 700, - text: "late hydration wave __LATE_GROW__", - } - const lateWaveMessages = [...initialMessages, lateMessage] - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: lateWaveMessages }) - }) - - await act(async () => { - mockMessageUpdated({ ...lateMessage, text: "late hydration wave __LATE_GROW__ updated" }) - }) - - const lateMutationStartTs = Date.now() - - await waitFor( - () => { - expect( - capture.events.some( - (event) => - event.event === "grouped-messages-length-change" && - getEventBoolean(event, "budgetExtended") === true && - getEventBoolean(event, "windowOpen") === true, - ), - ).toBe(true) - }, - { timeout: 1700 }, - ) - - await waitFor( - () => { - expect( - capture.events.some( - (event) => - event.event === "row-height-change-callback" && - getEventBoolean(event, "budgetExtended") === true && - getEventBoolean(event, "windowOpen") === true, - ), - ).toBe(true) - }, - { timeout: 3200 }, - ) - - await waitFor( - () => { - expect( - capture.events.some( - (event) => - event.event === "settle-start" && - (event.source === "grouped-messages-length-change" || - event.source === "row-height-growth") && - event.ts >= lateMutationStartTs, - ), - ).toBe(true) - }, - { timeout: 2200 }, - ) - - await waitFor( - () => { - expect( - capture.events.some( - (event) => - event.event === "settle-complete" && - event.reason === "stable" && - event.isAtBottom === true && - event.ts > initialSettleCompleteTs, - ), - ).toBe(true) - }, - { timeout: 2200 }, - ) - - const firstAtBottomTrue = capture.events.find( - (event) => event.event === "at-bottom-state-change" && event.isAtBottom === true, - ) - - const groupedLengthMutations = capture.events.filter( - (event) => event.event === "grouped-messages-length-change", - ) - const firstGroupedLengthMutation = groupedLengthMutations.at(0) - const lastGroupedLengthMutation = groupedLengthMutations.at(-1) - - const lateRawLengthMutations = capture.events.filter((event) => { - if (event.event !== "raw-messages-length-change") { - return false - } - const eventTs = getEventNumber(event, "ts") - return eventTs !== undefined && deadlineMs !== undefined && eventTs > deadlineMs - }) - - const successfulLateRearms = capture.events.filter((event) => { - if (event.event !== "grouped-messages-length-change" && event.event !== "row-height-change-callback") { - return false - } - const eventTs = getEventNumber(event, "ts") - if (eventTs === undefined || eventTs <= (deadlineMs ?? 0)) { - return false - } - return ( - getEventBoolean(event, "windowOpen") === true && - getEventBoolean(event, "shouldRearmSettle") === true - ) - }) - - const showScrollToBottomSuppressedWhileSettling = capture.events.filter( - (event) => - event.event === "show-scroll-to-bottom-suppressed" && getEventBoolean(event, "windowOpen") === true, - ) - - const stableSettleAfterLateWave = capture.events.find( - (event) => - event.event === "settle-complete" && - event.reason === "stable" && - event.isAtBottom === true && - event.ts > initialSettleCompleteTs, - ) - - console.info( - "[chat-scroll-repro][scenario=hydration-aware-rearm-after-late-wave]", - JSON.stringify({ - windowOpened, - initialSettleComplete, - firstAtBottomTrue, - firstGroupedLengthMutation, - lastGroupedLengthMutation, - lateRawLengthMutations, - successfulLateRearms, - showScrollToBottomSuppressedWhileSettling, - stableSettleAfterLateWave, - }), - ) - - expect(deadlineMs).toBeTruthy() - expect(firstAtBottomTrue).toBeTruthy() - expect(firstGroupedLengthMutation).toBeTruthy() - expect(lastGroupedLengthMutation).toBeTruthy() - expect(lateRawLengthMutations.length).toBeGreaterThan(0) - expect(successfulLateRearms.length).toBeGreaterThan(0) - expect(showScrollToBottomSuppressedWhileSettling.length).toBeGreaterThan(0) - expect(stableSettleAfterLateWave).toBeTruthy() - } finally { - capture.stop() - } - }) + const scrollable = getScrollable() + scrollable.scrollTop = 240 - it("stops forcing bottom when user interrupts settle via keyboard navigation", async () => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY - virtuosoHarness.atBottomSignalDelayMs = 16 - - const capture = createDebugCapture() - - try { - renderChatView() - - const baseTs = Date.now() - 25_000 - const initialMessages = buildLongHeterogeneousHistory(baseTs, false) - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: initialMessages }) - }) - - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-attempt")).toBe(true) - }, - { timeout: 1200 }, - ) - - await act(async () => { - fireEvent.keyDown(window, { key: "PageUp" }) - }) - - await waitFor( - () => { - expect( - capture.events.some( - (event) => event.event === "sticky-follow-cleared" && event.source === "keyboard-nav-up", - ), - ).toBe(true) - }, - { timeout: 1000 }, - ) - - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-abort-sticky-disabled")).toBe(true) - }, - { timeout: 1200 }, - ) - - const scrollCallsAtAbort = virtuosoHarness.scrollCalls - const settleAttemptsAtAbort = capture.events.filter((event) => event.event === "settle-attempt").length - - await sleep(140) - - const settleAttemptsAfterWait = capture.events.filter((event) => event.event === "settle-attempt").length - expect(virtuosoHarness.scrollCalls).toBe(scrollCallsAtAbort) - expect(settleAttemptsAfterWait).toBe(settleAttemptsAtAbort) - } finally { - capture.stop() - } - }) + await act(async () => { + fireEvent.pointerDown(scrollable) + scrollable.scrollTop = 120 + fireEvent.scroll(scrollable) + fireEvent.pointerUp(window) + }) - it("disengages sticky follow on pointer drag/manual upward scroll intent", async () => { - virtuosoHarness.scrollCalls = 0 - virtuosoHarness.atBottomAfterCalls = Number.POSITIVE_INFINITY - virtuosoHarness.atBottomSignalDelayMs = 20 - - const capture = createDebugCapture() - - try { - renderChatView() - - const baseTs = Date.now() - 5_000 - const initialMessages = buildLongHeterogeneousHistory(baseTs, false) - - await act(async () => { - mockPostMessage({ debug: true, clineMessages: initialMessages }) - }) - - await waitFor( - () => { - expect(capture.events.some((event) => event.event === "settle-attempt")).toBe(true) - }, - { timeout: 1200 }, - ) - - const scrollable = document.querySelector(".scrollable") - expect(scrollable).toBeInstanceOf(HTMLElement) - - const scrollableElement = scrollable as HTMLElement - scrollableElement.scrollTop = 220 - - await act(async () => { - fireEvent.pointerDown(scrollableElement) - scrollableElement.scrollTop = 120 - fireEvent.scroll(scrollableElement) - fireEvent.pointerUp(window) - }) - - await waitFor( - () => { - expect( - capture.events.some( - (event) => event.event === "sticky-follow-cleared" && event.source === "pointer-scroll-up", - ), - ).toBe(true) - }, - { timeout: 1200 }, - ) - } finally { - capture.stop() - } + expect(resolveFollowOutput(false)).toBe(false) }) }) diff --git a/webview-ui/src/utils/chatScrollDebug.ts b/webview-ui/src/utils/chatScrollDebug.ts deleted file mode 100644 index 82e9e67d2a4..00000000000 --- a/webview-ui/src/utils/chatScrollDebug.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const CHAT_SCROLL_DEBUG_EVENT_NAME = "roo:chat-scroll-debug" -const CHAT_SCROLL_DEBUG_STORAGE_KEY = "roo.chatScrollDebug" -const CHAT_SCROLL_DEBUG_QUERY_KEY = "chatScrollDebug" - -export interface ChatScrollDebugDetail { - event: string - [key: string]: unknown -} - -export function isChatScrollDebugEnabled(debugSetting?: boolean): boolean { - if (debugSetting === true) { - return true - } - - if (typeof window === "undefined") { - return false - } - - const runtimeFlag = (window as Window & { __ROO_CHAT_SCROLL_DEBUG__?: boolean }).__ROO_CHAT_SCROLL_DEBUG__ - if (runtimeFlag === true) { - return true - } - - const debugFromQuery = new URLSearchParams(window.location.search).get(CHAT_SCROLL_DEBUG_QUERY_KEY) - if (debugFromQuery === "1" || debugFromQuery === "true") { - return true - } - - return window.localStorage.getItem(CHAT_SCROLL_DEBUG_STORAGE_KEY) === "1" -} - -export function emitChatScrollDebug(enabled: boolean, detail: ChatScrollDebugDetail): void { - if (!enabled) { - return - } - - const payload = { - ts: Date.now(), - ...detail, - } - - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent(CHAT_SCROLL_DEBUG_EVENT_NAME, { detail: payload })) - } -} From bab657a4a3365d17ac3593902bb3c72b610182b1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 15 Feb 2026 19:26:30 -0700 Subject: [PATCH 4/9] fix(chat): redesign rehydration scroll lifecycle --- webview-ui/src/components/chat/ChatView.tsx | 221 +++++++++++++----- .../ChatView.scroll-debug-repro.spec.tsx | 61 +++++ 2 files changed, 223 insertions(+), 59 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 1331300de35..1549ab7ca95 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -65,8 +65,11 @@ 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 StickyFollowClearSource = "wheel-up" | "row-expansion" | "keyboard-nav-up" | "pointer-scroll-up" +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 @@ -175,7 +178,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction>({}) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) - const stickyFollowRef = useRef(false) + const [scrollPhase, setScrollPhase] = useState("USER_BROWSING_HISTORY") + const scrollPhaseRef = useRef("USER_BROWSING_HISTORY") const [showScrollToBottom, setShowScrollToBottom] = useState(false) const isAtBottomRef = useRef(false) const isSettlingRef = useRef(false) @@ -184,11 +188,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const settleAnimationFrameRef = useRef(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(null) + const reanchorAnimationFrameRef = useRef(null) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -221,7 +228,45 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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 } @@ -237,6 +282,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { + return false + } + if (settleTaskTsRef.current !== taskTs) { return false } @@ -262,13 +311,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + if (reanchorAnimationFrameRef.current !== null) { + cancelAnimationFrame(reanchorAnimationFrameRef.current) + reanchorAnimationFrameRef.current = null + } + }, []) + const completeInitialSettle = useCallback(() => { cancelInitialSettleFrame() isSettlingRef.current = false - if (!isAtBottomRef.current) { - setShowScrollToBottom(true) + if (isAtBottomRef.current && settleBottomConfirmedRef.current) { + enterAnchoredFollowing() + return } - }, [cancelInitialSettleFrame]) + + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(true) + }, [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase]) const runInitialSettleFrame = useCallback( (taskTs: number) => { @@ -279,7 +339,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction INITIAL_LOAD_SETTLE_MAX_FRAMES) { + completeInitialSettle() + return + } + + if (!isSettleWindowOpen(taskTs)) { completeInitialSettle() return } @@ -288,7 +354,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (!stickyFollowRef.current) { - return - } - - stickyFollowRef.current = false - }, []) - const startInitialSettle = useCallback( (taskTs: number) => { + if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { + return + } + if (!isSettleWindowOpen(taskTs)) { return } @@ -327,6 +389,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction runInitialSettleFrame(taskTs)) }, @@ -365,8 +428,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { isMountedRef.current = false + cancelReanchorFrame() } - }, []) + }, [cancelReanchorFrame]) const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), @@ -636,9 +700,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { 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 @@ -658,26 +726,35 @@ const ChatViewComponent: React.ForwardRefRenderFunction { cancelInitialSettleFrame() + cancelReanchorFrame() settleTaskTsRef.current = null settleDeadlineMsRef.current = null settleHardDeadlineMsRef.current = null isSettlingRef.current = false + settleBottomConfirmedRef.current = false } - }, [cancelInitialSettleFrame, startInitialSettle, task?.ts]) + }, [ + beginHydrationPinnedToBottom, + cancelInitialSettleFrame, + cancelReanchorFrame, + startInitialSettle, + task?.ts, + transitionScrollPhase, + ]) const taskTs = task?.ts @@ -720,11 +797,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Checking clineAsk isn't enough since messages effect may be called @@ -1478,7 +1555,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction scrollToBottomAuto(), 0) + scrollToBottomAuto() } } }, - [extendInitialSettleWindow, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle], + [extendInitialSettleWindow, isStreaming, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle], ) // Disable sticky follow when user scrolls up inside the chat container @@ -1561,10 +1635,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { const wheelEvent = event as WheelEvent if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - clearStickyFollow("wheel-up") + enterUserBrowsingHistory("wheel-up") } }, - [clearStickyFollow], + [enterUserBrowsingHistory], ) useEvent("wheel", handleWheel, window, { passive: true }) @@ -1616,10 +1690,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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) { @@ -1812,10 +1898,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -1963,19 +2049,43 @@ const ChatViewComponent: React.ForwardRefRenderFunction - isAtBottom || stickyFollowRef.current ? "auto" : false - } + followOutput={() => (scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto")} atBottomStateChange={(isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom - if (isSettlingRef.current && !isAtBottom) { + + 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 } - setShowScrollToBottom(!isAtBottom) - // stickyFollowRef is only cleared by explicit user actions - // (wheel-up, keyboard navigation up, pointer drag up, row expansion), - // not by transient bottom-detection - // state changes which can flicker during layout reflows. + + if (currentPhase === "ANCHORED_FOLLOWING" && isStreaming) { + scrollToBottomAuto() + setShowScrollToBottom(false) + return + } + + setShowScrollToBottom(currentPhase === "USER_BROWSING_HISTORY") }} atBottomThreshold={10} initialTopMostItemIndex={groupedMessages.length - 1} @@ -1991,14 +2101,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // 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}> @@ -2104,7 +2207,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottomRef.current) { + if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") { scrollToBottomAuto() } }} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index ad21fa3d4b6..0810e417d73 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -259,6 +259,20 @@ const getScrollable = (): HTMLElement => { 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 @@ -322,4 +336,51 @@ describe("ChatView scroll behavior regression coverage", () => { 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 }) + }) }) From 9c80c8eb0952348defa9db45895cbd3e697a8f08 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 16 Feb 2026 18:45:52 +0000 Subject: [PATCH 5/9] refactor(chat): extract scroll lifecycle into useScrollLifecycle hook - Extract ~400 lines of scroll lifecycle logic from ChatView.tsx into a dedicated useScrollLifecycle hook, reducing ChatView scroll-related refs from ~17 to 0 and making the logic testable in isolation. - Reduce INITIAL_LOAD_SETTLE_HARD_CAP_MS from 10s to 5s. If rehydration takes longer, there is likely a rendering performance issue worth investigating separately. - Document the scrollToIndex reversal: PR #6780 removed scrollToIndex due to jitter from stale numeric indices. The "LAST" constant used here resolves at call time, avoiding that issue. - All 6 existing scroll regression tests pass unchanged. --- webview-ui/src/components/chat/ChatView.tsx | 558 ++-------------- webview-ui/src/hooks/useScrollLifecycle.ts | 672 ++++++++++++++++++++ 2 files changed, 723 insertions(+), 507 deletions(-) create mode 100644 webview-ui/src/hooks/useScrollLifecycle.ts diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 1549ab7ca95..c54245cd06c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,6 +1,5 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent } from "react-use" -import debounce from "debounce" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" @@ -48,6 +47,7 @@ import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" import { Cloud } from "lucide-react" export interface ChatViewProps { @@ -62,36 +62,12 @@ 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 = ( { isHidden, showAnnouncement, hideAnnouncement }, ref, ) => { - const isMountedRef = useRef(true) - const [audioBaseUri] = useState(() => { return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || "" }) @@ -178,24 +154,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction>({}) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) - const [scrollPhase, setScrollPhase] = useState("USER_BROWSING_HISTORY") - const scrollPhaseRef = useRef("USER_BROWSING_HISTORY") - const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const isAtBottomRef = useRef(false) - const isSettlingRef = useRef(false) - const settleTaskTsRef = useRef(null) - const settleDeadlineMsRef = useRef(null) - const settleHardDeadlineMsRef = useRef(null) - const settleAnimationFrameRef = useRef(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(null) - const reanchorAnimationFrameRef = useRef(null) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -228,174 +186,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - 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, @@ -424,14 +214,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - isMountedRef.current = true - return () => { - isMountedRef.current = false - cancelReanchorFrame() - } - }, [cancelReanchorFrame]) - const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], @@ -697,64 +479,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - 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 - setCurrentFollowUpTs(null) // Clear follow-up answered state for new task - setIsCondensing(false) // Reset condensing state when switching tasks - // Note: sendingDisabled is not reset here as it's managed by message effects + everVisibleMessagesTsRef.current.clear() + setCurrentFollowUpTs(null) + setIsCondensing(false) - // Clear any pending auto-approval timeout from previous task if (autoApproveTimeoutRef.current) { clearTimeout(autoApproveTimeoutRef.current) autoApproveTimeoutRef.current = null } - // Reset user response flag for new task userRespondedRef.current = false - - // Ensure new task starts in deterministic hydration mode, pinned to - // the bottom until the message tail has settled. - if (task?.ts) { - 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 () => { - cancelInitialSettleFrame() - cancelReanchorFrame() - settleTaskTsRef.current = null - settleDeadlineMsRef.current = null - settleHardDeadlineMsRef.current = null - isSettlingRef.current = false - settleBottomConfirmedRef.current = false - } - }, [ - beginHydrationPinnedToBottom, - cancelInitialSettleFrame, - cancelReanchorFrame, - startInitialSettle, - task?.ts, - transitionScrollPhase, - ]) + }, [task?.ts]) const taskTs = task?.ts @@ -781,28 +519,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const prev = prevExpandedRowsRef.current - let wasAnyRowExpandedByUser = false - if (prev) { - // Check if any row transitioned from false/undefined to true - for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { - const ts = Number(tsKey) - if (isExpanded && !(prev[ts] ?? false)) { - wasAnyRowExpandedByUser = true - break - } - } - } - - // Expanding a row indicates the user is browsing; disable sticky follow - if (wasAnyRowExpandedByUser) { - enterUserBrowsingHistory("row-expansion") - } - - prevExpandedRowsRef.current = expandedRows // Store current state for next comparison - }, [enterUserBrowsingHistory, expandedRows]) - const isStreaming = useMemo(() => { // Checking clineAsk isn't enough since messages effect may be called // again for a tool for example, set clineAsk to its value, and if the @@ -1544,49 +1260,49 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const previousLength = groupedMessagesLengthRef.current - groupedMessagesLengthRef.current = groupedMessages.length - - if (previousLength === groupedMessages.length) { - return - } - - settleMutationVersionRef.current += 1 + // Scroll lifecycle is managed by a dedicated hook to keep ChatView focused + // on message handling and UI orchestration. + const { + showScrollToBottom, + handleRowHeightChange, + handleScrollToBottomClick, + enterUserBrowsingHistory, + followOutputCallback, + atBottomStateChangeCallback, + scrollToBottomAuto, + isAtBottomRef, + scrollPhaseRef, + } = useScrollLifecycle({ + virtuosoRef, + scrollContainerRef, + taskTs: task?.ts, + isStreaming, + isHidden, + hasTask: !!task, + groupedMessagesLength: groupedMessages.length, + }) - const settleTaskTs = settleTaskTsRef.current - if (settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) { - startInitialSettle(settleTaskTs) + // Expanding a row indicates the user is browsing; disable sticky follow. + // Placed after the hook call so enterUserBrowsingHistory is defined. + useEffect(() => { + const prev = prevExpandedRowsRef.current + let wasAnyRowExpandedByUser = false + if (prev) { + for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { + const ts = Number(tsKey) + if (isExpanded && !(prev[ts] ?? false)) { + wasAnyRowExpandedByUser = true + break + } + } } - }, [groupedMessages.length, extendInitialSettleWindow, startInitialSettle]) - - // scrolling - - const scrollToBottomSmooth = useMemo( - () => - debounce( - () => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }), - 10, - { - immediate: true, - }, - ), - [], - ) - useEffect(() => { - return () => { - scrollToBottomSmooth.clear() + if (wasAnyRowExpandedByUser) { + enterUserBrowsingHistory("row-expansion") } - }, [scrollToBottomSmooth]) - const scrollToBottomAuto = useCallback(() => { - virtuosoRef.current?.scrollToIndex({ - index: "LAST", - align: "end", - behavior: "auto", // Instant causes crash. - }) - }, []) + prevExpandedRowsRef.current = expandedRows + }, [enterUserBrowsingHistory, expandedRows]) const handleSetExpandedRow = useCallback( (ts: number, expand?: boolean) => { @@ -1608,111 +1324,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - 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 { - 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)) { - 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) { @@ -1857,51 +1468,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Check for Command/Ctrl + Period (with or without Shift) - // Using event.key to respect keyboard layouts (e.g., Dvorak) if ((event.metaKey || event.ctrlKey) && event.key === ".") { - event.preventDefault() // Prevent default browser behavior - + event.preventDefault() if (event.shiftKey) { - // Shift + Period = Previous mode switchToPreviousMode() } else { - // 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") } }, - [enterUserBrowsingHistory, isHidden, switchToNextMode, switchToPreviousMode, task], + [switchToNextMode, switchToPreviousMode], ) useEffect(() => { @@ -2049,44 +1629,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction (scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto")} - atBottomStateChange={(isAtBottom: boolean) => { - isAtBottomRef.current = isAtBottom - - 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") - }} + followOutput={followOutputCallback} + atBottomStateChange={atBottomStateChangeCallback} atBottomThreshold={10} initialTopMostItemIndex={groupedMessages.length - 1} /> diff --git a/webview-ui/src/hooks/useScrollLifecycle.ts b/webview-ui/src/hooks/useScrollLifecycle.ts new file mode 100644 index 00000000000..a23b8e65ab3 --- /dev/null +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -0,0 +1,672 @@ +/** + * useScrollLifecycle + * + * Encapsulates the chat scroll lifecycle extracted from ChatView, making the + * scroll logic testable in isolation and preventing it from growing the + * component further. + * + * ## Scroll Phase Model + * + * 1. **HYDRATING_PINNED_TO_BOTTOM** – Active during task switch / rehydration. + * A settle loop repeatedly calls `scrollToIndex` until Virtuoso confirms + * the viewport is at the bottom for a stable run of consecutive frames. + * Transient `atBottomStateChange(false)` signals from Virtuoso layout + * reflows are suppressed during this phase. + * + * 2. **ANCHORED_FOLLOWING** – The user is at the bottom and new content should + * be followed automatically. `followOutput` returns `"auto"`. + * + * 3. **USER_BROWSING_HISTORY** – The user scrolled away from the bottom (via + * wheel, keyboard, pointer drag, or row expansion). `followOutput` returns + * `false` and the scroll-to-bottom CTA appears. + * + * ## Note on scrollToIndex vs scrollTo + * + * This implementation uses `scrollToIndex({ index: "LAST", align: "end" })` + * rather than `scrollTo({ top: Number.MAX_SAFE_INTEGER })`. + * + * PR #6780 removed `scrollToIndex` because it caused scroll jitter. However, + * that issue was triggered by passing a *numeric* index that could become + * stale mid-render. The `"LAST"` constant is a Virtuoso built-in that + * resolves to the current last item at call time, avoiding the stale-index + * problem. Using `"LAST"` with `align: "end"` provides deterministic + * bottom-anchoring without the `MAX_SAFE_INTEGER` overshooting that + * `scrollTo` relied on. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useEvent } from "react-use" +import debounce from "debounce" +import type { VirtuosoHandle } from "react-virtuoso" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Soft deadline: resets on every new mutation observed during settle. */ +const INITIAL_LOAD_SETTLE_TIMEOUT_MS = 2500 + +/** + * Hard deadline: absolute upper bound for the settle window. + * + * Reduced from the original 10 s to 5 s. If rehydration takes longer than + * this, there is likely a rendering performance issue worth investigating + * separately rather than accommodating with a longer timeout. + */ +const INITIAL_LOAD_SETTLE_HARD_CAP_MS = 5000 + +/** Number of consecutive "at-bottom + stable tail" frames before we accept convergence. */ +const INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET = 3 + +/** Frame-count safety valve derived from the hard cap at ~60 fps. */ +const INITIAL_LOAD_SETTLE_MAX_FRAMES = Math.ceil(INITIAL_LOAD_SETTLE_HARD_CAP_MS / (1000 / 60)) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ScrollPhase = "HYDRATING_PINNED_TO_BOTTOM" | "ANCHORED_FOLLOWING" | "USER_BROWSING_HISTORY" + +export type ScrollFollowDisengageSource = "wheel-up" | "row-expansion" | "keyboard-nav-up" | "pointer-scroll-up" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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" +} + +// --------------------------------------------------------------------------- +// Hook interface +// --------------------------------------------------------------------------- + +export interface UseScrollLifecycleOptions { + virtuosoRef: React.RefObject + scrollContainerRef: React.RefObject + taskTs: number | undefined + isStreaming: boolean + isHidden: boolean + hasTask: boolean + groupedMessagesLength: number +} + +export interface UseScrollLifecycleReturn { + scrollPhase: ScrollPhase + showScrollToBottom: boolean + handleRowHeightChange: (isTaller: boolean) => void + handleScrollToBottomClick: () => void + enterUserBrowsingHistory: (source: ScrollFollowDisengageSource) => void + followOutputCallback: () => "auto" | false + atBottomStateChangeCallback: (isAtBottom: boolean) => void + scrollToBottomAuto: () => void + isAtBottomRef: React.MutableRefObject + scrollPhaseRef: React.MutableRefObject +} + +// --------------------------------------------------------------------------- +// Hook implementation +// --------------------------------------------------------------------------- + +export function useScrollLifecycle({ + virtuosoRef, + scrollContainerRef, + taskTs, + isStreaming, + isHidden, + hasTask, + groupedMessagesLength, +}: UseScrollLifecycleOptions): UseScrollLifecycleReturn { + // --- Mounted guard --- + const isMountedRef = useRef(true) + + // --- Phase state --- + const [scrollPhase, setScrollPhase] = useState("USER_BROWSING_HISTORY") + const scrollPhaseRef = useRef("USER_BROWSING_HISTORY") + + // --- Visibility state --- + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + + // --- Bottom detection --- + const isAtBottomRef = useRef(false) + + // --- Settle lifecycle refs --- + const isSettlingRef = useRef(false) + const settleTaskTsRef = useRef(null) + const settleDeadlineMsRef = useRef(null) + const settleHardDeadlineMsRef = useRef(null) + const settleAnimationFrameRef = useRef(null) + const settleStableFramesRef = useRef(0) + const settleFrameCountRef = useRef(0) + const settleBottomConfirmedRef = useRef(false) + const settleMutationVersionRef = useRef(0) + const settleObservedMutationVersionRef = useRef(0) + + // --- Mutation tracking --- + const groupedMessagesLengthRef = useRef(0) + + // --- Pointer scroll tracking --- + const pointerScrollActiveRef = useRef(false) + const pointerScrollLastTopRef = useRef(null) + + // --- Re-anchor frame --- + const reanchorAnimationFrameRef = useRef(null) + + // ----------------------------------------------------------------------- + // Phase transitions + // ----------------------------------------------------------------------- + + 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") + // Always show the scroll-to-bottom CTA when the user explicitly + // disengages. If they happen to still be at the physical bottom, + // the next Virtuoso atBottomStateChange(true) will hide it. + setShowScrollToBottom(true) + }, + [transitionScrollPhase], + ) + + // ----------------------------------------------------------------------- + // Settle window management + // ----------------------------------------------------------------------- + + const isSettleWindowOpen = useCallback((ts: number): boolean => { + if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { + return false + } + if (settleTaskTsRef.current !== ts) { + 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((ts: number): boolean => { + if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { + return false + } + if (settleTaskTsRef.current !== ts) { + 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 + }, []) + + // ----------------------------------------------------------------------- + // Animation frame management + // ----------------------------------------------------------------------- + + 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 + } + }, []) + + // ----------------------------------------------------------------------- + // Settle completion + // ----------------------------------------------------------------------- + + const completeInitialSettle = useCallback(() => { + cancelInitialSettleFrame() + isSettlingRef.current = false + if (isAtBottomRef.current && settleBottomConfirmedRef.current) { + enterAnchoredFollowing() + return + } + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(true) + }, [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase]) + + // ----------------------------------------------------------------------- + // Settle frame loop + // ----------------------------------------------------------------------- + + const runInitialSettleFrame = useCallback( + (ts: number) => { + if (!isMountedRef.current) { + return + } + if (!isSettlingRef.current || settleTaskTsRef.current !== ts) { + return + } + + settleFrameCountRef.current += 1 + if (settleFrameCountRef.current > INITIAL_LOAD_SETTLE_MAX_FRAMES) { + completeInitialSettle() + return + } + + if (!isSettleWindowOpen(ts)) { + 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(ts)) + }, + [completeInitialSettle, isSettleWindowOpen, virtuosoRef], + ) + + // ----------------------------------------------------------------------- + // Start settle + // ----------------------------------------------------------------------- + + const startInitialSettle = useCallback( + (ts: number) => { + if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { + return + } + if (!isSettleWindowOpen(ts)) { + return + } + if (isSettlingRef.current && settleTaskTsRef.current === ts) { + return + } + + cancelInitialSettleFrame() + settleTaskTsRef.current = ts + isSettlingRef.current = true + settleStableFramesRef.current = 0 + settleFrameCountRef.current = 0 + settleObservedMutationVersionRef.current = settleMutationVersionRef.current + settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(ts)) + }, + [cancelInitialSettleFrame, isSettleWindowOpen, runInitialSettleFrame], + ) + + // ----------------------------------------------------------------------- + // Scroll commands + // ----------------------------------------------------------------------- + + const scrollToBottomSmooth = useMemo( + () => + debounce( + () => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }), + 10, + { immediate: true }, + ), + [virtuosoRef], + ) + + const scrollToBottomAuto = useCallback(() => { + virtuosoRef.current?.scrollToIndex({ + index: "LAST", + align: "end", + behavior: "auto", + }) + }, [virtuosoRef]) + + // ----------------------------------------------------------------------- + // Lifecycle effects + // ----------------------------------------------------------------------- + + // Mounted guard + global cleanup + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + cancelReanchorFrame() + scrollToBottomSmooth.clear() + } + }, [cancelReanchorFrame, scrollToBottomSmooth]) + + // Keep phase ref in sync with state + useEffect(() => { + scrollPhaseRef.current = scrollPhase + }, [scrollPhase]) + + // Task switch: reset settle state and begin hydration + 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 = taskTs ?? null + settleDeadlineMsRef.current = null + settleHardDeadlineMsRef.current = null + + if (taskTs) { + beginHydrationPinnedToBottom() + isSettlingRef.current = false + settleDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS + settleHardDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS + startInitialSettle(taskTs) + } else { + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(false) + } + + return () => { + cancelInitialSettleFrame() + cancelReanchorFrame() + settleTaskTsRef.current = null + settleDeadlineMsRef.current = null + settleHardDeadlineMsRef.current = null + isSettlingRef.current = false + settleBottomConfirmedRef.current = false + } + }, [ + beginHydrationPinnedToBottom, + cancelInitialSettleFrame, + cancelReanchorFrame, + startInitialSettle, + taskTs, + transitionScrollPhase, + ]) + + // Grouped messages mutation tracking + useEffect(() => { + const previousLength = groupedMessagesLengthRef.current + groupedMessagesLengthRef.current = groupedMessagesLength + + if (previousLength === groupedMessagesLength) { + return + } + + settleMutationVersionRef.current += 1 + + const settleTaskTs = settleTaskTsRef.current + if (settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) { + startInitialSettle(settleTaskTs) + } + }, [groupedMessagesLength, extendInitialSettleWindow, startInitialSettle]) + + // ----------------------------------------------------------------------- + // Row height change handler + // ----------------------------------------------------------------------- + + const handleRowHeightChange = useCallback( + (isTaller: boolean) => { + 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 { + scrollToBottomAuto() + } + } + }, + [extendInitialSettleWindow, isStreaming, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle], + ) + + // ----------------------------------------------------------------------- + // Scroll-to-bottom click handler + // ----------------------------------------------------------------------- + + const handleScrollToBottomClick = useCallback(() => { + enterAnchoredFollowing() + scrollToBottomAuto() + cancelReanchorFrame() + reanchorAnimationFrameRef.current = requestAnimationFrame(() => { + reanchorAnimationFrameRef.current = null + if (scrollPhaseRef.current === "ANCHORED_FOLLOWING") { + scrollToBottomAuto() + } + }) + }, [cancelReanchorFrame, enterAnchoredFollowing, scrollToBottomAuto]) + + // ----------------------------------------------------------------------- + // Virtuoso callback: followOutput + // ----------------------------------------------------------------------- + + const followOutputCallback = useCallback((): "auto" | false => { + return scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto" + }, [scrollPhase]) + + // ----------------------------------------------------------------------- + // Virtuoso callback: atBottomStateChange + // ----------------------------------------------------------------------- + + const atBottomStateChangeCallback = useCallback( + (isAtBottom: boolean) => { + isAtBottomRef.current = isAtBottom + + 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") + }, + [enterAnchoredFollowing, enterUserBrowsingHistory, isStreaming, scrollToBottomAuto], + ) + + // ----------------------------------------------------------------------- + // User intent: wheel + // ----------------------------------------------------------------------- + + 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, scrollContainerRef], + ) + useEvent("wheel", handleWheel, window, { passive: true }) + + // ----------------------------------------------------------------------- + // User intent: pointer drag + // ----------------------------------------------------------------------- + + 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 + }, + [scrollContainerRef], + ) + + 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, scrollContainerRef], + ) + + 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 }) + + // ----------------------------------------------------------------------- + // User intent: keyboard navigation + // ----------------------------------------------------------------------- + + const handleScrollKeyDown = useCallback( + (event: Event) => { + const keyEvent = event as KeyboardEvent + + if (!hasTask || isHidden) { + return + } + + if (keyEvent.metaKey || keyEvent.ctrlKey || keyEvent.altKey) { + return + } + + if (keyEvent.key !== "PageUp" && keyEvent.key !== "Home" && keyEvent.key !== "ArrowUp") { + return + } + + if (isEditableKeyboardTarget(keyEvent.target)) { + return + } + + const activeElement = document.activeElement + const focusInsideChat = + activeElement instanceof HTMLElement && !!scrollContainerRef.current?.contains(activeElement) + const eventTargetInsideChat = + keyEvent.target instanceof Node && !!scrollContainerRef.current?.contains(keyEvent.target) + + if (focusInsideChat || eventTargetInsideChat || activeElement === document.body) { + enterUserBrowsingHistory("keyboard-nav-up") + } + }, + [enterUserBrowsingHistory, hasTask, isHidden, scrollContainerRef], + ) + useEvent("keydown", handleScrollKeyDown, window) + + // ----------------------------------------------------------------------- + // Return public API + // ----------------------------------------------------------------------- + + return { + scrollPhase, + showScrollToBottom, + handleRowHeightChange, + handleScrollToBottomClick, + enterUserBrowsingHistory, + followOutputCallback, + atBottomStateChangeCallback, + scrollToBottomAuto, + isAtBottomRef, + scrollPhaseRef, + } +} From 4f118c7ae08ae5bd2bc14e61315157807ac43422 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 16 Feb 2026 14:27:18 -0700 Subject: [PATCH 6/9] fix(chat): harden scroll lifecycle pointer intent and settle phase fallback --- .../ChatView.scroll-debug-repro.spec.tsx | 55 +++++++++++++++++++ webview-ui/src/hooks/useScrollLifecycle.ts | 51 ++++++++++++----- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index 0810e417d73..d3b4ddad1c4 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -46,6 +46,7 @@ interface VirtuosoHarnessState { signalDelayMs: number emitFalseOnDataChange: boolean followOutput: FollowOutput | undefined + emitAtBottom: (isAtBottom: boolean) => void } const harness = vi.hoisted(() => ({ @@ -54,6 +55,7 @@ const harness = vi.hoisted(() => ({ signalDelayMs: 20, emitFalseOnDataChange: true, followOutput: undefined, + emitAtBottom: () => {}, })) function nullDefaultModule() { @@ -139,6 +141,9 @@ vi.mock("react-virtuoso", () => { const timeoutIdsRef = useRef([]) harness.followOutput = followOutput + harness.emitAtBottom = (isAtBottom: boolean) => { + atBottomRef.current?.(isAtBottom) + } useImperativeHandle(ref, () => ({ scrollToIndex: () => { @@ -280,6 +285,7 @@ describe("ChatView scroll behavior regression coverage", () => { harness.signalDelayMs = 20 harness.emitFalseOnDataChange = true harness.followOutput = undefined + harness.emitAtBottom = () => {} }) it("rehydration converges to bottom", async () => { @@ -337,6 +343,31 @@ describe("ChatView scroll behavior regression coverage", () => { expect(resolveFollowOutput(false)).toBe(false) }) + it("nested scroller scroll events do not falsely disengage sticky follow", async () => { + await hydrate(4) + await waitForCalls(4) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + const nestedScrollable = document.createElement("div") + nestedScrollable.style.overflowY = "auto" + nestedScrollable.scrollTop = 0 + scrollable.appendChild(nestedScrollable) + + scrollable.scrollTop = 240 + + await act(async () => { + fireEvent.pointerDown(nestedScrollable) + nestedScrollable.scrollTop = 120 + fireEvent.scroll(nestedScrollable) + fireEvent.pointerUp(window) + }) + + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + it("wheel-up intent disengages sticky follow", async () => { await hydrate(4) await waitForCalls(4) @@ -355,6 +386,30 @@ describe("ChatView scroll behavior regression coverage", () => { }) }) + it("late settle completion cannot override user escape hatch", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(2, 1_200) + + await act(async () => { + harness.emitAtBottom(true) + }) + + expect(resolveFollowOutput(false)).toBe("auto") + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await sleep(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) diff --git a/webview-ui/src/hooks/useScrollLifecycle.ts b/webview-ui/src/hooks/useScrollLifecycle.ts index a23b8e65ab3..532e48b8502 100644 --- a/webview-ui/src/hooks/useScrollLifecycle.ts +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -154,6 +154,7 @@ export function useScrollLifecycle({ // --- Pointer scroll tracking --- const pointerScrollActiveRef = useRef(false) + const pointerScrollElementRef = useRef(null) const pointerScrollLastTopRef = useRef(null) // --- Re-anchor frame --- @@ -256,16 +257,28 @@ export function useScrollLifecycle({ // Settle completion // ----------------------------------------------------------------------- - const completeInitialSettle = useCallback(() => { - cancelInitialSettleFrame() - isSettlingRef.current = false - if (isAtBottomRef.current && settleBottomConfirmedRef.current) { - enterAnchoredFollowing() - return - } - transitionScrollPhase("USER_BROWSING_HISTORY") - setShowScrollToBottom(true) - }, [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase]) + const completeInitialSettle = useCallback( + (ts: number) => { + if (settleTaskTsRef.current !== ts) { + return + } + + cancelInitialSettleFrame() + isSettlingRef.current = false + + if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { + return + } + + if (isAtBottomRef.current && settleBottomConfirmedRef.current) { + enterAnchoredFollowing() + return + } + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(true) + }, + [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase], + ) // ----------------------------------------------------------------------- // Settle frame loop @@ -282,12 +295,12 @@ export function useScrollLifecycle({ settleFrameCountRef.current += 1 if (settleFrameCountRef.current > INITIAL_LOAD_SETTLE_MAX_FRAMES) { - completeInitialSettle() + completeInitialSettle(ts) return } if (!isSettleWindowOpen(ts)) { - completeInitialSettle() + completeInitialSettle(ts) return } @@ -304,7 +317,7 @@ export function useScrollLifecycle({ virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" }) if (settleStableFramesRef.current >= INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET) { - completeInitialSettle() + completeInitialSettle(ts) return } @@ -559,12 +572,14 @@ export function useScrollLifecycle({ const pointerTarget = pointerEvent.target if (!(pointerTarget instanceof HTMLElement)) { pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null pointerScrollLastTopRef.current = null return } if (!scrollContainerRef.current?.contains(pointerTarget)) { pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null pointerScrollLastTopRef.current = null return } @@ -573,14 +588,16 @@ export function useScrollLifecycle({ (pointerTarget.closest(".scrollable") as HTMLElement | null) ?? (pointerTarget.scrollHeight > pointerTarget.clientHeight ? pointerTarget : null) - pointerScrollActiveRef.current = true - pointerScrollLastTopRef.current = scroller?.scrollTop ?? 0 + pointerScrollActiveRef.current = scroller !== null + pointerScrollElementRef.current = scroller + pointerScrollLastTopRef.current = scroller?.scrollTop ?? null }, [scrollContainerRef], ) const handlePointerEnd = useCallback(() => { pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null pointerScrollLastTopRef.current = null }, []) @@ -599,6 +616,10 @@ export function useScrollLifecycle({ return } + if (pointerScrollElementRef.current !== scrollTarget) { + return + } + const previousTop = pointerScrollLastTopRef.current const currentTop = scrollTarget.scrollTop pointerScrollLastTopRef.current = currentTop From a29ac1fb471b7bfb804c948c942cbba2aaf2d9e0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 16 Feb 2026 17:27:10 -0700 Subject: [PATCH 7/9] test(chat): stabilize ChatView scroll debug repro flake --- .../ChatView.scroll-debug-repro.spec.tsx | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index d3b4ddad1c4..2f830c04fdf 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -225,7 +225,11 @@ const postState = (clineMessages: ClineMessage[]) => { }, } - window.postMessage(message, "*") + window.dispatchEvent( + new MessageEvent("message", { + data: message, + }), + ) } const renderView = () => @@ -240,20 +244,42 @@ const renderView = () => const hydrate = async (atBottomAfterCalls: number) => { harness.atBottomAfterCalls = atBottomAfterCalls renderView() + await act(async () => { + await Promise.resolve() + }) await act(async () => { postState(buildMessages(Date.now() - 3_000)) }) + await waitFor(() => { + const list = document.querySelector("[data-testid='virtuoso-item-list']") + expect(list).toBeTruthy() + expect(list?.getAttribute("data-count")).toBe("2") + }) } 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 waitForCallsSettled = async (idleMs = 80, timeoutMs = 2_000) => { + const deadline = Date.now() + timeoutMs + let lastSeen = harness.scrollCalls + + while (Date.now() < deadline) { + await sleep(idleMs) + const current = harness.scrollCalls + + if (current === lastSeen) { + await sleep(idleMs) + if (harness.scrollCalls === current) { + return + } + } + + lastSeen = current + } + + throw new Error(`Expected scroll calls to settle within ${timeoutMs}ms, last count: ${harness.scrollCalls}`) } const getScrollable = (): HTMLElement => { @@ -291,7 +317,7 @@ describe("ChatView scroll behavior regression coverage", () => { it("rehydration converges to bottom", async () => { await hydrate(6) await waitForCalls(6, 2_000) - await expectCallsStable() + await waitForCallsSettled() expect(document.querySelector(".codicon-chevron-down")).toBeNull() }) @@ -302,7 +328,7 @@ describe("ChatView scroll behavior regression coverage", () => { expect(document.querySelector(".codicon-chevron-down")).toBeNull() await waitForCalls(8, 2_000) - await expectCallsStable() + await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") }) @@ -327,7 +353,7 @@ describe("ChatView scroll behavior regression coverage", () => { it("non-wheel upward intent disengages sticky follow", async () => { await hydrate(4) await waitForCalls(4) - await expectCallsStable() + await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") const scrollable = getScrollable() @@ -346,7 +372,7 @@ describe("ChatView scroll behavior regression coverage", () => { it("nested scroller scroll events do not falsely disengage sticky follow", async () => { await hydrate(4) await waitForCalls(4) - await expectCallsStable() + await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") const scrollable = getScrollable() @@ -371,7 +397,7 @@ describe("ChatView scroll behavior regression coverage", () => { it("wheel-up intent disengages sticky follow", async () => { await hydrate(4) await waitForCalls(4) - await expectCallsStable() + await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") const scrollable = getScrollable() @@ -413,7 +439,7 @@ describe("ChatView scroll behavior regression coverage", () => { it("scroll-to-bottom CTA re-anchors with one interaction", async () => { await hydrate(4) await waitForCalls(4) - await expectCallsStable() + await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") await act(async () => { From f78fe81ced75d6f6d596d564cb07b582b0c94dd2 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 17 Feb 2026 11:01:23 -0700 Subject: [PATCH 8/9] refactor(chat): simplify hydration scroll lifecycle --- webview-ui/src/components/chat/ChatView.tsx | 1 - .../ChatView.scroll-debug-repro.spec.tsx | 67 +-- webview-ui/src/hooks/useScrollLifecycle.ts | 394 +++++------------- 3 files changed, 139 insertions(+), 323 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c54245cd06c..9da275634ad 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1279,7 +1279,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { harness.emitAtBottom = () => {} }) - it("rehydration converges to bottom", async () => { - await hydrate(6) - await waitForCalls(6, 2_000) + it("rehydration uses bounded bottom pinning", async () => { + await hydrate(2) + await waitForCalls(2, 1_200) await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") 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) + it("transient hydration-time not-at-bottom signals do not disable sticky follow", async () => { + await hydrate(2) + await waitForCalls(1, 1_200) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + + await act(async () => { + harness.emitAtBottom(false) + }) + expect(resolveFollowOutput(false)).toBe("auto") expect(document.querySelector(".codicon-chevron-down")).toBeNull() - await waitForCalls(8, 2_000) + await waitForCalls(2, 1_200) await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) expect(resolveFollowOutput(false)).toBe("auto") }) - it("user escape hatch during settle stops forced follow", async () => { + it("user escape hatch during hydration prevents repinning", async () => { await hydrate(Number.POSITIVE_INFINITY) - await waitForCalls(3, 1_200) + await waitForCalls(1, 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 act(async () => { + harness.emitAtBottom(true) + }) + + expect(resolveFollowOutput(false)).toBe(false) await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { timeout: 1_200, @@ -351,8 +364,8 @@ describe("ChatView scroll behavior regression coverage", () => { }) it("non-wheel upward intent disengages sticky follow", async () => { - await hydrate(4) - await waitForCalls(4) + await hydrate(2) + await waitForCalls(2) await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") @@ -370,8 +383,8 @@ describe("ChatView scroll behavior regression coverage", () => { }) it("nested scroller scroll events do not falsely disengage sticky follow", async () => { - await hydrate(4) - await waitForCalls(4) + await hydrate(2) + await waitForCalls(2) await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") @@ -395,8 +408,8 @@ describe("ChatView scroll behavior regression coverage", () => { }) it("wheel-up intent disengages sticky follow", async () => { - await hydrate(4) - await waitForCalls(4) + await hydrate(2) + await waitForCalls(2) await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") @@ -412,15 +425,9 @@ describe("ChatView scroll behavior regression coverage", () => { }) }) - it("late settle completion cannot override user escape hatch", async () => { + it("hydration completion cannot override user escape hatch", async () => { await hydrate(Number.POSITIVE_INFINITY) - await waitForCalls(2, 1_200) - - await act(async () => { - harness.emitAtBottom(true) - }) - - expect(resolveFollowOutput(false)).toBe("auto") + await waitForCalls(1, 1_200) await act(async () => { fireEvent.keyDown(window, { key: "PageUp" }) @@ -428,7 +435,7 @@ describe("ChatView scroll behavior regression coverage", () => { expect(resolveFollowOutput(false)).toBe(false) - await sleep(120) + await sleep(700) expect(resolveFollowOutput(false)).toBe(false) await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { @@ -437,8 +444,8 @@ describe("ChatView scroll behavior regression coverage", () => { }) it("scroll-to-bottom CTA re-anchors with one interaction", async () => { - await hydrate(4) - await waitForCalls(4) + await hydrate(2) + await waitForCalls(2) await waitForCallsSettled() expect(resolveFollowOutput(false)).toBe("auto") @@ -459,7 +466,7 @@ describe("ChatView scroll behavior regression coverage", () => { }) expect(resolveFollowOutput(false)).toBe("auto") - await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(callsBeforeClick + 2), { + await waitFor(() => expect(harness.scrollCalls).toBe(callsBeforeClick + 2), { timeout: 1_200, }) await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 }) diff --git a/webview-ui/src/hooks/useScrollLifecycle.ts b/webview-ui/src/hooks/useScrollLifecycle.ts index 532e48b8502..16a302b7861 100644 --- a/webview-ui/src/hooks/useScrollLifecycle.ts +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -1,37 +1,14 @@ /** * useScrollLifecycle * - * Encapsulates the chat scroll lifecycle extracted from ChatView, making the - * scroll logic testable in isolation and preventing it from growing the - * component further. + * Simplified chat scroll lifecycle with a short, time-boxed hydration window. * - * ## Scroll Phase Model - * - * 1. **HYDRATING_PINNED_TO_BOTTOM** – Active during task switch / rehydration. - * A settle loop repeatedly calls `scrollToIndex` until Virtuoso confirms - * the viewport is at the bottom for a stable run of consecutive frames. - * Transient `atBottomStateChange(false)` signals from Virtuoso layout - * reflows are suppressed during this phase. - * - * 2. **ANCHORED_FOLLOWING** – The user is at the bottom and new content should - * be followed automatically. `followOutput` returns `"auto"`. - * - * 3. **USER_BROWSING_HISTORY** – The user scrolled away from the bottom (via - * wheel, keyboard, pointer drag, or row expansion). `followOutput` returns - * `false` and the scroll-to-bottom CTA appears. - * - * ## Note on scrollToIndex vs scrollTo - * - * This implementation uses `scrollToIndex({ index: "LAST", align: "end" })` - * rather than `scrollTo({ top: Number.MAX_SAFE_INTEGER })`. - * - * PR #6780 removed `scrollToIndex` because it caused scroll jitter. However, - * that issue was triggered by passing a *numeric* index that could become - * stale mid-render. The `"LAST"` constant is a Virtuoso built-in that - * resolves to the current last item at call time, avoiding the stale-index - * problem. Using `"LAST"` with `align: "end"` provides deterministic - * bottom-anchoring without the `MAX_SAFE_INTEGER` overshooting that - * `scrollTo` relied on. + * - Task switch enters `HYDRATING_PINNED_TO_BOTTOM` + * - We issue one immediate `scrollToIndex("LAST")` and one post-render retry + * - During hydration, transient Virtuoso `atBottomStateChange(false)` signals + * are ignored so follow mode does not flicker off + * - User escape intent (wheel / keyboard / pointer-upward drag / row expansion) + * moves to `USER_BROWSING_HISTORY` and prevents forced re-pinning */ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -39,27 +16,7 @@ import { useEvent } from "react-use" import debounce from "debounce" import type { VirtuosoHandle } from "react-virtuoso" -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Soft deadline: resets on every new mutation observed during settle. */ -const INITIAL_LOAD_SETTLE_TIMEOUT_MS = 2500 - -/** - * Hard deadline: absolute upper bound for the settle window. - * - * Reduced from the original 10 s to 5 s. If rehydration takes longer than - * this, there is likely a rendering performance issue worth investigating - * separately rather than accommodating with a longer timeout. - */ -const INITIAL_LOAD_SETTLE_HARD_CAP_MS = 5000 - -/** Number of consecutive "at-bottom + stable tail" frames before we accept convergence. */ -const INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET = 3 - -/** Frame-count safety valve derived from the hard cap at ~60 fps. */ -const INITIAL_LOAD_SETTLE_MAX_FRAMES = Math.ceil(INITIAL_LOAD_SETTLE_HARD_CAP_MS / (1000 / 60)) +const HYDRATION_WINDOW_MS = 600 // --------------------------------------------------------------------------- // Types @@ -95,7 +52,6 @@ export interface UseScrollLifecycleOptions { isStreaming: boolean isHidden: boolean hasTask: boolean - groupedMessagesLength: number } export interface UseScrollLifecycleReturn { @@ -122,7 +78,6 @@ export function useScrollLifecycle({ isStreaming, isHidden, hasTask, - groupedMessagesLength, }: UseScrollLifecycleOptions): UseScrollLifecycleReturn { // --- Mounted guard --- const isMountedRef = useRef(true) @@ -137,20 +92,10 @@ export function useScrollLifecycle({ // --- Bottom detection --- const isAtBottomRef = useRef(false) - // --- Settle lifecycle refs --- - const isSettlingRef = useRef(false) - const settleTaskTsRef = useRef(null) - const settleDeadlineMsRef = useRef(null) - const settleHardDeadlineMsRef = useRef(null) - const settleAnimationFrameRef = useRef(null) - const settleStableFramesRef = useRef(0) - const settleFrameCountRef = useRef(0) - const settleBottomConfirmedRef = useRef(false) - const settleMutationVersionRef = useRef(0) - const settleObservedMutationVersionRef = useRef(0) - - // --- Mutation tracking --- - const groupedMessagesLengthRef = useRef(0) + // --- Hydration window --- + const isHydratingRef = useRef(false) + const hydrationTimeoutRef = useRef(null) + const hydrationRetryAnimationFrameRef = useRef(null) // --- Pointer scroll tracking --- const pointerScrollActiveRef = useRef(false) @@ -172,14 +117,6 @@ export function useScrollLifecycle({ 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) @@ -187,6 +124,10 @@ export function useScrollLifecycle({ const enterUserBrowsingHistory = useCallback( (_source: ScrollFollowDisengageSource) => { + if (hydrationRetryAnimationFrameRef.current !== null) { + cancelAnimationFrame(hydrationRetryAnimationFrameRef.current) + hydrationRetryAnimationFrameRef.current = null + } transitionScrollPhase("USER_BROWSING_HISTORY") // Always show the scroll-to-bottom CTA when the user explicitly // disengages. If they happen to still be at the physical bottom, @@ -196,56 +137,6 @@ export function useScrollLifecycle({ [transitionScrollPhase], ) - // ----------------------------------------------------------------------- - // Settle window management - // ----------------------------------------------------------------------- - - const isSettleWindowOpen = useCallback((ts: number): boolean => { - if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { - return false - } - if (settleTaskTsRef.current !== ts) { - 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((ts: number): boolean => { - if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { - return false - } - if (settleTaskTsRef.current !== ts) { - 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 - }, []) - - // ----------------------------------------------------------------------- - // Animation frame management - // ----------------------------------------------------------------------- - - const cancelInitialSettleFrame = useCallback(() => { - if (settleAnimationFrameRef.current !== null) { - cancelAnimationFrame(settleAnimationFrameRef.current) - settleAnimationFrameRef.current = null - } - }, []) - const cancelReanchorFrame = useCallback(() => { if (reanchorAnimationFrameRef.current !== null) { cancelAnimationFrame(reanchorAnimationFrameRef.current) @@ -253,106 +144,6 @@ export function useScrollLifecycle({ } }, []) - // ----------------------------------------------------------------------- - // Settle completion - // ----------------------------------------------------------------------- - - const completeInitialSettle = useCallback( - (ts: number) => { - if (settleTaskTsRef.current !== ts) { - return - } - - cancelInitialSettleFrame() - isSettlingRef.current = false - - if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { - return - } - - if (isAtBottomRef.current && settleBottomConfirmedRef.current) { - enterAnchoredFollowing() - return - } - transitionScrollPhase("USER_BROWSING_HISTORY") - setShowScrollToBottom(true) - }, - [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase], - ) - - // ----------------------------------------------------------------------- - // Settle frame loop - // ----------------------------------------------------------------------- - - const runInitialSettleFrame = useCallback( - (ts: number) => { - if (!isMountedRef.current) { - return - } - if (!isSettlingRef.current || settleTaskTsRef.current !== ts) { - return - } - - settleFrameCountRef.current += 1 - if (settleFrameCountRef.current > INITIAL_LOAD_SETTLE_MAX_FRAMES) { - completeInitialSettle(ts) - return - } - - if (!isSettleWindowOpen(ts)) { - completeInitialSettle(ts) - 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(ts) - return - } - - settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(ts)) - }, - [completeInitialSettle, isSettleWindowOpen, virtuosoRef], - ) - - // ----------------------------------------------------------------------- - // Start settle - // ----------------------------------------------------------------------- - - const startInitialSettle = useCallback( - (ts: number) => { - if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") { - return - } - if (!isSettleWindowOpen(ts)) { - return - } - if (isSettlingRef.current && settleTaskTsRef.current === ts) { - return - } - - cancelInitialSettleFrame() - settleTaskTsRef.current = ts - isSettlingRef.current = true - settleStableFramesRef.current = 0 - settleFrameCountRef.current = 0 - settleObservedMutationVersionRef.current = settleMutationVersionRef.current - settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(ts)) - }, - [cancelInitialSettleFrame, isSettleWindowOpen, runInitialSettleFrame], - ) - // ----------------------------------------------------------------------- // Scroll commands // ----------------------------------------------------------------------- @@ -375,6 +166,66 @@ export function useScrollLifecycle({ }) }, [virtuosoRef]) + const clearHydrationRetry = useCallback(() => { + if (hydrationRetryAnimationFrameRef.current !== null) { + cancelAnimationFrame(hydrationRetryAnimationFrameRef.current) + hydrationRetryAnimationFrameRef.current = null + } + }, []) + + const clearHydrationWindow = useCallback(() => { + isHydratingRef.current = false + if (hydrationTimeoutRef.current !== null) { + window.clearTimeout(hydrationTimeoutRef.current) + hydrationTimeoutRef.current = null + } + clearHydrationRetry() + }, [clearHydrationRetry]) + + const finishHydrationWindow = useCallback(() => { + if (!isMountedRef.current || !isHydratingRef.current) { + return + } + + if (scrollPhaseRef.current === "HYDRATING_PINNED_TO_BOTTOM") { + if (isAtBottomRef.current) { + enterAnchoredFollowing() + } else { + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(true) + } + } + + clearHydrationWindow() + }, [clearHydrationWindow, enterAnchoredFollowing, transitionScrollPhase]) + + const scheduleHydrationRetry = useCallback(() => { + clearHydrationRetry() + hydrationRetryAnimationFrameRef.current = requestAnimationFrame(() => { + hydrationRetryAnimationFrameRef.current = null + if (!isMountedRef.current || !isHydratingRef.current) { + return + } + if (scrollPhaseRef.current === "USER_BROWSING_HISTORY") { + return + } + scrollToBottomAuto() + }) + }, [clearHydrationRetry, scrollToBottomAuto]) + + const startHydrationWindow = useCallback(() => { + isHydratingRef.current = true + if (hydrationTimeoutRef.current !== null) { + window.clearTimeout(hydrationTimeoutRef.current) + } + hydrationTimeoutRef.current = window.setTimeout(() => { + finishHydrationWindow() + }, HYDRATION_WINDOW_MS) + + scrollToBottomAuto() + scheduleHydrationRetry() + }, [finishHydrationWindow, scheduleHydrationRetry, scrollToBottomAuto]) + // ----------------------------------------------------------------------- // Lifecycle effects // ----------------------------------------------------------------------- @@ -384,76 +235,37 @@ export function useScrollLifecycle({ isMountedRef.current = true return () => { isMountedRef.current = false + clearHydrationWindow() cancelReanchorFrame() scrollToBottomSmooth.clear() } - }, [cancelReanchorFrame, scrollToBottomSmooth]) + }, [cancelReanchorFrame, clearHydrationWindow, scrollToBottomSmooth]) // Keep phase ref in sync with state useEffect(() => { scrollPhaseRef.current = scrollPhase }, [scrollPhase]) - // Task switch: reset settle state and begin hydration + // Task switch: reset and begin a short hydration window useEffect(() => { - const taskSwitchMs = Date.now() - settleStableFramesRef.current = 0 - settleFrameCountRef.current = 0 - settleBottomConfirmedRef.current = false - settleMutationVersionRef.current = 0 - settleObservedMutationVersionRef.current = 0 isAtBottomRef.current = false - cancelInitialSettleFrame() + clearHydrationWindow() cancelReanchorFrame() - settleTaskTsRef.current = taskTs ?? null - settleDeadlineMsRef.current = null - settleHardDeadlineMsRef.current = null if (taskTs) { - beginHydrationPinnedToBottom() - isSettlingRef.current = false - settleDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS - settleHardDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS - startInitialSettle(taskTs) + transitionScrollPhase("HYDRATING_PINNED_TO_BOTTOM") + setShowScrollToBottom(false) + startHydrationWindow() } else { transitionScrollPhase("USER_BROWSING_HISTORY") setShowScrollToBottom(false) } return () => { - cancelInitialSettleFrame() + clearHydrationWindow() cancelReanchorFrame() - settleTaskTsRef.current = null - settleDeadlineMsRef.current = null - settleHardDeadlineMsRef.current = null - isSettlingRef.current = false - settleBottomConfirmedRef.current = false - } - }, [ - beginHydrationPinnedToBottom, - cancelInitialSettleFrame, - cancelReanchorFrame, - startInitialSettle, - taskTs, - transitionScrollPhase, - ]) - - // Grouped messages mutation tracking - useEffect(() => { - const previousLength = groupedMessagesLengthRef.current - groupedMessagesLengthRef.current = groupedMessagesLength - - if (previousLength === groupedMessagesLength) { - return } - - settleMutationVersionRef.current += 1 - - const settleTaskTs = settleTaskTsRef.current - if (settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) { - startInitialSettle(settleTaskTs) - } - }, [groupedMessagesLength, extendInitialSettleWindow, startInitialSettle]) + }, [cancelReanchorFrame, clearHydrationWindow, startHydrationWindow, taskTs, transitionScrollPhase]) // ----------------------------------------------------------------------- // Row height change handler @@ -461,16 +273,15 @@ export function useScrollLifecycle({ const handleRowHeightChange = useCallback( (isTaller: boolean) => { - settleMutationVersionRef.current += 1 - - const settleTaskTs = settleTaskTsRef.current - if (isTaller && settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) { - startInitialSettle(settleTaskTs) + if ( + scrollPhaseRef.current === "USER_BROWSING_HISTORY" || + scrollPhaseRef.current === "HYDRATING_PINNED_TO_BOTTOM" + ) { + return } - const shouldAutoFollowBottom = scrollPhaseRef.current !== "USER_BROWSING_HISTORY" const shouldForcePinForAnchoredStreaming = scrollPhaseRef.current === "ANCHORED_FOLLOWING" && isStreaming - if ((isAtBottomRef.current || shouldForcePinForAnchoredStreaming) && shouldAutoFollowBottom) { + if (isAtBottomRef.current || shouldForcePinForAnchoredStreaming) { if (isTaller) { scrollToBottomSmooth() } else { @@ -478,7 +289,7 @@ export function useScrollLifecycle({ } } }, - [extendInitialSettleWindow, isStreaming, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle], + [isStreaming, scrollToBottomSmooth, scrollToBottomAuto], ) // ----------------------------------------------------------------------- @@ -515,11 +326,18 @@ export function useScrollLifecycle({ const currentPhase = scrollPhaseRef.current - if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && isAtBottom) { - settleBottomConfirmedRef.current = true + if (!isAtBottom && isHydratingRef.current && currentPhase !== "USER_BROWSING_HISTORY") { + setShowScrollToBottom(false) + return } - if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && !isAtBottom) { + if (isAtBottom) { + if (currentPhase === "USER_BROWSING_HISTORY" && isHydratingRef.current) { + setShowScrollToBottom(true) + return + } + + enterAnchoredFollowing() return } @@ -528,14 +346,6 @@ export function useScrollLifecycle({ return } - if (isAtBottom) { - setShowScrollToBottom(false) - if (currentPhase === "USER_BROWSING_HISTORY") { - enterAnchoredFollowing() - } - return - } - if (currentPhase === "ANCHORED_FOLLOWING" && isStreaming) { scrollToBottomAuto() setShowScrollToBottom(false) From 6b471355889d701f6566d9502a832450ff0dcd5c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 17 Feb 2026 12:24:44 -0700 Subject: [PATCH 9/9] fix(chat): start existing task at latest message bottom --- webview-ui/src/components/chat/ChatView.tsx | 1 - .../ChatView.scroll-debug-repro.spec.tsx | 34 +++++++++++- webview-ui/src/hooks/useScrollLifecycle.ts | 52 +++++++------------ 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 9da275634ad..8fe4e85fb89 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1631,7 +1631,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction
{areButtonsVisible && ( diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index bbef0fd5107..c71df99f706 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -38,6 +38,7 @@ interface MockVirtuosoProps { atBottomStateChange?: (isAtBottom: boolean) => void followOutput?: FollowOutput className?: string + initialTopMostItemIndex?: number } interface VirtuosoHarnessState { @@ -45,6 +46,8 @@ interface VirtuosoHarnessState { atBottomAfterCalls: number signalDelayMs: number emitFalseOnDataChange: boolean + delayedGrowthMs: number | null + initialTopMostItemIndex: number | undefined followOutput: FollowOutput | undefined emitAtBottom: (isAtBottom: boolean) => void } @@ -54,6 +57,8 @@ const harness = vi.hoisted(() => ({ atBottomAfterCalls: Number.POSITIVE_INFINITY, signalDelayMs: 20, emitFalseOnDataChange: true, + delayedGrowthMs: null, + initialTopMostItemIndex: undefined, followOutput: undefined, emitAtBottom: () => {}, })) @@ -134,13 +139,14 @@ vi.mock("../ChatRow", () => ({ vi.mock("react-virtuoso", () => { const MockVirtuoso = React.forwardRef(function MockVirtuoso( - { data, itemContent, atBottomStateChange, followOutput, className }, + { data, itemContent, atBottomStateChange, followOutput, className, initialTopMostItemIndex }, ref, ) { const atBottomRef = useRef(atBottomStateChange) const timeoutIdsRef = useRef([]) harness.followOutput = followOutput + harness.initialTopMostItemIndex = initialTopMostItemIndex harness.emitAtBottom = (isAtBottom: boolean) => { atBottomRef.current?.(isAtBottom) } @@ -164,6 +170,13 @@ vi.mock("react-virtuoso", () => { if (harness.emitFalseOnDataChange) { atBottomStateChange?.(false) } + + if (harness.delayedGrowthMs !== null) { + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(false) + }, harness.delayedGrowthMs) + timeoutIdsRef.current.push(timeoutId) + } }, [data.length, atBottomStateChange]) useEffect( @@ -310,10 +323,17 @@ describe("ChatView scroll behavior regression coverage", () => { harness.atBottomAfterCalls = Number.POSITIVE_INFINITY harness.signalDelayMs = 20 harness.emitFalseOnDataChange = true + harness.delayedGrowthMs = null + harness.initialTopMostItemIndex = undefined harness.followOutput = undefined harness.emitAtBottom = () => {} }) + it("existing-task entry does not set a top-most initial anchor", async () => { + await hydrate(2) + expect(harness.initialTopMostItemIndex).toBeUndefined() + }) + it("rehydration uses bounded bottom pinning", async () => { await hydrate(2) await waitForCalls(2, 1_200) @@ -342,6 +362,18 @@ describe("ChatView scroll behavior regression coverage", () => { expect(resolveFollowOutput(false)).toBe("auto") }) + it("delayed last-row growth during hydration keeps anchored follow with one bounded repin", async () => { + harness.delayedGrowthMs = 320 + await hydrate(3) + await waitForCalls(1, 1_200) + + await sleep(950) + + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + it("user escape hatch during hydration prevents repinning", async () => { await hydrate(Number.POSITIVE_INFINITY) await waitForCalls(1, 1_200) diff --git a/webview-ui/src/hooks/useScrollLifecycle.ts b/webview-ui/src/hooks/useScrollLifecycle.ts index 16a302b7861..8a560f15f9b 100644 --- a/webview-ui/src/hooks/useScrollLifecycle.ts +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -17,6 +17,7 @@ import debounce from "debounce" import type { VirtuosoHandle } from "react-virtuoso" const HYDRATION_WINDOW_MS = 600 +const HYDRATION_RETRY_WINDOW_MS = 160 // --------------------------------------------------------------------------- // Types @@ -95,7 +96,7 @@ export function useScrollLifecycle({ // --- Hydration window --- const isHydratingRef = useRef(false) const hydrationTimeoutRef = useRef(null) - const hydrationRetryAnimationFrameRef = useRef(null) + const hydrationRetryUsedRef = useRef(false) // --- Pointer scroll tracking --- const pointerScrollActiveRef = useRef(false) @@ -124,10 +125,6 @@ export function useScrollLifecycle({ const enterUserBrowsingHistory = useCallback( (_source: ScrollFollowDisengageSource) => { - if (hydrationRetryAnimationFrameRef.current !== null) { - cancelAnimationFrame(hydrationRetryAnimationFrameRef.current) - hydrationRetryAnimationFrameRef.current = null - } transitionScrollPhase("USER_BROWSING_HISTORY") // Always show the scroll-to-bottom CTA when the user explicitly // disengages. If they happen to still be at the physical bottom, @@ -166,21 +163,14 @@ export function useScrollLifecycle({ }) }, [virtuosoRef]) - const clearHydrationRetry = useCallback(() => { - if (hydrationRetryAnimationFrameRef.current !== null) { - cancelAnimationFrame(hydrationRetryAnimationFrameRef.current) - hydrationRetryAnimationFrameRef.current = null - } - }, []) - const clearHydrationWindow = useCallback(() => { isHydratingRef.current = false + hydrationRetryUsedRef.current = false if (hydrationTimeoutRef.current !== null) { window.clearTimeout(hydrationTimeoutRef.current) hydrationTimeoutRef.current = null } - clearHydrationRetry() - }, [clearHydrationRetry]) + }, []) const finishHydrationWindow = useCallback(() => { if (!isMountedRef.current || !isHydratingRef.current) { @@ -191,30 +181,27 @@ export function useScrollLifecycle({ if (isAtBottomRef.current) { enterAnchoredFollowing() } else { - transitionScrollPhase("USER_BROWSING_HISTORY") - setShowScrollToBottom(true) + if (!hydrationRetryUsedRef.current) { + hydrationRetryUsedRef.current = true + scrollToBottomAuto() + hydrationTimeoutRef.current = window.setTimeout(() => { + finishHydrationWindow() + }, HYDRATION_RETRY_WINDOW_MS) + return + } + + // Retry budget exhausted. Keep anchored follow rather than + // downgrading to browsing mode due to non-user transient drift. + enterAnchoredFollowing() } } clearHydrationWindow() - }, [clearHydrationWindow, enterAnchoredFollowing, transitionScrollPhase]) - - const scheduleHydrationRetry = useCallback(() => { - clearHydrationRetry() - hydrationRetryAnimationFrameRef.current = requestAnimationFrame(() => { - hydrationRetryAnimationFrameRef.current = null - if (!isMountedRef.current || !isHydratingRef.current) { - return - } - if (scrollPhaseRef.current === "USER_BROWSING_HISTORY") { - return - } - scrollToBottomAuto() - }) - }, [clearHydrationRetry, scrollToBottomAuto]) + }, [clearHydrationWindow, enterAnchoredFollowing, scrollToBottomAuto]) const startHydrationWindow = useCallback(() => { isHydratingRef.current = true + hydrationRetryUsedRef.current = false if (hydrationTimeoutRef.current !== null) { window.clearTimeout(hydrationTimeoutRef.current) } @@ -223,8 +210,7 @@ export function useScrollLifecycle({ }, HYDRATION_WINDOW_MS) scrollToBottomAuto() - scheduleHydrationRetry() - }, [finishHydrationWindow, scheduleHydrationRetry, scrollToBottomAuto]) + }, [finishHydrationWindow, scrollToBottomAuto]) // ----------------------------------------------------------------------- // Lifecycle effects