mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(site): use ToolCollapsible for thinking blocks (#25445)
This commit is contained in:
@@ -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]));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+183
-14
@@ -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.");
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user