fix(site): stop the new-turn scroll snap on agent chat (#28213)

This commit is contained in:
Danielle Maywood
2026-08-18 21:01:43 +01:00
committed by GitHub
parent 166d92ba73
commit 72a6c8ae72
5 changed files with 215 additions and 30 deletions
@@ -46,6 +46,7 @@ import {
} from "#/testHelpers/storybook";
import AgentChatPage, { RIGHT_PANEL_OPEN_KEY } from "./AgentChatPage";
import type { AgentsPageOutletContext } from "./AgentsPageLayout";
import { buildLongConversation } from "./components/ChatConversation/storyFixtures";
// ---------------------------------------------------------------------------
// Layout wrapper: provides outlet context for the child route.
@@ -3162,6 +3163,102 @@ const switchedChatMessage: TypesGen.ChatMessage = {
content: [{ type: "text", text: "Current chat message" }],
};
export const SendingFromHistoryDoesNotSnapToBottom: Story = {
parameters: {
pixel: { exclude: true },
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
title: "Long chat",
status: "waiting",
},
{
messages: buildLongConversation(CHAT_ID, 40),
queued_messages: [],
has_more: false,
},
{ diffUrl: undefined },
),
},
decorators: [
(Story) => (
<div
style={{ height: "600px", display: "flex", flexDirection: "column" }}
>
<Story />
</div>
),
],
beforeEach: () => {
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
let releaseSend: (() => void) | undefined;
const sendGate = new Promise<void>((resolve) => {
releaseSend = resolve;
});
const sendSpy = spyOn(
API.experimental,
"createChatMessage",
).mockImplementation(async () => {
await sendGate;
return {
queued: false,
message: {
...MockChatMessage,
id: 41,
chat_id: CHAT_ID,
role: "user",
content: [{ type: "text", text: "Follow-up question." }],
},
};
});
// Rather than sampling native smooth-scroll progress (which depends on
// headless-browser scheduling and reduced-motion overrides), intercept the
// MessageScroller's own scroll calls. The eager scrollToEnd we regressed
// on reaches the viewport as a `scrollTo({ behavior: "smooth" })`.
const scrollToMock = spyOn(Element.prototype, "scrollTo");
try {
const editor = await canvas.findByTestId("chat-message-input");
await userEvent.click(editor);
await userEvent.type(editor, "Follow-up question.");
const viewport = canvas.getByRole("region", { name: "Messages" });
// Scroll the reader into history before sending, the repro starting
// point.
viewport.scrollTop = 0;
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(sendSpy).toHaveBeenCalledTimes(1);
});
// While the send POST is still pending, the scroller must not be told
// to move anywhere: the new user row it anchors to does not exist yet.
// The gated mock keeps the POST in-flight until this runs, so a single
// assertion is deterministic without polling animation frames.
expect(scrollToMock).not.toHaveBeenCalled();
// Resolve the send and let the turn settle. The scroller re-anchors
// to the new prompt with a single non-smooth scroll.
releaseSend?.();
await canvas.findByTestId("chat-message-message:41");
await waitFor(() => {
expect(scrollToMock).toHaveBeenCalledTimes(1);
// The anchor re-position is a non-smooth scroll; a smooth call here
// would be the eager scrollToEnd regression resurfacing.
expect(scrollToMock.mock.calls[0]?.[0]).toMatchObject({
behavior: "auto",
});
});
} finally {
scrollToMock.mockRestore();
}
},
};
export const SendResponseAfterChatSwitch: Story = {
render: () => <AgentChatSwitchHarness />,
parameters: {
@@ -1640,14 +1640,6 @@ const AgentChatPage: FC = () => {
throw new CompactCommandPendingError();
}
// Sends and /compact are an explicit ask to be at the live edge,
// even one that appends no visible prompt. History edits scroll
// only after the mutation succeeds, so a rejected edit cannot
// pull a reader of older history to the live edge.
if (editedMessageID === undefined) {
scrollToEnd({ behavior: "smooth" });
}
if (isExactCompactSubmission && compactCommandResolution === "available") {
// Optimistically show the running state before awaiting so
// a fast compaction cannot race this write: the worker's
@@ -41,6 +41,7 @@ import {
} from "./AgentChatPageView";
import type { ChatDetailError } from "./components/ChatConversation/chatError";
import { createChatStore } from "./components/ChatConversation/chatStore";
import { buildLongConversation } from "./components/ChatConversation/storyFixtures";
import type { ModelSelectorOption } from "./components/ChatElements";
import { lastActiveSidebarTabStorageKeyPrefix } from "./utils/sidebarTabStorage";
@@ -1115,22 +1116,6 @@ export const NotFoundSidebarCollapsed: Story = {
// Transcript scrolling stories
// ---------------------------------------------------------------------------
/** Generate a long conversation so the scroll container overflows. */
const buildLongConversation = (count: number): TypesGen.ChatMessage[] => {
const messages: TypesGen.ChatMessage[] = [];
for (let i = 1; i <= count; i++) {
const role: TypesGen.ChatMessageRole = i % 2 === 1 ? "user" : "assistant";
const text =
role === "user"
? `Question ${Math.ceil(i / 2)}: Can you explain concept ${Math.ceil(i / 2)} in detail?`
: `Sure! Here is a detailed explanation of concept ${Math.floor(i / 2)}. `.repeat(
4,
);
messages.push(buildMessage(i, role, text));
}
return messages;
};
const scrollStoryDecorators: Decorator[] = [
(Story) => (
<div
@@ -1221,10 +1206,12 @@ export const UserPromptsRenderOnce: Story = {
};
const startEdgeStore = buildStoreWithMessages(
buildLongConversation(40).slice(1),
buildLongConversation(AGENT_ID, 40).slice(1),
);
const streamCompletionStore = buildStoreWithMessages(buildLongConversation(40));
const streamCompletionStore = buildStoreWithMessages(
buildLongConversation(AGENT_ID, 40),
);
let releaseStartEdgeFetch: (() => void) | undefined;
let startEdgeFetchGate: Promise<void>;
@@ -1250,7 +1237,9 @@ export const ReachingTheStartLoadsEarlierMessages: Story = {
/>
),
play: async ({ canvasElement }) => {
startEdgeStore.replaceMessages(buildLongConversation(40).slice(1));
startEdgeStore.replaceMessages(
buildLongConversation(AGENT_ID, 40).slice(1),
);
startEdgeStore.setChatStatus("waiting");
startEdgeFetchSpy.mockClear();
const canvas = within(canvasElement);
@@ -1295,7 +1284,7 @@ export const StreamCompletionKeepsViewportPosition: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={streamCompletionStore} />,
play: async ({ canvasElement }) => {
streamCompletionStore.replaceMessages(buildLongConversation(40));
streamCompletionStore.replaceMessages(buildLongConversation(AGENT_ID, 40));
streamCompletionStore.setChatStatus("waiting");
const canvas = within(canvasElement);
const viewport = getViewport(canvas);
@@ -1349,6 +1338,69 @@ export const StreamCompletionKeepsViewportPosition: Story = {
},
};
const thinkingShiftStore = buildStoreWithMessages(
buildLongConversation(AGENT_ID, 40),
);
/**
* The Thinking indicator must hand off to streaming text without collapsing
* the live row for a frame. The anchored prompt must not move.
*/
export const ThinkingHandoffKeepsPromptPosition: Story = {
parameters: { pixel: { exclude: true } },
decorators: scrollStoryDecorators,
render: () => <StoryAgentChatPageView store={thinkingShiftStore} />,
play: async ({ canvasElement }) => {
thinkingShiftStore.replaceMessages(buildLongConversation(AGENT_ID, 40));
thinkingShiftStore.setChatStatus("waiting");
const canvas = within(canvasElement);
const viewport = getViewport(canvas);
await waitForScrollOverflow(viewport);
await settleScroller();
scrollTo(viewport, viewport.scrollHeight);
await settleScroller();
// Begin a turn: the prompt is appended and the chat goes running, so
// the live row shows the Thinking indicator with no stream output yet.
thinkingShiftStore.batch(() => {
thinkingShiftStore.upsertDurableMessages([
buildMessage(41, "user", "Follow-up question."),
]);
thinkingShiftStore.setChatStatus("running");
});
await canvas.findByTestId("chat-message-live-assistant");
await canvas.findByTestId("live-activity-slot");
await settleScroller();
const prompt = canvas.getByTestId("chat-message-message:41");
const liveRow = canvas.getByTestId("chat-message-live-assistant");
const promptTop = () =>
prompt.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
// Capture the baseline while the Thinking indicator is shown: the bug
// shrank the live row below this for one frame when the first chunk
// arrived. Guard against a degenerate unpainted baseline so the height
// assertion below cannot silently become a tautology.
const anchoredTop = promptTop();
const thinkingHeight = liveRow.getBoundingClientRect().height;
expect(thinkingHeight).toBeGreaterThan(0);
// The first stream chunk replaces the Thinking indicator with text. The
// live row must never shrink below its Thinking-indicator height, so the
// anchored prompt and everything above it must stay put. Position uses a
// tolerance because rect tops are fractional; a 24px drop is 6x it.
thinkingShiftStore.applyMessageParts([
{ type: "text", text: "Here is the start of the answer." },
]);
for (let i = 0; i < 6; i++) {
expect(liveRow.getBoundingClientRect().height).toBeGreaterThanOrEqual(
thinkingHeight,
);
expect(Math.abs(promptTop() - anchoredTop)).toBeLessThan(4);
await new Promise<void>((r) => requestAnimationFrame(() => r()));
}
},
};
const underflowFetchSpy = fn();
const UnderflowPaginationStory: FC = () => {
@@ -1438,7 +1490,7 @@ const retryFetchSpy = fn();
const RetryPaginationStory: FC = () => {
const store = useRef(
buildStoreWithMessages(buildLongConversation(40)),
buildStoreWithMessages(buildLongConversation(AGENT_ID, 40)),
).current;
const [hasError, setHasError] = useState(true);
const [isFetching, setIsFetching] = useState(false);
@@ -1,5 +1,6 @@
import { PauseIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "#/utils/cn";
import { Shimmer } from "../ChatElements";
import { ToolIcon } from "../ChatElements/tools/ToolIcon";
import { ChatStatusCallout } from "./ChatStatusCallout";
@@ -46,7 +47,17 @@ export const AssistantOutput: FC<AssistantOutputProps> = ({
: undefined;
return (
<div className="relative flex flex-col gap-2 overflow-visible">
<div
className={cn(
"relative flex flex-col gap-2 overflow-visible",
// While the turn is live, hold a one-line floor so the Thinking
// indicator's height survives the handoff to streaming text instead
// of collapsing for a frame. Once text renders it exceeds the floor,
// so this is a no-op after the first visible characters. Durable
// messages carry no liveStatus and are unaffected.
liveStatus && "min-h-6",
)}
>
<BlockList {...blockProps} />
{callout && <ChatStatusCallout status={callout} />}
{liveStatus &&
@@ -18,6 +18,39 @@ export type StoryStreamRenderState = {
liveStatus: LiveStatusModel;
};
/**
* Generate a long conversation so the scroll container overflows in
* transcript-scrolling stories.
*/
export const buildLongConversation = (
chatId: string,
count: number,
): TypesGen.ChatMessage[] => {
const messages: TypesGen.ChatMessage[] = [];
for (let i = 1; i <= count; i++) {
const role: TypesGen.ChatMessageRole = i % 2 === 1 ? "user" : "assistant";
const turn = Math.ceil(i / 2);
messages.push({
id: i,
chat_id: chatId,
created_at: new Date(Date.now() - (count - i) * 60_000).toISOString(),
role,
content: [
{
type: "text",
text:
role === "user"
? `Question ${turn}: Can you explain concept ${turn} in detail?`
: `Sure! Here is a detailed explanation of concept ${turn}. `.repeat(
4,
),
},
],
});
}
return messages;
};
const DEFAULT_LIVE_STATUS_PARAMS: DeriveLiveStatusParams = {
streamState: null,
retryState: null,