diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 9b5b699eea..7dbf18e0e0 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -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({ )}
+ {conversationOutlineEnabled && ( `Showing ${loaded} of ${total}`, download: "Download", thinking: "Thinking", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 5716a23ea0..aeab4303d0 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index e2723fdbdd..4909d80efb 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -254,6 +254,7 @@ export const zhCN: Translations = { more: "更多", search: "搜索", loadMore: "加载更多", + scrollToBottom: "滚动到底部", showingOf: (loaded, total) => `已显示 ${loaded} / 共 ${total}`, download: "下载", thinking: "思考", diff --git a/frontend/tests/e2e/scroll-to-bottom-button.spec.ts b/frontend/tests/e2e/scroll-to-bottom-button.spec.ts new file mode 100644 index 0000000000..a92801fd73 --- /dev/null +++ b/frontend/tests/e2e/scroll-to-bottom-button.spec.ts @@ -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); + }); +}); diff --git a/frontend/tests/unit/components/ai-elements/conversation-scroll-button.dom.test.tsx b/frontend/tests/unit/components/ai-elements/conversation-scroll-button.dom.test.tsx new file mode 100644 index 0000000000..82cc94a21a --- /dev/null +++ b/frontend/tests/unit/components/ai-elements/conversation-scroll-button.dom.test.tsx @@ -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(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders and scrolls to the bottom when the conversation is scrolled up", () => { + stickToBottomContext.isAtBottom = false; + render(); + 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(); + expect( + screen.getByRole("button", { name: "Scroll to bottom" }), + ).toBeDefined(); + }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-list-approval.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-approval.dom.test.tsx index d0ce7bec18..f9bcfe58fb 100644 --- a/frontend/tests/unit/components/workspace/messages/message-list-approval.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/messages/message-list-approval.dom.test.tsx @@ -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 }) => (
{children}
), diff --git a/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx index 7f6182c7f5..c12c3d4de6 100644 --- a/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/messages/message-list-reasoning.dom.test.tsx @@ -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 }) => (
{children}
), diff --git a/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx index 33f6ced0c2..aa7046b2b7 100644 --- a/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/messages/message-list-run-activity.dom.test.tsx @@ -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 }) => (
{children}
), diff --git a/frontend/tests/unit/components/workspace/messages/message-list-scheduled.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-scheduled.dom.test.tsx index 93c05ccecc..a97fbc25a6 100644 --- a/frontend/tests/unit/components/workspace/messages/message-list-scheduled.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/messages/message-list-scheduled.dom.test.tsx @@ -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 }) => (
{children}
),