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.
This commit is contained in:
Mathias Fredriksson
2026-04-02 08:58:00 +00:00
committed by GitHub
parent 78fa8094cc
commit bbeff0d4b5
3 changed files with 47 additions and 1 deletions
@@ -64,6 +64,7 @@ const defaultArgs: Omit<
"parsedMessages"
> = {
subagentTitles: new Map(),
isTurnActive: false,
};
const meta: Meta<typeof ConversationTimeline> = {
@@ -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
@@ -1009,6 +1009,11 @@ interface ConversationTimelineProps {
mcpServers?: readonly TypesGen.MCPServerConfig[];
computerUseSubagentIds?: Set<string>;
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<ConversationTimelineProps>(
@@ -1022,6 +1027,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
mcpServers,
computerUseSubagentIds,
showDesktopPreviews,
isTurnActive,
}) => {
if (parsedMessages.length === 0) {
return null;
@@ -1055,7 +1061,9 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
lastAsstId = null;
}
}
if (lastAsstId != null) lastAssistantPerTurnIds.add(lastAsstId);
if (lastAsstId != null && !isTurnActive) {
lastAssistantPerTurnIds.add(lastAsstId);
}
return parsedMessages.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
@@ -13,6 +13,7 @@ import {
import { ConversationTimeline } from "./ChatConversation/ConversationTimeline";
import { getLatestContextUsage } from "./ChatConversation/chatHelpers";
import {
isActiveChatStatus,
selectChatStatus,
selectHasStreamState,
selectMessagesByID,
@@ -64,6 +65,9 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
}) => {
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<ChatPageTimelineProps> = ({
mcpServers={mcpServers}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={false}
isTurnActive={isTurnActive}
/>
<LiveStreamTail
store={store}