diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 7dbf18e0e0..1f5541dab9 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -105,6 +105,7 @@ import { findSubtaskResultMessage, } from "@/core/tasks/subtask-render"; import type { AgentThreadState } from "@/core/threads"; +import { LOCAL_HUMAN_MESSAGE_ID_PREFIX } from "@/core/threads/hooks"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; @@ -563,6 +564,64 @@ export function MessageList({ prevIsLoading.current = thread.isLoading; }, [groupedMessages, thread.error, thread.isLoading, threadId]); + // Pin a freshly sent user turn to the top of the viewport, ChatGPT-style: + // the reply then grows downward below the reading position instead of the + // viewport chasing the bottom. The list escapes the stick lock and grows a + // flexible end spacer so the last row can actually reach the top (see + // `pinGroupKey` in VirtualMessageList); scrolling down or the + // scroll-to-bottom button re-locks the follow. + // + // "Freshly sent" is identified by the optimistic message identity + // (LOCAL_HUMAN_MESSAGE_ID_PREFIX, minted once per turn in the thread hooks + // and shared with the server echo), not by comparing group tails: the + // optimistic append can render before `isLoading` flips, and a regenerate + // masks the superseded assistant messages first, which exposes an existing + // human turn as the last group mid-run. Identity also excludes history + // loads (prepend), thread switches and restoring a mid-run thread. + const [pinGroupKey, setPinGroupKey] = useState(null); + const lastSeenThreadIdRef = useRef(threadId); + const pinnedKeysRef = useRef>(new Set()); + // Captured during the first render, not inside the effect: React StrictMode + // (the dev default) runs effects mount → cleanup → mount with refs + // preserved, so a run-once flag mutated in the effect would let the second + // pass fall through and pin a restored mid-run thread. The mount-time tail + // absorbs idempotently on every pass. + const mountTailKeyRef = useRef(undefined); + if (mountTailKeyRef.current === undefined) { + const firstGroup = groupedMessages.at(-1); + mountTailKeyRef.current = + firstGroup?.id ?? firstGroup?.messages[0]?.id ?? null; + } + useEffect(() => { + const lastGroup = groupedMessages.at(-1); + const lastGroupKey = lastGroup?.id ?? lastGroup?.messages[0]?.id; + if (lastGroupKey != null && lastGroupKey === mountTailKeyRef.current) { + // The tail that already existed at mount (restoring a mid-run thread) + // is never a fresh send. + pinnedKeysRef.current.add(lastGroupKey); + } + if (lastSeenThreadIdRef.current !== threadId) { + lastSeenThreadIdRef.current = threadId; + setPinGroupKey(null); + if (lastGroupKey) pinnedKeysRef.current.add(lastGroupKey); + return; + } + if (!thread.isLoading) { + // The run finished (or never started): drop the pin so the end spacer + // collapses with it. + setPinGroupKey(null); + return; + } + if ( + lastGroup?.type === "human" && + lastGroupKey?.startsWith(LOCAL_HUMAN_MESSAGE_ID_PREFIX) && + !pinnedKeysRef.current.has(lastGroupKey) + ) { + pinnedKeysRef.current.add(lastGroupKey); + setPinGroupKey(lastGroupKey); + } + }, [groupedMessages, thread.isLoading, threadId]); + useEffect(() => { const startTime = turnStartTimeRef.current; if ( @@ -1309,6 +1368,7 @@ export function MessageList({ ref={virtualMessageListRef} groups={groupedMessages} isLoading={thread.isLoading} + pinGroupKey={pinGroupKey} onActiveGroupChange={ conversationOutlineEnabled ? handleActiveGroupChange : undefined } @@ -1813,6 +1873,10 @@ export function MessageList({ virtualMessageListRef.current?.cancelPinFollow()} /> {conversationOutlineEnabled && ( diff --git a/frontend/src/components/workspace/messages/virtual-message-list.tsx b/frontend/src/components/workspace/messages/virtual-message-list.tsx index f56689c079..49e3a391aa 100644 --- a/frontend/src/components/workspace/messages/virtual-message-list.tsx +++ b/frontend/src/components/workspace/messages/virtual-message-list.tsx @@ -11,7 +11,9 @@ import { useEffect, useImperativeHandle, useLayoutEffect, + useMemo, useRef, + useState, type Key, type ReactNode, } from "react"; @@ -24,6 +26,7 @@ const ESTIMATED_ROW_HEIGHT = 176; const GROUP_START_OFFSET = 16; const VIRTUAL_SCROLL_SETTLE_ATTEMPTS = 4; const STATIC_SCROLL_SETTLE_ATTEMPTS = 2; +const PIN_SETTLE_FRAMES = 5; type GroupAlignment = "start" | "center"; @@ -42,10 +45,27 @@ type VirtualMessageListProps = { */ renderAfterGroup?: (index: number) => ReactNode; onActiveGroupChange?: (groupIndex: number) => void; + /** + * Key of a freshly sent user group to pin to the top of the viewport + * (ChatGPT-style send behavior). Keyed, not indexed: history pagination + * prepends groups and would shift an index. While set, a flexible spacer + * grows at the end of the list so the pinned row can actually reach the + * top — it is the last row, so without the spacer the browser clamps the + * scroll — and the spacer shrinks as the streamed reply grows beneath it. + * `null` clears it. + */ + pinGroupKey?: string | null; }; export type VirtualMessageListHandle = { scrollToGroup: (groupIndex: number, options?: ScrollToGroupOptions) => void; + /** + * Hands scroll control back to the user immediately: cancels a running + * send-pin chain (escape ticks and settle frames) without clearing the + * pin itself. Wired to explicit user intents that do not originate as + * input on the scroll element, e.g. the scroll-to-bottom button. + */ + cancelPinFollow: () => void; }; function groupKey(group: MessageGroup | undefined, index: number): Key { @@ -60,12 +80,21 @@ export const VirtualMessageList = forwardRef< VirtualMessageListHandle, VirtualMessageListProps >(function VirtualMessageList( - { groups, isLoading, renderGroup, renderAfterGroup, onActiveGroupChange }, + { + groups, + isLoading, + renderGroup, + renderAfterGroup, + onActiveGroupChange, + pinGroupKey, + }, ref, ) { const { isAtBottom, scrollRef, scrollToBottom, stopScroll } = useStickToBottomContext(); const listRef = useRef(null); + const [pinSpacerHeight, setPinSpacerHeight] = useState(0); + const pinSpacerHeightRef = useRef(0); const activeIndex = isLoading ? groups.length - 1 : -1; const getItemKey = useCallback( (index: number) => groupKey(groups[index], index), @@ -131,6 +160,199 @@ export const VirtualMessageList = forwardRef< [scrollRef], ); + const pinnedIndex = useMemo(() => { + if (pinGroupKey == null) { + return null; + } + const index = groups.findIndex( + (_, groupIndex) => String(getItemKey(groupIndex)) === pinGroupKey, + ); + return index >= 0 ? index : null; + }, [getItemKey, groups, pinGroupKey]); + + const updatePinSpacer = useCallback(() => { + const viewport = scrollRef.current; + if (pinnedIndex == null || !viewport) { + return; + } + const row = listRef.current?.querySelector( + `[data-message-group-index="${pinnedIndex}"]`, + ); + if (!row) { + return; + } + const rowRect = row.getBoundingClientRect(); + // Everything after the pinned row, excluding this spacer itself and the + // row's own height: the spacer must make up whatever is missing so the + // row can still reach the top of the viewport. + const afterContent = Math.max( + 0, + viewport.scrollHeight - + pinSpacerHeightRef.current - + (rowRect.bottom + viewport.scrollTop), + ); + const needed = Math.max( + 0, + viewport.clientHeight - + rowRect.height - + GROUP_START_OFFSET - + afterContent, + ); + pinSpacerHeightRef.current = needed; + setPinSpacerHeight((previous) => (previous === needed ? previous : needed)); + }, [pinnedIndex, scrollRef]); + + // Runs the pin chain once per pin key: frame one sizes the spacer, frame + // two aligns, then a few settle frames re-align (the virtualized branch + // re-measures rows after the alignment). The whole chain — rAF ids and + // escape timeouts — lives in a ref and is NOT cancelled by this effect's + // cleanup: groups churn on every stream chunk, and a chunk landing between + // the frames would otherwise cancel the pending alignment mid-chain and + // restart it against already-grown geometry (the spacer computes to 0 and + // the pin collapses). Only clearing the pin or unmount cancels. + // + // The spacer is sized once and stays frozen for the run: re-measuring per + // chunk would shrink it in lockstep with the reply, and the net-negative + // resize makes use-stick-to-bottom re-lock its follow while the bottom gap + // is still inside its 70px band — after which every chunk drags the + // viewport down without user input. A frozen spacer keeps each chunk a + // net-positive resize (the follow stays escaped), and an explicit user + // scroll down re-locks normally through scroll events. The spacer + // collapses when the pin clears. + const pinChainKeyRef = useRef(null); + const pinFramesRef = useRef([]); + const pinEscapeTimersRef = useRef([]); + // User-intent listeners, attached for the chain's lifetime: `wheel`, + // `touchstart` and `pointerdown` never fire for programmatic `scrollTo`, so + // they distinguish real scrolling (wheel/trackpad/touch, scrollbar and + // selection drags) from the resize-animation churn the chain defends + // against. The first genuine input hands scroll control back to the user. + const pinUserIntentBoundRef = useRef(false); + const cancelPinChain = useCallback(() => { + for (const frame of pinFramesRef.current) { + cancelAnimationFrame(frame); + } + pinFramesRef.current = []; + for (const timer of pinEscapeTimersRef.current) { + clearTimeout(timer); + } + pinEscapeTimersRef.current = []; + if (pinUserIntentBoundRef.current) { + pinUserIntentBoundRef.current = false; + const viewport = scrollRef.current; + if (viewport) { + viewport.removeEventListener("wheel", cancelPinChain); + viewport.removeEventListener("touchstart", cancelPinChain); + viewport.removeEventListener("pointerdown", cancelPinChain); + } + } + }, [scrollRef]); + const armPinUserIntent = useCallback(() => { + if (pinUserIntentBoundRef.current) { + return; + } + const viewport = scrollRef.current; + if (!viewport) { + return; + } + pinUserIntentBoundRef.current = true; + viewport.addEventListener("wheel", cancelPinChain, { passive: true }); + viewport.addEventListener("touchstart", cancelPinChain, { + passive: true, + }); + viewport.addEventListener("pointerdown", cancelPinChain, { + passive: true, + }); + }, [cancelPinChain, scrollRef]); + useLayoutEffect(() => { + if (pinnedIndex == null) { + pinChainKeyRef.current = null; + cancelPinChain(); + if (pinSpacerHeightRef.current !== 0) { + pinSpacerHeightRef.current = 0; + setPinSpacerHeight(0); + } + return; + } + if (pinChainKeyRef.current === pinGroupKey) { + // The chain already ran (or is running) for this pin; groups churn + // every stream chunk and must not restart it. + return; + } + pinChainKeyRef.current = pinGroupKey ?? null; + // A second send within the first chain's 2s window starts a new chain; + // without this cancel the old chain's pending ticks keep aligning the + // previous pin (and its timer array is overwritten, orphaning them so + // they can never be cancelled). + cancelPinChain(); + armPinUserIntent(); + const trackFrame = (callback: FrameRequestCallback) => { + const frame = requestAnimationFrame((time) => { + pinFramesRef.current = pinFramesRef.current.filter( + (pending) => pending !== frame, + ); + callback(time); + }); + pinFramesRef.current.push(frame); + return frame; + }; + trackFrame(() => { + updatePinSpacer(); + trackFrame(() => { + // Escaping before the alignment is not redundant even though pinning + // scrolls down: locking back in on a down-scroll is the library's + // default. + stopScroll(); + alignGroupToViewport(pinnedIndex, "start", "auto"); + // The alignment scrolls down into the spacer region while a + // stick-to-bottom resize animation (from the optimistic message + // growth) is still writing scroll offsets toward the spacer bottom. + // Every write's scroll event re-locks the follow, and a locked follow + // later chases the real bottom once the reply grows. The animation + // converges to the spacer bottom over ~350ms — visually close to the + // pinned position but not exact. So each escape tick also re-aligns: + // the chain spans the animation's duration plus its end-of-animation + // requeue, and the LAST tick runs after the animation has died, so + // the exact pinned offset wins. The chain spans two seconds: a send + // that lands while the restored thread's initial virtual positioning + // and row measurement are still settling interleaves with that churn, + // which outlasts the resize animation itself. After the chain, the + // user's own down-scroll re-locks normally. + pinEscapeTimersRef.current = [ + 0, 100, 200, 350, 500, 700, 900, 1100, 1400, 1700, 2000, + ].map((delay) => + window.setTimeout(() => { + stopScroll(); + alignGroupToViewport(pinnedIndex, "start", "auto"); + }, delay), + ); + // Settle frames re-align against post-alignment measurement shifts. + let pending = PIN_SETTLE_FRAMES; + const scheduleSettle = () => { + if (pending <= 0) { + return; + } + pending -= 1; + trackFrame(() => { + alignGroupToViewport(pinnedIndex, "start", "auto"); + scheduleSettle(); + }); + }; + scheduleSettle(); + }); + }); + }, [ + alignGroupToViewport, + armPinUserIntent, + cancelPinChain, + groups, + pinGroupKey, + pinnedIndex, + stopScroll, + updatePinSpacer, + ]); + useEffect(() => cancelPinChain, [cancelPinChain]); + useImperativeHandle( ref, () => ({ @@ -138,6 +360,9 @@ export const VirtualMessageList = forwardRef< if (groupIndex < 0 || groupIndex >= groups.length) { return; } + // Outline chapter navigation is explicit user intent: a running + // send-pin chain must not revert it on its next tick. + cancelPinChain(); stopScroll(); const behavior = options?.behavior ?? "auto"; const align = options?.align ?? "start"; @@ -164,9 +389,13 @@ export const VirtualMessageList = forwardRef< }; requestAnimationFrame(settleOnExactGroup); }, + cancelPinFollow() { + cancelPinChain(); + }, }), [ alignGroupToViewport, + cancelPinChain, groups.length, shouldVirtualize, stopScroll, @@ -310,6 +539,9 @@ export const VirtualMessageList = forwardRef< {renderAfterGroup?.(index)} ))} + {pinSpacerHeight > 0 && ( +
+ )}
); } @@ -337,6 +569,16 @@ export const VirtualMessageList = forwardRef< ); })} + {pinSpacerHeight > 0 && ( +
+ )}
); }); diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 24ed3adf5b..d6db7d934e 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -510,6 +510,15 @@ function clearReconnectRun(threadId: string, runId: string): void { // Storage access is best-effort and must never block stream cleanup. } } + +/** + * Prefix of the client-generated id minted once per turn for the visible + * human message (`${LOCAL_HUMAN_MESSAGE_ID_PREFIX}${uuid()}`); the optimistic + * display copy and the submitted message share it, and the server echo + * (`__user`) keeps it. Consumers that must recognize an optimistic human + * (e.g. the send-scroll pin) match on this prefix instead of restating it. + */ +export const LOCAL_HUMAN_MESSAGE_ID_PREFIX = "local-human-"; /** * The turn this client submitted, recorded at dispatch time. The visible human * input gets one client-generated identity shared by the optimistic display @@ -3071,7 +3080,7 @@ export function useThreadStream({ // checkpoint must never promote an older history-only human into this // turn's anchor). const hideFromUI = options?.additionalKwargs?.hide_from_ui === true; - const humanMessageId = `local-human-${uuid()}`; + const humanMessageId = `${LOCAL_HUMAN_MESSAGE_ID_PREFIX}${uuid()}`; localTurnAnchorRef.current = { threadId, humanIdentity: hideFromUI ? null : `message:${humanMessageId}`, diff --git a/frontend/tests/e2e/scroll-on-send.spec.ts b/frontend/tests/e2e/scroll-on-send.spec.ts new file mode 100644 index 0000000000..2c00466cf9 --- /dev/null +++ b/frontend/tests/e2e/scroll-on-send.spec.ts @@ -0,0 +1,326 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; + +const SENT_TEXT = "Pin this message to the top of the viewport."; +const TALL_ANSWER = Array.from( + { length: 24 }, + (_, index) => + `Paragraph ${index}: the streamed reply keeps growing line after line, ` + + "and the viewport must stay at the reader's position instead of " + + "chasing the bottom of the conversation.", +).join("\n\n"); + +function fillerMessages(turns: number) { + return Array.from({ length: turns }, (_, 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(); +} + +/** + * SSE server that answers the run handshake, then — once the send has been + * pinned — delivers the reply and holds the connection open. The held + * connection keeps `thread.isLoading` true so the pin and its end spacer + * stay alive for the assertions. `progressive` delivers the reply one + * paragraph per 200ms tick (real streaming shape); otherwise the whole tall + * answer arrives in one `values` frame. + */ +async function startHeldRunStream(options?: { progressive?: boolean }) { + const progressive = options?.progressive ?? false; + const paragraphs = TALL_ANSWER.split("\n\n"); + const timers: NodeJS.Timeout[] = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + request.on("end", () => { + let inputMessages: unknown[] = []; + try { + inputMessages = + (JSON.parse(body) as { input?: { messages?: unknown[] } }).input + ?.messages ?? []; + } catch { + inputMessages = []; + } + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + }); + const write = (event: string, data: unknown) => { + response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + const answerFrame = (content: string) => ({ + messages: [ + ...inputMessages, + { + type: "ai", + id: "msg-ai-scroll-send", + content, + additional_kwargs: {}, + response_metadata: {}, + invalid_tool_calls: [], + }, + ], + }); + write("metadata", { + run_id: "run-scroll-send", + thread_id: MOCK_THREAD_ID, + }); + if (progressive) { + paragraphs.forEach((paragraph, index) => { + timers.push( + setTimeout( + () => + write( + "values", + answerFrame(paragraphs.slice(0, index + 1).join("\n\n")), + ), + 600 + index * 200, + ), + ); + }); + } else { + timers.push( + setTimeout(() => write("values", answerFrame(TALL_ANSWER)), 600), + ); + } + // Connection stays open: the run remains in flight. + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/runs/stream`, + async close() { + for (const timer of timers) clearTimeout(timer); + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +test.describe("Scroll on send", () => { + // ChatGPT-style send behavior: the freshly sent user turn aligns with the + // top of the viewport, and the streamed reply grows downward below the + // reading position instead of the viewport chasing the bottom. A flexible + // end spacer makes the last row able to reach the top at all, and the + // scroll-to-bottom button re-locks the follow when the user wants it back. + async function expectSendPinned( + page: Page, + fillerTurns: number, + options?: { progressive?: boolean }, + ) { + const streamServer = await startHeldRunStream(options); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Scroll on send", + updated_at: "2026-11-14T12:00:00Z", + messages: fillerMessages(fillerTurns), + }, + ], + runStreamHandler: (route) => route.continue({ url: streamServer.url }), + }); + + try { + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + const lastFiller = page + .getByText(`Filler question ${fillerTurns - 1}`, { exact: false }) + .first(); + await expect(lastFiller).toBeVisible({ timeout: 15_000 }); + + const scroller = page.getByRole("log").locator("> div"); + // The restored thread settles pinned to the bottom. + await expect + .poll(() => + scroller.evaluate( + (el) => el.scrollHeight - el.clientHeight - el.scrollTop, + ), + ) + .toBeLessThanOrEqual(1); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill(SENT_TEXT); + await textarea.press("Enter"); + + const sentMessage = page.getByText(SENT_TEXT, { exact: true }); + await expect(sentMessage).toBeVisible({ timeout: 15_000 }); + + // The user's message now sits at the top of the viewport (the group + // start offset is 16px), no longer hugging the bottom edge. + const scrollerHandle = await scroller.elementHandle(); + const sentTopInViewport = () => + sentMessage.evaluate( + (el, scrollerEl) => + el.getBoundingClientRect().top - + scrollerEl.getBoundingClientRect().top, + scrollerHandle, + ); + await expect.poll(sentTopInViewport).toBeLessThanOrEqual(64); + + // The tall streamed reply lands below the pinned message. Escape means + // the viewport does NOT chase it: the distance to the bottom grows. + await expect + .poll(() => + scroller.evaluate( + (el) => el.scrollHeight - el.clientHeight - el.scrollTop, + ), + ) + .toBeGreaterThan(200); + + // A slow bottom-chasing animation (a resurrected follow lock) would + // drag the message up past the viewport top within this window; the + // pin must stay put after the content has settled. + await page.waitForTimeout(1_000); + await expect.poll(sentTopInViewport).toBeLessThanOrEqual(64); + const settledGap = await scroller.evaluate( + (el) => el.scrollHeight - el.clientHeight - el.scrollTop, + ); + expect(settledGap).toBeGreaterThan(70); + + // And the follow can be re-locked on demand: the jump button appears + // because the list escaped the stick lock. + await expect( + page.getByRole("button", { name: "Scroll to bottom" }), + ).toBeVisible(); + } finally { + await streamServer.close(); + } + } + + test("pins the sent user message to the top (static list)", async ({ + page, + }) => { + await expectSendPinned(page, 12); + }); + + test("pins the sent user message to the top (virtualized list)", async ({ + page, + }) => { + // 32 turns = 64 groups, past the list's virtualization threshold (60). + await expectSendPinned(page, 32); + }); + + // Regression: a progressively streamed reply (one paragraph per 200ms) must + // not re-lock the follow. If the end spacer shrank in lockstep with each + // chunk, the net-negative resize re-locks use-stick-to-bottom while the + // bottom gap is inside its 70px band, and every subsequent chunk drags the + // viewport down without user input. + test("keeps the pin while the reply streams in progressively", async ({ + page, + }) => { + await expectSendPinned(page, 12, { progressive: true }); + }); + + // The send-pin chain (escape + re-align ticks for ~2s after a send) must + // hand scroll control back on the first genuine user input. A wheel over + // the list inside the chain window cancels it; without the cancel, a later + // tick would revert the user's position back to the pinned offset. + test("hands scroll control back when the user wheels inside the chain window", async ({ + page, + }) => { + const streamServer = await startHeldRunStream(); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Scroll on send", + updated_at: "2026-11-14T12:00:00Z", + messages: fillerMessages(12), + }, + ], + runStreamHandler: (route) => route.continue({ url: streamServer.url }), + }); + + try { + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect( + page.getByText("Filler question 11", { exact: false }).first(), + ).toBeVisible({ timeout: 15_000 }); + + const scroller = page.getByRole("log").locator("> div"); + await expect + .poll(() => + scroller.evaluate( + (el) => el.scrollHeight - el.clientHeight - el.scrollTop, + ), + ) + .toBeLessThanOrEqual(1); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill(SENT_TEXT); + await textarea.press("Enter"); + + const sentMessage = page.getByText(SENT_TEXT, { exact: true }); + await expect(sentMessage).toBeVisible({ timeout: 15_000 }); + const scrollerHandle = await scroller.elementHandle(); + const sentTopInViewport = () => + sentMessage.evaluate( + (el, scrollerEl) => + el.getBoundingClientRect().top - + scrollerEl.getBoundingClientRect().top, + scrollerHandle, + ); + // The pin landed; still well inside the 2s chain window. + await expect.poll(sentTopInViewport).toBeLessThanOrEqual(64); + + 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, -240); + + // The wheel-up moves the viewport up, pushing the pinned message down + // by the same amount (~240px past its pinned offset). Past several + // would-be chain ticks (350/500/700/900/1100ms) the user's position + // must hold instead of snapping back to the pin offset. + await page.waitForTimeout(1_500); + await expect.poll(sentTopInViewport).toBeGreaterThan(200); + await page.waitForTimeout(400); + await expect.poll(sentTopInViewport).toBeGreaterThan(200); + + // The user is now free of the follow; the jump button shows. + await expect( + page.getByRole("button", { name: "Scroll to bottom" }), + ).toBeVisible(); + } finally { + await streamServer.close(); + } + }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-list-scroll-on-send.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-scroll-on-send.dom.test.tsx new file mode 100644 index 0000000000..6d098538e9 --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/message-list-scroll-on-send.dom.test.tsx @@ -0,0 +1,338 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { cleanup, render } from "@testing-library/react"; +import { + forwardRef, + StrictMode, + useImperativeHandle, + type ReactNode, +} from "react"; + +import { MessageList } from "@/components/workspace/messages/message-list"; +import type { VirtualMessageListHandle } from "@/components/workspace/messages/virtual-message-list"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; +import type { MessageGroup } from "@/core/messages/utils"; + +const mocks = rs.hoisted(() => ({ + pinnedGroups: [] as (string | null)[], +})); + +rs.mock("@/components/workspace/messages/message-group", () => ({ + MessageGroup: () => null, + getMessageGroupReasoningMessage: () => undefined, +})); +rs.mock("@/components/ai-elements/conversation", () => ({ + ConversationScrollButton: () => null, + Conversation: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + ConversationContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +rs.mock("@/components/workspace/messages/virtual-message-list", () => ({ + VirtualMessageList: forwardRef< + VirtualMessageListHandle, + { + groups: MessageGroup[]; + pinGroupKey?: string | null; + renderGroup: (group: MessageGroup, index: number) => ReactNode; + renderAfterGroup?: (index: number) => ReactNode; + } + >(function VirtualMessageListMock({ groups, pinGroupKey, renderGroup }, ref) { + mocks.pinnedGroups.push(pinGroupKey ?? null); + useImperativeHandle(ref, () => ({ + scrollToGroup: () => undefined, + cancelPinFollow: () => undefined, + })); + return ( +
+ {groups.map((group, index) => ( +
+ {renderGroup(group, index)} +
+ ))} +
+ ); + }), +})); +rs.mock("@/components/workspace/messages/message-list-item", () => ({ + MessageListItem: ({ message }: { message: Message }) => ( +
+ ), +})); +rs.mock("@/components/workspace/messages/subtask-card", () => ({ + SubtaskCard: () => null, +})); +rs.mock("@/components/workspace/messages/scheduled-task-card", () => ({ + ScheduledTaskCard: () => null, + taskPagePath: (id: string) => `/workspace/scheduled-tasks?task_id=${id}`, +})); +rs.mock("next/link", () => ({ + default: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + +afterEach(() => { + cleanup(); + mocks.pinnedGroups = []; +}); + +const HUMAN_1 = { id: "human-1", type: "human", content: "First question" }; +const AI_1 = { id: "ai-1", type: "ai", content: "First answer" }; +const HUMAN_2 = { + id: "local-human-2", + type: "human", + content: "Second question", +}; + +function view({ + threadId, + messages, + isLoading, +}: { + threadId: string; + messages: Message[]; + isLoading: boolean; +}) { + return ( + undefined, t: enUS }} + > + undefined, + } as unknown as React.ComponentProps["thread"] + } + /> + + ); +} + +function lastPinnedGroup(): string | null { + return mocks.pinnedGroups.at(-1) ?? null; +} + +function idle(threadId = "thread-1") { + return view({ + threadId, + messages: [HUMAN_1, AI_1] as Message[], + isLoading: false, + }); +} + +describe("MessageList scroll-on-send", () => { + it("pins a newly sent user turn to the top of the viewport", () => { + const { rerender } = render(idle()); + expect(lastPinnedGroup()).toBeNull(); + + // The send: an optimistic human message lands while the run starts. + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBe("local-human-2"); + }); + + it("re-pins on a second send while the first run is still active", () => { + const { rerender } = render(idle()); + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + }), + ); + expect(lastPinnedGroup()).toBe("local-human-2"); + + // A queued second send appends another optimistic human turn; the pin + // moves to the newest one. + const HUMAN_3 = { + id: "local-human-3", + type: "human", + content: "Third question", + }; + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2, HUMAN_3] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBe("local-human-3"); + }); + + it("keeps pinning the same turn while its reply streams in", () => { + const { rerender } = render(idle()); + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + }), + ); + expect(lastPinnedGroup()).toBe("local-human-2"); + + // Streaming chunks grow the reply without adding a human group. + rerender( + view({ + threadId: "thread-1", + messages: [ + HUMAN_1, + AI_1, + HUMAN_2, + { id: "ai-2", type: "ai", content: "answer" }, + ] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBe("local-human-2"); + }); + + it("does not pin when a run starts without a new user turn", () => { + const { rerender } = render(idle()); + + // A regenerate: the run restarts on the existing turn. + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBeNull(); + }); + + it("does not pin when a regenerate masks the superseded assistant turn", () => { + const { rerender } = render(idle()); + + // The real regenerate flow removes the superseded assistant messages + // before the replay starts, so an EXISTING human turn becomes the last + // group mid-run. Nothing was appended; there must be no pin. + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBeNull(); + }); + + it("does not pin when loading older history prepends messages", () => { + const { rerender } = render(idle()); + + // History pagination prepends an older page; the last group is unchanged. + rerender( + view({ + threadId: "thread-1", + messages: [ + { id: "human-0", type: "human", content: "Older question" }, + HUMAN_1, + AI_1, + ] as Message[], + isLoading: false, + }), + ); + + expect(lastPinnedGroup()).toBeNull(); + }); + + it("does not pin when mounting mid-run with a trailing human group", () => { + // Restoring a thread (or opening a sidecar chat) whose run is already in + // flight: the trailing human turn is not a fresh send. + render( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBeNull(); + }); + + it("does not pin when StrictMode double-runs the mount with a mid-run thread", () => { + // React StrictMode (the App Router dev default) runs effects + // mount → cleanup → mount with refs preserved. The mount absorption must + // survive the second pass instead of falling through to the pin path. + render( + + {view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + })} + , + ); + + expect(lastPinnedGroup()).toBeNull(); + }); + + it("clears the pin when the run finishes", () => { + const { rerender } = render(idle()); + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + }), + ); + expect(lastPinnedGroup()).toBe("local-human-2"); + + // The reply completed: the end spacer must collapse with the pin. + rerender( + view({ + threadId: "thread-1", + messages: [ + HUMAN_1, + AI_1, + HUMAN_2, + { id: "ai-2", type: "ai", content: "final answer" }, + ] as Message[], + isLoading: false, + }), + ); + + expect(lastPinnedGroup()).toBeNull(); + }); + + it("clears the pin when switching to another thread", () => { + const { rerender } = render(idle()); + rerender( + view({ + threadId: "thread-1", + messages: [HUMAN_1, AI_1, HUMAN_2] as Message[], + isLoading: true, + }), + ); + expect(lastPinnedGroup()).toBe("local-human-2"); + + // Opening another thread absorbs its last group instead of re-pinning. + rerender( + view({ + threadId: "thread-2", + messages: [HUMAN_1] as Message[], + isLoading: true, + }), + ); + + expect(lastPinnedGroup()).toBeNull(); + }); +});