diff --git a/site/src/pages/AgentsPage/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/AgentDetailView.stories.tsx index 6eadaa76cc..19dce7a02e 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.stories.tsx @@ -5,7 +5,7 @@ import { API } from "api/api"; import type * as TypesGen from "api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated"; import type { ModelSelectorOption } from "components/ai-elements"; -import { fn, spyOn } from "storybook/test"; +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { createChatStore } from "./AgentDetail/ChatContext"; import { @@ -428,3 +428,93 @@ export const NotFoundSidebarCollapsed: Story = { /> ), }; + +// --------------------------------------------------------------------------- +// Scroll-to-bottom button stories +// --------------------------------------------------------------------------- + +/** Generate a long conversation so the scroll container overflows. */ +const buildLongConversation = (count: number): TypesGen.ChatMessage[] => { + const messages: TypesGen.ChatMessage[] = []; + for (let i = 1; i <= count; i++) { + const role: TypesGen.ChatMessageRole = i % 2 === 1 ? "user" : "assistant"; + const text = + role === "user" + ? `Question ${Math.ceil(i / 2)}: Can you explain concept ${Math.ceil(i / 2)} in detail?` + : `Sure! Here is a detailed explanation of concept ${Math.floor(i / 2)}. `.repeat( + 4, + ); + messages.push(buildMessage(i, role, text)); + } + return messages; +}; + +/** Scroll-to-bottom button appears after scrolling up in a long + * conversation, and clicking it returns to the bottom. */ +export const ScrollToBottomButton: Story = { + args: { + store: buildStoreWithMessages(buildLongConversation(40)), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The button should be hidden initially — it has aria-hidden="true" + // when not shown, so queryByRole correctly returns null. + expect( + canvas.queryByRole("button", { name: "Scroll to bottom" }), + ).toBeNull(); + + // Find the scroll container via data-testid. + const scrollContainer = canvas.getByTestId("scroll-container"); + + // Wait for content to render and create overflow. + await waitFor(() => { + expect(scrollContainer.scrollHeight).toBeGreaterThan( + scrollContainer.clientHeight, + ); + }); + + // 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. + const maxScroll = + scrollContainer.scrollHeight - scrollContainer.clientHeight; + scrollContainer.scrollTop = -maxScroll; + if (Math.abs(scrollContainer.scrollTop) < 100) { + scrollContainer.scrollTop = maxScroll; + } + scrollContainer.dispatchEvent(new Event("scroll")); + + // Button should become visible (enters the accessibility tree). + const button = await waitFor(() => { + const btn = canvas.getByRole("button", { name: "Scroll to bottom" }); + expect(btn).toBeVisible(); + return btn; + }); + + // Click the button to scroll back to the bottom. + await userEvent.click(button); + + // Button should be hidden again. The click handler immediately + // hides it, so this doesn't depend on smooth scroll completing. + await waitFor(() => { + expect( + canvas.queryByRole("button", { name: "Scroll to bottom" }), + ).toBeNull(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index 698f9cdcc0..bd1cbf2249 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -1,8 +1,16 @@ import type * as TypesGen from "api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated"; import type { ModelSelectorOption } from "components/ai-elements"; -import { ArchiveIcon } from "lucide-react"; -import { type FC, type RefObject, useEffect, useRef, useState } from "react"; +import { Button } from "components/Button/Button"; +import { ArchiveIcon, ArrowDownIcon } from "lucide-react"; +import { + type FC, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; @@ -503,6 +511,8 @@ export const AgentDetailNotFoundView: FC = ({ * renders — CSS scroll anchoring is unreliable in flex-col-reverse * containers. */ +const SCROLL_THRESHOLD = 100; + const ScrollAnchoredContainer: FC<{ scrollContainerRef: RefObject; isFetchingMoreMessages: boolean; @@ -522,6 +532,7 @@ const ScrollAnchoredContainer: FC<{ isFetchingRef.current = isFetchingMoreMessages; const onFetchRef = useRef(onFetchMoreMessages); onFetchRef.current = onFetchMoreMessages; + const [showScrollToBottom, setShowScrollToBottom] = useState(false); // Sentinel observer — triggers loading older messages. // All changing values are read from refs so the observer @@ -566,14 +577,76 @@ const ScrollAnchoredContainer: FC<{ observer.observe(sentinel); }, [isFetchingMoreMessages]); + // 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 to see + // older messages makes scrollTop negative. + // + // Throttled to once per animation frame so we avoid calling + // setState on every high-frequency scroll event. + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + let rafId: number | null = null; + + const handleScroll = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + const isAtBottom = Math.abs(container.scrollTop) < SCROLL_THRESHOLD; + setShowScrollToBottom(!isAtBottom); + rafId = null; + }); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + container.removeEventListener("scroll", handleScroll); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [scrollContainerRef]); + + const handleScrollToBottom = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + 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); + }, [scrollContainerRef]); + return ( -
- {children} - {hasMoreMessages &&
} +
+
+ {children} + {hasMoreMessages &&
} +
+
+ +
); };