diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 90eb51f098..90ae69547b 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -675,7 +675,8 @@ const AgentDetail: FC = () => { clearStreamError(); setPendingEditMessageId(editedMessageID); if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0; + const el = scrollContainerRef.current; + el.scrollTop = el.scrollHeight - el.clientHeight; } store.clearStreamState(); try { @@ -706,7 +707,8 @@ const AgentDetail: FC = () => { clearChatErrorReason(agentId); clearStreamError(); if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0; + const el = scrollContainerRef.current; + el.scrollTop = el.scrollHeight - el.clientHeight; } // No optimistic rendering — the message will appear in the diff --git a/site/src/pages/AgentsPage/components/AgentDetail/useAgentTranscriptAutoScroll.ts b/site/src/pages/AgentsPage/components/AgentDetail/useAgentTranscriptAutoScroll.ts new file mode 100644 index 0000000000..57de1af509 --- /dev/null +++ b/site/src/pages/AgentsPage/components/AgentDetail/useAgentTranscriptAutoScroll.ts @@ -0,0 +1,227 @@ +import type { RefObject } from "react"; +import { useEffect, useRef, useState } from "react"; + +const SCROLL_THRESHOLD = 100; + +const isNearBottom = (container: HTMLElement): boolean => { + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + return distanceFromBottom <= SCROLL_THRESHOLD; +}; + +type RafIdRef = { current: number | null }; +type BooleanRef = { current: boolean }; + +const cancelRaf = (rafIdRef: RafIdRef) => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } +}; + +const cancelPendingPins = ( + pinOuterRafIdRef: RafIdRef, + pinInnerRafIdRef: RafIdRef, +) => { + cancelRaf(pinOuterRafIdRef); + cancelRaf(pinInnerRafIdRef); +}; + +const cancelScrollStateUpdate = (scrollStateRafIdRef: RafIdRef) => { + cancelRaf(scrollStateRafIdRef); +}; + +const scheduleBottomPin = ( + scrollContainerRef: RefObject, + autoScrollRef: BooleanRef, + pinOuterRafIdRef: RafIdRef, + pinInnerRafIdRef: RafIdRef, +) => { + const container = scrollContainerRef.current; + if (!container) return; + + cancelPendingPins(pinOuterRafIdRef, pinInnerRafIdRef); + // Double-RAF ensures React's commit phase and the browser's + // layout pass both complete before pinning to bottom. The first + // RAF defers past React's commit; the second defers past the + // browser's layout calculation, guaranteeing scrollHeight is + // accurate. + pinOuterRafIdRef.current = requestAnimationFrame(() => { + pinOuterRafIdRef.current = null; + pinInnerRafIdRef.current = requestAnimationFrame(() => { + pinInnerRafIdRef.current = null; + const nextContainer = scrollContainerRef.current; + if (!nextContainer || !autoScrollRef.current) return; + nextContainer.scrollTop = Math.max( + nextContainer.scrollHeight - nextContainer.clientHeight, + 0, + ); + }); + }); +}; + +interface UseAgentTranscriptAutoScrollResult { + contentRef: RefObject; + showScrollToBottom: boolean; + jumpToBottom: () => void; +} + +export function useAgentTranscriptAutoScroll( + scrollContainerRef: RefObject, +): UseAgentTranscriptAutoScrollResult { + const contentRef = useRef(null); + const autoScrollRef = useRef(true); + const isProgrammaticScrollRef = useRef(false); + const scrollStateRafIdRef = useRef(null); + const pinOuterRafIdRef = useRef(null); + const pinInnerRafIdRef = useRef(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const scheduleButtonStateUpdate = () => { + if (scrollStateRafIdRef.current !== null) return; + scrollStateRafIdRef.current = requestAnimationFrame(() => { + scrollStateRafIdRef.current = null; + const nextContainer = scrollContainerRef.current; + if (!nextContainer) return; + const shouldShow = !isNearBottom(nextContainer); + setShowScrollToBottom(shouldShow); + }); + }; + + const handleScroll = () => { + const nearBottom = isNearBottom(container); + + if (isProgrammaticScrollRef.current) { + if (nearBottom) { + isProgrammaticScrollRef.current = false; + autoScrollRef.current = true; + cancelScrollStateUpdate(scrollStateRafIdRef); + setShowScrollToBottom(false); + } + return; + } + + autoScrollRef.current = nearBottom; + scheduleButtonStateUpdate(); + }; + + const handleUserInterrupt = () => { + isProgrammaticScrollRef.current = false; + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + container.addEventListener("wheel", handleUserInterrupt, { passive: true }); + container.addEventListener("touchstart", handleUserInterrupt, { + passive: true, + }); + + scheduleBottomPin( + scrollContainerRef, + autoScrollRef, + pinOuterRafIdRef, + pinInnerRafIdRef, + ); + + return () => { + container.removeEventListener("scroll", handleScroll); + container.removeEventListener("wheel", handleUserInterrupt); + container.removeEventListener("touchstart", handleUserInterrupt); + }; + }, [scrollContainerRef]); + + useEffect(() => { + const container = scrollContainerRef.current; + const content = contentRef.current; + if (!container || !content) return; + + const initialRect = content.getBoundingClientRect(); + let prevContentHeight = initialRect.height; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + const nextHeight = + entry?.contentRect.height ?? content.getBoundingClientRect().height; + const heightDelta = nextHeight - prevContentHeight; + + prevContentHeight = nextHeight; + + if (Math.abs(heightDelta) < 1 || !autoScrollRef.current) { + return; + } + + scheduleBottomPin( + scrollContainerRef, + autoScrollRef, + pinOuterRafIdRef, + pinInnerRafIdRef, + ); + }); + + observer.observe(content); + + return () => { + observer.disconnect(); + }; + }, [scrollContainerRef]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + let prevContainerHeight = container.clientHeight; + const observer = new ResizeObserver((entries) => { + const nextHeight = + entries[0]?.contentRect.height ?? container.clientHeight; + const heightDelta = nextHeight - prevContainerHeight; + prevContainerHeight = nextHeight; + + if (Math.abs(heightDelta) < 1 || !autoScrollRef.current) { + return; + } + + scheduleBottomPin( + scrollContainerRef, + autoScrollRef, + pinOuterRafIdRef, + pinInnerRafIdRef, + ); + }); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [scrollContainerRef]); + + useEffect(() => { + return () => { + cancelPendingPins(pinOuterRafIdRef, pinInnerRafIdRef); + cancelScrollStateUpdate(scrollStateRafIdRef); + }; + }, []); + + const jumpToBottom = () => { + const container = scrollContainerRef.current; + if (!container) return; + + autoScrollRef.current = true; + isProgrammaticScrollRef.current = true; + cancelScrollStateUpdate(scrollStateRafIdRef); + setShowScrollToBottom(false); + container.scrollTo({ + top: container.scrollHeight - container.clientHeight, + behavior: "smooth", + }); + }; + + return { + contentRef, + showScrollToBottom, + jumpToBottom, + }; +} diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index 452a74bbb4..5fc9f28aa4 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -504,14 +504,17 @@ const waitForScrollOverflow = async (scrollContainer: HTMLElement) => { }; const scrollAwayFromBottom = (scrollContainer: HTMLElement) => { - const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight; - scrollContainer.scrollTop = -maxScroll; - if (Math.abs(scrollContainer.scrollTop) < 100) { - scrollContainer.scrollTop = maxScroll; - } + // Normal order: scrollTop = 0 is top, scrollTop = + // scrollHeight - clientHeight is bottom. Set to top to get + // maximally away from bottom. + scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); }; +/** Distance in pixels from the bottom of a scroll container. */ +const distFromBottom = (el: HTMLElement): number => + el.scrollHeight - el.scrollTop - el.clientHeight; + /** Helper that extracts the current messages array from a store. */ const getStoreMessages = ( store: ReturnType, @@ -551,10 +554,7 @@ export const ScrollToBottomButton: Story = { // Wait for content to render and create overflow. await waitForScrollOverflow(scrollContainer); - // Scroll up. In flex-col-reverse containers, Chrome uses - // negative scrollTop values when scrolled away from the - // bottom. Try negative first, fall back to positive for - // other engines. + // Scroll away from the bottom. scrollAwayFromBottom(scrollContainer); // Button should become visible (enters the accessibility tree). @@ -608,7 +608,9 @@ export const ScrollPositionPreservedOnNewContent: Story = { // Record position while clearly away from the bottom. const scrollTopBefore = scrollContainer.scrollTop; - expect(Math.abs(scrollTopBefore)).toBeGreaterThan(50); + const distFromBottomBefore = distFromBottom(scrollContainer); + expect(scrollTopBefore).toBeLessThan(5); + expect(distFromBottomBefore).toBeGreaterThan(50); const existing = getStoreMessages(preservedScrollStore); preservedScrollStore.replaceMessages( @@ -626,11 +628,19 @@ export const ScrollPositionPreservedOnNewContent: Story = { ]), ); - // Wait for ResizeObserver + RAF compensation to settle. - // We should remain significantly away from the bottom. + // Wait for ResizeObserver updates to settle. We should + // remain significantly away from the bottom and keep the + // same reading position. await waitFor( () => { - expect(Math.abs(scrollContainer.scrollTop)).toBeGreaterThan(50); + const distanceFromBottom = distFromBottom(scrollContainer); + expect(scrollContainer.scrollTop).toBeGreaterThanOrEqual( + scrollTopBefore - 5, + ); + expect(scrollContainer.scrollTop).toBeLessThanOrEqual( + scrollTopBefore + 5, + ); + expect(distanceFromBottom).toBeGreaterThan(50); }, { timeout: 2000 }, ); @@ -653,8 +663,14 @@ export const ScrollPinnedToBottomOnNewContent: Story = { await waitForScrollOverflow(scrollContainer); - // Verify the starting position is pinned to the bottom. - expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5); + // Wait for the initial double-RAF pin to bottom to complete. + await waitFor( + () => { + const initialDistFromBottom = distFromBottom(scrollContainer); + expect(initialDistFromBottom).toBeLessThan(5); + }, + { timeout: 2000 }, + ); expect( canvas.queryByRole("button", { name: "Scroll to bottom" }), ).toBeNull(); @@ -676,7 +692,8 @@ export const ScrollPinnedToBottomOnNewContent: Story = { // Wait for the double-RAF pin to complete. await waitFor( () => { - expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5); + const distanceFromBottom = distFromBottom(scrollContainer); + expect(distanceFromBottom).toBeLessThan(5); }, { timeout: 2000 }, ); diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx index 3db8c57597..a961a359ab 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx @@ -11,6 +11,7 @@ import type { ChatDetailError } from "../utils/usageLimitMessage"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; import type { useChatStore } from "./AgentDetail/ChatContext"; import { AgentDetailTopBar } from "./AgentDetail/TopBar"; +import { useAgentTranscriptAutoScroll } from "./AgentDetail/useAgentTranscriptAutoScroll"; import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetailContent"; import { ChatConversationSkeleton, @@ -406,7 +407,7 @@ export const AgentDetailLoadingView: FC = ({ isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} /> -
+
@@ -485,31 +486,6 @@ export const AgentDetailNotFoundView: FC = ({ ); }; -/** - * Scroll container that uses flex-col-reverse for bottom-anchored chat - * layout. In this layout scrollTop = 0 means the user is at the - * bottom (most recent content); scrolling up moves scrollTop away from - * 0 (negative in Chrome, positive in Firefox). - * - * Handles: - * - Loading older message pages via an IntersectionObserver sentinel. - * - ResizeObserver-driven scroll anchoring for transcript and viewport - * size changes. - * - A floating "Scroll to bottom" button when the user is scrolled - * away from the bottom. - * - * CSS scroll anchoring is unreliable in flex-col-reverse containers, - * so all position restoration is done manually. - */ -const SCROLL_THRESHOLD = 100; - -// In flex-col-reverse, scrollTop is 0 at the bottom. Its sign -// when scrolled up varies by engine (negative in Chrome, positive -// in Firefox). The user is "near bottom" when close to 0. -function isNearBottom(container: HTMLElement): boolean { - return Math.abs(container.scrollTop) < SCROLL_THRESHOLD; -} - const ScrollAnchoredContainer: FC<{ scrollContainerRef: RefObject; isFetchingMoreMessages: boolean; @@ -527,18 +503,14 @@ const ScrollAnchoredContainer: FC<{ const observerRef = useRef(null); const isFetchingRef = useRef(isFetchingMoreMessages); const onFetchRef = useRef(onFetchMoreMessages); - const autoScrollRef = useRef(true); - const contentRef = useRef(null); - // Guard flag: true while a programmatic scroll adjustment is in-flight. - // The scroll handler skips autoScrollRef updates and re-render triggers - // when this is set, preventing user-visible jitter. Cleared when the - // scroll reaches its destination or the user actively interrupts. - const isRestoringScrollRef = useRef(false); + const prevScrollHeightRef = useRef(0); + const { contentRef, showScrollToBottom, jumpToBottom } = + useAgentTranscriptAutoScroll(scrollContainerRef); + useEffect(() => { isFetchingRef.current = isFetchingMoreMessages; onFetchRef.current = onFetchMoreMessages; }, [isFetchingMoreMessages, onFetchMoreMessages]); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); // Sentinel observer — triggers loading older messages. // All changing values are read from refs so the observer @@ -583,244 +555,45 @@ const ScrollAnchoredContainer: FC<{ observer.observe(sentinel); }, [isFetchingMoreMessages]); + // Pagination prepend: preserve scroll position when older + // messages are loaded. Snapshot scrollHeight once at fetch + // start, then compensate by the delta when the fetch + // completes. useEffect(() => { + if (isFetchingMoreMessages) { + prevScrollHeightRef.current = + scrollContainerRef.current?.scrollHeight ?? 0; + return; + } + const container = scrollContainerRef.current; - const content = contentRef.current; - if (!container || !content) return; + const prevScrollHeight = prevScrollHeightRef.current; + if (!container || prevScrollHeight === 0) return; + prevScrollHeightRef.current = 0; - const initialContentRect = content.getBoundingClientRect(); - let prevContentHeight = initialContentRect.height; - let prevContentWidth = initialContentRect.width; - let pinOuterRafId: number | null = null; - let pinInnerRafId: number | null = null; - let restoreGuardRafId: number | null = null; - - const cancelPendingPins = () => { - if (pinOuterRafId !== null) { - cancelAnimationFrame(pinOuterRafId); + const rafId = requestAnimationFrame(() => { + const nextContainer = scrollContainerRef.current; + if (!nextContainer) return; + const delta = nextContainer.scrollHeight - prevScrollHeight; + if (delta > 0) { + nextContainer.scrollTop += delta; } - if (pinInnerRafId !== null) { - cancelAnimationFrame(pinInnerRafId); - } - pinOuterRafId = null; - pinInnerRafId = null; - }; - - const scheduleBottomPin = () => { - cancelPendingPins(); - isRestoringScrollRef.current = true; - // Double-RAF lets React's commit phase and the browser's - // layout pass both complete before we pin to bottom. - pinOuterRafId = requestAnimationFrame(() => { - pinOuterRafId = null; - pinInnerRafId = requestAnimationFrame(() => { - pinInnerRafId = null; - if (!autoScrollRef.current) { - isRestoringScrollRef.current = false; - return; - } - if (restoreGuardRafId !== null) { - cancelAnimationFrame(restoreGuardRafId); - } - container.scrollTop = 0; - restoreGuardRafId = requestAnimationFrame(() => { - isRestoringScrollRef.current = false; - restoreGuardRafId = null; - }); - }); - }); - }; - - const compensateScroll = (delta: number) => { - if (restoreGuardRafId !== null) { - cancelAnimationFrame(restoreGuardRafId); - } - isRestoringScrollRef.current = true; - // In flex-col-reverse, "away from bottom" can be either - // negative (Chrome) or positive (Firefox). Detect which - // convention applies and compensate accordingly. - if (container.scrollTop < 0) { - // Negative convention: subtract to move away from 0 (bottom). - container.scrollTop -= delta; - } else { - // Positive convention: add to move away from 0 (bottom). - container.scrollTop += delta; - } - restoreGuardRafId = requestAnimationFrame(() => { - isRestoringScrollRef.current = false; - restoreGuardRafId = null; - }); - }; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - const nextHeight = - entry?.contentRect.height ?? content.getBoundingClientRect().height; - const nextWidth = - entry?.contentRect.width ?? content.getBoundingClientRect().width; - const delta = nextHeight - prevContentHeight; - const widthChanged = Math.abs(nextWidth - prevContentWidth) > 1; - prevContentHeight = nextHeight; - prevContentWidth = nextWidth; - if (Math.abs(delta) < 1) { - return; - } - - // Skip compensation during pagination. Older messages are - // prepended in flex-col-reverse which grows content into the - // overflow direction; the browser preserves scrollTop for us. - if (isFetchingRef.current) { - return; - } - - // Skip compensation during reflow. Width changes indicate the - // height delta is distributed through the transcript rather than - // appended at the bottom, so applying the full delta would - // overcompensate and jump the user. - if (widthChanged) { - return; - } - - if (autoScrollRef.current) { - scheduleBottomPin(); - return; - } - - compensateScroll(delta); }); - observer.observe(content); return () => { - observer.disconnect(); - cancelPendingPins(); - if (restoreGuardRafId !== null) { - cancelAnimationFrame(restoreGuardRafId); - } - isRestoringScrollRef.current = false; + cancelAnimationFrame(rafId); }; - }, [scrollContainerRef]); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - let prevContainerHeight = container.clientHeight; - let restoreGuardRafId: number | null = null; - - const observer = new ResizeObserver((entries) => { - const nextHeight = - entries[0]?.contentRect.height ?? container.clientHeight; - const delta = nextHeight - prevContainerHeight; - prevContainerHeight = nextHeight; - if (Math.abs(delta) < 1 || !autoScrollRef.current) { - return; - } - - if (restoreGuardRafId !== null) { - cancelAnimationFrame(restoreGuardRafId); - } - isRestoringScrollRef.current = true; - container.scrollTop = 0; - restoreGuardRafId = requestAnimationFrame(() => { - isRestoringScrollRef.current = false; - restoreGuardRafId = null; - }); - }); - observer.observe(container); - - return () => { - observer.disconnect(); - if (restoreGuardRafId !== null) { - cancelAnimationFrame(restoreGuardRafId); - } - isRestoringScrollRef.current = false; - }; - }, [scrollContainerRef]); - - // Track scroll position to show/hide the scroll-to-bottom button. - // In a flex-col-reverse container, scrollTop = 0 means the user - // is at the bottom (most recent content). Scrolling up moves - // scrollTop away from 0, with the sign varying by engine. - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - let rafId: number | null = null; - - const handleScroll = () => { - // While a programmatic scroll is in progress (e.g. smooth - // scroll-to-bottom), suppress normal handling. Clear the - // guard once the scroll reaches the bottom so normal - // tracking resumes. User-input interruptions are handled - // separately via wheel/touchstart listeners. - if (isRestoringScrollRef.current) { - if (isNearBottom(container)) { - isRestoringScrollRef.current = false; - autoScrollRef.current = true; - } - return; - } - - const nearBottom = isNearBottom(container); - autoScrollRef.current = nearBottom; - - // Throttle the button visibility state update to once per - // frame. This is the only part that triggers a re-render. - if (rafId !== null) return; - rafId = requestAnimationFrame(() => { - setShowScrollToBottom((prev) => { - const shouldShow = !isNearBottom(container); - return prev === shouldShow ? prev : shouldShow; - }); - rafId = null; - }); - }; - - const handleUserInterrupt = () => { - if (isRestoringScrollRef.current) { - isRestoringScrollRef.current = false; - } - }; - - container.addEventListener("scroll", handleScroll, { passive: true }); - container.addEventListener("wheel", handleUserInterrupt, { - passive: true, - }); - container.addEventListener("touchstart", handleUserInterrupt, { - passive: true, - }); - return () => { - container.removeEventListener("scroll", handleScroll); - container.removeEventListener("wheel", handleUserInterrupt); - container.removeEventListener("touchstart", handleUserInterrupt); - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - }; - }, [scrollContainerRef]); - - const handleScrollToBottom = () => { - const container = scrollContainerRef.current; - if (!container) return; - autoScrollRef.current = true; - isRestoringScrollRef.current = true; - container.scrollTo({ top: 0, behavior: "smooth" }); - // Hide immediately so the button doesn't linger while the - // smooth scroll animates. If the user interrupts the scroll - // before it reaches the bottom, the scroll handler will - // re-show the button. - setShowScrollToBottom(false); - }; + }, [isFetchingMoreMessages, scrollContainerRef]); return (
-
{children}
{hasMoreMessages &&
} +
{children}