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 &&
}
+
+
);
};