feat(frontend): pin the sent message to the top of the viewport (#8261)

* feat(frontend): pin the sent message to the top of the viewport

After a send, keep the user's turn at the top of the viewport and let the
streamed reply grow downward below the reading position, instead of the
viewport chasing the bottom (ChatGPT-style send behavior).

MessageList detects a freshly appended trailing human group while a run is
active and hands its key to VirtualMessageList. The list then grows a
flexible end spacer sized to the remaining viewport, so the last row can
actually reach the top (the browser clamps the scroll without it), escapes
the stick-to-bottom lock around the alignment, and re-escapes on a timeout
chain until the resize animation from the optimistic message settles; the
spacer shrinks as the reply grows and collapses when the run finishes.
Scrolling down or the scroll-to-bottom button re-locks the follow. History
pagination, thread switches and regenerates are excluded by construction
and covered by DOM tests; Playwright covers the static and the virtualized
list against a held run stream.

* style(frontend): fix scroll-on-send test lint

* fix(frontend): send-pin review follow-ups

Review follow-ups on top of the send pin:

- Hand scroll control back on the first genuine user input: the pin chain
  arms wheel/touchstart/pointerdown listeners on the scroller, and outline
  navigation plus the scroll-to-bottom button cancel it through
  cancelPinFollow — previously every tick reverted user scrolling for ~2s
  after a send.
- Capture the mount-time tail key during the first render so the mount
  absorption is idempotent under StrictMode's double effect run.
- Export LOCAL_HUMAN_MESSAGE_ID_PREFIX from core/threads/hooks and use it
  at both the mint site and the pin predicate.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
hataa 2026-11-15 20:49:43 +08:00 committed by GitHub
parent 9134cf26c6
commit ec44f2ff3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 981 additions and 2 deletions

View File

@ -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<string | null>(null);
const lastSeenThreadIdRef = useRef(threadId);
const pinnedKeysRef = useRef<Set<string>>(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<string | null | undefined>(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({
<ConversationScrollButton
aria-label={t.common.scrollToBottom}
className="z-10"
// The jump button is explicit user intent that originates outside
// the scroll element, so its input events cannot cancel a running
// send-pin chain; do it here.
onPointerDown={() => virtualMessageListRef.current?.cancelPinFollow()}
/>
</Conversation>
{conversationOutlineEnabled && (

View File

@ -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<HTMLDivElement | null>(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<HTMLElement>(
`[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<string | null>(null);
const pinFramesRef = useRef<number[]>([]);
const pinEscapeTimersRef = useRef<number[]>([]);
// 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)}
</div>
))}
{pinSpacerHeight > 0 && (
<div aria-hidden style={{ height: pinSpacerHeight }} />
)}
</div>
);
}
@ -337,6 +569,16 @@ export const VirtualMessageList = forwardRef<
</div>
);
})}
{pinSpacerHeight > 0 && (
<div
aria-hidden
className="absolute top-0 left-0 w-full"
style={{
height: pinSpacerHeight,
transform: `translateY(${virtualizer.getTotalSize()}px)`,
}}
/>
)}
</div>
);
});

View File

@ -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
* (`<id>__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}`,

View File

@ -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<void>((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<void>((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();
}
});
});

View File

@ -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 }) => (
<div>{children}</div>
),
ConversationContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
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 (
<div>
{groups.map((group, index) => (
<div key={`${group.type}:${group.id}`}>
{renderGroup(group, index)}
</div>
))}
</div>
);
}),
}));
rs.mock("@/components/workspace/messages/message-list-item", () => ({
MessageListItem: ({ message }: { message: Message }) => (
<div data-testid={`item-${message.type}`} />
),
}));
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 }) => (
<a href={href}>{children}</a>
),
}));
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 (
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<MessageList
threadId={threadId}
thread={
{
messages,
isLoading,
isThreadLoading: false,
values: {},
getMessagesMetadata: () => undefined,
} as unknown as React.ComponentProps<typeof MessageList>["thread"]
}
/>
</I18nContext.Provider>
);
}
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(
<StrictMode>
{view({
threadId: "thread-1",
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
isLoading: true,
})}
</StrictMode>,
);
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();
});
});