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,