fix(site/src/pages/AgentsPage): suppress last-message spacer during active stream (#25120)

This commit is contained in:
Danielle Maywood
2026-05-12 12:31:40 +01:00
committed by GitHub
parent caabb3c4ab
commit a55430b8fd
4 changed files with 129 additions and 1 deletions
@@ -479,6 +479,7 @@ const ChatMessageItem = memo<{
editingMessageId?: number | null;
isAfterEditingMessage?: boolean;
hideActions?: boolean;
hasActiveStream?: boolean;
// When true, renders a gradient overlay inside the bubble
// that fades text out toward the bottom. Used by the sticky
@@ -503,6 +504,7 @@ const ChatMessageItem = memo<{
editingMessageId,
isAfterEditingMessage = false,
hideActions = false,
hasActiveStream = false,
fadeFromBottom = false,
onImplementPlan,
onSendAskUserQuestionResponse,
@@ -525,6 +527,7 @@ const ChatMessageItem = memo<{
message,
parsed,
hideActions,
hasActiveStream,
});
if (displayState.shouldHide) {
return null;
@@ -961,6 +964,7 @@ function computeLastInChainFlags(
message: entry.message,
parsed: entry.parsed,
hideActions: false,
hasActiveStream: false,
});
if (entry.message.role !== "user") {
flags[i] = nextVisibleIsUser;
@@ -988,7 +992,7 @@ interface ConversationTimelineProps {
urlTransform?: UrlTransform;
mcpServers?: readonly TypesGen.MCPServerConfig[];
showDesktopPreviews?: boolean;
isTurnActive?: boolean;
hasActiveStream?: boolean;
}
export const ConversationTimeline = memo<ConversationTimelineProps>(
@@ -1004,6 +1008,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
urlTransform,
mcpServers,
showDesktopPreviews,
hasActiveStream,
}) => {
const lastInChainFlags = computeLastInChainFlags(parsedMessages);
@@ -1104,6 +1109,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
hideActions={!isLastInChain}
hasActiveStream={Boolean(hasActiveStream)}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
@@ -42,10 +42,12 @@ export const deriveMessageDisplayState = ({
message,
parsed,
hideActions,
hasActiveStream,
}: {
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
hideActions: boolean;
hasActiveStream: boolean;
}): MessageDisplayState => {
const isUser = message.role === "user";
const userInlineContent = isUser
@@ -64,6 +66,7 @@ export const deriveMessageDisplayState = ({
parsed.sources.length > 0;
const needsAssistantBottomSpacer =
!hideActions &&
!hasActiveStream &&
!isUser &&
!hasCopyableContent &&
(Boolean(parsed.reasoning) ||
@@ -0,0 +1,118 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { createChatStore } from "./ChatConversation/chatStore";
import {
buildStreamRenderState,
FIXTURE_NOW,
} from "./ChatConversation/storyFixtures";
import { ChatPageTimeline } from "./ChatPageContent";
const meta = {
title: "pages/AgentsPage/ChatPageContent",
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
const CHAT_ID = "chat-page-content-stories";
const buildMessage = (
id: number,
role: TypesGen.ChatMessageRole,
content: TypesGen.ChatMessagePart[],
): TypesGen.ChatMessage => ({
id,
chat_id: CHAT_ID,
created_at: new Date(FIXTURE_NOW - (10 - id) * 60_000).toISOString(),
role,
content,
});
const buildRegressionStore = () => {
const store = createChatStore();
store.replaceMessages([
buildMessage(1, "user", [{ type: "text", text: "Read the source files" }]),
buildMessage(2, "assistant", [
{
type: "reasoning",
text: "I should read SKILL.md and main.go to understand the codebase.",
},
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "read_file",
args: { path: "SKILL.md" },
},
{
type: "tool-call",
tool_call_id: "tool-2",
tool_name: "read_file",
args: { path: "main.go" },
},
]),
buildMessage(3, "tool", [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { output: "# SKILL.md contents" },
},
]),
buildMessage(4, "tool", [
{
type: "tool-result",
tool_call_id: "tool-2",
result: { output: "package main" },
},
]),
]);
return store;
};
export const StreamingToolCallGapRegression: Story = {
render: () => {
const store = buildRegressionStore();
const { streamState } = buildStreamRenderState([
{
type: "tool-call",
tool_call_id: "tool-streaming",
tool_name: "read_file",
args: { path: "types.go" },
},
]);
store.setStreamState(streamState);
store.setChatStatus("pending");
return (
<ChatPageTimeline
chatID={CHAT_ID}
store={store}
persistedError={undefined}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByTestId("assistant-bottom-spacer")).toBeNull();
},
};
export const SpacerVisibleWhenNotStreaming: Story = {
render: () => {
const store = buildRegressionStore();
return (
<ChatPageTimeline
chatID={CHAT_ID}
store={store}
persistedError={undefined}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByTestId("assistant-bottom-spacer")).toBeInTheDocument();
},
};
@@ -118,6 +118,7 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
hasActiveStream={hasStream}
urlTransform={urlTransform}
mcpServers={mcpServers}
showDesktopPreviews={false}