diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 5dab93d008..96bfb280a9 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 fbd7db0743..8fe4e85fb8 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 { @@ -68,8 +68,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const isMountedRef = useRef(true) - const [audioBaseUri] = useState(() => { return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || "" }) @@ -156,9 +154,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction>({}) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) - const stickyFollowRef = useRef(false) - const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const isAtBottomRef = useRef(false) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -219,13 +214,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - isMountedRef.current = true - return () => { - isMountedRef.current = false - } - }, []) - const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], @@ -491,38 +479,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // 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 anchored to the bottom. Virtuoso's - // initialTopMostItemIndex fires at mount but the message data may - // arrive asynchronously, so we also engage sticky follow and - // explicitly scroll after a frame to handle the race. - let rafId: number | undefined - if (task?.ts) { - stickyFollowRef.current = true - rafId = requestAnimationFrame(() => { - virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) - }) - } - return () => { - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - } - } }, [task?.ts]) const taskTs = task?.ts @@ -550,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) { - stickyFollowRef.current = false - } - - prevExpandedRowsRef.current = expandedRows // Store current state for next comparison - }, [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 @@ -1313,28 +1260,48 @@ const ChatViewComponent: React.ForwardRefRenderFunction - debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, { - immediate: true, - }), - [], - ) + // 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, + }) + // Expanding a row indicates the user is browsing; disable sticky follow. + // Placed after the hook call so enterUserBrowsingHistory is defined. useEffect(() => { - return () => { - scrollToBottomSmooth.clear() + 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 + } + } } - }, [scrollToBottomSmooth]) - const scrollToBottomAuto = useCallback(() => { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "auto", // Instant causes crash. - }) - }, []) + if (wasAnyRowExpandedByUser) { + enterUserBrowsingHistory("row-expansion") + } + + prevExpandedRowsRef.current = expandedRows + }, [enterUserBrowsingHistory, expandedRows]) const handleSetExpandedRow = useCallback( (ts: number, expand?: boolean) => { @@ -1356,28 +1323,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottomRef.current) { - if (isTaller) { - scrollToBottomSmooth() - } else { - setTimeout(() => scrollToBottomAuto(), 0) - } - } - }, - [scrollToBottomSmooth, scrollToBottomAuto], - ) - - // 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 - } - }, []) - useEvent("wheel", handleWheel, window, { passive: true }) - // Effect to clear checkpoint warning when messages appear or task changes useEffect(() => { if (isHidden || !task) { @@ -1522,19 +1467,15 @@ 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() } } @@ -1687,17 +1628,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} - atBottomStateChange={(isAtBottom: boolean) => { - isAtBottomRef.current = isAtBottom - setShowScrollToBottom(!isAtBottom) - // Clear sticky follow when user scrolls away from bottom - if (!isAtBottom) { - stickyFollowRef.current = false - } - }} + followOutput={followOutputCallback} + atBottomStateChange={atBottomStateChangeCallback} atBottomThreshold={10} - initialTopMostItemIndex={groupedMessages.length - 1} /> {areButtonsVisible && ( @@ -1710,14 +1643,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 +1749,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 0000000000..c71df99f70 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -0,0 +1,506 @@ +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 + initialTopMostItemIndex?: number +} + +interface VirtuosoHarnessState { + scrollCalls: number + atBottomAfterCalls: number + signalDelayMs: number + emitFalseOnDataChange: boolean + delayedGrowthMs: number | null + initialTopMostItemIndex: number | undefined + followOutput: FollowOutput | undefined + emitAtBottom: (isAtBottom: boolean) => void +} + +const harness = vi.hoisted(() => ({ + scrollCalls: 0, + atBottomAfterCalls: Number.POSITIVE_INFINITY, + signalDelayMs: 20, + emitFalseOnDataChange: true, + delayedGrowthMs: null, + initialTopMostItemIndex: undefined, + followOutput: undefined, + emitAtBottom: () => {}, +})) + +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, initialTopMostItemIndex }, + ref, + ) { + const atBottomRef = useRef(atBottomStateChange) + const timeoutIdsRef = useRef([]) + + harness.followOutput = followOutput + harness.initialTopMostItemIndex = initialTopMostItemIndex + harness.emitAtBottom = (isAtBottom: boolean) => { + atBottomRef.current?.(isAtBottom) + } + + 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) + } + + if (harness.delayedGrowthMs !== null) { + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(false) + }, harness.delayedGrowthMs) + timeoutIdsRef.current.push(timeoutId) + } + }, [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.dispatchEvent( + new MessageEvent("message", { + data: message, + }), + ) +} + +const renderView = () => + render( + + + + + , + ) + +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 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 => { + 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.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) + await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + 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(2, 1_200) + await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + 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) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await act(async () => { + harness.emitAtBottom(true) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("non-wheel upward intent disengages sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + 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("nested scroller scroll events do not falsely disengage sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + 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(2) + await waitForCalls(2) + await waitForCallsSettled() + 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("hydration completion cannot override user escape hatch", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(1, 1_200) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await sleep(700) + + 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(2) + await waitForCalls(2) + await waitForCallsSettled() + 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).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 new file mode 100644 index 0000000000..8a560f15f9 --- /dev/null +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -0,0 +1,489 @@ +/** + * useScrollLifecycle + * + * Simplified chat scroll lifecycle with a short, time-boxed hydration window. + * + * - 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" +import { useEvent } from "react-use" +import debounce from "debounce" +import type { VirtuosoHandle } from "react-virtuoso" + +const HYDRATION_WINDOW_MS = 600 +const HYDRATION_RETRY_WINDOW_MS = 160 + +// --------------------------------------------------------------------------- +// 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 +} + +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, +}: 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) + + // --- Hydration window --- + const isHydratingRef = useRef(false) + const hydrationTimeoutRef = useRef(null) + const hydrationRetryUsedRef = useRef(false) + + // --- Pointer scroll tracking --- + const pointerScrollActiveRef = useRef(false) + const pointerScrollElementRef = useRef(null) + 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 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], + ) + + const cancelReanchorFrame = useCallback(() => { + if (reanchorAnimationFrameRef.current !== null) { + cancelAnimationFrame(reanchorAnimationFrameRef.current) + reanchorAnimationFrameRef.current = null + } + }, []) + + // ----------------------------------------------------------------------- + // 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]) + + const clearHydrationWindow = useCallback(() => { + isHydratingRef.current = false + hydrationRetryUsedRef.current = false + if (hydrationTimeoutRef.current !== null) { + window.clearTimeout(hydrationTimeoutRef.current) + hydrationTimeoutRef.current = null + } + }, []) + + const finishHydrationWindow = useCallback(() => { + if (!isMountedRef.current || !isHydratingRef.current) { + return + } + + if (scrollPhaseRef.current === "HYDRATING_PINNED_TO_BOTTOM") { + if (isAtBottomRef.current) { + enterAnchoredFollowing() + } else { + 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, scrollToBottomAuto]) + + const startHydrationWindow = useCallback(() => { + isHydratingRef.current = true + hydrationRetryUsedRef.current = false + if (hydrationTimeoutRef.current !== null) { + window.clearTimeout(hydrationTimeoutRef.current) + } + hydrationTimeoutRef.current = window.setTimeout(() => { + finishHydrationWindow() + }, HYDRATION_WINDOW_MS) + + scrollToBottomAuto() + }, [finishHydrationWindow, scrollToBottomAuto]) + + // ----------------------------------------------------------------------- + // Lifecycle effects + // ----------------------------------------------------------------------- + + // Mounted guard + global cleanup + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + clearHydrationWindow() + cancelReanchorFrame() + scrollToBottomSmooth.clear() + } + }, [cancelReanchorFrame, clearHydrationWindow, scrollToBottomSmooth]) + + // Keep phase ref in sync with state + useEffect(() => { + scrollPhaseRef.current = scrollPhase + }, [scrollPhase]) + + // Task switch: reset and begin a short hydration window + useEffect(() => { + isAtBottomRef.current = false + clearHydrationWindow() + cancelReanchorFrame() + + if (taskTs) { + transitionScrollPhase("HYDRATING_PINNED_TO_BOTTOM") + setShowScrollToBottom(false) + startHydrationWindow() + } else { + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(false) + } + + return () => { + clearHydrationWindow() + cancelReanchorFrame() + } + }, [cancelReanchorFrame, clearHydrationWindow, startHydrationWindow, taskTs, transitionScrollPhase]) + + // ----------------------------------------------------------------------- + // Row height change handler + // ----------------------------------------------------------------------- + + const handleRowHeightChange = useCallback( + (isTaller: boolean) => { + if ( + scrollPhaseRef.current === "USER_BROWSING_HISTORY" || + scrollPhaseRef.current === "HYDRATING_PINNED_TO_BOTTOM" + ) { + return + } + + const shouldForcePinForAnchoredStreaming = scrollPhaseRef.current === "ANCHORED_FOLLOWING" && isStreaming + if (isAtBottomRef.current || shouldForcePinForAnchoredStreaming) { + if (isTaller) { + scrollToBottomSmooth() + } else { + scrollToBottomAuto() + } + } + }, + [isStreaming, scrollToBottomSmooth, scrollToBottomAuto], + ) + + // ----------------------------------------------------------------------- + // 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 (!isAtBottom && isHydratingRef.current && currentPhase !== "USER_BROWSING_HISTORY") { + setShowScrollToBottom(false) + return + } + + if (isAtBottom) { + if (currentPhase === "USER_BROWSING_HISTORY" && isHydratingRef.current) { + setShowScrollToBottom(true) + return + } + + enterAnchoredFollowing() + return + } + + if (currentPhase === "ANCHORED_FOLLOWING" && !isAtBottom && pointerScrollActiveRef.current) { + enterUserBrowsingHistory("pointer-scroll-up") + 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 + pointerScrollElementRef.current = null + pointerScrollLastTopRef.current = null + return + } + + if (!scrollContainerRef.current?.contains(pointerTarget)) { + pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null + pointerScrollLastTopRef.current = null + return + } + + const scroller = + (pointerTarget.closest(".scrollable") as HTMLElement | null) ?? + (pointerTarget.scrollHeight > pointerTarget.clientHeight ? pointerTarget : null) + + 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 + }, []) + + const handlePointerActiveScroll = useCallback( + (event: Event) => { + if (!pointerScrollActiveRef.current) { + return + } + + const scrollTarget = event.target + if (!(scrollTarget instanceof HTMLElement)) { + return + } + + if (!scrollContainerRef.current?.contains(scrollTarget)) { + return + } + + if (pointerScrollElementRef.current !== 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, + } +}