From a55430b8fd59eece42b0bc06c8fdf2af670015f0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 12 May 2026 12:31:40 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage): suppress last-message spacer during active stream (#25120) --- .../ChatConversation/ConversationTimeline.tsx | 8 +- .../ChatConversation/messageHelpers.ts | 3 + .../components/ChatPageContent.stories.tsx | 118 ++++++++++++++++++ .../AgentsPage/components/ChatPageContent.tsx | 1 + 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index e38f65e8ce..792a85bd1b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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( @@ -1004,6 +1008,7 @@ export const ConversationTimeline = memo( urlTransform, mcpServers, showDesktopPreviews, + hasActiveStream, }) => { const lastInChainFlags = computeLastInChainFlags(parsedMessages); @@ -1104,6 +1109,7 @@ export const ConversationTimeline = memo( urlTransform={urlTransform} isAfterEditingMessage={afterEditingMessageIds.has(message.id)} hideActions={!isLastInChain} + hasActiveStream={Boolean(hasActiveStream)} mcpServers={mcpServers} subagentTitles={subagentTitles} subagentVariants={subagentVariants} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index 74f8003574..d789aa7ec2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -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) || diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx new file mode 100644 index 0000000000..41ed77551a --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -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; + +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 ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByTestId("assistant-bottom-spacer")).toBeNull(); + }, +}; + +export const SpacerVisibleWhenNotStreaming: Story = { + render: () => { + const store = buildRegressionStore(); + + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByTestId("assistant-bottom-spacer")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index e3e3444f5d..d45605d95a 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -118,6 +118,7 @@ export const ChatPageTimeline: FC = ({ onImplementPlan={onImplementPlan} onSendAskUserQuestionResponse={onSendAskUserQuestionResponse} isChatCompleted={isChatCompleted} + hasActiveStream={hasStream} urlTransform={urlTransform} mcpServers={mcpServers} showDesktopPreviews={false}