From b52c0bdb562a410af310e1b8324861f30a1f2cbb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 13 May 2026 12:51:49 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage/components): unify live thinking spacing and sizing (#25192) --- .../ChatConversation/ConversationTimeline.tsx | 7 +++ .../ChatConversation/StreamingOutput.tsx | 6 +- .../ChatConversation/messageHelpers.test.ts | 57 ++++++++++++++++++- .../ChatConversation/messageHelpers.ts | 3 + .../components/ChatPageContent.stories.tsx | 20 +++++++ .../AgentsPage/components/ChatPageContent.tsx | 6 ++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 792a85bd1b..5054e01363 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -480,6 +480,7 @@ const ChatMessageItem = memo<{ isAfterEditingMessage?: boolean; hideActions?: boolean; hasActiveStream?: boolean; + isAwaitingFirstStreamChunk?: boolean; // When true, renders a gradient overlay inside the bubble // that fades text out toward the bottom. Used by the sticky @@ -505,6 +506,7 @@ const ChatMessageItem = memo<{ isAfterEditingMessage = false, hideActions = false, hasActiveStream = false, + isAwaitingFirstStreamChunk = false, fadeFromBottom = false, onImplementPlan, onSendAskUserQuestionResponse, @@ -528,6 +530,7 @@ const ChatMessageItem = memo<{ parsed, hideActions, hasActiveStream, + isAwaitingFirstStreamChunk, }); if (displayState.shouldHide) { return null; @@ -965,6 +968,7 @@ function computeLastInChainFlags( parsed: entry.parsed, hideActions: false, hasActiveStream: false, + isAwaitingFirstStreamChunk: false, }); if (entry.message.role !== "user") { flags[i] = nextVisibleIsUser; @@ -993,6 +997,7 @@ interface ConversationTimelineProps { mcpServers?: readonly TypesGen.MCPServerConfig[]; showDesktopPreviews?: boolean; hasActiveStream?: boolean; + isAwaitingFirstStreamChunk?: boolean; } export const ConversationTimeline = memo( @@ -1009,6 +1014,7 @@ export const ConversationTimeline = memo( mcpServers, showDesktopPreviews, hasActiveStream, + isAwaitingFirstStreamChunk, }) => { const lastInChainFlags = computeLastInChainFlags(parsedMessages); @@ -1110,6 +1116,7 @@ export const ConversationTimeline = memo( isAfterEditingMessage={afterEditingMessageIds.has(message.id)} hideActions={!isLastInChain} hasActiveStream={Boolean(hasActiveStream)} + isAwaitingFirstStreamChunk={Boolean(isAwaitingFirstStreamChunk)} mcpServers={mcpServers} subagentTitles={subagentTitles} subagentVariants={subagentVariants} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx index 435b599d54..135b0a6c37 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx @@ -29,12 +29,12 @@ const hasTextOrReasoningBlock = (blocks: readonly RenderBlock[]): boolean => /** * Placeholder shown during streaming before text or reasoning - * blocks arrive. Uses the same shimmer animation as the - * collapsible thinking disclosure label. + * blocks arrive. Uses the same shimmer animation and typography + * as the ChatStatusCallout status placeholder. */ const StreamingThinkingPlaceholder: FC = () => (
- + Thinking
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts index 8add3c9b0a..2a5e8b7ea3 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts @@ -14,12 +14,16 @@ const buildMessage = ( content, }); -const getDisplayState = (message: ChatMessage) => +const getDisplayState = ( + message: ChatMessage, + overrides: Partial[0]> = {}, +) => deriveMessageDisplayState({ message, parsed: parseMessageContent(message.content), hideActions: false, hasActiveStream: false, + ...overrides, }); describe("deriveMessageDisplayState", () => { @@ -58,4 +62,55 @@ describe("deriveMessageDisplayState", () => { expect(getDisplayState(message).hasCopyableContent).toBe(false); }); + + it("shows the assistant spacer for reasoning messages when no suppressing flags apply", () => { + const message = buildMessage( + [{ type: "reasoning", text: "I should think before answering." }], + "assistant", + ); + + expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(true); + }); + + it("suppresses the assistant spacer while awaiting the first stream chunk", () => { + const message = buildMessage( + [{ type: "reasoning", text: "I should think before answering." }], + "assistant", + ); + + expect( + getDisplayState(message, { isAwaitingFirstStreamChunk: true }) + .needsAssistantBottomSpacer, + ).toBe(false); + }); + + it("keeps the assistant spacer hidden when actions are hidden", () => { + const message = buildMessage( + [{ type: "reasoning", text: "I should think before answering." }], + "assistant", + ); + + expect( + getDisplayState(message, { hideActions: true }) + .needsAssistantBottomSpacer, + ).toBe(false); + }); + + it("keeps the assistant spacer hidden when a stream is active", () => { + const message = buildMessage( + [{ type: "reasoning", text: "I should think before answering." }], + "assistant", + ); + + expect( + getDisplayState(message, { hasActiveStream: true }) + .needsAssistantBottomSpacer, + ).toBe(false); + }); + + it("never shows the assistant spacer on user messages", () => { + const message = buildMessage([{ type: "text", text: "Hello" }], "user"); + + expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(false); + }); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index 776b2bc01d..e85c1c8c25 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -42,11 +42,13 @@ export const deriveMessageDisplayState = ({ parsed, hideActions, hasActiveStream, + isAwaitingFirstStreamChunk = false, }: { message: TypesGen.ChatMessage; parsed: ParsedMessageContent; hideActions: boolean; hasActiveStream: boolean; + isAwaitingFirstStreamChunk?: boolean; }): MessageDisplayState => { const isUser = message.role === "user"; const userInlineContent = isUser @@ -66,6 +68,7 @@ export const deriveMessageDisplayState = ({ const needsAssistantBottomSpacer = !hideActions && !hasActiveStream && + !isAwaitingFirstStreamChunk && !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 index 41ed77551a..066f4de4fd 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -99,6 +99,26 @@ export const StreamingToolCallGapRegression: Story = { }, }; +export const StartingPhaseToolCallGapRegression: Story = { + render: () => { + const store = buildRegressionStore(); + store.setChatStatus("running"); + + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + canvas.getAllByText("Thinking..."); + expect(canvas.queryByTestId("assistant-bottom-spacer")).toBeNull(); + }, +}; + export const SpacerVisibleWhenNotStreaming: Story = { render: () => { const store = buildRegressionStore(); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index d45605d95a..a0afdfb5ee 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -22,6 +22,7 @@ import { getLatestContextUsage } from "./ChatConversation/chatHelpers"; import { selectChatStatus, selectHasStreamState, + selectIsAwaitingFirstStreamChunk, selectMessagesByID, selectOrderedMessageIDs, selectQueuedMessages, @@ -75,6 +76,10 @@ export const ChatPageTimeline: FC = ({ const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); const chatStatus = useChatSelector(store, selectChatStatus); const hasStream = useChatSelector(store, selectHasStreamState); + const isAwaitingFirstStreamChunk = useChatSelector( + store, + selectIsAwaitingFirstStreamChunk, + ); const isChatCompleted = !hasStream && chatStatus !== "pending"; const messages = orderedMessageIDs @@ -119,6 +124,7 @@ export const ChatPageTimeline: FC = ({ onSendAskUserQuestionResponse={onSendAskUserQuestionResponse} isChatCompleted={isChatCompleted} hasActiveStream={hasStream} + isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk} urlTransform={urlTransform} mcpServers={mcpServers} showDesktopPreviews={false}