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..1549ab7ca95 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -62,8 +62,30 @@ export interface ChatViewRef { export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. +const INITIAL_LOAD_SETTLE_TIMEOUT_MS = 2500 +const INITIAL_LOAD_SETTLE_HARD_CAP_MS = 10000 +const INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET = 3 +const INITIAL_LOAD_SETTLE_MAX_FRAMES = Math.ceil(INITIAL_LOAD_SETTLE_HARD_CAP_MS / (1000 / 60)) + +type ScrollPhase = "HYDRATING_PINNED_TO_BOTTOM" | "ANCHORED_FOLLOWING" | "USER_BROWSING_HISTORY" + +type ScrollFollowDisengageSource = "wheel-up" | "row-expansion" | "keyboard-nav-up" | "pointer-scroll-up" + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 +const isEditableKeyboardTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) { + return false + } + + if (target.isContentEditable) { + return true + } + + const tagName = target.tagName + return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" +} + const ChatViewComponent: React.ForwardRefRenderFunction = ( { isHidden, showAnnouncement, hideAnnouncement }, ref, @@ -156,9 +178,24 @@ 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) + 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< @@ -191,6 +228,174 @@ 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, @@ -223,8 +428,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { isMountedRef.current = false + cancelReanchorFrame() } - }, []) + }, [cancelReanchorFrame]) const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), @@ -492,6 +698,19 @@ 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 @@ -507,23 +726,35 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) - }) + beginHydrationPinnedToBottom() + isSettlingRef.current = false + settleDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS + settleHardDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS + startInitialSettle(task.ts) + } else { + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(false) } return () => { - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - } + cancelInitialSettleFrame() + cancelReanchorFrame() + settleTaskTsRef.current = null + settleDeadlineMsRef.current = null + settleHardDeadlineMsRef.current = null + isSettlingRef.current = false + settleBottomConfirmedRef.current = false } - }, [task?.ts]) + }, [ + beginHydrationPinnedToBottom, + cancelInitialSettleFrame, + cancelReanchorFrame, + startInitialSettle, + task?.ts, + transitionScrollPhase, + ]) const taskTs = task?.ts @@ -566,11 +797,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction { // Checking clineAsk isn't enough since messages effect may be called @@ -1313,13 +1544,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + const previousLength = groupedMessagesLengthRef.current + groupedMessagesLengthRef.current = groupedMessages.length + + if (previousLength === groupedMessages.length) { + return + } + + settleMutationVersionRef.current += 1 + + const settleTaskTs = settleTaskTsRef.current + if (settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) { + startInitialSettle(settleTaskTs) + } + }, [groupedMessages.length, extendInitialSettleWindow, startInitialSettle]) + // scrolling const scrollToBottomSmooth = useMemo( () => - debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, { - immediate: true, - }), + debounce( + () => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }), + 10, + { + immediate: true, + }, + ), [], ) @@ -1330,8 +1581,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, + virtuosoRef.current?.scrollToIndex({ + index: "LAST", + align: "end", behavior: "auto", // Instant causes crash. }) }, []) @@ -1358,25 +1610,108 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottomRef.current) { + settleMutationVersionRef.current += 1 + + const settleTaskTs = settleTaskTsRef.current + if (isTaller && settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) { + startInitialSettle(settleTaskTs) + } + + const shouldAutoFollowBottom = scrollPhaseRef.current !== "USER_BROWSING_HISTORY" + const shouldForcePinForAnchoredStreaming = scrollPhaseRef.current === "ANCHORED_FOLLOWING" && isStreaming + if ((isAtBottomRef.current || shouldForcePinForAnchoredStreaming) && shouldAutoFollowBottom) { if (isTaller) { scrollToBottomSmooth() } else { - setTimeout(() => scrollToBottomAuto(), 0) + scrollToBottomAuto() } } }, - [scrollToBottomSmooth, scrollToBottomAuto], + [extendInitialSettleWindow, isStreaming, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle], ) // Disable sticky follow when user scrolls up inside the chat container - const handleWheel = useCallback((event: Event) => { - const wheelEvent = event as WheelEvent - if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - stickyFollowRef.current = false + const handleWheel = useCallback( + (event: Event) => { + const wheelEvent = event as WheelEvent + if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) { + enterUserBrowsingHistory("wheel-up") + } + }, + [enterUserBrowsingHistory], + ) + useEvent("wheel", handleWheel, window, { passive: true }) + + const handlePointerDown = useCallback((event: Event) => { + const pointerEvent = event as PointerEvent + const pointerTarget = pointerEvent.target + if (!(pointerTarget instanceof HTMLElement)) { + pointerScrollActiveRef.current = false + pointerScrollLastTopRef.current = null + return + } + + if (!scrollContainerRef.current?.contains(pointerTarget)) { + pointerScrollActiveRef.current = false + pointerScrollLastTopRef.current = null + return } + + const scroller = + (pointerTarget.closest(".scrollable") as HTMLElement | null) ?? + (pointerTarget.scrollHeight > pointerTarget.clientHeight ? pointerTarget : null) + + pointerScrollActiveRef.current = true + pointerScrollLastTopRef.current = scroller?.scrollTop ?? 0 }, []) - 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) { + 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(() => { @@ -1537,9 +1872,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -1687,14 +2049,43 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} + followOutput={() => (scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto")} atBottomStateChange={(isAtBottom: boolean) => { isAtBottomRef.current = isAtBottom - setShowScrollToBottom(!isAtBottom) - // Clear sticky follow when user scrolls away from bottom - if (!isAtBottom) { - stickyFollowRef.current = false + + const currentPhase = scrollPhaseRef.current + if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && isAtBottom) { + settleBottomConfirmedRef.current = true } + + if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && !isAtBottom) { + return + } + + if ( + currentPhase === "ANCHORED_FOLLOWING" && + !isAtBottom && + pointerScrollActiveRef.current + ) { + enterUserBrowsingHistory("pointer-scroll-up") + return + } + + if (isAtBottom) { + setShowScrollToBottom(false) + if (currentPhase === "USER_BROWSING_HISTORY") { + enterAnchoredFollowing() + } + return + } + + if (currentPhase === "ANCHORED_FOLLOWING" && isStreaming) { + scrollToBottomAuto() + setShowScrollToBottom(false) + return + } + + setShowScrollToBottom(currentPhase === "USER_BROWSING_HISTORY") }} atBottomThreshold={10} initialTopMostItemIndex={groupedMessages.length - 1} @@ -1710,14 +2101,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // 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}> @@ -1823,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 new file mode 100644 index 00000000000..0810e417d73 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -0,0 +1,386 @@ +import React, { useEffect, useImperativeHandle, useRef } from "react" +import { act, fireEvent, render, waitFor } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import type { ClineMessage } from "@roo-code/types" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" + +import ChatView, { type ChatViewProps } from "../ChatView" + +type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false + +interface ExtensionStateMessage { + type: "state" + state: { + version: string + clineMessages: ClineMessage[] + taskHistory: unknown[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + cloudIsAuthenticated: boolean + telemetrySetting: "enabled" | "disabled" | "unset" + } +} + +interface MockVirtuosoHandle { + scrollToIndex: (options: { + index: number | "LAST" + align?: "end" | "start" | "center" + behavior?: "auto" | "smooth" + }) => void +} + +interface MockVirtuosoProps { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + atBottomStateChange?: (isAtBottom: boolean) => void + followOutput?: FollowOutput + className?: string +} + +interface VirtuosoHarnessState { + scrollCalls: number + atBottomAfterCalls: number + signalDelayMs: number + emitFalseOnDataChange: boolean + followOutput: FollowOutput | undefined +} + +const harness = vi.hoisted(() => ({ + scrollCalls: 0, + atBottomAfterCalls: Number.POSITIVE_INFINITY, + signalDelayMs: 20, + emitFalseOnDataChange: true, + followOutput: undefined, +})) + +function nullDefaultModule() { + return { default: () => null } +} + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) +vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => [vi.fn()]) })) +vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ CloudUpsellDialog: () => null })) +vi.mock("@src/hooks/useCloudUpsell", () => ({ + useCloudUpsell: () => ({ + isOpen: false, + openUpsell: vi.fn(), + closeUpsell: vi.fn(), + handleConnect: vi.fn(), + }), +})) + +vi.mock("../common/TelemetryBanner", nullDefaultModule) +vi.mock("../common/VersionIndicator", nullDefaultModule) +vi.mock("../history/HistoryPreview", nullDefaultModule) +vi.mock("@src/components/welcome/RooHero", nullDefaultModule) +vi.mock("@src/components/welcome/RooTips", nullDefaultModule) +vi.mock("../Announcement", nullDefaultModule) +vi.mock("./TaskHeader", () => ({ default: () =>
})) +vi.mock("./ProfileViolationWarning", nullDefaultModule) +vi.mock("../common/DismissibleUpsell", nullDefaultModule) + +vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null })) +vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null })) +vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock("../ChatTextArea", () => { + const MockTextArea = React.forwardRef(function MockTextArea( + props: { + inputValue?: string + setInputValue?: (value: string) => void + onSend: () => void + sendingDisabled?: boolean + }, + ref: React.ForwardedRef<{ focus: () => void }>, + ) { + useImperativeHandle(ref, () => ({ focus: () => {} })) + + return ( + props.setInputValue?.(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !props.sendingDisabled) { + props.onSend() + } + }} + /> + ) + }) + + return { default: MockTextArea, ChatTextArea: MockTextArea } +}) + +vi.mock("../ChatRow", () => ({ + default: ({ message }: { message: ClineMessage }) =>
{message.ts}
, +})) + +vi.mock("react-virtuoso", () => { + const MockVirtuoso = React.forwardRef(function MockVirtuoso( + { data, itemContent, atBottomStateChange, followOutput, className }, + ref, + ) { + const atBottomRef = useRef(atBottomStateChange) + const timeoutIdsRef = useRef([]) + + harness.followOutput = followOutput + + useImperativeHandle(ref, () => ({ + scrollToIndex: () => { + harness.scrollCalls += 1 + const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(reachedBottom) + }, harness.signalDelayMs) + timeoutIdsRef.current.push(timeoutId) + }, + })) + + useEffect(() => { + atBottomRef.current = atBottomStateChange + }, [atBottomStateChange]) + + useEffect(() => { + if (harness.emitFalseOnDataChange) { + atBottomStateChange?.(false) + } + }, [data.length, atBottomStateChange]) + + useEffect( + () => () => { + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)) + timeoutIdsRef.current = [] + }, + [], + ) + + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }) + + return { Virtuoso: MockVirtuoso } +}) + +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, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }, + } + + window.postMessage(message, "*") +} + +const renderView = () => + render( + + + + + , + ) + +const hydrate = async (atBottomAfterCalls: number) => { + harness.atBottomAfterCalls = atBottomAfterCalls + renderView() + await act(async () => { + postState(buildMessages(Date.now() - 3_000)) + }) +} + +const waitForCalls = async (min: number, timeout = 1_500) => { + await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(min), { timeout }) +} + +const expectCallsStable = async (ms = 120) => { + await sleep(ms) + const snapshot = harness.scrollCalls + await sleep(ms) + expect(harness.scrollCalls).toBe(snapshot) +} + +const getScrollable = (): HTMLElement => { + const scrollable = document.querySelector(".scrollable") + if (!(scrollable instanceof HTMLElement)) { + throw new Error("Expected ChatView scrollable container") + } + return scrollable +} + +const getScrollToBottomButton = (): HTMLButtonElement => { + const icon = document.querySelector(".codicon-chevron-down") + if (!(icon instanceof HTMLElement)) { + throw new Error("Expected scroll-to-bottom icon") + } + + const button = icon.closest("button") + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected scroll-to-bottom button") + } + + return button +} + +describe("ChatView scroll behavior regression coverage", () => { + beforeEach(() => { + harness.scrollCalls = 0 + harness.atBottomAfterCalls = Number.POSITIVE_INFINITY + harness.signalDelayMs = 20 + harness.emitFalseOnDataChange = true + harness.followOutput = undefined + }) + + it("rehydration converges to bottom", async () => { + await hydrate(6) + await waitForCalls(6, 2_000) + await expectCallsStable() + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("transient settle-time not-at-bottom signals do not disable sticky follow", async () => { + await hydrate(8) + await waitForCalls(2, 1_200) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + + await waitForCalls(8, 2_000) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") + }) + + it("user escape hatch during settle stops forced follow", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(3, 1_200) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + const callsAfterEscape = harness.scrollCalls + await sleep(260) + expect(harness.scrollCalls).toBe(callsAfterEscape) + + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("non-wheel upward intent disengages sticky follow", async () => { + await hydrate(4) + await waitForCalls(4) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + scrollable.scrollTop = 240 + + await act(async () => { + fireEvent.pointerDown(scrollable) + scrollable.scrollTop = 120 + fireEvent.scroll(scrollable) + fireEvent.pointerUp(window) + }) + + expect(resolveFollowOutput(false)).toBe(false) + }) + + it("wheel-up intent disengages sticky follow", async () => { + await hydrate(4) + await waitForCalls(4) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + + await act(async () => { + fireEvent.wheel(scrollable, { deltaY: -120 }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("scroll-to-bottom CTA re-anchors with one interaction", async () => { + await hydrate(4) + await waitForCalls(4) + await expectCallsStable() + expect(resolveFollowOutput(false)).toBe("auto") + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + + const callsBeforeClick = harness.scrollCalls + harness.atBottomAfterCalls = callsBeforeClick + 2 + + await act(async () => { + getScrollToBottomButton().click() + }) + + expect(resolveFollowOutput(false)).toBe("auto") + await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(callsBeforeClick + 2), { + timeout: 1_200, + }) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 }) + }) +})