diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
index 550cac3d32..0b3e95f1d4 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
@@ -1629,6 +1629,64 @@ export const AssistantMessageCopyButton: Story = {
},
};
+/**
+ * Assistant messages that end with a tool call get no copy button,
+ * because the action row would otherwise render directly below the
+ * tool row instead of below copyable text.
+ */
+export const NoCopyButtonAfterTrailingToolCall: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: buildMessages([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [{ type: "text", text: "Run the tests" }],
+ },
+ {
+ ...baseMessage,
+ id: 2,
+ role: "assistant",
+ content: [
+ { type: "text", text: "Running the test suite now." },
+ {
+ type: "tool-call",
+ tool_call_id: "call-exec-1",
+ tool_name: "execute",
+ args: { command: "make test" },
+ },
+ ],
+ },
+ {
+ ...baseMessage,
+ id: 3,
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ tool_call_id: "call-exec-1",
+ tool_name: "execute",
+ result: { output: "ok" },
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText("Running the test suite now.");
+ // The assistant message ends with a tool call, so no copy button.
+ const actions = canvas.getAllByTestId("message-actions");
+ expect(actions).toHaveLength(1);
+ for (const actionRow of actions) {
+ expect(
+ within(actionRow).getByRole("button", { name: "Copy message" }),
+ ).toBeInTheDocument();
+ }
+ },
+};
+
/** Persisted ask-user-question answers survive reloads. */
export const AskUserQuestionSubmittedAnswer: Story = {
args: {
@@ -1918,7 +1976,7 @@ export const MultiAssistantTurnCopyButton: Story = {
};
/**
- * Regression: thinking-only assistant messages must have consistent
+ * Thinking-only assistant messages must have consistent
* bottom spacing before the next user bubble. A spacer div fills the
* gap that would normally come from the invisible action bar.
*/
@@ -1957,6 +2015,40 @@ export const ThinkingOnlyAssistantSpacing: Story = {
// it should still have visible text and a spacer element.
expect(canvas.getByText("Explain this code")).toBeInTheDocument();
expect(canvas.getByText("Any progress?")).toBeInTheDocument();
+ expect(canvas.getByTestId("assistant-bottom-spacer")).toBeInTheDocument();
+ },
+};
+
+/** No following bubble to space against; the spacer would be a dangling blank. */
+export const NoSpacerAfterTrailingThinkingMessage: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: buildMessages([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [{ type: "text", text: "Explain this code" }],
+ },
+ {
+ ...baseMessage,
+ id: 2,
+ role: "assistant",
+ content: [
+ {
+ type: "reasoning",
+ text: "Let me think about this step by step.",
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(canvas.getByText("Explain this code")).toBeInTheDocument();
+ expect(
+ canvas.queryByTestId("assistant-bottom-spacer"),
+ ).not.toBeInTheDocument();
},
};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
index 7d4e0a6b70..9b3812b6be 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
@@ -28,7 +28,6 @@ import {
Message,
MessageContent,
Response,
- Shimmer,
Tool,
} from "../ChatElements";
import { WebSearchSources } from "../ChatElements/tools";
@@ -38,8 +37,7 @@ import {
ReadFileTool,
} from "../ChatElements/tools/ReadFileTool";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
-import { ToolCollapsible } from "../ChatElements/tools/ToolCollapsible";
-import { ToolIcon } from "../ChatElements/tools/ToolIcon";
+import { ToolCall } from "../ChatElements/tools/ToolCall";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import {
@@ -156,24 +154,19 @@ const ReasoningDisclosure = memo<{
return (
-
setManualToggle(open)}
- header={
- <>
-
- {isStreaming ? (
-
- {title}
-
- ) : (
- {title}
- )}
- >
- }
>
- {hasText && (
+
+
- )}
-
+
+
);
},
@@ -533,6 +526,11 @@ const ChatMessageItem = memo<{
hasActiveStream?: boolean;
isAwaitingFirstStreamChunk?: boolean;
+ // The bottom spacer fakes the height of the hidden action bar so
+ // chain-end messages keep even spacing before the next bubble.
+ // The last transcript message has nothing after it, so the spacer
+ // would render as a dangling blank at the end of the chat.
+ isLastMessage?: boolean;
// When true, renders a gradient overlay inside the bubble
// that fades text out toward the bottom. Used by the sticky
// overlay to indicate truncated content.
@@ -561,6 +559,7 @@ const ChatMessageItem = memo<{
hideActions = false,
hasActiveStream = false,
isAwaitingFirstStreamChunk = false,
+ isLastMessage = false,
fadeFromBottom = false,
onImplementPlan,
onSendAskUserQuestionResponse,
@@ -744,7 +743,7 @@ const ChatMessageItem = memo<{
)}
)}
- {displayState.needsAssistantBottomSpacer && (
+ {displayState.needsAssistantBottomSpacer && !isLastMessage && (
)}
{previewImage && (
@@ -1289,6 +1288,7 @@ export const ConversationTimeline = memo(
hideActions={!isLastInChain}
hasActiveStream={Boolean(hasActiveStream)}
isAwaitingFirstStreamChunk={Boolean(isAwaitingFirstStreamChunk)}
+ isLastMessage={msgIdx === displayMessages.length - 1}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx
index b2324de155..9527269db8 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx
@@ -56,7 +56,7 @@ export const ReconnectingAfterDisconnect: Story = {
expect(canvasElement.textContent).toMatch(/reconnecting in \d+s/i);
});
expect(canvas.queryByText("Unexpected error")).not.toBeInTheDocument();
- expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
+ expect(canvas.queryByTestId("live-activity-slot")).not.toBeInTheDocument();
expect(canvas.queryByText("Thinking...")).not.toBeInTheDocument();
},
};
@@ -79,7 +79,7 @@ export const RetryWithVisibleReason: Story = {
expect(
canvas.getByText(/anthropic returned an unexpected error/i),
).toBeVisible();
- expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
+ expect(canvas.queryByTestId("live-activity-slot")).not.toBeInTheDocument();
expect(canvas.queryByText("Thinking...")).not.toBeInTheDocument();
expect(canvas.getByText(/attempt 1/i)).toBeVisible();
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
@@ -259,7 +259,7 @@ export const StartingShowsThinkingActivity: Story = {
},
};
-export const ResponseKeepsActivitySlotReserved: Story = {
+export const ResponseDoesNotRenderActivitySlot: Story = {
args: {
streamState: responseStreamState.streamState,
streamTools: responseStreamState.streamTools,
@@ -267,7 +267,7 @@ export const ResponseKeepsActivitySlotReserved: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
+ expect(canvas.queryByTestId("live-activity-slot")).not.toBeInTheDocument();
},
};
@@ -291,7 +291,7 @@ export const RunningToolsSuppressThinkingActivity: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
+ expect(canvas.queryByTestId("live-activity-slot")).not.toBeInTheDocument();
expect(
canvas.getByRole("button", { name: /expand command/i }),
).toBeVisible();
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
index 99cb9b30f4..56cb79014d 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
@@ -1,7 +1,6 @@
import type { FC } from "react";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
-import { cn } from "#/utils/cn";
import {
ConversationItem,
Message,
@@ -19,18 +18,10 @@ import type { MergedTool, StreamState } from "./types";
const hasCalloutLiveStatus = (liveStatus: LiveStatusModel): boolean =>
liveStatus.phase === "retrying" || liveStatus.phase === "reconnecting";
-const LiveActivitySlot: FC<{
- visible: boolean;
- detached: boolean;
-}> = ({ visible, detached }) => (
+const LiveActivitySlot: FC = () => (
@@ -72,8 +63,6 @@ export const StreamingOutput: FC<{
streamState,
streamTools,
});
- const hasVisibleFlowContent =
- shouldShowBlocks || hasCalloutLiveStatus(liveStatus);
const conversationItemProps = { role: "assistant" as const };
@@ -98,10 +87,7 @@ export const StreamingOutput: FC<{
{hasCalloutLiveStatus(liveStatus) && (
)}
-
+ {showActivity && }
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts
index 883f72280e..d7487a7f64 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts
@@ -206,6 +206,82 @@ describe("deriveMessageDisplayState", () => {
expect(getDisplayState(message).hasCopyableContent).toBe(false);
});
+ it("does not mark assistant messages ending with a tool call as copyable", () => {
+ const tool: MergedTool = {
+ id: "execute-1",
+ name: "execute",
+ args: { command: "pnpm test" },
+ isError: false,
+ status: "completed",
+ };
+ const message = buildMessage(
+ [{ type: "text", text: "Running the tests now." }],
+ "assistant",
+ );
+
+ const state = getDisplayState(message, {
+ parsed: parsed({
+ markdown: "Running the tests now.",
+ tools: [tool],
+ blocks: [
+ { type: "response", text: "Running the tests now." },
+ { type: "tool", id: tool.id },
+ ],
+ }),
+ });
+
+ expect(state.hasCopyableContent).toBe(false);
+ });
+
+ it("marks assistant messages ending with text after a tool call as copyable", () => {
+ const tool: MergedTool = {
+ id: "execute-1",
+ name: "execute",
+ args: { command: "pnpm test" },
+ isError: false,
+ status: "completed",
+ };
+ const message = buildMessage(
+ [{ type: "text", text: "All tests passed." }],
+ "assistant",
+ );
+
+ const state = getDisplayState(message, {
+ parsed: parsed({
+ markdown: "All tests passed.",
+ tools: [tool],
+ blocks: [
+ { type: "tool", id: tool.id },
+ { type: "response", text: "All tests passed." },
+ ],
+ }),
+ });
+
+ expect(state.hasCopyableContent).toBe(true);
+ });
+
+ it("does not mark assistant messages ending with a thinking block as copyable", () => {
+ // Intended: the action row renders below the whole message, so a
+ // trailing thinking disclosure has the same visual problem as a
+ // trailing tool call even though copyable markdown exists.
+ const message = buildMessage(
+ [{ type: "text", text: "Here is my answer." }],
+ "assistant",
+ );
+
+ const state = getDisplayState(message, {
+ parsed: parsed({
+ markdown: "Here is my answer.",
+ blocks: [
+ { type: "response", text: "Here is my answer." },
+ { type: "thinking", text: "Reconsidering the edge cases." },
+ ],
+ }),
+ });
+
+ expect(state.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." }],
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts
index c0cc884cd3..08a6a47657 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts
@@ -72,10 +72,14 @@ const getRenderableContentState = (parsed: ParsedMessageContent) => {
const hasThinkingOnlyContent =
visibleBlocks.length > 0 &&
visibleBlocks.every((block) => block.type === "thinking");
+ const endsWithResponseBlock =
+ visibleBlocks.length > 0 &&
+ visibleBlocks[visibleBlocks.length - 1].type === "response";
return {
hasRenderableContent,
hasThinkingOnlyContent,
+ endsWithResponseBlock,
};
};
@@ -139,9 +143,16 @@ export const deriveMessageDisplayState = ({
const hasUserMessageBody =
userInlineContent.length > 0 || Boolean(parsed.markdown.trim());
const hasFileBlocks = userFileBlocks.length > 0;
+ const { hasThinkingOnlyContent, endsWithResponseBlock } =
+ getRenderableContentState(parsed);
+ // The copy action row renders below the whole message, so assistant
+ // messages only get one when the last visible block is text.
+ // Otherwise the button would sit under a tool call with nothing
+ // copyable directly above it.
const hasCopyableContent =
- Boolean(parsed.markdown.trim()) && !hasFileAttachments;
- const { hasThinkingOnlyContent } = getRenderableContentState(parsed);
+ Boolean(parsed.markdown.trim()) &&
+ !hasFileAttachments &&
+ (isUser || endsWithResponseBlock);
const needsAssistantBottomSpacer =
!hideActions &&
!hasActiveStream &&
diff --git a/site/src/pages/AgentsPage/components/ChatElements/TranscriptRow.tsx b/site/src/pages/AgentsPage/components/ChatElements/TranscriptRow.tsx
index c4dacabe18..2155a664d8 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/TranscriptRow.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/TranscriptRow.tsx
@@ -6,10 +6,7 @@ type TranscriptRowProps = ComponentPropsWithRef<"div"> & {
asChild?: boolean;
};
-/**
- * Some transcript rows bypass ToolCollapsible, so they need one shared place
- * to keep the collapsed row height aligned across the chat timeline.
- */
+/** Consistent min-height for transcript rows that bypass the ToolCall primitives. */
export const TranscriptRow: FC = ({
asChild = false,
className,
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx
deleted file mode 100644
index 257af63137..0000000000
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { ChevronDownIcon } from "lucide-react";
-import type { FC, ReactNode } from "react";
-import { useState } from "react";
-import { cn } from "#/utils/cn";
-import { TranscriptRow } from "../TranscriptRow";
-
-type ToolCollapsibleAriaLabel = string | ((expanded: boolean) => string);
-type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
-
-interface ToolCollapsibleProps {
- children: ReactNode;
- header: ToolCollapsibleHeader;
- headerActions?: ReactNode;
- headerStatus?: ReactNode;
- hasContent?: boolean;
- defaultExpanded?: boolean;
- expanded?: boolean;
- onExpandedChange?: (expanded: boolean) => void;
- ariaLabel?: ToolCollapsibleAriaLabel;
- className?: string;
- headerClassName?: string;
-}
-
-export const ToolCollapsible: FC = ({
- children,
- header,
- headerActions,
- headerStatus,
- hasContent = true,
- defaultExpanded = false,
- expanded: expandedProp,
- onExpandedChange,
- ariaLabel,
- className,
- headerClassName,
-}) => {
- const [uncontrolledExpanded, setUncontrolledExpanded] =
- useState(defaultExpanded);
- const expanded = expandedProp ?? uncontrolledExpanded;
- const renderedHeader =
- typeof header === "function" ? header(expanded) : header;
- const toggleExpanded = () => {
- const nextExpanded = !expanded;
- if (expandedProp === undefined) {
- setUncontrolledExpanded(nextExpanded);
- }
- onExpandedChange?.(nextExpanded);
- };
- const headerButton = hasContent ? (
-
-
-
- ) : (
-
- {renderedHeader}
- {headerStatus}
-
- );
-
- return (
-
- {headerActions ? (
-
- {headerButton}
-
- {headerActions}
-
-
- ) : (
- headerButton
- )}
- {expanded && hasContent && children}
-
- );
-};
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WebSearchSources.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WebSearchSources.tsx
index 0ede427fd2..090b4f3fa4 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/WebSearchSources.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WebSearchSources.tsx
@@ -1,17 +1,13 @@
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "#/utils/cn";
-import { ToolCollapsible } from "./ToolCollapsible";
+import { ToolCall } from "./ToolCall";
interface WebSearchSourcesProps {
sources: Array<{ url: string; title: string }>;
}
-/**
- * Renders web search sources as a collapsible tool card, consistent
- * with other tool call renderings. The collapsed header shows a globe
- * icon and "Searched N sources"; expanding reveals clickable pills.
- */
+/** Collapsible web-search result pills, styled as a ToolCall row. */
const WebSearchSources: FC = ({ sources }) => {
// Deduplicate sources by URL, keeping the first occurrence.
const unique = (() => {
@@ -32,23 +28,24 @@ const WebSearchSources: FC = ({ sources }) => {
const detail = unique.length === 1 ? "1 result" : `${unique.length} results`;
return (
- 0}
- header={
- <>
+ 0}>
+
+
-
- Searched {detail}
-
- >
- }
- >
-
- {unique.map((source) => (
-
- ))}
-
-
+
+
+ Searched {detail}
+
+
+
+
+
+ {unique.map((source) => (
+
+ ))}
+
+
+
);
};
diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx
index 51b1287316..481046dd28 100644
--- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx
@@ -37,6 +37,8 @@ const buildThinkingSpacerStore = () => {
text: "I should think before answering.",
},
]),
+ // A following message is needed so the spacer renders.
+ buildMessage(3, "user", [{ type: "text", text: "Any progress?" }]),
]);
return store;