feat(site): add scroll-to-bottom button to agent chat (#23212)

This commit is contained in:
Danielle Maywood
2026-03-18 22:30:09 +00:00
committed by GitHub
parent be1c06dec9
commit 0339c083ab
2 changed files with 173 additions and 10 deletions
@@ -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) => (
<div
style={{
height: "600px",
display: "flex",
flexDirection: "column",
}}
>
<Story />
</div>
),
],
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();
});
},
};
+82 -9
View File
@@ -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<AgentDetailNotFoundViewProps> = ({
* renders — CSS scroll anchoring is unreliable in flex-col-reverse
* containers.
*/
const SCROLL_THRESHOLD = 100;
const ScrollAnchoredContainer: FC<{
scrollContainerRef: RefObject<HTMLDivElement | null>;
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 (
<div
ref={scrollContainerRef}
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
style={{ overflowAnchor: "none" }}
>
{children}
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={scrollContainerRef}
data-testid="scroll-container"
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
{children}
{hasMoreMessages && <div ref={sentinelRef} className="h-px shrink-0" />}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center overflow-y-auto py-2 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<Button
variant="outline"
size="icon"
className={cn(
"rounded-full bg-surface-primary shadow-md transition-all duration-200",
showScrollToBottom
? "pointer-events-auto translate-y-0 opacity-100"
: "translate-y-2 opacity-0",
)}
onClick={handleScrollToBottom}
aria-label="Scroll to bottom"
aria-hidden={!showScrollToBottom || undefined}
tabIndex={showScrollToBottom ? undefined : -1}
>
<ArrowDownIcon />
</Button>
</div>
</div>
);
};