fix(site): use ToolCollapsible for thinking blocks (#25445)

This commit is contained in:
Danielle Maywood
2026-05-20 21:58:19 +01:00
committed by GitHub
parent 63900d212d
commit 889add734e
20 changed files with 847 additions and 286 deletions
@@ -2433,7 +2433,27 @@ export const WithEveryTool: Story = {
expect(canvas.getByText(/Editing 2 files/)).toBeInTheDocument();
expect(canvas.getByText(/Reading CHANGELOG\.md/)).toBeInTheDocument();
expect(canvas.getByText(/Writing CHANGELOG\.md/)).toBeInTheDocument();
expect(canvas.getByText(/Attached auth-split\.md/)).toBeInTheDocument();
expect(
canvas.getByRole("button", { name: /Spawned Workspace diagnostics/i }),
).toBeInTheDocument();
expect(
canvas.getByRole("button", { name: /Read skill deep-review/i }),
).toBeInTheDocument();
});
const rowHeights = [
canvas.getByText(/Attached auth-split\.md/),
canvas.getByRole("button", {
name: /Spawned Workspace diagnostics/i,
}),
canvas.getByRole("button", { name: /Read skill deep-review/i }),
].map((label) => {
const row = label.closest("[data-transcript-row]");
expect(row).toBeInstanceOf(HTMLElement);
return Math.round((row as HTMLElement).getBoundingClientRect().height);
});
expect(new Set(rowHeights)).toEqual(new Set([24]));
},
};
@@ -1869,32 +1869,69 @@ export const SourcesOnlyAssistantSpacing: Story = {
},
};
export const NoRenderableContentFallbackSpacing: Story = {
/**
* Regression: assistant messages whose only tool row resolves to null
* must not leave behind an empty transcript wrapper or an extra gap.
*/
export const HiddenAssistantToolMessageDoesNotRenderGap: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 101,
role: "assistant",
content: [],
id: 201,
role: "user",
content: [{ type: "text", text: "Run the command" }],
},
{
...baseMessage,
id: 102,
id: 202,
role: "assistant",
content: [{ type: "text", text: "Done." }],
},
{
...baseMessage,
id: 203,
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "hidden-execute",
tool_name: "execute",
args: {},
},
],
},
{
...baseMessage,
id: 204,
role: "user",
content: [{ type: "text", text: "Thanks for trying!" }],
content: [{ type: "text", text: "Thanks!" }],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Message has no renderable content."),
).toBeInTheDocument();
expect(
document.querySelector('[data-testid="assistant-bottom-spacer"]'),
).toBeInTheDocument();
canvas.queryByText("Message has no renderable content."),
).not.toBeInTheDocument();
for (const el of canvasElement.querySelectorAll(
'[data-testid="message-actions"]',
)) {
if (el instanceof HTMLElement) {
el.style.opacity = "1";
}
}
const timeline = canvas.getByTestId("conversation-timeline");
const renderedRows = Array.from(
timeline.querySelectorAll('[data-role="user"], [data-role="assistant"]'),
);
expect(renderedRows).toHaveLength(3);
expect(renderedRows[1]).toHaveAttribute("data-role", "assistant");
expect(renderedRows[1]).toHaveTextContent("Done.");
expect(canvas.getAllByTestId("message-actions")).toHaveLength(3);
},
};
@@ -2211,12 +2248,144 @@ export const ThinkingBlockWithToolCall: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /thinking/i }),
).toBeInTheDocument();
const thinkingButton = canvas.getByRole("button", { name: /thinking/i });
expect(thinkingButton).toBeInTheDocument();
expect(
canvas.getByRole("button", { name: /read package\.json/i }),
).toBeInTheDocument();
const toolButton = canvas.getByRole("button", {
name: /read package\.json/i,
});
const thinkingContainer = thinkingButton.closest("[data-transcript-row]");
const toolContainer = toolButton.closest("[data-transcript-row]");
expect(thinkingContainer).toBeInstanceOf(HTMLElement);
expect(toolContainer).toBeInstanceOf(HTMLElement);
expect(toolContainer?.firstElementChild).not.toHaveAttribute("data-state");
expect(thinkingContainer?.firstElementChild).not.toHaveAttribute(
"data-state",
);
expect(
canvas.queryByTestId("assistant-bottom-spacer"),
).not.toBeInTheDocument();
},
};
/** Shell-style tool rows should keep the same collapsed height as Thinking. */
export const ThinkingBlockWithShellTools: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "always_collapsed" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{
type: "reasoning",
text: "I should inspect the current chat spacing before patching it.",
},
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: { command: "pnpm test" },
},
{
type: "tool-call",
tool_call_id: "tool-2",
tool_name: "process_output",
args: { process_id: "process-1" },
},
],
},
{
...baseMessage,
id: 2,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-1",
tool_name: "execute",
result: { output: "", wall_duration_ms: "667" },
},
],
},
{
...baseMessage,
id: 3,
role: "tool",
content: [
{
type: "tool-result",
tool_call_id: "tool-2",
tool_name: "process_output",
result: { output: "Spacing looks stable." },
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const thinkingButton = canvas.getByRole("button", { name: /thinking/i });
const executeButton = canvas.getByRole("button", {
name: /expand command/i,
});
const processOutputButton = canvas.getByRole("button", {
name: /expand process output/i,
});
const thinkingRow = thinkingButton.closest(
"[data-transcript-row]",
)?.firstElementChild;
const executeRow = executeButton.closest(
"[data-transcript-row]",
)?.firstElementChild;
const processOutputRow = processOutputButton.closest(
"[data-transcript-row]",
)?.firstElementChild;
expect(thinkingRow).toBeInstanceOf(HTMLElement);
expect(executeRow).toBeInstanceOf(HTMLElement);
expect(processOutputRow).toBeInstanceOf(HTMLElement);
const rowHeights = [thinkingRow, executeRow, processOutputRow].map((row) =>
Math.round((row as HTMLElement).getBoundingClientRect().height),
);
expect(new Set(rowHeights)).toHaveLength(1);
const wrappers = [
thinkingButton.closest("[data-transcript-row]"),
executeButton.closest("[data-transcript-row]"),
processOutputButton.closest("[data-transcript-row]"),
].map((row) => row as HTMLElement);
const gaps = [
Math.round(
wrappers[1].getBoundingClientRect().top -
wrappers[0].getBoundingClientRect().bottom,
),
Math.round(
wrappers[2].getBoundingClientRect().top -
wrappers[1].getBoundingClientRect().bottom,
),
];
expect(gaps).toEqual([8, 8]);
},
};
@@ -1,9 +1,4 @@
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
PencilIcon,
} from "lucide-react";
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from "lucide-react";
import {
type FC,
Fragment,
@@ -20,11 +15,6 @@ import type * as TypesGen from "#/api/typesGenerated";
import type { ThinkingDisplayMode } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/Collapsible/Collapsible";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import {
Tooltip,
@@ -43,6 +33,7 @@ import {
} from "../ChatElements";
import { WebSearchSources } from "../ChatElements/tools";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ToolCollapsible } from "../ChatElements/tools/ToolCollapsible";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import {
@@ -152,61 +143,39 @@ const ReasoningDisclosure = memo<{
}, [displayTextLength, isPreviewConstrained]);
return (
<div
data-tool-call=""
className={cn(
"py-0.5",
// Collapse padding between adjacent tool/thinking blocks.
"[&:has(+[data-tool-call])]:pb-0",
"[[data-tool-call]+&]:pt-0",
)}
>
<Collapsible
open={expanded}
onOpenChange={(open) => setManualToggle(open)}
<div data-transcript-row="">
<ToolCollapsible
className="w-full"
>
<CollapsibleTrigger
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
"flex w-full items-center gap-2 cursor-pointer",
"text-content-secondary transition-colors hover:text-content-primary",
)}
>
{isStreaming ? (
expanded={expanded}
onExpandedChange={(open) => setManualToggle(open)}
header={
isStreaming ? (
<Shimmer as="span" className="text-[13px]">
Thinking
</Shimmer>
) : (
<span className="text-[13px]">Thinking</span>
)}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</CollapsibleTrigger>
)
}
>
{hasText && (
<CollapsibleContent>
<div
ref={previewScrollRef}
className={cn(
"mt-1.5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
<div
ref={previewScrollRef}
className={cn(
"mt-1.5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
{displayText}
</Response>
</div>
</CollapsibleContent>
{displayText}
</Response>
</div>
)}
</Collapsible>
</ToolCollapsible>
</div>
);
},
@@ -552,10 +521,6 @@ const ChatMessageItem = memo<{
return null;
}
const hasRenderableContent =
parsed.blocks.length > 0 ||
parsed.tools.length > 0 ||
parsed.sources.length > 0;
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
};
@@ -580,8 +545,8 @@ const ChatMessageItem = memo<{
) : (
<Message className="w-full">
<MessageContent className="whitespace-normal">
{/* Keep consecutive shell tools tighter because execute/process_output pairs read as one terminal interaction. */}
<div className="relative space-y-3 overflow-visible [&>[data-shell-tool]+[data-shell-tool]]:mt-2">
{/* Keep assistant content spacing consistent by letting the parent stack own every top-level gap. */}
<div className="relative flex flex-col gap-2 overflow-visible">
<BlockList
blocks={parsed.blocks}
tools={parsed.tools}
@@ -606,11 +571,6 @@ const ChatMessageItem = memo<{
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
Message has no renderable content.
</div>
)}
</div>
</MessageContent>
</Message>
@@ -279,13 +279,34 @@ export const ThinkingDuringStreamingWithToolCalls: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Tool-only stream chunks can otherwise clear the activity indicator before text arrives.
const matches = canvas.getAllByText("Thinking");
expect(matches.length).toBeGreaterThanOrEqual(1);
expect(canvas.getAllByText("Thinking").length).toBeGreaterThanOrEqual(1);
const thinkingBlock = matches[0].parentElement;
expect(thinkingBlock).toBeInstanceOf(HTMLElement);
expect(getComputedStyle(thinkingBlock as HTMLElement).marginTop).toBe(
"8px",
const toolCallWrappers = Array.from(
canvasElement.querySelectorAll("[data-transcript-row]"),
);
expect(toolCallWrappers).toHaveLength(3);
const thinkingWrapper = toolCallWrappers.at(-1);
const previousToolWrapper = toolCallWrappers.at(-2);
expect(thinkingWrapper).toBeInstanceOf(HTMLElement);
expect(previousToolWrapper).toBeInstanceOf(HTMLElement);
expect(thinkingWrapper).toHaveTextContent("Thinking");
const gap = Math.round(
(thinkingWrapper as HTMLElement).getBoundingClientRect().top -
(previousToolWrapper as HTMLElement).getBoundingClientRect().bottom,
);
expect(gap).toBe(8);
// The placeholder inner row must match the committed collapsed
// Thinking row height so the transition from streaming to settled
// does not jump. ToolCollapsible enforces min-h-6 (24px).
const placeholderRow = (thinkingWrapper as HTMLElement).firstElementChild;
expect(placeholderRow).toBeInstanceOf(HTMLElement);
expect(
Math.round(
(placeholderRow as HTMLElement).getBoundingClientRect().height,
),
).toBe(24);
},
};
@@ -7,6 +7,7 @@ import {
MessageContent,
Shimmer,
} from "../ChatElements";
import { TranscriptRow } from "../ChatElements/TranscriptRow";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ChatStatusCallout } from "./ChatStatusCallout";
import { BlockList } from "./ConversationTimeline";
@@ -33,10 +34,12 @@ const hasTextOrReasoningBlock = (blocks: readonly RenderBlock[]): boolean =>
* as the ChatStatusCallout status placeholder.
*/
const StreamingThinkingPlaceholder: FC = () => (
<div className="flex w-full items-center gap-2 py-0.5 text-content-secondary">
<Shimmer as="span" className="text-[13px] leading-relaxed">
Thinking
</Shimmer>
<div data-transcript-row="" className="text-content-secondary">
<TranscriptRow className="w-full gap-2">
<Shimmer as="span" className="text-[13px] leading-relaxed">
Thinking
</Shimmer>
</TranscriptRow>
</div>
);
@@ -92,7 +95,7 @@ export const StreamingOutput: FC<{
<ConversationItem {...conversationItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-2">
<div className="relative flex flex-col gap-2 overflow-visible">
{shouldShowBlocks && (
<BlockList
blocks={blocks}
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
import { deriveMessageDisplayState } from "./messageHelpers";
import { parseMessageContent } from "./messageParsing";
import { parseMessagesWithMergedTools } from "./messageParsing";
import type { ParsedMessageContent } from "./types";
const buildMessage = (
content: ChatMessagePart[],
@@ -14,13 +15,16 @@ const buildMessage = (
content,
});
const getParsedMessage = (message: ChatMessage) =>
parseMessagesWithMergedTools([message])[0].parsed;
const getDisplayState = (
message: ChatMessage,
overrides: Partial<Parameters<typeof deriveMessageDisplayState>[0]> = {},
) =>
deriveMessageDisplayState({
message,
parsed: parseMessageContent(message.content),
parsed: getParsedMessage(message),
hideActions: false,
hasActiveStream: false,
...overrides,
@@ -63,7 +67,7 @@ describe("deriveMessageDisplayState", () => {
expect(getDisplayState(message).hasCopyableContent).toBe(false);
});
it("shows the assistant spacer for reasoning messages when no suppressing flags apply", () => {
it("shows the assistant spacer for thinking-only messages when no suppressing flags apply", () => {
const message = buildMessage(
[{ type: "reasoning", text: "I should think before answering." }],
"assistant",
@@ -72,6 +76,40 @@ describe("deriveMessageDisplayState", () => {
expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(true);
});
it("hides the assistant spacer when thinking is followed by a tool call", () => {
const message = buildMessage(
[
{ type: "reasoning", text: "I should think before acting." },
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: { command: "pnpm storybook --no-open" },
},
],
"assistant",
);
expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(false);
});
it("shows the assistant spacer when thinking is followed by a hidden execute tool", () => {
const message = buildMessage(
[
{ type: "reasoning", text: "I should think before acting." },
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: {},
},
],
"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." }],
@@ -113,4 +151,68 @@ describe("deriveMessageDisplayState", () => {
expect(getDisplayState(message).needsAssistantBottomSpacer).toBe(false);
});
it("hides assistant messages with no renderable content", () => {
const message = buildMessage([], "assistant");
expect(getDisplayState(message).shouldHide).toBe(true);
});
it("hides assistant messages whose execute tool renders nothing", () => {
const message = buildMessage(
[
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: {},
},
],
"assistant",
);
expect(getDisplayState(message).shouldHide).toBe(true);
});
it("keeps assistant messages visible when execute shows a real command", () => {
const message = buildMessage(
[
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "execute",
args: { command: "pnpm test" },
},
],
"assistant",
);
expect(getDisplayState(message).shouldHide).toBe(false);
});
it("hides running wait_agent messages until the chat id is available", () => {
const message = buildMessage([], "assistant");
const parsed: ParsedMessageContent = {
...getParsedMessage(message),
blocks: [{ type: "tool", id: "wait-1" }],
tools: [
{
id: "wait-1",
name: "wait_agent",
args: {},
isError: false,
status: "running",
},
],
};
expect(
deriveMessageDisplayState({
message,
parsed,
hideActions: false,
hasActiveStream: false,
}).shouldHide,
).toBe(true);
});
});
@@ -1,4 +1,5 @@
import type * as TypesGen from "#/api/typesGenerated";
import { shouldRenderTool } from "../ChatElements/tools/toolVisibility";
import type { ParsedMessageContent, RenderBlock } from "./types";
export type UserInlineRenderBlock =
@@ -37,6 +38,33 @@ const isMetadataOnlyMessage = (
parts.length > 0 &&
parts.every((part) => part.type === "context-file" || part.type === "skill");
const getRenderableContentState = (parsed: ParsedMessageContent) => {
const visibleTools = parsed.tools.filter((tool) =>
shouldRenderTool({
name: tool.name,
status: tool.status,
args: tool.args,
result: tool.result,
}),
);
const visibleToolIds = new Set(visibleTools.map((tool) => tool.id));
const visibleBlocks = parsed.blocks.filter(
(block) => block.type !== "tool" || visibleToolIds.has(block.id),
);
const hasRenderableContent =
visibleBlocks.length > 0 ||
visibleTools.length > 0 ||
parsed.sources.length > 0;
const hasThinkingOnlyContent =
visibleBlocks.length > 0 &&
visibleBlocks.every((block) => block.type === "thinking");
return {
hasRenderableContent,
hasThinkingOnlyContent,
};
};
export const deriveMessageDisplayState = ({
message,
parsed,
@@ -61,19 +89,15 @@ export const deriveMessageDisplayState = ({
const hasFileBlocks = userFileBlocks.length > 0;
const hasCopyableContent =
Boolean(parsed.markdown.trim()) && !hasFileAttachments;
const hasRenderableContent =
parsed.blocks.length > 0 ||
parsed.tools.length > 0 ||
parsed.sources.length > 0;
const { hasRenderableContent, hasThinkingOnlyContent } =
getRenderableContentState(parsed);
const needsAssistantBottomSpacer =
!hideActions &&
!hasActiveStream &&
!isAwaitingFirstStreamChunk &&
!isUser &&
!hasCopyableContent &&
(Boolean(parsed.reasoning) ||
parsed.sources.length > 0 ||
!hasRenderableContent);
(hasThinkingOnlyContent || parsed.sources.length > 0);
const hasToolResultsOnly =
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
@@ -85,7 +109,8 @@ export const deriveMessageDisplayState = ({
shouldHide:
hasToolResultsOnly ||
isProviderToolResultOnlyMessage(parts) ||
isMetadataOnlyMessage(parts),
isMetadataOnlyMessage(parts) ||
(!isUser && !hasRenderableContent),
userInlineContent,
userFileBlocks,
hasUserMessageBody,
@@ -0,0 +1,23 @@
import { Slot } from "radix-ui";
import type { ComponentPropsWithRef, FC } from "react";
import { cn } from "#/utils/cn";
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.
*/
export const TranscriptRow: FC<TranscriptRowProps> = ({
asChild = false,
className,
...props
}) => {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp {...props} className={cn("flex min-h-6 items-center", className)} />
);
};
@@ -9,6 +9,7 @@ import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
import { cn } from "#/utils/cn";
import { TranscriptRow } from "../TranscriptRow";
import type { ToolStatus } from "./utils";
export type AskUserQuestion = {
@@ -536,16 +537,16 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
if (isError) {
return (
<div className="w-full">
<div
<TranscriptRow
role="alert"
className="flex items-center gap-1.5 py-0.5 text-[13px] text-content-secondary"
className="gap-1.5 text-[13px] text-content-secondary"
>
<TriangleAlertIcon
aria-label="Error"
className="h-3.5 w-3.5 shrink-0 text-content-secondary"
/>
<span>{errorMessage || "Failed to ask questions"}</span>
</div>
</TranscriptRow>
</div>
);
}
@@ -554,11 +555,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
return (
<div className="w-full">
{isRunning ? (
<div
role="status"
aria-live="polite"
className="flex items-center gap-1.5 py-0.5"
>
<TranscriptRow role="status" aria-live="polite" className="gap-1.5">
<span className="text-[13px] text-content-secondary">
Asking for clarification...
</span>
@@ -566,7 +563,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
data-testid="ask-user-question-loading-icon"
className="h-3.5 w-3.5 shrink-0 animate-spin text-content-secondary motion-reduce:animate-none"
/>
</div>
</TranscriptRow>
) : (
<p className="text-[13px] italic text-content-secondary">
No questions available.
@@ -680,11 +677,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
return (
<div className="w-full">
{isRunning && (
<div
role="status"
aria-live="polite"
className="flex items-center gap-1.5 py-0.5"
>
<TranscriptRow role="status" aria-live="polite" className="gap-1.5">
<span className="text-[13px] text-content-secondary">
Asking for clarification...
</span>
@@ -692,7 +685,7 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
data-testid="ask-user-question-loading-icon"
className="h-3.5 w-3.5 shrink-0 animate-spin text-content-secondary motion-reduce:animate-none"
/>
</div>
</TranscriptRow>
)}
{isInteractive ? (
@@ -20,6 +20,7 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { TranscriptRow } from "../TranscriptRow";
import {
type AgentDisplayState,
isAgentDisplayOpen,
@@ -93,21 +94,25 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
return (
<div className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5">
<button
type="button"
aria-expanded={outputOpen}
aria-label={outputToggleLabel}
onClick={() => setOutputOpen((value) => !value)}
className="col-start-1 row-start-1 m-0 flex w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left font-[inherit] font-normal text-[inherit] text-content-secondary transition-colors hover:text-content-primary"
<TranscriptRow
asChild
className="col-start-1 row-start-1 m-0 w-full min-w-0 cursor-pointer gap-2 border-0 bg-transparent p-0 text-left font-[inherit] font-normal text-[inherit] text-content-secondary transition-colors hover:text-content-primary"
>
<ShellCommandLine
command={command}
modelIntent={modelIntent}
durationLabel={durationLabel}
expanded={outputOpen}
/>
</button>
<div className="col-start-2 row-start-1 flex shrink-0 items-center gap-1">
<button
type="button"
aria-expanded={outputOpen}
aria-label={outputToggleLabel}
onClick={() => setOutputOpen((value) => !value)}
>
<ShellCommandLine
command={command}
modelIntent={modelIntent}
durationLabel={durationLabel}
expanded={outputOpen}
/>
</button>
</TranscriptRow>
<TranscriptRow className="col-start-2 row-start-1 shrink-0 gap-1">
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
@@ -157,7 +162,7 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
label="Copy command"
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
/>
</div>
</TranscriptRow>
{outputOpen && (
<ShellTranscriptBody
command={command}
@@ -111,7 +111,13 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
exit {exitCode}
</span>
)}
{hasOutput && <CopyButton text={output} label="Copy output" />}
{hasOutput && (
<CopyButton
text={output}
label="Copy output"
className="-my-0.5 size-6 p-0"
/>
)}
</>
) : undefined
}
@@ -10,6 +10,7 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { Response } from "../Response";
import { TranscriptRow } from "../TranscriptRow";
import type { ToolStatus } from "./utils";
export const ProposePlanTool: React.FC<{
@@ -72,7 +73,7 @@ export const ProposePlanTool: React.FC<{
return (
<div className="w-full">
<div className="flex items-center gap-1.5 py-0.5 text-content-secondary">
<TranscriptRow className="gap-1.5 text-content-secondary">
<span className="text-[13px]">
{isRunning ? `Proposing ${filename}` : `Proposed ${filename}`}
</span>
@@ -92,7 +93,7 @@ export const ProposePlanTool: React.FC<{
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</div>
</TranscriptRow>
{hasDisplayContent ? (
<>
<Response>{displayContent}</Response>
@@ -137,10 +138,10 @@ export const ProposePlanTool: React.FC<{
)
)}
{fetchLoading && (
<div className="flex items-center gap-1.5 py-2 text-[13px] text-content-secondary">
<TranscriptRow className="gap-1.5 text-[13px] text-content-secondary">
<LoaderIcon className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
Loading plan
</div>
</TranscriptRow>
)}
</div>
);
@@ -5,6 +5,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { TranscriptRow } from "../TranscriptRow";
import type { ToolStatus } from "./utils";
/**
@@ -26,7 +27,7 @@ export const ReadTemplateTool: React.FC<{
: "Read template";
return (
<div className="flex items-center gap-1.5 text-content-secondary">
<TranscriptRow className="gap-1.5 text-content-secondary">
<span className="text-[13px]">{label}</span>
{isError && (
<Tooltip>
@@ -41,6 +42,6 @@ export const ReadTemplateTool: React.FC<{
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</div>
</TranscriptRow>
);
};
@@ -15,6 +15,7 @@ import { cn } from "#/utils/cn";
import { safeBuildAgentChatPath } from "../../../utils/navigation";
import { Response } from "../Response";
import { Shimmer } from "../Shimmer";
import { TranscriptRow } from "../TranscriptRow";
import { useDesktopPanel } from "./DesktopPanelContext";
import { InlineDesktopPreview } from "./InlineDesktopPreview";
import { RecordingPreview } from "./RecordingPreview";
@@ -192,58 +193,60 @@ export const SubagentTool: React.FC<{
return (
<div className="w-full">
<button
type="button"
aria-expanded={hasExpandableContent ? expanded : undefined}
onClick={() => hasExpandableContent && setExpanded((v) => !v)}
<TranscriptRow
asChild
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
"flex w-full items-center gap-2",
"text-content-secondary transition-colors",
"m-0 w-full gap-2 border-0 bg-transparent p-0 text-left font-[inherit] text-[inherit] text-content-secondary transition-colors",
hasExpandableContent && "cursor-pointer hover:text-content-primary",
)}
>
<SubagentStatusIcon
subagentStatus={subagentStatus}
toolStatus={toolStatus}
isError={isError}
isTimeout={isTimeout}
iconKind={descriptor.iconKind}
showDesktopPreview={showDesktopPreview}
/>{" "}
<span className="min-w-0 truncate text-[13px]">
{getSubagentLabel(
showDesktopPreview,
toolStatus,
descriptor,
title,
isTimeout,
)}
{agentChatPath && (
<Link
to={{ pathname: agentChatPath, search: location.search }}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View agent"
>
<ExternalLinkIcon className="h-3 w-3" />
</Link>
)}
</span>
{hasExpandableContent && (
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
<button
type="button"
aria-expanded={hasExpandableContent ? expanded : undefined}
onClick={() => hasExpandableContent && setExpanded((v) => !v)}
>
<SubagentStatusIcon
subagentStatus={subagentStatus}
toolStatus={toolStatus}
isError={isError}
isTimeout={isTimeout}
iconKind={descriptor.iconKind}
showDesktopPreview={showDesktopPreview}
/>{" "}
<span className="min-w-0 truncate text-[13px]">
{getSubagentLabel(
showDesktopPreview,
toolStatus,
descriptor,
title,
isTimeout,
)}
{agentChatPath && (
<Link
to={{ pathname: agentChatPath, search: location.search }}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View agent"
>
<ExternalLinkIcon className="h-3 w-3" />
</Link>
)}
/>
)}
{durationLabel && (
<span className="ml-auto shrink-0 text-xs">
{`Worked for ${durationLabel}`}
</span>
)}
</button>
{hasExpandableContent && (
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
)}
{durationLabel && (
<span className="ml-auto shrink-0 text-xs">
{`Worked for ${durationLabel}`}
</span>
)}
</button>
</TranscriptRow>
{showDesktopPreview && desktopChatId && toolStatus !== "completed" && (
<div className="mt-1.5 overflow-hidden rounded-lg border border-solid border-border-default">
@@ -848,6 +848,7 @@ export const CloseAgentRunningWithoutChatId: Story = {
await waitFor(() => {
expect(canvasElement.textContent?.trim()).toBe("");
});
expect(canvasElement.querySelector("[data-transcript-row]")).toBeNull();
expect(canvas.queryByRole("button")).toBeNull();
expect(canvas.queryByRole("link", { name: "View agent" })).toBeNull();
},
@@ -42,6 +42,7 @@ import {
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolLabel } from "./ToolLabel";
import { getExecuteRenderData, shouldRenderTool } from "./toolVisibility";
import {
asNumber,
asRecord,
@@ -215,53 +216,6 @@ const parseAskUserQuestionResult = (
return null;
};
type ExecuteRenderData = {
command: string;
output: string;
durationMs?: number;
isBackgrounded: boolean;
authenticateURL: string;
providerLabel: string;
};
const getExecuteRenderData = (
args: unknown,
result: unknown,
): ExecuteRenderData => {
const parsedArgs = parseArgs(args);
const command = parsedArgs ? asString(parsedArgs.command) : "";
const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : "";
const durationMs = rec
? (asNumber(rec.wall_duration_ms, { parseString: true }) ??
asNumber(rec.duration_ms, { parseString: true }))
: undefined;
const isBackgrounded = Boolean(
rec && asString(rec.background_process_id).trim(),
);
const authenticateURL = rec?.auth_required
? asString(rec.authenticate_url).trim()
: "";
const providerLabel = toProviderLabel(
rec ? asString(rec.provider_display_name).trim() : "",
rec ? asString(rec.provider_id).trim() : "",
rec ? asString(rec.provider_type).trim() : "",
);
return {
command,
output,
durationMs,
isBackgrounded,
authenticateURL,
providerLabel,
};
};
const shouldHideExecuteTool = (data: ExecuteRenderData): boolean => {
return data.command.trim().length === 0 && !data.authenticateURL;
};
const ExecuteRenderer: FC<ToolRendererProps> = ({
status,
args,
@@ -273,10 +227,6 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
}) => {
const data = getExecuteRenderData(args, result);
if (shouldHideExecuteTool(data)) {
return null;
}
if (data.authenticateURL) {
return (
<ExecuteAuthRequiredTool
@@ -598,20 +548,6 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
}
}
// Postpone rendering wait_agent, message_agent, and close_agent
// until the chat_id has been parsed from the streaming args.
// Without it we cannot determine variant or title, which causes
// a brief flash of the generic lifecycle copy.
if (!chatId && status === "running") {
if (
descriptor.action === "wait" ||
descriptor.action === "message" ||
descriptor.action === "close"
) {
return null;
}
}
return (
<SubagentTool
descriptor={descriptor}
@@ -1096,25 +1032,18 @@ export const Tool = memo(
? SubagentRenderer
: (toolRenderers[name] ?? GenericToolRenderer);
const isShellTool = name === "execute" || name === "process_output";
if (
name === "execute" &&
shouldHideExecuteTool(getExecuteRenderData(args, result))
) {
if (!shouldRenderTool({ name, status, args, result })) {
return null;
}
return (
<div
ref={ref}
data-tool-call=""
data-shell-tool={isShellTool ? "" : undefined}
data-transcript-row=""
className={cn(
isShellTool || name === "propose_plan" || name === "advisor"
? "w-full py-0.5"
: "py-0.5",
// Keep back-to-back tool cards visually grouped so stacked tool calls do not look double-spaced.
"[&:has(+[data-tool-call])]:pb-0",
"[[data-tool-call]+&]:pt-0",
? "w-full"
: undefined,
className,
)}
{...props}
@@ -3,6 +3,7 @@ import type { FC, ReactNode } from "react";
import { useState } from "react";
import type { AgentDisplayMode } from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { TranscriptRow } from "../TranscriptRow";
import {
type AgentDisplayState,
isAgentDisplayOpen,
@@ -18,6 +19,8 @@ interface ToolCollapsibleProps {
headerActions?: ReactNode;
hasContent?: boolean;
defaultExpanded?: boolean;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
ariaLabel?: ToolCollapsibleAriaLabel;
className?: string;
headerClassName?: string;
@@ -49,47 +52,60 @@ export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
headerActions,
hasContent = true,
defaultExpanded = false,
expanded: expandedProp,
onExpandedChange,
ariaLabel,
className,
headerClassName,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
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 ? (
<button
type="button"
aria-expanded={expanded}
aria-label={
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel
}
onClick={() => setExpanded(!expanded)}
<TranscriptRow
asChild
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
"flex items-center gap-2 cursor-pointer",
"text-content-secondary transition-colors hover:text-content-primary",
"m-0 cursor-pointer gap-2 border-0 bg-transparent p-0 text-left font-[inherit] text-[inherit] text-content-secondary transition-colors hover:text-content-primary",
headerActions ? "min-w-0 flex-1" : "w-full",
headerClassName,
)}
>
{renderedHeader}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
<button
type="button"
aria-expanded={expanded}
aria-label={
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel
}
onClick={toggleExpanded}
>
{renderedHeader}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
</TranscriptRow>
) : (
<div
<TranscriptRow
className={cn(
"flex items-center gap-2 text-content-secondary",
"gap-2 text-content-secondary",
headerActions && "min-w-0 flex-1",
headerClassName,
)}
>
{renderedHeader}
</div>
</TranscriptRow>
);
return (
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { getExecuteRenderData, shouldRenderTool } from "./toolVisibility";
describe("toolVisibility", () => {
describe("getExecuteRenderData", () => {
it("parses execute output and auth metadata from result payloads", () => {
expect(
getExecuteRenderData(
{ command: "git fetch origin" },
{
output: " fetched ",
wall_duration_ms: "47200",
background_process_id: "process-1",
auth_required: true,
authenticate_url: "https://example.com/auth",
provider_display_name: "GitHub",
},
),
).toEqual({
command: "git fetch origin",
output: "fetched",
durationMs: 47200,
isBackgrounded: true,
authenticateURL: "https://example.com/auth",
providerLabel: "GitHub",
});
});
});
describe("shouldRenderTool", () => {
it("hides execute rows with neither a command nor an auth prompt", () => {
expect(
shouldRenderTool({
name: "execute",
status: "completed",
args: {},
result: { output: "ignored" },
}),
).toBe(false);
});
it("keeps execute rows when auth is required even without a command", () => {
expect(
shouldRenderTool({
name: "execute",
status: "completed",
args: {},
result: {
auth_required: true,
authenticate_url: "https://example.com/auth",
},
}),
).toBe(true);
});
it("hides running wait_agent rows until chat_id is available", () => {
expect(
shouldRenderTool({
name: "wait_agent",
status: "running",
args: {},
result: { status: "pending" },
}),
).toBe(false);
});
it("hides running message_agent rows until chat_id is available", () => {
expect(
shouldRenderTool({
name: "message_agent",
status: "running",
args: { message: "continue" },
result: { status: "pending" },
}),
).toBe(false);
});
it("hides running close_agent rows until chat_id is available", () => {
expect(
shouldRenderTool({
name: "close_agent",
status: "running",
args: {},
result: { status: "running" },
}),
).toBe(false);
});
it("renders running lifecycle rows once args provide the chat_id", () => {
expect(
shouldRenderTool({
name: "wait_agent",
status: "running",
args: { chat_id: "child-chat-1" },
result: { status: "pending" },
}),
).toBe(true);
});
it("renders completed lifecycle rows even if chat_id is absent", () => {
expect(
shouldRenderTool({
name: "close_agent",
status: "completed",
args: {},
result: { status: "completed" },
}),
).toBe(true);
});
it("keeps unrelated tools visible", () => {
expect(
shouldRenderTool({
name: "read_file",
status: "completed",
args: { path: "README.md" },
result: { content: "docs" },
}),
).toBe(true);
});
});
});
@@ -0,0 +1,112 @@
import { getSubagentChatId, getSubagentDescriptor } from "./subagentDescriptor";
import {
asNumber,
asRecord,
asString,
parseArgs,
type ToolStatus,
toProviderLabel,
} from "./utils";
type ExecuteRenderData = {
command: string;
output: string;
durationMs?: number;
isBackgrounded: boolean;
authenticateURL: string;
providerLabel: string;
};
/**
* Execute payloads can arrive partially populated, so visibility and rendering
* share one defensive, normalized interpretation of args and results here.
*/
export const getExecuteRenderData = (
args: unknown,
result: unknown,
): ExecuteRenderData => {
const parsedArgs = parseArgs(args);
const command = parsedArgs ? asString(parsedArgs.command) : "";
const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : "";
const durationMs = rec
? (asNumber(rec.wall_duration_ms, { parseString: true }) ??
asNumber(rec.duration_ms, { parseString: true }))
: undefined;
const isBackgrounded = Boolean(
rec && asString(rec.background_process_id).trim(),
);
const authenticateURL = rec?.auth_required
? asString(rec.authenticate_url).trim()
: "";
const providerLabel = toProviderLabel(
rec ? asString(rec.provider_display_name).trim() : "",
rec ? asString(rec.provider_id).trim() : "",
rec ? asString(rec.provider_type).trim() : "",
);
return {
command,
output,
durationMs,
isBackgrounded,
authenticateURL,
providerLabel,
};
};
const shouldRenderExecuteTool = (data: ExecuteRenderData): boolean => {
return data.command.trim().length > 0 || Boolean(data.authenticateURL);
};
const shouldRenderSubagentLifecycleTool = ({
name,
status,
args,
result,
}: {
name: string;
status: ToolStatus;
args?: unknown;
result?: unknown;
}): boolean => {
const descriptor = getSubagentDescriptor({ name, args, result });
if (!descriptor || status !== "running") {
return true;
}
if (
descriptor.action !== "wait" &&
descriptor.action !== "message" &&
descriptor.action !== "close"
) {
return true;
}
// Wait, message, and close rows can stream before their target chat_id
// arrives. Hiding them until that id exists avoids flashing generic
// lifecycle copy before the transcript can resolve the real title.
return Boolean(getSubagentChatId({ args, result }));
};
/**
* Centralize tool-row visibility so transcript message hiding stays in sync
* with <Tool> row rendering and hidden rows never leave empty gaps behind.
*/
export const shouldRenderTool = ({
name,
status,
args,
result,
}: {
name: string;
status: ToolStatus;
args?: unknown;
result?: unknown;
}): boolean => {
if (name === "execute") {
return shouldRenderExecuteTool(getExecuteRenderData(args, result));
}
return shouldRenderSubagentLifecycleTool({ name, status, args, result });
};
@@ -71,6 +71,22 @@ const buildRegressionStore = () => {
return store;
};
const buildThinkingSpacerStore = () => {
const store = createChatStore();
store.replaceMessages([
buildMessage(1, "user", [{ type: "text", text: "Read the source files" }]),
buildMessage(2, "assistant", [
{
type: "reasoning",
text: "I should think before answering.",
},
]),
]);
return store;
};
export const StreamingToolCallGapRegression: Story = {
render: () => {
const store = buildRegressionStore();
@@ -121,7 +137,7 @@ export const StartingPhaseToolCallGapRegression: Story = {
export const SpacerVisibleWhenNotStreaming: Story = {
render: () => {
const store = buildRegressionStore();
const store = buildThinkingSpacerStore();
return (
<ChatPageTimeline
@@ -133,6 +149,39 @@ export const SpacerVisibleWhenNotStreaming: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
canvas.getByRole("button", { name: /thinking/i });
expect(canvas.getByTestId("assistant-bottom-spacer")).toBeInTheDocument();
},
};
export const HiddenAssistantPlaceholderDoesNotRender: Story = {
render: () => {
const store = createChatStore();
store.replaceMessages([
buildMessage(1, "user", [{ type: "text", text: "Run the command" }]),
buildMessage(2, "assistant", [{ type: "text", text: "Done." }]),
buildMessage(3, "assistant", []),
buildMessage(4, "user", [{ type: "text", text: "Thanks!" }]),
]);
return (
<ChatPageTimeline
chatID={CHAT_ID}
store={store}
persistedError={undefined}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText("Message has no renderable content.")).toBeNull();
const rows = canvasElement.querySelectorAll(
'[data-role="user"], [data-role="assistant"]',
);
expect(rows).toHaveLength(3);
expect(rows[1]).toHaveAttribute("data-role", "assistant");
expect(rows[1]).toHaveTextContent("Done.");
},
};