mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): stop the new-turn scroll snap on agent chat (#28213)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user