diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index d45bb36992..5585409474 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -668,8 +668,7 @@ const AgentDetail: FC = () => { clearStreamError(); setPendingEditMessageId(editedMessageID); if (scrollContainerRef.current) { - const el = scrollContainerRef.current; - el.scrollTop = el.scrollHeight - el.clientHeight; + scrollContainerRef.current.scrollTop = 0; } store.clearStreamState(); try { @@ -697,8 +696,7 @@ const AgentDetail: FC = () => { clearChatErrorReason(agentId); clearStreamError(); if (scrollContainerRef.current) { - const el = scrollContainerRef.current; - el.scrollTop = el.scrollHeight - el.clientHeight; + scrollContainerRef.current.scrollTop = 0; } // 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 deleted file mode 100644 index 57de1af509..0000000000 --- a/site/src/pages/AgentsPage/components/AgentDetail/useAgentTranscriptAutoScroll.ts +++ /dev/null @@ -1,227 +0,0 @@ -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 0681c6023a..327ddb4f36 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -506,17 +506,14 @@ const waitForScrollOverflow = async (scrollContainer: HTMLElement) => { }; const scrollAwayFromBottom = (scrollContainer: HTMLElement) => { - // Normal order: scrollTop = 0 is top, scrollTop = - // scrollHeight - clientHeight is bottom. Set to top to get - // maximally away from bottom. - scrollContainer.scrollTop = 0; + const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight; + scrollContainer.scrollTop = -maxScroll; + if (Math.abs(scrollContainer.scrollTop) < 100) { + scrollContainer.scrollTop = maxScroll; + } 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, @@ -556,7 +553,10 @@ export const ScrollToBottomButton: Story = { // Wait for content to render and create overflow. await waitForScrollOverflow(scrollContainer); - // Scroll away from the bottom. + // 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. scrollAwayFromBottom(scrollContainer); // Button should become visible (enters the accessibility tree). @@ -610,9 +610,7 @@ export const ScrollPositionPreservedOnNewContent: Story = { // Record position while clearly away from the bottom. const scrollTopBefore = scrollContainer.scrollTop; - const distFromBottomBefore = distFromBottom(scrollContainer); - expect(scrollTopBefore).toBeLessThan(5); - expect(distFromBottomBefore).toBeGreaterThan(50); + expect(Math.abs(scrollTopBefore)).toBeGreaterThan(50); const existing = getStoreMessages(preservedScrollStore); preservedScrollStore.replaceMessages( @@ -630,19 +628,11 @@ export const ScrollPositionPreservedOnNewContent: Story = { ]), ); - // Wait for ResizeObserver updates to settle. We should - // remain significantly away from the bottom and keep the - // same reading position. + // Wait for ResizeObserver + RAF compensation to settle. + // We should remain significantly away from the bottom. await waitFor( () => { - const distanceFromBottom = distFromBottom(scrollContainer); - expect(scrollContainer.scrollTop).toBeGreaterThanOrEqual( - scrollTopBefore - 5, - ); - expect(scrollContainer.scrollTop).toBeLessThanOrEqual( - scrollTopBefore + 5, - ); - expect(distanceFromBottom).toBeGreaterThan(50); + expect(Math.abs(scrollContainer.scrollTop)).toBeGreaterThan(50); }, { timeout: 2000 }, ); @@ -665,14 +655,8 @@ export const ScrollPinnedToBottomOnNewContent: Story = { await waitForScrollOverflow(scrollContainer); - // Wait for the initial double-RAF pin to bottom to complete. - await waitFor( - () => { - const initialDistFromBottom = distFromBottom(scrollContainer); - expect(initialDistFromBottom).toBeLessThan(5); - }, - { timeout: 2000 }, - ); + // Verify the starting position is pinned to the bottom. + expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5); expect( canvas.queryByRole("button", { name: "Scroll to bottom" }), ).toBeNull(); @@ -694,8 +678,7 @@ export const ScrollPinnedToBottomOnNewContent: Story = { // Wait for the double-RAF pin to complete. await waitFor( () => { - const distanceFromBottom = distFromBottom(scrollContainer); - expect(distanceFromBottom).toBeLessThan(5); + expect(Math.abs(scrollContainer.scrollTop)).toBeLessThan(5); }, { timeout: 2000 }, ); diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx index a961a359ab..3db8c57597 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx @@ -11,7 +11,6 @@ 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, @@ -407,7 +406,7 @@ export const AgentDetailLoadingView: FC = ({ isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} /> -
+
@@ -486,6 +485,31 @@ 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; @@ -503,14 +527,18 @@ const ScrollAnchoredContainer: FC<{ const observerRef = useRef(null); const isFetchingRef = useRef(isFetchingMoreMessages); const onFetchRef = useRef(onFetchMoreMessages); - const prevScrollHeightRef = useRef(0); - const { contentRef, showScrollToBottom, jumpToBottom } = - useAgentTranscriptAutoScroll(scrollContainerRef); - + 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); 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 @@ -555,45 +583,244 @@ 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 prevScrollHeight = prevScrollHeightRef.current; - if (!container || prevScrollHeight === 0) return; - prevScrollHeightRef.current = 0; + const content = contentRef.current; + if (!container || !content) return; - const rafId = requestAnimationFrame(() => { - const nextContainer = scrollContainerRef.current; - if (!nextContainer) return; - const delta = nextContainer.scrollHeight - prevScrollHeight; - if (delta > 0) { - nextContainer.scrollTop += delta; + 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); } + 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 () => { - cancelAnimationFrame(rafId); + observer.disconnect(); + cancelPendingPins(); + if (restoreGuardRafId !== null) { + cancelAnimationFrame(restoreGuardRafId); + } + isRestoringScrollRef.current = false; }; - }, [isFetchingMoreMessages, scrollContainerRef]); + }, [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); + }; return (
- {hasMoreMessages &&
}
{children}
+ {hasMoreMessages &&
}