From 72a6c8ae7288d0b7431a7ebbc81f3b4f5d451659 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Aug 2026 21:01:43 +0100 Subject: [PATCH] fix(site): stop the new-turn scroll snap on agent chat (#28213) --- .../AgentsPage/AgentChatPage.stories.tsx | 97 +++++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 8 -- .../AgentsPage/AgentChatPageView.stories.tsx | 94 ++++++++++++++---- .../ChatConversation/AssistantOutput.tsx | 13 ++- .../ChatConversation/storyFixtures.ts | 33 +++++++ 5 files changed, 215 insertions(+), 30 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 0ebf3dfefa..9c17715e2f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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) => ( +
+ +
+ ), + ], + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((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: () => , parameters: { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index ac007b8ef9..4ea32c0957 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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 diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 40f398ffe0..d56fb1a7a7 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -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) => (
void) | undefined; let startEdgeFetchGate: Promise; @@ -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: () => , 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: () => , + 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((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); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx index 506c13cac9..058303eb6d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx @@ -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 = ({ : undefined; return ( -
+
{callout && } {liveStatus && diff --git a/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts b/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts index a604867439..94279b270a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts @@ -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,