feat(frontend): show a scroll-to-bottom button in the message list (#8205)

The ConversationScrollButton component existed but was never mounted,
so a conversation scrolled up had no way back to the latest messages.
Mount it inside the Conversation container with an accessible name from
the new common.scrollToBottom locale key, and lift it above the
subagent card wrapper's relative z-1 so a card scrolled under the
control cannot intercept its clicks.

Cover the stacking behavior with a Playwright test that wheels the list
up, asserts a card box paints across the button's center point, and
clicks through; the existing message-list DOM tests that mock the
conversation module stub the new import.

Co-authored-by: Nan Gao <thunderggnn@gmail.com>
This commit is contained in:
hataa 2026-11-13 15:58:56 +08:00 committed by GitHub
parent 161c3503d0
commit ba4e60dec4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 228 additions and 0 deletions

View File

@ -24,6 +24,7 @@ import {
Conversation,
ConversationContent,
type ConversationProps,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source";
@ -1809,6 +1810,10 @@ export function MessageList({
)}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
<ConversationScrollButton
aria-label={t.common.scrollToBottom}
className="z-10"
/>
</Conversation>
{conversationOutlineEnabled && (
<ConversationOutline

View File

@ -267,6 +267,7 @@ export const enUS: Translations = {
more: "More",
search: "Search",
loadMore: "Load more",
scrollToBottom: "Scroll to bottom",
showingOf: (loaded, total) => `Showing ${loaded} of ${total}`,
download: "Download",
thinking: "Thinking",

View File

@ -225,6 +225,7 @@ export interface Translations {
more: string;
search: string;
loadMore: string;
scrollToBottom: string;
showingOf: (loaded: number, total: number) => string;
download: string;
thinking: string;

View File

@ -254,6 +254,7 @@ export const zhCN: Translations = {
more: "更多",
search: "搜索",
loadMore: "加载更多",
scrollToBottom: "滚动到底部",
showingOf: (loaded, total) => `已显示 ${loaded} / 共 ${total}`,
download: "下载",
thinking: "思考",

View File

@ -0,0 +1,174 @@
import { expect, test, type Locator } from "@playwright/test";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
const FIRST_TASK_PROMPT =
"Summarize the repository layout and report the result back to the parent agent.";
const LAST_TASK_PROMPT =
"Verify the subtask card markup and report accessibility gaps to the parent agent.";
const TASK_PROMPTS = [
FIRST_TASK_PROMPT,
"Audit the thread sync hooks and report streaming regressions to the parent agent.",
LAST_TASK_PROMPT,
];
// use-stick-to-bottom treats the list as "at the bottom" within 70px
// (STICK_TO_BOTTOM_OFFSET_PX), so the escape scroll must exceed that to
// reveal the button. It must also land the last card box across the button's
// center slot (~36px above the container's bottom edge): measured on this
// layout the card sits 90px above the content end, so the covering window is
// roughly 140-180px of scroll-up; the midpoint leaves margin on both sides.
const SCROLL_UP_PX = 160;
const FILLER_MESSAGES = Array.from({ length: 12 }, (_, index) => [
{
type: "human",
id: `msg-human-filler-${index}`,
content: [
{
type: "text",
text: `Filler question ${index}: describe the message list scroll behavior in detail.`,
},
],
},
{
type: "ai",
id: `msg-ai-filler-${index}`,
content: `Filler answer ${index}: the message list keeps the latest turn in view, escapes the stick lock when the user scrolls up, and exposes a jump control to return.`,
additional_kwargs: {},
response_metadata: {},
invalid_tool_calls: [],
},
]).flat();
const subtaskThread = {
thread_id: MOCK_THREAD_ID,
title: "Scroll to bottom with a subtask card",
updated_at: "2026-11-13T12:00:00Z",
messages: [
...FILLER_MESSAGES,
{
type: "human",
id: "msg-human-subtask",
content: [
{ type: "text", text: "Kick off a subtask for the scroll test." },
],
},
{
type: "ai",
id: "msg-ai-subtask",
content: "",
additional_kwargs: {},
response_metadata: {},
tool_calls: TASK_PROMPTS.map((prompt, index) => ({
id: `call-scroll-subtask-${index}`,
name: "task",
args: {
subagent_type: "general-purpose",
prompt,
},
type: "tool_call",
})),
invalid_tool_calls: [],
},
],
};
/** The subtask card container (matches the idiom in subtask-card.spec.ts). */
function subtaskCard(title: Locator): Locator {
return title.locator("xpath=ancestor::div[contains(@class,'rounded-lg')][1]");
}
test.describe("Scroll-to-bottom button", () => {
// The card wrapper renders `relative z-1` (message-list.tsx); the button is
// absolutely positioned without a stacking level, so a card scrolled under
// the control paints over it and swallows the click. The Playwright click
// below is the hit-test: an intercepted pointer fails the actionability wait.
test("stays clickable above a subtask card scrolled under it", async ({
page,
}) => {
test.setTimeout(60_000);
mockLangGraphAPI(page, { threads: [subtaskThread] });
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.reload();
const lastCard = subtaskCard(page.getByTitle(LAST_TASK_PROMPT));
await expect(lastCard).toBeVisible({ timeout: 15_000 });
// `role="log"` lands on the StickToBottom wrapper; its only child is the
// actual scroll container (Content's scrollRef, put on computed
// `overflow: auto` by the library). Escape the stick lock the way a real
// user does — a wheel-up over the list, which the library handles in
// `handleWheel` directly. The restored thread's animation can still snap
// an escape back to the bottom and unmount the button, so the whole
// cycle — pin, wheel, geometry precondition, hit-test, click — retries
// until one round completes end to end. 160px clears the 70px near-bottom band and lands a card box
// across the button's slot.
const scroller = page.getByRole("log").locator("> div");
const button = page.getByRole("button", { name: "Scroll to bottom" });
await expect(async () => {
await scroller.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
await expect
.poll(() =>
scroller.evaluate(
(el) => el.scrollHeight - el.clientHeight - el.scrollTop,
),
)
.toBeLessThanOrEqual(1);
const box = await scroller.boundingBox();
if (!box) {
throw new Error("scroll container is not visible");
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -SCROLL_UP_PX);
await expect(button).toBeVisible({ timeout: 2_000 });
// Stacking check, in a single frame: one of the opaque card boxes must
// cover the button's center point (otherwise the click would pass
// vacuously), and the topmost element at that point must be the button
// itself — without the z-index the card wins the hit-test. One
// predicate, because separate polls leave a window where the restore
// animation snaps the list back down and unmounts the button.
const cardHandle = await subtaskCard(
page.getByTitle(FIRST_TASK_PROMPT),
).elementHandle();
await expect
.poll(() =>
button.evaluate((el, cardEl) => {
if (!cardEl?.parentElement) {
return false;
}
const b = el.getBoundingClientRect();
const cx = b.left + b.width / 2;
const cy = b.top + b.height / 2;
const covered = Array.from(cardEl.parentElement.children).some(
(child) => {
const r = child.getBoundingClientRect();
return (
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom
);
},
);
if (!covered) {
return false;
}
const hit = document.elementFromPoint(cx, cy);
return hit === el || el.contains(hit);
}, cardHandle),
)
.toBe(true);
await button.click({ timeout: 2_000 });
}).toPass({ timeout: 45_000 });
await expect
.poll(() =>
scroller.evaluate(
(el) => el.scrollTop + el.clientHeight >= el.scrollHeight - 4,
),
)
.toBe(true);
await expect(button).toHaveCount(0);
});
});

View File

@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ConversationScrollButton } from "@/components/ai-elements/conversation";
const stickToBottomContext = {
isAtBottom: true,
scrollToBottom: rs.fn(),
};
rs.mock("use-stick-to-bottom", () => ({
useStickToBottomContext: () => stickToBottomContext,
}));
describe("ConversationScrollButton", () => {
afterEach(() => {
cleanup();
stickToBottomContext.isAtBottom = true;
stickToBottomContext.scrollToBottom = rs.fn();
});
it("does not render when the conversation is at the bottom", () => {
render(<ConversationScrollButton />);
expect(screen.queryByRole("button")).toBeNull();
});
it("renders and scrolls to the bottom when the conversation is scrolled up", () => {
stickToBottomContext.isAtBottom = false;
render(<ConversationScrollButton />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(stickToBottomContext.scrollToBottom).toHaveBeenCalledTimes(1);
});
it("passes an accessible name through to the button", () => {
stickToBottomContext.isAtBottom = false;
render(<ConversationScrollButton aria-label="Scroll to bottom" />);
expect(
screen.getByRole("button", { name: "Scroll to bottom" }),
).toBeDefined();
});
});

View File

@ -13,6 +13,7 @@ import type {
import type { MessageGroup } from "@/core/messages/utils";
rs.mock("@/components/ai-elements/conversation", () => ({
ConversationScrollButton: () => null,
Conversation: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),

View File

@ -19,6 +19,7 @@ rs.mock("@/components/workspace/messages/message-group", () => ({
getMessageGroupReasoningMessage: selectReasoning,
}));
rs.mock("@/components/ai-elements/conversation", () => ({
ConversationScrollButton: () => null,
Conversation: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),

View File

@ -13,6 +13,7 @@ rs.mock("@/components/workspace/messages/message-group", () => ({
getMessageGroupReasoningMessage: () => undefined,
}));
rs.mock("@/components/ai-elements/conversation", () => ({
ConversationScrollButton: () => null,
Conversation: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),

View File

@ -20,6 +20,7 @@ rs.mock("@/components/workspace/messages/message-group", () => ({
getMessageGroupReasoningMessage: () => undefined,
}));
rs.mock("@/components/ai-elements/conversation", () => ({
ConversationScrollButton: () => null,
Conversation: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),