From bbeff0d4b5e0c35106dc8760ef195cb537961fbb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 2 Apr 2026 11:58:00 +0300 Subject: [PATCH] fix(site/src/pages/AgentsPage): hide copy button during active turn (#23962) The copy button on the last assistant message was showing even while the turn was still in progress (agent streaming or running tool calls). The content is not final at that point, so the button should be suppressed until the turn completes. The `lastAssistantPerTurnIds` computation unconditionally included the trailing assistant message. Now it checks a new `isTurnActive` prop derived from `isActiveChatStatus(chatStatus) || hasStreamState` and skips the trailing ID when the turn is active. Completed turns (those followed by a user message) are unaffected. --- .../ConversationTimeline.stories.tsx | 33 +++++++++++++++++++ .../ChatConversation/ConversationTimeline.tsx | 10 +++++- .../AgentsPage/components/ChatPageContent.tsx | 5 +++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index aa8df60931..458418ba31 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -64,6 +64,7 @@ const defaultArgs: Omit< "parsedMessages" > = { subagentTitles: new Map(), + isTurnActive: false, }; const meta: Meta = { @@ -766,6 +767,38 @@ export const CopyButtonWritesToClipboard: Story = { }, }; +/** + * When the turn is still active (isTurnActive=true), the trailing + * assistant message should NOT show a copy button because the + * content is not yet final. + */ +export const NoCopyButtonDuringActiveTurn: Story = { + args: { + ...defaultArgs, + isTurnActive: true, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [{ type: "text", text: "Fix the bug" }], + }, + { + ...baseMessage, + id: 2, + role: "assistant", + content: [{ type: "text", text: "Let me look at the code." }], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.queryByTestId("assistant-copy-button"), + ).not.toBeInTheDocument(); + }, +}; + /** * Regression: copy button appears only on the last assistant message * in a turn that includes tool calls. The isLastAssistantMessage diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index f080e111a1..7caff5e8ed 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1009,6 +1009,11 @@ interface ConversationTimelineProps { mcpServers?: readonly TypesGen.MCPServerConfig[]; computerUseSubagentIds?: Set; showDesktopPreviews?: boolean; + // When true the current turn is still in progress (the agent + // is streaming or a tool call is running). The copy button on + // the trailing assistant message is suppressed because the + // content is not yet final. + isTurnActive: boolean; } export const ConversationTimeline = memo( @@ -1022,6 +1027,7 @@ export const ConversationTimeline = memo( mcpServers, computerUseSubagentIds, showDesktopPreviews, + isTurnActive, }) => { if (parsedMessages.length === 0) { return null; @@ -1055,7 +1061,9 @@ export const ConversationTimeline = memo( lastAsstId = null; } } - if (lastAsstId != null) lastAssistantPerTurnIds.add(lastAsstId); + if (lastAsstId != null && !isTurnActive) { + lastAssistantPerTurnIds.add(lastAsstId); + } return parsedMessages.map(({ message, parsed }) => message.role === "user" ? ( = ({ }) => { const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); + const chatStatus = useChatSelector(store, selectChatStatus); + const hasStreamState = useChatSelector(store, selectHasStreamState); + const isTurnActive = isActiveChatStatus(chatStatus) || hasStreamState; const messages = orderedMessageIDs .map((messageID) => messagesByID.get(messageID)) @@ -91,6 +95,7 @@ export const ChatPageTimeline: FC = ({ mcpServers={mcpServers} computerUseSubagentIds={computerUseSubagentIds} showDesktopPreviews={false} + isTurnActive={isTurnActive} />