fix(site/src/pages/AgentsPage): improve live tool activity (#26147)

This commit is contained in:
Danielle Maywood
2026-06-09 19:50:29 +01:00
committed by GitHub
parent 0d2c9f904a
commit 909f6f4e1a
44 changed files with 1929 additions and 1526 deletions
+1 -1
View File
@@ -1506,7 +1506,7 @@ const AgentChatPage: FC = () => {
if (!response.queued) {
store.clearStreamState();
// Optimistically set status to "running" so the
// "Thinking..." indicator appears immediately.
// Thinking indicator appears immediately.
// The server accepted the message (not queued),
// so it will start processing. The WebSocket
// status:running event no-ops via the
@@ -1,41 +1,15 @@
import { type FC, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Link } from "#/components/Link/Link";
import { Shimmer } from "../ChatElements";
import { TranscriptRow } from "../ChatElements/TranscriptRow";
import { ToolIcon } from "../ChatElements/tools/ToolIcon";
import { getProviderStatusURL } from "./chatStatusHelpers";
import type { LiveStatusModel } from "./liveStatusModel";
const THINKING_TEXT = "Thinking...";
type RetryOrFailedStatus = Extract<
LiveStatusModel,
{ phase: "retrying" } | { phase: "failed" }
>;
type ReconnectingStatus = Extract<LiveStatusModel, { phase: "reconnecting" }>;
const StatusPlaceholder: FC<{
text: string;
shimmer?: boolean;
showThinkingIcon?: boolean;
}> = ({ text, shimmer = false, showThinkingIcon = false }) => {
return (
<TranscriptRow className="gap-2 text-content-secondary">
{showThinkingIcon && <ToolIcon name="thinking" isError={false} />}
{shimmer ? (
<Shimmer as="span" className="text-[13px] leading-6">
{text}
</Shimmer>
) : (
<span className="text-[13px] leading-6 text-content-secondary">
{text}
</span>
)}
</TranscriptRow>
);
};
/**
* Syncs with the system clock to produce a live countdown from an
* ISO-8601 deadline. Polls at 100ms so the displayed second flips
@@ -178,25 +152,12 @@ export const ChatStatusCallout: FC<{
switch (status.phase) {
case "idle":
case "streaming":
return null;
case "starting":
return (
<StatusPlaceholder text={THINKING_TEXT} shimmer showThinkingIcon />
);
return null;
case "retrying":
return (
<>
<StatusAlert status={status} />
<StatusPlaceholder text={THINKING_TEXT} shimmer />
</>
);
return <StatusAlert status={status} />;
case "reconnecting":
return (
<>
<ReconnectingAlert status={status} />
<StatusPlaceholder text={THINKING_TEXT} shimmer />
</>
);
return <ReconnectingAlert status={status} />;
case "failed":
return <StatusAlert status={status} />;
}
@@ -33,23 +33,6 @@ const meta: Meta<typeof StreamingOutput> = {
export default meta;
type Story = StoryObj<typeof StreamingOutput>;
/** Default shimmer placeholder with no stream state. */
export const ThinkingPlaceholder: Story = {
args: {
streamState: null,
streamTools: [],
liveStatus: buildLiveStatus({ isAwaitingFirstStreamChunk: true }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const matches = canvas.getAllByText("Thinking...");
expect(matches.length).toBeGreaterThanOrEqual(1);
expect(
canvas.queryByRole("heading", { name: /retrying request/i }),
).not.toBeInTheDocument();
},
};
/** Transport reconnects render a non-terminal reconnecting callout. */
export const ReconnectingAfterDisconnect: Story = {
args: {
@@ -73,8 +56,8 @@ export const ReconnectingAfterDisconnect: Story = {
expect(canvasElement.textContent).toMatch(/reconnecting in \d+s/i);
});
expect(canvas.queryByText("Unexpected error")).not.toBeInTheDocument();
const thinkingMatches = canvas.getAllByText(/thinking\.\.\.$/i);
expect(thinkingMatches.length).toBeGreaterThanOrEqual(1);
expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
expect(canvas.queryByText("Thinking...")).not.toBeInTheDocument();
},
};
@@ -96,6 +79,8 @@ export const RetryWithVisibleReason: Story = {
expect(
canvas.getByText(/anthropic returned an unexpected error/i),
).toBeVisible();
expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
expect(canvas.queryByText("Thinking...")).not.toBeInTheDocument();
expect(canvas.getByText(/attempt 1/i)).toBeVisible();
expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument();
expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument();
@@ -254,12 +239,40 @@ export const RetryStreamSilenceTimeout: Story = {
},
};
/**
* During streaming, if only tool-call blocks have arrived (no text
* or reasoning), the "Thinking" indicator should still be visible
* alongside the tool cards.
*/
export const ThinkingDuringStreamingWithToolCalls: Story = {
const responseStreamState = buildStreamRenderState([
{
type: "text" as const,
text: "The answer is streaming.",
},
]);
export const StartingShowsThinkingActivity: Story = {
args: {
streamState: null,
streamTools: [],
liveStatus: buildLiveStatus({ isAwaitingFirstStreamChunk: true }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Thinking")).toBeVisible();
expect(canvas.getByTestId("live-activity-slot")).toBeVisible();
},
};
export const ResponseKeepsActivitySlotReserved: Story = {
args: {
streamState: responseStreamState.streamState,
streamTools: responseStreamState.streamTools,
liveStatus: responseStreamState.liveStatus,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
},
};
/** Tool-only streams use running tool affordances instead of generic thinking. */
export const RunningToolsSuppressThinkingActivity: Story = {
args: {
...buildStreamRenderState([
{
@@ -278,31 +291,89 @@ export const ThinkingDuringStreamingWithToolCalls: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Tool-only stream chunks can otherwise clear the activity indicator before text arrives.
expect(canvas.getAllByText("Thinking").length).toBeGreaterThanOrEqual(1);
expect(canvas.getByTestId("live-activity-slot")).not.toBeVisible();
expect(
canvas.getByRole("button", { name: /expand command/i }),
).toBeVisible();
expect(canvas.getByText(/reading README\.md/i)).toBeVisible();
},
};
const executeButton = canvas.getByRole("button", {
name: /expand command/i,
});
const readFileLabel = canvas.getByText(/reading README\.md/i);
const thinkingText = canvas.getAllByText("Thinking").at(-1);
expect(thinkingText).toBeInstanceOf(HTMLElement);
const editFilesArgs = {
files: JSON.stringify([
{
path: "src/config.ts",
edits: [
{
old_text: "const timeout = 30;",
new_text: "const timeout = 60;",
},
],
},
]),
};
const wrappers = [
executeButton.closest("[data-transcript-row]") ?? executeButton,
readFileLabel.closest("[data-tool-call]") ?? readFileLabel,
(thinkingText as HTMLElement).closest("[data-transcript-row]") ??
(thinkingText as HTMLElement),
];
expect(wrappers.at(-1)).toHaveTextContent("Thinking");
const editFilesRunningState = buildStreamRenderState([
{
type: "tool-call",
tool_call_id: "edit-tool",
tool_name: "edit_files",
args: editFilesArgs,
},
]);
const gap = Math.round(
wrappers[2].getBoundingClientRect().top -
wrappers[1].getBoundingClientRect().bottom,
const editFilesEmptyDeltaState = buildStreamRenderState([
{
type: "tool-call",
tool_call_id: "edit-tool",
tool_name: "edit_files",
args: editFilesArgs,
},
{
type: "tool-result",
tool_call_id: "edit-tool",
tool_name: "edit_files",
result_delta: "",
},
]);
const getEditFilesToolHeight = (canvasElement: HTMLElement) => {
const editTool = canvasElement.querySelector("[data-transcript-row]");
expect(editTool).not.toBeNull();
return Math.round(editTool?.getBoundingClientRect().height ?? 0);
};
/** Empty result deltas should not create an invisible completed tool result. */
export const EditFilesEmptyDeltaKeepsRunningHeight: Story = {
render: () => {
return (
<div className="flex flex-col gap-2">
<div data-testid="running-edit-files">
<StreamingOutput
streamState={editFilesRunningState.streamState}
streamTools={editFilesRunningState.streamTools}
liveStatus={editFilesRunningState.liveStatus}
/>
</div>
<div data-testid="empty-delta-edit-files">
<StreamingOutput
streamState={editFilesEmptyDeltaState.streamState}
streamTools={editFilesEmptyDeltaState.streamTools}
liveStatus={editFilesEmptyDeltaState.liveStatus}
/>
</div>
</div>
);
expect(gap).toBe(8);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const running = canvas.getByTestId("running-edit-files");
const emptyDelta = canvas.getByTestId("empty-delta-edit-files");
const placeholderRow = wrappers[2].firstElementChild ?? wrappers[2];
expect(Math.round(placeholderRow.getBoundingClientRect().height)).toBe(24);
expect(within(running).getByText(/Editing files/)).toBeVisible();
expect(within(emptyDelta).getByText(/Editing files/)).toBeVisible();
expect(getEditFilesToolHeight(emptyDelta)).toBe(
getEditFilesToolHeight(running),
);
},
};
@@ -1,47 +1,41 @@
import type { FC } from "react";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import {
ConversationItem,
Message,
MessageContent,
Shimmer,
} from "../ChatElements";
import { TranscriptRow } from "../ChatElements/TranscriptRow";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ToolIcon } from "../ChatElements/tools/ToolIcon";
import { ChatStatusCallout } from "./ChatStatusCallout";
import { BlockList } from "./ConversationTimeline";
import type { LiveStatusModel } from "./liveStatusModel";
import type { MergedTool, RenderBlock, StreamState } from "./types";
import { shouldShowGenericThinking } from "./streamingActivity";
import type { MergedTool, StreamState } from "./types";
const hasTransientLiveStatus = (liveStatus: LiveStatusModel): boolean =>
liveStatus.phase === "starting" ||
liveStatus.phase === "retrying" ||
liveStatus.phase === "reconnecting";
const hasCalloutLiveStatus = (liveStatus: LiveStatusModel): boolean =>
liveStatus.phase === "retrying" || liveStatus.phase === "reconnecting";
/**
* True when the block list contains at least one text or reasoning
* block. Tool-call blocks don't count; the placeholder should
* remain visible between tool calls so the user knows the model
* is still working.
*/
const hasTextOrReasoningBlock = (blocks: readonly RenderBlock[]): boolean =>
blocks.some((b) => b.type === "response" || b.type === "thinking");
/**
* Placeholder shown during streaming before text or reasoning
* blocks arrive. Uses the same shimmer animation and typography
* as the ChatStatusCallout status placeholder.
*/
const StreamingThinkingPlaceholder: FC = () => (
<div data-transcript-row="" className="text-content-secondary">
<TranscriptRow className="w-full gap-2">
<ToolIcon name="thinking" isError={false} />
<Shimmer as="span" className="text-[13px] leading-6">
Thinking
</Shimmer>
</TranscriptRow>
const LiveActivitySlot: FC<{
visible: boolean;
detached: boolean;
}> = ({ visible, detached }) => (
<div
data-testid="live-activity-slot"
aria-hidden={!visible}
className={cn(
"flex items-center gap-2 text-content-secondary",
detached ? "pointer-events-none absolute left-0 top-full mt-2" : "h-6",
!visible && "invisible",
)}
>
<ToolIcon name="thinking" isError={false} />
<Shimmer as="span" className="text-[13px] leading-6">
Thinking
</Shimmer>
</div>
);
@@ -73,21 +67,13 @@ export const StreamingOutput: FC<{
liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput;
const blocks = shouldShowBlocks ? (streamState?.blocks ?? []) : [];
// During streaming, keep showing the "Thinking..." indicator
// until text or reasoning blocks arrive. This bridges the
// visual gap between the "starting" phase placeholder and the
// first visible content, preventing the indicator from
// flickering away when only tool-call parts (or whitespace-
// only text deltas) have been received so far.
const needsStreamingThinking =
isStreaming && !hasTextOrReasoningBlock(blocks);
const shouldShowStatusCallout =
hasTransientLiveStatus(liveStatus) || needsStreamingThinking;
if (!shouldShowBlocks && !shouldShowStatusCallout) {
return null;
}
const showActivity = shouldShowGenericThinking({
liveStatus,
streamState,
streamTools,
});
const hasVisibleFlowContent =
shouldShowBlocks || hasCalloutLiveStatus(liveStatus);
const conversationItemProps = { role: "assistant" as const };
@@ -109,10 +95,13 @@ export const StreamingOutput: FC<{
mcpServers={mcpServers}
/>
)}
{needsStreamingThinking && <StreamingThinkingPlaceholder />}
{!needsStreamingThinking && hasTransientLiveStatus(liveStatus) && (
{hasCalloutLiveStatus(liveStatus) && (
<ChatStatusCallout status={liveStatus} />
)}
<LiveActivitySlot
visible={showActivity}
detached={hasVisibleFlowContent}
/>
</div>
</MessageContent>
</Message>
@@ -774,7 +774,6 @@ describe("selectIsAwaitingFirstStreamChunk", () => {
store.setChatStatus("running");
store.upsertDurableMessage(makeMessage(3, "user", "follow-up"));
// "Thinking..." should appear immediately.
expect(selectIsAwaitingFirstStreamChunk(store.getSnapshot())).toBe(true);
});
@@ -782,7 +781,7 @@ describe("selectIsAwaitingFirstStreamChunk", () => {
const store = createChatStore();
// Simulate the WS batch: [message(user), status:pending].
// This is the exact event order from the server when the
// user sends a message. "Thinking..." must appear during
// user sends a message. The Thinking indicator must appear during
// the pending phase so there is no visual gap before the
// server transitions to running.
store.upsertDurableMessage(makeMessage(1, "user", "sweet ty"));
@@ -3271,7 +3271,7 @@ describe("thinking indicator event ordering", () => {
// Server sends message_part BEFORE status:running in the same
// WebSocket frame. This is the event ordering that previously
// caused the "Thinking..." indicator to be skipped.
// caused the Thinking indicator to be skipped.
act(() => {
mockSocket.emitDataBatch([
{
@@ -3291,7 +3291,7 @@ describe("thinking indicator event ordering", () => {
// After the batch, the status should be "running" but stream
// parts should NOT have been applied yet (deferred to
// setTimeout). This is the window where "Thinking..." shows.
// setTimeout). This is the window where the Thinking indicator shows.
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
expect(result.current.streamState).toBeNull();
@@ -661,7 +661,7 @@ export const selectIsAwaitingFirstStreamChunk = (
const latestMessage = selectLatestDurableMessage(state);
const latestMessageNeedsAssistantResponse =
!latestMessage || latestMessage.role !== "assistant";
// Show the "Thinking..." indicator when the store has no stream
// Show the Thinking indicator when the store has no stream
// data yet and the conversation is waiting for an assistant
// response. For "running" status we use the existing broad
// check (any non-assistant latest message). For "pending" we
@@ -276,6 +276,120 @@ describe("applyMessagePartToStreamState", () => {
).toBe("completed");
});
it("completes a streamed tool result when an empty delta carries the final result", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
type: "tool-call",
tool_name: "advisor",
tool_call_id: "call-advisor-2",
args: { question: "What is the safe path?" },
});
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "advisor",
tool_call_id: "call-advisor-2",
result_delta: "Use ",
});
expect(state!.toolResults["call-advisor-2"]).toMatchObject({
result: "Use ",
isStreaming: true,
});
expect(
buildStreamTools(state!.toolCalls, state!.toolResults)[0].status,
).toBe("running");
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "advisor",
tool_call_id: "call-advisor-2",
result_delta: "",
result: {
type: "advice",
advice: "Use small steps.",
advisor_model: "test-provider/test-model",
remaining_uses: "2",
},
});
expect(state!.toolResults["call-advisor-2"]).toMatchObject({
result: {
type: "advice",
advice: "Use small steps.",
advisor_model: "test-provider/test-model",
remaining_uses: "2",
},
isError: false,
});
expect(state!.toolResults["call-advisor-2"].isStreaming).toBeUndefined();
expect(
buildStreamTools(state!.toolCalls, state!.toolResults)[0].status,
).toBe("completed");
});
it("marks a streamed tool result as error when an empty delta carries is_error", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
type: "tool-call",
tool_name: "advisor",
tool_call_id: "call-advisor-3",
args: { question: "What is the safe path?" },
});
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "advisor",
tool_call_id: "call-advisor-3",
result_delta: "partial advice",
});
expect(state!.toolResults["call-advisor-3"]).toMatchObject({
result: "partial advice",
isStreaming: true,
});
expect(
buildStreamTools(state!.toolCalls, state!.toolResults)[0].status,
).toBe("running");
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "advisor",
tool_call_id: "call-advisor-3",
result_delta: "",
is_error: true,
});
expect(state!.toolResults["call-advisor-3"]).toMatchObject({
result: "partial advice",
isError: true,
});
expect(state!.toolResults["call-advisor-3"].isStreaming).toBeUndefined();
expect(
buildStreamTools(state!.toolCalls, state!.toolResults)[0].status,
).toBe("error");
});
it("ignores empty tool result deltas", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
type: "tool-call",
tool_name: "edit_files",
tool_call_id: "edit-1",
args: { files: "[]" },
});
state = applyMessagePartToStreamState(state, {
type: "tool-result",
tool_name: "edit_files",
tool_call_id: "edit-1",
result_delta: "",
});
expect(state).not.toBeNull();
expect(state!.toolResults["edit-1"]).toBeUndefined();
expect(
buildStreamTools(state!.toolCalls, state!.toolResults)[0].status,
).toBe("running");
});
it("resets streaming tool result deltas", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
@@ -116,6 +116,16 @@ export const applyMessagePartToStreamState = (
toolResults,
};
}
if (
part.result_delta === "" &&
part.result === undefined &&
!part.is_error
) {
return {
...nextState,
blocks: ensureToolBlock(nextState.blocks, toolCallID),
};
}
const nextResult = mergeStreamPayload(
existing?.result,
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import type { LiveStatusModel } from "./liveStatusModel";
import { shouldShowGenericThinking } from "./streamingActivity";
import type { MergedTool, StreamState } from "./types";
const liveStatus = (phase: LiveStatusModel["phase"]): LiveStatusModel => {
switch (phase) {
case "idle":
return { phase: "idle", hasAccumulatedOutput: false };
case "starting":
return { phase: "starting", hasAccumulatedOutput: false };
case "streaming":
return { phase: "streaming", hasAccumulatedOutput: false };
case "retrying":
return {
phase: "retrying",
hasAccumulatedOutput: false,
attempt: 1,
kind: "generic",
title: "Retrying request",
message: "Retrying",
};
case "reconnecting":
return {
phase: "reconnecting",
hasAccumulatedOutput: false,
attempt: 1,
delayMs: 1000,
retryingAt: "2026-03-10T00:00:01.000Z",
title: "Reconnecting",
message: "Reconnecting",
};
case "failed":
return {
phase: "failed",
hasAccumulatedOutput: false,
kind: "generic",
title: "Failed",
message: "Failed",
};
}
};
const streamState = (blocks: StreamState["blocks"]): StreamState => ({
blocks,
toolCalls: {},
toolResults: {},
sources: [],
});
const tool = (status: MergedTool["status"]): MergedTool => ({
id: status,
name: "read_file",
isError: false,
status,
});
describe("shouldShowGenericThinking", () => {
it("shows for starting", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("starting"),
streamState: null,
streamTools: [],
}),
).toBe(true);
});
it("shows for streaming with no readable blocks or running tools", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
streamState: null,
streamTools: [],
}),
).toBe(true);
});
it("hides for streaming with a running tool", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
streamState: streamState([{ type: "tool", id: "read-1" }]),
streamTools: [tool("running")],
}),
).toBe(false);
});
it("shows after tools complete but before readable output", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
streamState: streamState([{ type: "tool", id: "read-1" }]),
streamTools: [tool("completed")],
}),
).toBe(true);
});
it("hides when response text is visible", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
streamState: streamState([{ type: "response", text: "hello" }]),
streamTools: [],
}),
).toBe(false);
});
it("hides when reasoning is visible", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
streamState: streamState([{ type: "thinking", text: "thinking" }]),
streamTools: [],
}),
).toBe(false);
});
it.each([
"idle",
"retrying",
"reconnecting",
"failed",
] as const)("hides for %s", (phase) => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus(phase),
streamState: null,
streamTools: [],
}),
).toBe(false);
});
});
@@ -0,0 +1,24 @@
import type { LiveStatusModel } from "./liveStatusModel";
import type { MergedTool, StreamState } from "./types";
const hasTextOrThinkingBlock = (streamState: StreamState | null): boolean =>
streamState?.blocks.some(
(block) => block.type === "response" || block.type === "thinking",
) ?? false;
const hasRunningTool = (streamTools: readonly MergedTool[]): boolean =>
streamTools.some((tool) => tool.status === "running");
export const shouldShowGenericThinking = ({
liveStatus,
streamState,
streamTools,
}: {
liveStatus: LiveStatusModel;
streamState: StreamState | null;
streamTools: readonly MergedTool[];
}): boolean =>
liveStatus.phase === "starting" ||
(liveStatus.phase === "streaming" &&
!hasTextOrThinkingBlock(streamState) &&
!hasRunningTool(streamTools));
@@ -454,7 +454,7 @@ export const useChatStore = (
// partial output. Other events (status, retry,
// queue_update) must NOT flush — status changes
// need to be visible before parts so the
// "Thinking..." indicator can render, and retry
// Thinking indicator can render, and retry
// clears stream state which a flush would
// re-populate.
if (streamEvent.type === "message" || streamEvent.type === "error") {
@@ -1,10 +1,9 @@
import { CircleAlertIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { CircleAlertIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { cn } from "#/utils/cn";
import { Response } from "../Response";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import { ToolLabel } from "./ToolLabel";
import type { ToolStatus } from "./utils";
@@ -46,117 +45,124 @@ export const AdvisorTool: React.FC<AdvisorToolProps> = ({
const showLimitReached = resultType === "limit_reached";
const showError = isError || resultType === "error";
const headerStatus = showLimitReached ? (
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0 text-content-warning" />
) : showError ? (
<CircleAlertIcon className="mt-0.5 size-3.5 shrink-0 text-content-destructive" />
) : (
<ToolCall.Status className="mt-0.5 text-content-secondary" />
);
return (
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={status}
isError={showError}
errorMessage={effectiveErrorMessage}
hasContent
defaultExpanded
headerClassName="items-start"
header={(expanded) => (
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2 leading-4">
<ToolIcon
name="advisor"
isError={showError}
isRunning={isRunning}
/>
<ToolLabel
name="advisor"
args={{ question: questionText }}
result={resultType ? { type: resultType } : undefined}
/>
{isRunning && (
<span className="shrink-0 rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
{RUNNING_MESSAGE}
</span>
)}
{advisorModelText && (
<span className="min-w-0 truncate rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
{advisorModelText}
</span>
)}
{remainingUses !== undefined && (
<span className="shrink-0 rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
{remainingUses.toLocaleString("en-US")} uses left
</span>
)}
</div>
<span
className={cn(
"ml-6 block whitespace-normal break-words text-[13px]",
"font-normal leading-5 text-content-primary",
"[overflow-wrap:anywhere]",
!expanded && "line-clamp-2",
)}
>
{questionText}
</span>
</div>
)}
headerStatus={
showLimitReached ? (
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0 text-content-warning" />
) : showError ? (
<CircleAlertIcon className="mt-0.5 size-3.5 shrink-0 text-content-destructive" />
) : isRunning ? (
<LoaderIcon className="mt-0.5 size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
) : null
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default bg-surface-primary"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
data-testid="advisor-tool-scroll-area"
>
<div className="space-y-3 px-3 py-2">
{isRunning && adviceText.length === 0 ? (
<div role="status" className="text-sm text-content-secondary">
Reviewing context and preparing guidance.
</div>
) : showLimitReached ? (
<div
role="status"
className="flex items-start gap-3 rounded-md border border-solid border-border-warning bg-surface-orange p-3 text-sm text-content-primary"
>
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-content-warning" />
<div className="space-y-1">
<p className="m-0 font-medium">Advisor limit reached.</p>
<p className="m-0 text-content-primary">
{LIMIT_REACHED_MESSAGE}
</p>
</div>
</div>
) : showError ? (
<div
role="alert"
className="flex items-start gap-3 rounded-md border border-solid border-border-destructive bg-surface-red p-3 text-sm text-content-primary"
>
<CircleAlertIcon className="mt-0.5 size-4 shrink-0 text-content-destructive" />
<div className="space-y-1">
<p className="m-0 font-medium">Advisor request failed.</p>
<p className="m-0 text-content-primary [overflow-wrap:anywhere]">
{effectiveErrorMessage}
</p>
</div>
</div>
) : (
<section className="space-y-2" aria-label="Advisor advice">
<div>
<span className="inline-flex rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
Advice
<ToolCall.HeaderButton className="items-start">
<ToolCall.State>
{({ expanded }) => (
<>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2 leading-4">
<ToolCall.LeadingIcon name="advisor" />
<ToolLabel
name="advisor"
args={{ question: questionText }}
result={resultType ? { type: resultType } : undefined}
/>
{isRunning && (
<span className="shrink-0 rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
{RUNNING_MESSAGE}
</span>
)}
{advisorModelText && (
<span className="min-w-0 truncate rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
{advisorModelText}
</span>
)}
{remainingUses !== undefined && (
<span className="shrink-0 rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
{remainingUses.toLocaleString("en-US")} uses left
</span>
)}
</div>
<span
className={cn(
"ml-6 block whitespace-normal break-words text-[13px]",
"font-normal leading-5 text-content-primary",
"[overflow-wrap:anywhere]",
!expanded && "line-clamp-2",
)}
>
{questionText}
</span>
</div>
<Response
streaming={isRunning}
className="[&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-[15px] [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-sm [&_h3]:mb-1 [&_h3]:mt-2.5 [&_h3]:text-[13px] [&_h4]:mt-2 [&_h4]:text-[13px] [&_h5]:text-xs [&_h6]:text-xs"
>
{adviceText || EMPTY_ADVICE_MESSAGE}
</Response>
</section>
{headerStatus}
<ToolCall.Chevron className="mt-0.5" />
</>
)}
</div>
</ScrollArea>
</ToolCollapsible>
</ToolCall.State>
</ToolCall.HeaderButton>
<ToolCall.Content>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default bg-surface-primary"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
data-testid="advisor-tool-scroll-area"
>
<div className="space-y-3 px-3 py-2">
{isRunning && adviceText.length === 0 ? (
<div role="status" className="text-sm text-content-secondary">
Reviewing context and preparing guidance.
</div>
) : showLimitReached ? (
<div
role="status"
className="flex items-start gap-3 rounded-md border border-solid border-border-warning bg-surface-orange p-3 text-sm text-content-primary"
>
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-content-warning" />
<div className="space-y-1">
<p className="m-0 font-medium">Advisor limit reached.</p>
<p className="m-0 text-content-primary">
{LIMIT_REACHED_MESSAGE}
</p>
</div>
</div>
) : showError ? (
<div
role="alert"
className="flex items-start gap-3 rounded-md border border-solid border-border-destructive bg-surface-red p-3 text-sm text-content-primary"
>
<CircleAlertIcon className="mt-0.5 size-4 shrink-0 text-content-destructive" />
<div className="space-y-1">
<p className="m-0 font-medium">Advisor request failed.</p>
<p className="m-0 text-content-primary [overflow-wrap:anywhere]">
{effectiveErrorMessage}
</p>
</div>
</div>
) : (
<section className="space-y-2" aria-label="Advisor advice">
<div>
<span className="inline-flex rounded-full border border-solid border-border-default px-2 text-[13px] leading-4 text-content-secondary">
Advice
</span>
</div>
<Response
streaming={isRunning}
className="[&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-[15px] [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-sm [&_h3]:mb-1 [&_h3]:mt-2.5 [&_h3]:text-[13px] [&_h4]:mt-2 [&_h4]:text-[13px] [&_h5]:text-xs [&_h6]:text-xs"
>
{adviceText || EMPTY_ADVICE_MESSAGE}
</Response>
</section>
)}
</div>
</ScrollArea>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -115,15 +115,34 @@ export const Running: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const liveRegion = canvas.getByRole("status");
expect(liveRegion).toHaveAttribute("aria-live", "polite");
expect(canvas.getByText("Asking for clarification...")).toBeInTheDocument();
expect(
canvas.getByTestId("ask-user-question-loading-icon"),
canvas.getByRole("img", { name: "Tool call running" }),
).toBeInTheDocument();
expect(canvas.getAllByRole("radio")).toHaveLength(3);
},
};
export const RunningEmptyQuestions: Story = {
args: {
status: "running",
args: { questions: [] },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const liveRegion = canvas.getByRole("status");
expect(liveRegion).toHaveAttribute("aria-live", "polite");
expect(canvas.getByText("Asking for clarification...")).toBeInTheDocument();
expect(
canvas.getByRole("img", { name: "Tool call running" }),
).toBeInTheDocument();
},
};
export const InteractiveSingleQuestion: Story = {
args: {
status: "completed",
@@ -452,6 +471,10 @@ export const ErrorState: Story = {
"The planning agent could not deliver follow-up questions.",
),
).toBeInTheDocument();
expect(canvas.getByLabelText("Error")).toBeInTheDocument();
expect(
canvas.getByRole("img", {
name: "The planning agent could not deliver follow-up questions.",
}),
).toBeInTheDocument();
},
};
@@ -9,8 +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 { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
export type AskUserQuestion = {
@@ -537,18 +536,18 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
if (isError) {
return (
<div className="w-full">
<TranscriptRow
role="alert"
className="gap-2 text-[13px] text-content-secondary"
<div className="w-full" role="alert">
<ToolCall.Root
status={status}
isError
errorMessage={errorMessage || "Failed to ask questions"}
hasContent={false}
>
<ToolIcon name="ask_user_question" isError={isError} />
<TriangleAlertIcon
aria-label="Error"
className="size-3.5 shrink-0 text-content-secondary"
<ToolCall.Header
iconName="ask_user_question"
label={errorMessage || "Failed to ask questions"}
/>
<span>{errorMessage || "Failed to ask questions"}</span>
</TranscriptRow>
</ToolCall.Root>
</div>
);
}
@@ -557,24 +556,17 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
return (
<div className="w-full">
{isRunning ? (
<TranscriptRow
<ToolCall.Root
status={status}
hasContent={false}
role="status"
aria-live="polite"
className="gap-2 text-content-secondary"
>
<ToolIcon
name="ask_user_question"
isError={false}
isRunning={isRunning}
<ToolCall.Header
iconName="ask_user_question"
label="Asking for clarification..."
/>
<span className="text-[13px] text-content-secondary">
Asking for clarification...
</span>
<LoaderIcon
data-testid="ask-user-question-loading-icon"
className="size-3.5 shrink-0 animate-spin text-content-secondary motion-reduce:animate-none"
/>
</TranscriptRow>
</ToolCall.Root>
) : (
<p className="text-[13px] italic text-content-secondary">
No questions available.
@@ -688,24 +680,17 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
return (
<div className="w-full">
{isRunning && (
<TranscriptRow
<ToolCall.Root
status={status}
hasContent={false}
role="status"
aria-live="polite"
className="gap-2 text-content-secondary"
>
<ToolIcon
name="ask_user_question"
isError={false}
isRunning={isRunning}
<ToolCall.Header
iconName="ask_user_question"
label="Asking for clarification..."
/>
<span className="text-[13px] text-content-secondary">
Asking for clarification...
</span>
<LoaderIcon
data-testid="ask-user-question-loading-icon"
className="size-3.5 shrink-0 animate-spin text-content-secondary motion-reduce:animate-none"
/>
</TranscriptRow>
</ToolCall.Root>
)}
{isInteractive ? (
@@ -1,14 +1,7 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { Response } from "../Response";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
/**
@@ -25,48 +18,28 @@ export const ChatSummarizedTool: React.FC<{
const isRunning = status === "running";
return (
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to summarize conversation"}
hasContent={hasSummary}
header={
<>
<ToolIcon
name="chat_summarized"
isError={isError}
isRunning={isRunning}
/>
<span className="text-[13px] leading-6">
{isRunning ? "Summarizing…" : "Summarized"}
</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to summarize conversation"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{summary}</Response>
</div>
</ScrollArea>
</ToolCollapsible>
<ToolCall.Header
iconName="chat_summarized"
label={isRunning ? "Summarizing…" : "Summarized"}
/>
<ToolCall.Content>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{summary}</Response>
</div>
</ScrollArea>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,14 +1,7 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ImageLightbox } from "../../ImageLightbox";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
/**
@@ -34,65 +27,49 @@ export const ComputerTool: React.FC<{
const imageSrc = hasImage ? `data:${mimeType};base64,${imageData}` : "";
return (
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to take screenshot"}
hasContent={hasContent}
defaultExpanded={hasImage}
header={
<>
<ToolIcon name="computer" isError={isError} isRunning={isRunning} />
<span className="text-[13px] leading-6">
{isRunning ? "Taking screenshot…" : "Screenshot"}
</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to take screenshot"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
{hasImage ? (
<>
<div className="mt-1.5 overflow-hidden rounded-md border border-solid border-border-default">
<button
type="button"
className="cursor-pointer bg-transparent p-0 border-none"
onClick={() => setShowLightbox(true)}
>
<img
<ToolCall.Header
iconName="computer"
label={isRunning ? "Taking screenshot…" : "Screenshot"}
/>
<ToolCall.Content>
{hasImage ? (
<>
<div className="mt-1.5 overflow-hidden rounded-md border border-solid border-border-default">
<button
type="button"
className="cursor-pointer bg-transparent p-0 border-none"
onClick={() => setShowLightbox(true)}
>
<img
src={imageSrc}
alt="Screenshot from computer tool"
className="max-h-96 w-auto object-contain"
/>
</button>
</div>
{showLightbox && (
<ImageLightbox
src={imageSrc}
alt="Screenshot from computer tool"
className="max-h-96 w-auto object-contain"
onClose={() => setShowLightbox(false)}
/>
</button>
)}
</>
) : hasText ? (
<div className="mt-1.5 rounded-md border border-solid border-border-default px-3 py-2">
<pre className="whitespace-pre-wrap text-xs text-content-secondary">
{text}
</pre>
</div>
{showLightbox && (
<ImageLightbox
src={imageSrc}
onClose={() => setShowLightbox(false)}
/>
)}
</>
) : hasText ? (
<div className="mt-1.5 rounded-md border border-solid border-border-default px-3 py-2">
<pre className="whitespace-pre-wrap text-xs text-content-secondary">
{text}
</pre>
</div>
) : null}
</ToolCollapsible>
) : null}
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,13 +1,7 @@
import { ExternalLinkIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { ExternalLinkIcon } from "lucide-react";
import type React from "react";
import { Link } from "react-router";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import { asRecord, asString, type ToolStatus } from "./utils";
import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection";
@@ -44,7 +38,6 @@ export const CreateWorkspaceTool: React.FC<{
const parsed = JSON.parse(resultJson);
rec = asRecord(parsed);
} catch {
// resultJson might already be an object or invalid JSON
rec = asRecord(resultJson);
}
}
@@ -66,54 +59,37 @@ export const CreateWorkspaceTool: React.FC<{
const hasBuildLogs = isRunning || Boolean(buildId);
const header = (
<>
<ToolIcon
name="create_workspace"
isError={isError}
isRunning={isRunning}
/>
<span className="text-[13px] leading-6">{label}</span>
{workspaceLink && !isRunning && (
<Link
to={workspaceLink}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View workspace"
>
<ExternalLinkIcon className="size-3" />
</Link>
)}
</>
);
const headerStatus = (
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to create workspace"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
);
return (
<div className="w-full">
<ToolCollapsible
header={header}
headerStatus={headerStatus}
hasContent={hasBuildLogs}
defaultExpanded={isRunning}
>
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to create workspace"}
hasContent={hasBuildLogs}
defaultExpanded={isRunning}
>
<ToolCall.HeaderLayout>
<ToolCall.HeaderButton>
<ToolCall.LeadingIcon name="create_workspace" />
<ToolCall.Label>{label}</ToolCall.Label>
<ToolCall.Status />
<ToolCall.Chevron />
</ToolCall.HeaderButton>
{workspaceLink && !isRunning && (
<ToolCall.HeaderActions>
<Link
to={workspaceLink}
className="inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View workspace"
>
<ExternalLinkIcon className="size-3" />
</Link>
</ToolCall.HeaderActions>
)}
</ToolCall.HeaderLayout>
<ToolCall.Content>
<WorkspaceBuildLogSection status={status} buildId={buildId} />
</ToolCollapsible>
</div>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,22 +1,15 @@
import { useTheme } from "@emotion/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import {
DIFFS_FONT_STYLE,
type EditFilesFileEntry,
@@ -63,58 +56,41 @@ export const EditFilesTool: React.FC<{
}
return (
<AgentDisplayModeToolCollapsible
<ToolCall.Root
key={`${codeDiffDisplayMode ?? "auto"}:${EDIT_FILES_AUTO_DISPLAY_STATE}`}
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to edit files"}
hasContent={hasDiffs}
displayMode={codeDiffDisplayMode}
autoDisplayState={EDIT_FILES_AUTO_DISPLAY_STATE}
header={
<>
<ToolIcon name="edit_files" isError={isError} isRunning={isRunning} />
<span className="text-[13px] leading-6">{label}</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to edit files"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
defaultView={displayState}
>
<div className="mt-1.5 space-y-1.5">
{diffs.map((diff, i) =>
diff ? (
<ScrollArea
key={files[i].path}
data-testid="edit-file-diff"
className="rounded-md border border-solid border-border-default text-2xs"
viewportClassName={
isAgentDisplayFullyExpanded(displayState)
? "max-h-[80vh]"
: "max-h-64"
}
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={stripNoNewline(diff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : null,
)}
</div>
</AgentDisplayModeToolCollapsible>
<ToolCall.Header iconName="edit_files" label={label} />
<ToolCall.Content>
<div className="mt-1.5 space-y-1.5">
{diffs.map((diff, i) =>
diff ? (
<ScrollArea
key={files[i].path}
data-testid="edit-file-diff"
className="rounded-md border border-solid border-border-default text-2xs"
viewportClassName={
isAgentDisplayFullyExpanded(displayState)
? "max-h-[80vh]"
: "max-h-64"
}
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={stripNoNewline(diff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : null,
)}
</div>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,15 +1,12 @@
import {
CheckIcon,
ChevronDownIcon,
CircleAlertIcon,
ExternalLinkIcon,
LayersIcon,
LoaderIcon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { CopyButton } from "#/components/CopyButton/CopyButton";
@@ -20,13 +17,11 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { TranscriptRow } from "../TranscriptRow";
import {
type AgentDisplayState,
isAgentDisplayOpen,
resolveAgentDisplayState,
} from "./displayMode";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ExecuteTranscriptBlock } from "./toolVisibility";
import {
formatShellDurationMs,
@@ -49,33 +44,7 @@ type ExecuteToolProps = {
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
};
type ExecuteToolInnerProps = ExecuteToolProps & {
outputInitiallyOpen: boolean;
};
export const ExecuteTool: React.FC<ExecuteToolProps> = (props) => {
const hasTranscriptBlocks = props.transcriptBlocks.length > 0;
const autoDisplayState: AgentDisplayState =
hasTranscriptBlocks ||
props.status === "running" ||
props.isBackgrounded ||
!!props.killedBySignal
? "preview"
: "collapsed";
const resolvedDisplayState = resolveAgentDisplayState(
props.shellToolDisplayMode,
autoDisplayState,
);
return (
<ExecuteToolInner
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
{...props}
outputInitiallyOpen={isAgentDisplayOpen(resolvedDisplayState)}
/>
);
};
const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
export const ExecuteTool: React.FC<ExecuteToolProps> = ({
command,
transcriptBlocks,
status,
@@ -85,106 +54,117 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
killedBySignal,
modelIntent,
parsedCommands,
outputInitiallyOpen,
shellToolDisplayMode,
}) => {
const hasCommand = command.trim().length > 0;
const hasTranscriptBlocks = transcriptBlocks.length > 0;
const autoDisplayState: AgentDisplayState =
hasTranscriptBlocks ||
status === "running" ||
isBackgrounded ||
!!killedBySignal
? "preview"
: "collapsed";
const isRunning = status === "running";
const showFailureIndicator = isError && !isRunning;
const [outputOpen, setOutputOpen] = useState(outputInitiallyOpen);
const outputToggleLabel = outputOpen ? "Collapse command" : "Expand command";
const durationLabel = formatShellDurationMs(durationMs);
const { commandLabel, durationSuffix } = getShellCommandLine({
command,
modelIntent,
parsedCommands,
durationLabel,
});
const defaultView = resolveAgentDisplayState(
shellToolDisplayMode,
autoDisplayState,
);
if (!hasCommand) {
return null;
}
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">
<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"
>
<button
type="button"
aria-expanded={outputOpen}
aria-label={outputToggleLabel}
onClick={() => setOutputOpen((value) => !value)}
>
<ShellCommandLine
command={command}
modelIntent={modelIntent}
parsedCommands={parsedCommands}
durationLabel={durationLabel}
expanded={outputOpen}
<ToolCall.Root
key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
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"
status={status}
isError={isError}
errorMessage="Command failed"
hasContent
defaultView={defaultView}
ariaLabel={(expanded) =>
expanded ? "Collapse command" : "Expand command"
}
>
<ToolCall.HeaderLayout>
<ToolCall.HeaderButton className="col-start-1 row-start-1 min-w-0 font-normal">
<ToolCall.LeadingIcon name="execute" />
<span className="flex min-w-0 items-baseline">
<ToolCall.Label>{commandLabel}</ToolCall.Label>
{durationSuffix && (
<span className="ml-1 shrink-0 text-content-secondary">
{durationSuffix}
</span>
)}
</span>
<ToolCall.Status />
<ToolCall.Chevron />
</ToolCall.HeaderButton>
<ToolCall.HeaderActions>
{isBackgrounded && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Running in background"
role="img"
className="flex shrink-0 text-content-secondary"
>
<LayersIcon aria-hidden className="size-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>Running in background</TooltipContent>
</Tooltip>
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
<CopyButton
text={command}
label="Copy command"
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100 focus-visible:opacity-100"
/>
</button>
</TranscriptRow>
<TranscriptRow className="col-start-2 row-start-1 shrink-0 gap-1">
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{showFailureIndicator && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Command failed"
role="img"
className="flex shrink-0 text-content-secondary"
>
<TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>Command failed</TooltipContent>
</Tooltip>
)}
{isBackgrounded && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Running in background"
role="img"
className="flex shrink-0 text-content-secondary"
>
<LayersIcon aria-hidden className="size-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>Running in background</TooltipContent>
</Tooltip>
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
<CopyButton
text={command}
label="Copy command"
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
/>
</TranscriptRow>
{outputOpen && (
</ToolCall.HeaderActions>
</ToolCall.HeaderLayout>
<ToolCall.Content>
<ShellTranscriptBody
command={command}
transcriptBlocks={transcriptBlocks}
isError={isError}
/>
)}
</div>
</ToolCall.Content>
</ToolCall.Root>
);
};
const ShellCommandLine: React.FC<{
type ShellCommandLineInput = {
command: string;
modelIntent?: string;
parsedCommands?: readonly string[][];
durationLabel: string;
expanded?: boolean;
}> = ({ command, modelIntent, parsedCommands, durationLabel, expanded }) => {
};
const getShellCommandLine = ({
command,
modelIntent,
parsedCommands,
durationLabel,
}: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => {
const intentLabel = sanitizeExecuteModelIntent(modelIntent, command);
const summary =
parsedCommands && parsedCommands.length > 0
@@ -194,27 +174,11 @@ const ShellCommandLine: React.FC<{
const commandLabel = intentLabel
? `${intentLabel} using ${commandDisplay}`
: `Ran ${commandDisplay}`;
const durationSuffix = durationLabel ? ` for ${durationLabel}` : "";
return (
<>
<ToolIcon name="execute" isError={false} />
<span className="min-w-0 truncate text-[13px] font-normal leading-6 text-current">
{commandLabel}
{durationSuffix && (
<span className="text-content-secondary">{durationSuffix}</span>
)}
</span>
{expanded !== undefined && (
<ChevronDownIcon
className={cn(
"size-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
)}
</>
);
return {
commandLabel,
durationSuffix: durationLabel ? ` for ${durationLabel}` : "",
};
};
const ShellTranscriptBody: React.FC<{
@@ -331,32 +295,63 @@ export const WaitForExternalAuthTool: React.FC<{
}) => {
const isRunning = status === "running";
let label = `Waiting for ${providerLabel} authentication...`;
let icon: React.ReactNode = (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-link" />
);
let statusIcon: React.ReactNode = isRunning ? (
<LoaderIcon
aria-label="Authentication in progress"
role="img"
className="size-3.5 shrink-0 animate-spin text-content-link motion-reduce:animate-none"
/>
) : null;
if (isError) {
label =
errorMessage ||
`Failed while waiting for ${providerLabel} authentication`;
icon = (
<TriangleAlertIcon className="size-3.5 shrink-0 text-content-secondary" />
statusIcon = (
<OctagonXIcon
aria-label="Authentication failed"
role="img"
className="size-3.5 shrink-0 text-content-destructive"
/>
);
} else if (timedOut) {
label = `Timed out waiting for ${providerLabel} authentication`;
icon = (
<CircleAlertIcon className="size-3.5 shrink-0 text-content-warning" />
statusIcon = (
<CircleAlertIcon
aria-label="Authentication timed out"
role="img"
className="size-3.5 shrink-0 text-content-warning"
/>
);
} else if (authenticated && !isRunning) {
label = `Authenticated with ${providerLabel}`;
icon = <CheckIcon className="size-3.5 shrink-0 text-content-success" />;
statusIcon = (
<CheckIcon
aria-label="Authentication completed"
role="img"
className="size-3.5 shrink-0 text-content-success"
/>
);
}
return (
<div className="w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary px-3 py-2">
<div className="flex items-center gap-2">
{icon}
<span className="text-[13px] text-content-primary">{label}</span>
</div>
</div>
<ToolCall.Root
className="w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary px-3 py-2"
status={status}
isError={isError}
errorMessage={
errorMessage ||
`Failed while waiting for ${providerLabel} authentication`
}
hasContent={false}
>
<ToolCall.HeaderLayout>
<ToolCall.HeaderButton className="min-w-0 flex-1 font-normal text-content-secondary">
<ToolCall.LeadingIcon>{statusIcon}</ToolCall.LeadingIcon>
<ToolCall.Label className="text-content-primary">
{label}
</ToolCall.Label>
</ToolCall.HeaderButton>
</ToolCall.HeaderLayout>
</ToolCall.Root>
);
};
@@ -1,13 +1,7 @@
import { ExternalLinkIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { ExternalLinkIcon } from "lucide-react";
import type React from "react";
import { Link } from "react-router";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import { asRecord, asString, type ToolStatus } from "./utils";
/**
@@ -32,69 +26,47 @@ export const ListTemplatesTool: React.FC<{
: `Listed ${count} templates`;
return (
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to list templates"}
hasContent={hasContent}
header={
<>
<ToolIcon
name="list_templates"
isError={isError}
isRunning={isRunning}
/>
<span className="text-[13px] leading-6">{label}</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to list templates"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
<div className="mt-1.5">
{templates.map((template, index) => {
const rec = asRecord(template);
if (!rec) {
return null;
}
const name = asString(rec.name);
const displayName = asString(rec.display_name);
const templateName = displayName || name || `Template ${index + 1}`;
<ToolCall.Header iconName="list_templates" label={label} />
<ToolCall.Content>
<div className="mt-1.5">
{templates.map((template, index) => {
const rec = asRecord(template);
if (!rec) {
return null;
}
const name = asString(rec.name);
const displayName = asString(rec.display_name);
const templateName = displayName || name || `Template ${index + 1}`;
if (!name) {
return (
<div key={index} className="text-[13px] text-content-secondary">
{templateName}
</div>
);
}
if (!name) {
return (
<div key={index} className="text-[13px] text-content-secondary">
{templateName}
<div key={name} className="flex items-center gap-1.5">
<Link
to={`/templates/${name}`}
className="flex items-center gap-1.5 text-[13px] text-content-secondary opacity-50 transition-opacity hover:opacity-100"
>
<span>{templateName}</span>
<ExternalLinkIcon className="size-3 shrink-0" />
</Link>
</div>
);
}
return (
<div key={name} className="flex items-center gap-1.5">
<Link
to={`/templates/${name}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1.5 text-[13px] text-content-secondary opacity-50 transition-opacity hover:opacity-100"
>
<span>{templateName}</span>
<ExternalLinkIcon className="size-3 shrink-0" />
</Link>
</div>
);
})}
</div>
</ToolCollapsible>
})}
</div>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,4 +1,4 @@
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
import { ChevronDownIcon, OctagonXIcon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
@@ -15,8 +15,7 @@ import {
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
type ProcessOutputToolProps = {
@@ -24,12 +23,13 @@ type ProcessOutputToolProps = {
isRunning: boolean;
exitCode: number | null;
isError: boolean;
errorMessage?: string;
killedBySignal?: "kill" | "terminate";
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
};
type ProcessOutputToolInnerProps = ProcessOutputToolProps & {
autoDisplayState: AgentDisplayState;
defaultView: AgentDisplayState;
outputInitiallyFullyExpanded: boolean;
};
@@ -44,7 +44,7 @@ export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = (props) => {
<ProcessOutputToolInner
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
{...props}
autoDisplayState={autoDisplayState}
defaultView={resolvedDisplayState}
outputInitiallyFullyExpanded={isAgentDisplayFullyExpanded(
resolvedDisplayState,
)}
@@ -57,9 +57,9 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
isRunning,
exitCode,
isError,
errorMessage,
killedBySignal,
shellToolDisplayMode,
autoDisplayState,
defaultView,
outputInitiallyFullyExpanded,
}) => {
const [outputFullyExpanded, setOutputFullyExpanded] = useState(
@@ -81,32 +81,26 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
const hasHeaderActions = Boolean(killedBySignal) || showExitCode || hasOutput;
return (
<AgentDisplayModeToolCollapsible
<ToolCall.Root
className="group/proc w-full"
status={isRunning ? "running" : isError ? "error" : "completed"}
isError={isError}
errorMessage={errorMessage || "Failed to read process output"}
hasContent={hasOutput}
displayMode={shellToolDisplayMode}
autoDisplayState={autoDisplayState}
defaultView={defaultView}
ariaLabel={(expanded) =>
expanded ? "Collapse process output" : "Expand process output"
}
header={
<>
<ToolIcon
name="process_output"
isError={isError}
isRunning={isRunning}
/>
<span className="text-[13px] leading-6">Process output</span>
</>
}
headerStatus={
isRunning ? (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
) : undefined
}
headerActions={
hasHeaderActions ? (
<>
>
<ToolCall.HeaderLayout>
<ToolCall.HeaderButton>
<ToolCall.LeadingIcon name="process_output" />
<ToolCall.Label>Process output</ToolCall.Label>
<ToolCall.Status />
<ToolCall.Chevron />
</ToolCall.HeaderButton>
{hasHeaderActions && (
<ToolCall.HeaderActions>
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
@@ -126,53 +120,54 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
<CopyButton
text={output}
label="Copy output"
className="-my-0.5 size-6 p-0"
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/proc:opacity-100 focus-visible:opacity-100"
/>
)}
</>
) : undefined
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
scrollBarClassName="w-1.5"
>
<pre
ref={measureRef}
style={
outputFullyExpanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
isError ? "text-content-destructive" : "text-content-secondary",
)}
</ToolCall.HeaderActions>
)}
</ToolCall.HeaderLayout>
<ToolCall.Content>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
scrollBarClassName="w-1.5"
>
{output}
</pre>
</ScrollArea>
{overflows && (
<button
type="button"
aria-expanded={outputFullyExpanded}
onClick={toggleOutputExpansion}
className="border-0 bg-transparent m-0 mt-0.5 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center rounded-md py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={
outputFullyExpanded
? "Collapse full process output"
: "Expand full process output"
}
>
<ChevronDownIcon
<pre
ref={measureRef}
style={
outputFullyExpanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"size-3 transition-transform",
outputFullyExpanded && "rotate-180",
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
isError ? "text-content-destructive" : "text-content-secondary",
)}
/>
</button>
)}
</AgentDisplayModeToolCollapsible>
>
{output}
</pre>
</ScrollArea>
{overflows && (
<button
type="button"
aria-expanded={outputFullyExpanded}
onClick={toggleOutputExpansion}
className="border-0 bg-transparent m-0 mt-0.5 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center rounded-md py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={
outputFullyExpanded
? "Collapse full process output"
: "Expand full process output"
}
>
<ChevronDownIcon
className={cn(
"size-3 transition-transform",
outputFullyExpanded && "rotate-180",
)}
/>
</button>
)}
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -162,7 +162,11 @@ export const ErrorState: Story = {
expect(
canvas.getByText(`Proposed ${defaultPlanFilename}`),
).toBeInTheDocument();
expect(canvas.getByLabelText("Error")).toBeInTheDocument();
expect(
canvas.getByRole("img", {
name: "Failed to read file: file not found",
}),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "Implement plan" }),
).not.toBeInTheDocument();
@@ -253,6 +257,8 @@ export const FileIDFetchError: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByLabelText("Error")).toBeInTheDocument();
expect(
await canvas.findByRole("img", { name: "Failed to load plan" }),
).toBeInTheDocument();
},
};
@@ -1,4 +1,4 @@
import { LoaderIcon, PlayIcon, TriangleAlertIcon } from "lucide-react";
import { LoaderIcon, PlayIcon } from "lucide-react";
import type React from "react";
import { useMutation, useQuery } from "react-query";
import { API } from "#/api/api";
@@ -11,7 +11,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { Response } from "../Response";
import { TranscriptRow } from "../TranscriptRow";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
export const ProposePlanTool: React.FC<{
@@ -74,37 +74,26 @@ export const ProposePlanTool: React.FC<{
return (
<div className="w-full">
<TranscriptRow className="gap-2 text-content-secondary">
<ToolIcon
name="propose_plan"
isError={effectiveError}
isRunning={isRunning}
<ToolCall.Root
status={status}
isError={effectiveError}
errorMessage={effectiveErrorMessage || "Failed to propose plan"}
hasContent={false}
>
<ToolCall.Header
iconName="propose_plan"
label={isRunning ? `Proposing ${filename}…` : `Proposed ${filename}`}
/>
<span className="text-[13px] leading-6">
{isRunning ? `Proposing ${filename}…` : `Proposed ${filename}`}
</span>
{effectiveError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon
aria-label="Error"
className="size-3.5 shrink-0 text-content-secondary"
/>
</TooltipTrigger>
<TooltipContent>
{effectiveErrorMessage || "Failed to propose plan"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</TranscriptRow>
</ToolCall.Root>
{hasDisplayContent ? (
<>
<Response>{displayContent}</Response>
<div className="flex items-center gap-2">
<CopyButton text={displayContent} label="Copy plan" />
<div className="group/plan-actions flex items-center gap-2">
<CopyButton
text={displayContent}
label="Copy plan"
className="opacity-0 transition-opacity group-hover/plan-actions:opacity-100 focus-visible:opacity-100"
/>
{canImplementPlan && (
<Tooltip>
<TooltipTrigger asChild>
@@ -1,16 +1,9 @@
import { useTheme } from "@emotion/react";
import { File as FileViewer } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { asRecord, asString } from "../runtimeTypeUtils";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import {
DIFFS_FONT_STYLE,
getFileViewerOptionsMinimal,
@@ -92,41 +85,26 @@ export const ReadFileTool: React.FC<{
const label = isRunning ? `Reading ${filename}…` : `Read ${filename}`;
return (
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to read file"}
hasContent={hasContent}
expanded={expanded}
onExpandedChange={onExpandedChange}
header={
<>
<ToolIcon name="read_file" isError={isError} isRunning={isRunning} />
<span className="text-[13px] leading-6">{label}</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to read file"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
{isError && (
<div className="mt-1 text-xs text-content-destructive">
{errorMessage || "Failed to read file"}
</div>
)}
{content.length > 0 && <ReadFileContent path={path} content={content} />}
</ToolCollapsible>
<ToolCall.Header iconName="read_file" label={label} />
<ToolCall.Content>
{isError && (
<div className="mt-1 text-xs text-content-destructive">
{errorMessage || "Failed to read file"}
</div>
)}
{content.length > 0 && (
<ReadFileContent path={path} content={content} />
)}
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,14 +1,7 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { type FC, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import type { MergedTool } from "../../ChatConversation/types";
import { getReadFileToolData, ReadFileTool } from "./ReadFileTool";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
type ReadFileItem = {
id: string;
@@ -44,61 +37,44 @@ export const ReadFilesTool: FC<{
return (
<div data-tool-call="">
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={isRunning ? "running" : isError ? "error" : "completed"}
isError={isError}
errorMessage={errorMessage || "Failed to read one or more files"}
hasContent={hasContent}
expanded={expanded}
onExpandedChange={onExpandedChange}
header={
<>
<ToolIcon
name="read_file"
isError={isError}
isRunning={isRunning}
/>
<span className="text-[13px] leading-6">{label}</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to read one or more files"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
<div className="space-y-1 py-0.5 pl-3">
{items.map((item) => (
<div key={item.id}>
<ReadFileTool
path={item.path}
content={item.content}
status={item.status}
isError={item.isError}
errorMessage={item.errorMessage}
expanded={expandedFileIDs.has(item.id)}
onExpandedChange={(nextExpanded) => {
setExpandedFileIDs((previous) => {
const next = new Set(previous);
if (nextExpanded) {
next.add(item.id);
} else {
next.delete(item.id);
}
return next;
});
}}
/>
</div>
))}
</div>
</ToolCollapsible>
<ToolCall.Header iconName="read_file" label={label} />
<ToolCall.Content>
<div className="space-y-1 py-0.5 pl-3">
{items.map((item) => (
<div key={item.id}>
<ReadFileTool
path={item.path}
content={item.content}
status={item.status}
isError={item.isError}
errorMessage={item.errorMessage}
expanded={expandedFileIDs.has(item.id)}
onExpandedChange={(nextExpanded) => {
setExpandedFileIDs((previous) => {
const next = new Set(previous);
if (nextExpanded) {
next.add(item.id);
} else {
next.delete(item.id);
}
return next;
});
}}
/>
</div>
))}
</div>
</ToolCall.Content>
</ToolCall.Root>
</div>
);
};
@@ -1,14 +1,7 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { Response } from "../Response";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
export const ReadSkillTool: React.FC<{
@@ -22,46 +15,30 @@ export const ReadSkillTool: React.FC<{
const isRunning = status === "running";
return (
<ToolCollapsible
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to read skill"}
hasContent={hasContent}
header={
<>
<ToolIcon name="read_skill" isError={isError} isRunning={isRunning} />
<span className="text-[13px] leading-6">
{isRunning ? `Reading ${label}…` : `Read ${label}`}
</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to read skill"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
{body && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{body}</Response>
</div>
</ScrollArea>
)}
</ToolCollapsible>
<ToolCall.Header
iconName="read_skill"
label={isRunning ? `Reading ${label}…` : `Read ${label}`}
/>
<ToolCall.Content>
{body && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{body}</Response>
</div>
</ScrollArea>
)}
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,12 +1,5 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { TranscriptRow } from "../TranscriptRow";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
/**
@@ -28,22 +21,13 @@ export const ReadTemplateTool: React.FC<{
: "Read template";
return (
<TranscriptRow className="gap-2 text-content-secondary">
<ToolIcon name="read_template" isError={isError} isRunning={isRunning} />
<span className="text-[13px] leading-6">{label}</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to read template"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</TranscriptRow>
<ToolCall.Root
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to read template"}
hasContent={false}
>
<ToolCall.Header iconName="read_template" label={label} />
</ToolCall.Root>
);
};
@@ -1,12 +1,5 @@
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import type { ToolStatus } from "./utils";
import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection";
@@ -41,47 +34,21 @@ export const StartWorkspaceTool: FC<StartWorkspaceToolProps> = ({
? `Started ${workspaceName}`
: "Started workspace";
const header = (
<>
<ToolIcon
name="start_workspace"
isError={isError}
isRunning={isRunning}
/>
<span className="text-[13px] leading-6">{label}</span>
</>
);
const headerStatus = (
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to start workspace"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
);
// Show collapsible with build logs when there's a build to show.
const hasBuildLogs = (isRunning || Boolean(buildId)) && !noBuild;
return (
<div className="w-full">
<ToolCollapsible
header={header}
headerStatus={headerStatus}
hasContent={hasBuildLogs}
defaultExpanded={isRunning}
>
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to start workspace"}
hasContent={hasBuildLogs}
defaultExpanded={isRunning}
>
<ToolCall.Header iconName="start_workspace" label={label} />
<ToolCall.Content>
<WorkspaceBuildLogSection status={status} buildId={buildId} />
</ToolCollapsible>
</div>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -1,6 +1,5 @@
import {
BotIcon,
ChevronDownIcon,
CircleXIcon,
ClockIcon,
ExternalLinkIcon,
@@ -11,20 +10,14 @@ import type React from "react";
import { useState } from "react";
import { Link, useLocation } from "react-router";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
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";
import type { SubagentAction, SubagentDescriptor } from "./subagentDescriptor";
import {
isSubagentSuccessStatus,
shortDurationMs,
type ToolStatus,
} from "./utils";
import { ToolCall } from "./ToolCall";
import { isSubagentSuccessStatus, type ToolStatus } from "./utils";
const SUBAGENT_VERBS: Record<
SubagentAction,
@@ -68,11 +61,7 @@ function getSubagentLabel(
isTimeout: boolean,
): React.ReactNode {
if (showDesktopPreview && toolStatus === "running") {
return (
<Shimmer as="span" className="text-[13px] leading-6">
Using the computer...
</Shimmer>
);
return "Using the computer...";
}
if (
descriptor.variant === "computer_use" &&
@@ -156,7 +145,6 @@ export const SubagentTool: React.FC<{
subagentStatus: string;
prompt?: string;
message?: string;
durationMs?: number;
report?: string;
toolStatus: ToolStatus;
isError: boolean;
@@ -174,7 +162,6 @@ export const SubagentTool: React.FC<{
subagentStatus,
prompt,
message,
durationMs,
report,
toolStatus,
isError,
@@ -190,32 +177,30 @@ export const SubagentTool: React.FC<{
const hasMessage = Boolean(message?.trim());
const hasReport = Boolean(report?.trim());
const hasExpandableContent = hasPrompt || hasMessage || hasReport;
const durationLabel = shortDurationMs(durationMs);
const agentChatPath = safeBuildAgentChatPath({ chatId });
return (
<div className="w-full">
<TranscriptRow
asChild
className={cn(
"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",
)}
>
<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]">
<ToolCall.Root
className="w-full"
status={toolStatus}
isError={isError}
hasContent={hasExpandableContent}
expanded={expanded}
onExpandedChange={setExpanded}
>
<ToolCall.HeaderLayout>
<ToolCall.HeaderButton alwaysButton>
<ToolCall.LeadingIcon>
<SubagentStatusIcon
subagentStatus={subagentStatus}
toolStatus={toolStatus}
isError={isError}
isTimeout={isTimeout}
iconKind={descriptor.iconKind}
showDesktopPreview={showDesktopPreview}
/>
</ToolCall.LeadingIcon>
<ToolCall.Label>
{getSubagentLabel(
showDesktopPreview,
toolStatus,
@@ -223,32 +208,21 @@ export const SubagentTool: React.FC<{
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="size-3" />
</Link>
)}
</span>
{hasExpandableContent && (
<ChevronDownIcon
className={cn(
"size-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>
</ToolCall.Label>
<ToolCall.Chevron />
</ToolCall.HeaderButton>
{agentChatPath && (
<ToolCall.HeaderActions>
<Link
to={{ pathname: agentChatPath, search: location.search }}
className="inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View agent"
>
<ExternalLinkIcon className="size-3" />
</Link>
</ToolCall.HeaderActions>
)}
</ToolCall.HeaderLayout>
{showDesktopPreview && desktopChatId && toolStatus !== "completed" && (
<div className="mt-1.5 overflow-hidden rounded-lg border border-solid border-border-default">
@@ -267,41 +241,43 @@ export const SubagentTool: React.FC<{
/>
</div>
)}
{expanded && hasPrompt && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{prompt ?? ""}</Response>
</div>
</ScrollArea>
)}
<ToolCall.Content>
{hasPrompt && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{prompt ?? ""}</Response>
</div>
</ScrollArea>
)}
{expanded && hasMessage && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{message ?? ""}</Response>
</div>
</ScrollArea>
)}
{hasMessage && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{message ?? ""}</Response>
</div>
</ScrollArea>
)}
{expanded && hasReport && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{report ?? ""}</Response>
</div>
</ScrollArea>
)}
</div>
{hasReport && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{report ?? ""}</Response>
</div>
</ScrollArea>
)}
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -364,7 +364,9 @@ export const ExecuteSuccess: Story = {
expect(
canvas.queryByRole("img", { name: "Running in background" }),
).not.toBeInTheDocument();
expect(canvas.getByText(/for 47\.2s/)).toBeVisible();
const durationSuffix = canvas.getByText("for 47.2s");
expect(durationSuffix).toBeVisible();
expect(durationSuffix.tagName).toBe("SPAN");
expect(canvas.queryByText("2 lines")).not.toBeInTheDocument();
},
};
@@ -527,6 +529,21 @@ export const ProcessOutputAlwaysExpanded: Story = {
},
};
export const ProcessOutputStringError: Story = {
args: {
name: "process_output",
status: "error",
isError: true,
result: "permission denied",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("img", { name: "Failed to read process output" }),
).toBeVisible();
},
};
export const ExecuteAuthRequired: Story = {
args: {
result: {
@@ -576,6 +593,9 @@ export const WaitForExternalAuthRunning: Story = {
expect(
canvas.getByText("Waiting for GitHub authentication..."),
).toBeInTheDocument();
expect(
canvas.getByRole("img", { name: "Authentication in progress" }),
).toBeVisible();
},
};
@@ -804,57 +824,6 @@ export const SubagentAwaitPreferredTitle: Story = {
},
};
export const SubagentRequestMetadata: Story = {
args: {
name: "spawn_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
request_id: "request-123",
duration_ms: 1530,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Worked for 2s")).toBeInTheDocument();
},
};
export const SubagentAwaitRequestMetadata: Story = {
args: {
name: "wait_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
request_id: "request-123",
duration_ms: 1530,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Worked for 2s")).toBeInTheDocument();
},
};
export const SubagentMessageRequestMetadata: Story = {
args: {
name: "message_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
request_id: "request-123",
duration_ms: 1530,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Worked for 2s")).toBeInTheDocument();
},
};
export const SpawnSubagentGeneralRunning: Story = {
args: {
name: "spawn_agent",
@@ -892,7 +861,6 @@ export const SpawnSubagentGeneralCompleted: Story = {
type: "general",
title: "Workspace diagnostics",
status: "completed",
duration_ms: 3200,
},
},
play: async ({ canvasElement }) => {
@@ -900,7 +868,6 @@ export const SpawnSubagentGeneralCompleted: Story = {
expect(
canvas.getByRole("button", { name: /Spawned Workspace diagnostics/ }),
).toBeInTheDocument();
expect(canvas.getByText("Worked for 3s")).toBeInTheDocument();
},
};
@@ -938,7 +905,6 @@ export const SpawnSubagentExploreCompleted: Story = {
chat_id: "spawn-explore-child",
type: "explore",
status: "completed",
duration_ms: 4100,
},
},
play: async ({ canvasElement }) => {
@@ -946,7 +912,6 @@ export const SpawnSubagentExploreCompleted: Story = {
expect(
canvas.getByRole("button", { name: /Spawned Explore agent/ }),
).toBeInTheDocument();
expect(canvas.getByText("Worked for 4s")).toBeInTheDocument();
},
};
@@ -1005,7 +970,6 @@ export const SpawnSubagentComputerUseCompleted: Story = {
type: "computer_use",
title: "Visual regression check",
status: "completed",
duration_ms: "12400",
},
},
play: async ({ canvasElement }) => {
@@ -1013,7 +977,6 @@ export const SpawnSubagentComputerUseCompleted: Story = {
expect(
canvas.getByRole("button", { name: /Spawned Visual regression check/ }),
).toBeInTheDocument();
expect(canvas.getByText("Worked for 12s")).toBeInTheDocument();
},
};
@@ -2065,6 +2028,38 @@ export const GenericToolFailedNoResult: Story = {
},
};
export const GenericToolStringError: Story = {
args: {
name: "web_search",
status: "error",
isError: true,
result: "Network unreachable",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("img", { name: "Web search failed" }),
).toBeVisible();
},
};
export const GenericMCPToolStringError: Story = {
args: {
name: "linear__list_issues",
status: "error",
isError: true,
result: "Authentication token expired",
mcpServerConfigId: "mcp-server-1",
mcpServers: sampleMCPServers,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("img", { name: "List issues failed" }),
).toBeVisible();
},
};
const longCodeLine =
'export const config = { apiUrl: "https://coder.example.com/api/v2/workspaces", token: "abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", retries: 5 };';
@@ -2308,14 +2303,12 @@ export const SpawnComputerUseAgentCompleted: Story = {
chat_id: "desktop-child-1",
title: "Visual regression check",
status: "completed",
duration_ms: "12400",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Spawned/)).toBeInTheDocument();
expect(canvas.getByText(/Visual regression check/)).toBeInTheDocument();
expect(canvas.getByText("Worked for 12s")).toBeInTheDocument();
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/desktop-child-1",
@@ -1,14 +1,8 @@
import { useTheme } from "@emotion/react";
import { File as FileViewer } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { type ComponentPropsWithRef, type FC, memo } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { AdvisorTool, type AdvisorToolResultType } from "./AdvisorTool";
import {
@@ -39,8 +33,7 @@ import {
isSubagentToolName,
type SubagentVariant,
} from "./subagentDescriptor";
import { ToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import { ToolLabel } from "./ToolLabel";
import { getExecuteRenderData, shouldRenderTool } from "./toolVisibility";
import {
@@ -56,6 +49,7 @@ import {
getFileViewerOptions,
getFileViewerOptionsNoHeader,
getWriteFileDiff,
humanizeMCPToolName,
isSubagentSuccessStatus,
mapSubagentStatusToToolStatus,
parseArgs,
@@ -273,6 +267,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
const exitCode = rec
? (asNumber(rec.exit_code, { parseString: true }) ?? null)
: null;
const errorMessage = rec ? asString(rec.error || rec.message) : "";
return (
<ProcessOutputTool
@@ -280,6 +275,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
isRunning={status === "running"}
exitCode={exitCode}
isError={isError}
errorMessage={errorMessage || undefined}
killedBySignal={killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
/>
@@ -496,9 +492,6 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
streamSubagentStatus = subagentStatusOverrides?.get(chatId) || "";
}
const subagentStatus = streamSubagentStatus || resultSubagentStatus;
const durationMs = rec
? asNumber(rec.duration_ms, { parseString: true })
: undefined;
const report = rec ? asString(rec.report) : "";
const recordingFileId = rec ? asString(rec.recording_file_id) : "";
const thumbnailFileId = rec ? asString(rec.thumbnail_file_id) : "";
@@ -555,7 +548,6 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
subagentStatus={subagentStatus}
prompt={prompt || undefined}
message={subagentMessage || undefined}
durationMs={chatId ? durationMs : undefined}
report={chatId ? report || undefined : undefined}
toolStatus={subagentToolStatus}
isError={subagentIsError}
@@ -888,6 +880,17 @@ const GenericToolContent: FC<GenericToolContentProps> = ({
);
};
const getGenericToolErrorMessage = ({
name,
mcpSlug,
}: {
name: string;
mcpSlug?: string;
}): string => {
const displayName = humanizeMCPToolName(mcpSlug ?? "", name);
return `${displayName} failed`;
};
const GenericToolRenderer: FC<ToolRendererProps> = ({
name,
status,
@@ -918,67 +921,47 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
: undefined;
const hasContent = Boolean(toolInput || fileContent || resultOutput);
const isRunning = status === "running";
const rec = asRecord(result);
const errorMessage = rec ? asString(rec.error || rec.message) : "";
const toolHeader = (
<>
<ToolIcon
name={name}
isError={status === "error" || isError}
iconUrl={mcpServer?.icon_url}
isRunning={isRunning}
serverName={mcpServer?.display_name}
/>
{modelIntent ? (
<span className="truncate text-[13px]">
{formatModelIntentLabel(modelIntent)}
</span>
) : (
<ToolLabel
name={name}
args={args}
result={result}
mcpSlug={mcpServer?.slug}
/>
)}
</>
);
const toolHeaderStatus = (
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>{errorMessage || "Tool call failed"}</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
);
const toolContent = (
<GenericToolContent
toolInput={toolInput}
fileContent={fileContent}
fileContentOptions={fileContentOptions}
isDark={isDark}
resultOutput={resultOutput}
/>
);
const fallbackErrorMessage = getGenericToolErrorMessage({
name,
mcpSlug: mcpServer?.slug,
});
return (
<ToolCollapsible
<ToolCall.Root
status={status}
isError={isError}
errorMessage={errorMessage || fallbackErrorMessage}
hasContent={hasContent}
header={toolHeader}
headerStatus={toolHeaderStatus}
>
{toolContent}
</ToolCollapsible>
<ToolCall.Header
iconName={name}
iconUrl={mcpServer?.icon_url}
serverName={mcpServer?.display_name}
label={
modelIntent ? (
formatModelIntentLabel(modelIntent)
) : (
<ToolLabel
name={name}
args={args}
result={result}
mcpSlug={mcpServer?.slug}
/>
)
}
/>
<ToolCall.Content>
<GenericToolContent
toolInput={toolInput}
fileContent={fileContent}
fileContentOptions={fileContentOptions}
isDark={isDark}
resultOutput={resultOutput}
/>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -0,0 +1,132 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { ToolCall } from "./ToolCall";
const meta: Meta = {
title: "pages/AgentsPage/ChatElements/tools/ToolCall",
decorators: [
(Story) => (
<div className="mx-auto w-full max-w-3xl py-6 font-sans text-xs">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Running: Story = {
render: () => (
<ToolCall.Root status="running" hasContent={false}>
<ToolCall.Header iconName="read_file" label="Reading README.md" />
</ToolCall.Root>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Reading README.md")).toBeVisible();
expect(canvas.queryByRole("button")).not.toBeInTheDocument();
expect(
canvas.getByRole("img", { name: "Tool call running" }),
).toBeVisible();
},
};
export const Completed: Story = {
render: () => (
<ToolCall.Root status="completed" hasContent={false}>
<ToolCall.Header iconName="read_file" label="Read README.md" />
</ToolCall.Root>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Read README.md")).toBeVisible();
expect(
canvas.queryByRole("img", { name: "Tool call running" }),
).not.toBeInTheDocument();
},
};
export const Failed: Story = {
render: () => (
<ToolCall.Root
status="error"
isError
errorMessage="Failed to read file"
hasContent={false}
>
<ToolCall.Header iconName="read_file" label="Read README.md" />
</ToolCall.Root>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Read README.md")).toBeVisible();
expect(
canvas.getByRole("img", { name: "Failed to read file" }),
).toBeVisible();
expect(
canvas.queryByRole("img", { name: "Tool call running" }),
).not.toBeInTheDocument();
},
};
export const RunningWithBackendError: Story = {
render: () => (
<ToolCall.Root
status="running"
isError
errorMessage="Failed to read file"
hasContent={false}
>
<ToolCall.Header iconName="read_file" label="Reading README.md" />
</ToolCall.Root>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Reading README.md")).toBeVisible();
expect(
canvas.getByRole("img", { name: "Tool call running" }),
).toBeVisible();
expect(
canvas.queryByRole("img", { name: "Failed to read file" }),
).not.toBeInTheDocument();
},
};
export const Collapsible: Story = {
render: () => (
<ToolCall.Root
status="completed"
hasContent
ariaLabel={(expanded) =>
expanded ? "Collapse read file" : "Expand read file"
}
>
<ToolCall.Header iconName="read_file" label="Read README.md" />
<ToolCall.Content>
<div className="mt-1.5 rounded-md border border-solid border-border-default p-3">
File contents
</div>
</ToolCall.Content>
</ToolCall.Root>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.tab();
const button = canvas.getByRole("button", { name: "Expand read file" });
expect(button).toHaveFocus();
expect(button).toHaveAttribute("aria-expanded", "false");
expect(canvas.queryByText("File contents")).not.toBeInTheDocument();
await userEvent.keyboard("{Enter}");
const expandedButton = canvas.getByRole("button", {
name: "Collapse read file",
});
expect(expandedButton).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("File contents")).toBeVisible();
await userEvent.keyboard(" ");
expect(
canvas.getByRole("button", { name: "Expand read file" }),
).toHaveAttribute("aria-expanded", "false");
expect(canvas.queryByText("File contents")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,439 @@
import { ChevronDownIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
import {
type ComponentPropsWithoutRef,
createContext,
type FC,
type ReactNode,
useContext,
useState,
} from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { Shimmer } from "../Shimmer";
import { TranscriptRow } from "../TranscriptRow";
import type { SubagentIconKind } from "./subagentDescriptor";
import { ToolIcon } from "./ToolIcon";
import type { ToolStatus } from "./utils";
/**
* Shared display states for tool call rows.
*
* `preview` is an initial or externally controlled display state for
* renderers that want content visible without treating the row as fully
* expanded. The built-in header toggle only switches between
* `collapsed` and `expanded`, so toggle callbacks never emit `preview`.
*/
export type ToolCallView = "collapsed" | "preview" | "expanded";
type ToolCallAriaLabel = string | ((expanded: boolean) => string);
type ToolCallContextValue = {
active: boolean;
ariaLabel?: ToolCallAriaLabel;
collapsible: boolean;
errorMessage?: string;
expanded: boolean;
failed: boolean;
onToggle: () => void;
status: ToolStatus;
view: ToolCallView;
};
const ToolCallContext = createContext<ToolCallContextValue | null>(null);
const useToolCallContext = () => {
const context = useContext(ToolCallContext);
if (!context) {
throw new Error(
"ToolCall components must be rendered inside ToolCall.Root",
);
}
return context;
};
/**
* Props for {@link ToolCall.Root}.
*
* The root can be controlled with `view` or `expanded`, or uncontrolled
* with `defaultView` and `defaultExpanded`. When both uncontrolled props
* are provided, `defaultView` wins because it can represent the more
* specific `preview` state.
*
* `hasContent` controls whether the header behaves like a toggle. When
* it is false, the header stays static and content is never shown.
*
* Standard `div` attributes are forwarded to the wrapper element so
* callers can attach semantics such as live region roles.
*/
type ToolCallRootProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
children: ReactNode;
status: ToolStatus;
isError?: boolean;
errorMessage?: string;
hasContent?: boolean;
defaultExpanded?: boolean;
defaultView?: ToolCallView;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onViewChange?: (view: ToolCallView) => void;
ariaLabel?: ToolCallAriaLabel;
view?: ToolCallView;
};
/**
* Provides shared state for tool-call rows and renders the wrapper div.
*
* The wrapper tracks the current display state, derives `expanded` from
* it, and forwards wrapper attributes like `role` or `aria-live` to the
* rendered `div`.
*/
const Root: FC<ToolCallRootProps> = ({
children,
status,
isError = false,
errorMessage,
hasContent = true,
defaultExpanded = false,
defaultView,
expanded: expandedProp,
onExpandedChange,
onViewChange,
ariaLabel,
className,
view: viewProp,
...divProps
}) => {
const [uncontrolledView, setUncontrolledView] = useState<ToolCallView>(
defaultView ?? (defaultExpanded ? "expanded" : "collapsed"),
);
const controlledView =
viewProp ??
(expandedProp === undefined
? undefined
: expandedProp
? "expanded"
: "collapsed");
const view = controlledView ?? uncontrolledView;
const expanded = view !== "collapsed";
const collapsible = hasContent;
const active = status === "running";
const failed = status !== "running" && (isError || status === "error");
const onToggle = () => {
const nextView: ToolCallView = expanded ? "collapsed" : "expanded";
if (controlledView === undefined) {
setUncontrolledView(nextView);
}
onViewChange?.(nextView);
onExpandedChange?.(nextView !== "collapsed");
};
return (
<ToolCallContext.Provider
value={{
active,
ariaLabel,
collapsible,
errorMessage,
expanded,
failed,
onToggle,
status,
view,
}}
>
<div className={className} {...divProps}>
{children}
</div>
</ToolCallContext.Provider>
);
};
type ToolCallHeaderRowProps = {
children: ReactNode;
className?: string;
};
const HeaderRow: FC<ToolCallHeaderRowProps> = ({ children, className }) => (
<TranscriptRow className={cn("gap-2 text-content-secondary", className)}>
{children}
</TranscriptRow>
);
type ToolCallHeaderButtonProps = {
children: ReactNode;
className?: string;
alwaysButton?: boolean;
};
const HeaderButton: FC<ToolCallHeaderButtonProps> = ({
children,
className,
alwaysButton = false,
}) => {
const { ariaLabel, collapsible, expanded, onToggle } = useToolCallContext();
if (!collapsible && !alwaysButton) {
return (
<HeaderRow className={cn("min-w-0", className)}>{children}</HeaderRow>
);
}
return (
<TranscriptRow
asChild
className={cn(
"m-0 min-w-0 max-w-full gap-2 border-0 bg-transparent p-0 text-left font-[inherit] text-[inherit] text-content-secondary transition-colors",
collapsible && "cursor-pointer hover:text-content-primary",
className,
)}
>
<button
type="button"
aria-expanded={collapsible ? expanded : undefined}
aria-label={
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel
}
onClick={collapsible ? onToggle : undefined}
>
{children}
</button>
</TranscriptRow>
);
};
type ToolCallLeadingIconProps = {
name?: string;
children?: ReactNode;
iconUrl?: string;
serverName?: string;
subagentIconKind?: SubagentIconKind;
};
const LeadingIcon: FC<ToolCallLeadingIconProps> = ({
name,
children,
iconUrl,
serverName,
subagentIconKind,
}) => {
const { active, failed } = useToolCallContext();
if (children) {
return <>{children}</>;
}
if (!name) {
return null;
}
return (
<ToolIcon
name={name}
isError={failed}
isRunning={active}
iconUrl={iconUrl}
serverName={serverName}
subagentIconKind={subagentIconKind}
/>
);
};
type ToolCallLabelProps = {
children: ReactNode;
className?: string;
shimmerWhenActive?: boolean;
};
const Label: FC<ToolCallLabelProps> = ({
children,
className,
shimmerWhenActive = true,
}) => {
const { active } = useToolCallContext();
const labelClassName = cn(
"min-w-0 truncate text-[13px] leading-6",
className,
);
if (active && shimmerWhenActive && typeof children === "string") {
return (
<Shimmer as="span" className={labelClassName}>
{children}
</Shimmer>
);
}
return <span className={labelClassName}>{children}</span>;
};
type ToolCallStatusProps = {
className?: string;
errorMessage?: string;
};
const Status: FC<ToolCallStatusProps> = ({ className, errorMessage }) => {
const {
active,
errorMessage: contextErrorMessage,
failed,
} = useToolCallContext();
const message = errorMessage || contextErrorMessage || "Tool call failed";
return (
<>
{active && (
<LoaderIcon
aria-label="Tool call running"
role="img"
className={cn(
"size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current",
className,
)}
/>
)}
{failed && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={message}
role="img"
className={cn("flex shrink-0 text-current", className)}
>
<TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>{message}</TooltipContent>
</Tooltip>
)}
</>
);
};
const Chevron: FC<{ className?: string }> = ({ className }) => {
const { collapsible, expanded } = useToolCallContext();
if (!collapsible) {
return null;
}
return (
<ChevronDownIcon
className={cn(
"size-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
className,
)}
/>
);
};
const Actions: FC<{ children: ReactNode; className?: string }> = ({
children,
className,
}) => (
<div className={cn("flex shrink-0 items-center gap-1", className)}>
{children}
</div>
);
const HeaderActions: FC<{ children: ReactNode; className?: string }> = ({
children,
className,
}) => {
return <Actions className={cn("ml-auto", className)}>{children}</Actions>;
};
const HeaderLayout: FC<{ children: ReactNode; className?: string }> = ({
children,
className,
}) => (
<div className={cn("flex w-full items-center gap-2", className)}>
{children}
</div>
);
type ToolCallStateProps = {
children: (state: ToolCallContextValue) => ReactNode;
};
/**
* Render-prop access to the current tool-call state.
*
* This exposes the same derived state that the shared primitives use,
* including the resolved view, whether the row is expanded, and whether
* the row is considered active or failed.
*/
const State: FC<ToolCallStateProps> = ({ children }) =>
children(useToolCallContext());
type ToolCallHeaderProps = {
iconName?: string;
label: ReactNode;
iconUrl?: string;
serverName?: string;
subagentIconKind?: SubagentIconKind;
secondaryLabel?: ReactNode;
trailing?: ReactNode;
showStatus?: boolean;
headerClassName?: string;
};
/**
* Convenience header that renders the standard leading icon, label,
* optional secondary label, status indicator, trailing content, and
* chevron.
*
* Use this when a tool follows the default header layout. Callers that
* need custom emphasis or status colors can compose the lower-level
* primitives directly instead.
*/
const Header: FC<ToolCallHeaderProps> = ({
iconName,
label,
iconUrl,
serverName,
subagentIconKind,
secondaryLabel,
trailing,
showStatus = true,
headerClassName,
}) => {
return (
<HeaderButton className={headerClassName}>
<LeadingIcon
name={iconName}
iconUrl={iconUrl}
serverName={serverName}
subagentIconKind={subagentIconKind}
/>
<Label>{label}</Label>
{secondaryLabel}
{showStatus && <Status />}
{trailing}
<Chevron />
</HeaderButton>
);
};
type ToolCallContentProps = {
children: ReactNode;
};
const Content: FC<ToolCallContentProps> = ({ children }) => {
const { collapsible, expanded } = useToolCallContext();
if (!collapsible || !expanded) {
return null;
}
return <>{children}</>;
};
export const ToolCall = {
Root,
HeaderRow,
HeaderButton,
LeadingIcon,
Label,
Status,
Chevron,
Actions,
HeaderActions,
HeaderLayout,
State,
Header,
Content,
};
@@ -1,14 +1,8 @@
import { ChevronDownIcon } from "lucide-react";
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,
resolveAgentDisplayState,
} from "./displayMode";
type ToolCollapsibleAriaLabel = string | ((expanded: boolean) => string);
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
@@ -27,26 +21,6 @@ interface ToolCollapsibleProps {
headerClassName?: string;
}
interface AgentDisplayModeToolCollapsibleProps
extends Omit<ToolCollapsibleProps, "defaultExpanded"> {
displayMode: AgentDisplayMode | undefined;
autoDisplayState: AgentDisplayState;
}
export const AgentDisplayModeToolCollapsible: FC<
AgentDisplayModeToolCollapsibleProps
> = ({ displayMode, autoDisplayState, ...props }) => {
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
return (
<ToolCollapsible
key={`${displayMode ?? "auto"}:${autoDisplayState}`}
{...props}
defaultExpanded={isAgentDisplayOpen(displayState)}
/>
);
};
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
children,
header,
@@ -1,22 +1,15 @@
import { useTheme } from "@emotion/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import { ToolIcon } from "./ToolIcon";
import { ToolCall } from "./ToolCall";
import {
DIFFS_FONT_STYLE,
getDiffViewerOptions,
@@ -47,53 +40,36 @@ export const WriteFileTool: React.FC<{
const label = isRunning ? `Writing ${filename}…` : `Wrote ${filename}`;
return (
<AgentDisplayModeToolCollapsible
<ToolCall.Root
key={`${codeDiffDisplayMode ?? "auto"}:${WRITE_FILE_AUTO_DISPLAY_STATE}`}
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to write file"}
hasContent={hasDiff}
displayMode={codeDiffDisplayMode}
autoDisplayState={WRITE_FILE_AUTO_DISPLAY_STATE}
header={
<>
<ToolIcon name="write_file" isError={isError} isRunning={isRunning} />
<span className="text-[13px] leading-6">{label}</span>
</>
}
headerStatus={
<>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="size-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to write file"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
defaultView={displayState}
>
{hasDiff && (
<ScrollArea
data-testid="write-file-diff"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName={
isAgentDisplayFullyExpanded(displayState)
? "max-h-[80vh]"
: "max-h-64"
}
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={stripNoNewline(diff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
)}
</AgentDisplayModeToolCollapsible>
<ToolCall.Header iconName="write_file" label={label} />
<ToolCall.Content>
{hasDiff && (
<ScrollArea
data-testid="write-file-diff"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName={
isAgentDisplayFullyExpanded(displayState)
? "max-h-[80vh]"
: "max-h-64"
}
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={stripNoNewline(diff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
)}
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
isAgentDisplayFullyExpanded,
isAgentDisplayOpen,
resolveAgentDisplayState,
} from "./displayMode";
@@ -20,14 +19,6 @@ describe("resolveAgentDisplayState", () => {
});
});
describe("isAgentDisplayOpen", () => {
it("returns whether a display state shows content", () => {
expect(isAgentDisplayOpen("collapsed")).toBe(false);
expect(isAgentDisplayOpen("preview")).toBe(true);
expect(isAgentDisplayOpen("expanded")).toBe(true);
});
});
describe("isAgentDisplayFullyExpanded", () => {
it("returns whether a display state uses a fully expanded view", () => {
expect(isAgentDisplayFullyExpanded("expanded")).toBe(true);
@@ -1,6 +1,7 @@
import type { AgentDisplayMode } from "#/api/typesGenerated";
import type { ToolCallView } from "./ToolCall";
export type AgentDisplayState = "collapsed" | "preview" | "expanded";
export type AgentDisplayState = ToolCallView;
export const resolveAgentDisplayState = (
mode: AgentDisplayMode | undefined,
@@ -21,10 +22,6 @@ export const resolveAgentDisplayState = (
}
};
export const isAgentDisplayOpen = (state: AgentDisplayState): boolean => {
return state !== "collapsed";
};
export const isAgentDisplayFullyExpanded = (
state: AgentDisplayState,
): boolean => {
@@ -27,7 +27,6 @@ import {
parseServerEditDiffText,
parseServerEditResults,
sanitizeExecuteModelIntent,
shortDurationMs,
stripSvnIndexHeaders,
summarizeParsedCommands,
toProviderLabel,
@@ -103,47 +102,6 @@ describe("toProviderLabel", () => {
});
});
describe("shortDurationMs", () => {
it("returns empty string for undefined", () => {
expect(shortDurationMs(undefined)).toBe("");
});
it("returns empty string for negative values", () => {
expect(shortDurationMs(-1)).toBe("");
expect(shortDurationMs(-1000)).toBe("");
});
it("returns 0s for zero milliseconds", () => {
expect(shortDurationMs(0)).toBe("0s");
});
it("formats sub-second durations", () => {
expect(shortDurationMs(500)).toBe("1s");
expect(shortDurationMs(100)).toBe("0s");
});
it("formats seconds", () => {
expect(shortDurationMs(1000)).toBe("1s");
expect(shortDurationMs(30_000)).toBe("30s");
expect(shortDurationMs(59_000)).toBe("59s");
expect(shortDurationMs(59_499)).toBe("59s");
});
it("formats minutes", () => {
expect(shortDurationMs(59_500)).toBe("1m");
expect(shortDurationMs(60_000)).toBe("1m");
expect(shortDurationMs(300_000)).toBe("5m");
expect(shortDurationMs(3_540_000)).toBe("59m");
expect(shortDurationMs(3_569_999)).toBe("59m");
});
it("formats hours", () => {
expect(shortDurationMs(3_570_000)).toBe("1h");
expect(shortDurationMs(3_600_000)).toBe("1h");
expect(shortDurationMs(7_200_000)).toBe("2h");
});
});
describe("formatShellDurationMs", () => {
it("returns empty string for invalid values", () => {
expect(formatShellDurationMs(undefined)).toBe("");
@@ -119,26 +119,6 @@ export const toProviderLabel = (
return "Git provider";
};
/**
* Formats a duration in milliseconds into a compact label using
* the same style as {@link shortRelativeTime} in utils/time.
*/
export const shortDurationMs = (durationMs: number | undefined): string => {
if (durationMs === undefined || durationMs < 0) {
return "";
}
const seconds = Math.round(durationMs / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.round(durationMs / 60_000);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.round(durationMs / 3_600_000);
return `${hours}h`;
};
const roundToTenths = (value: number): number => Number(value.toFixed(1));
export const formatShellDurationMs = (
@@ -2,10 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { createChatStore } from "./ChatConversation/chatStore";
import {
buildStreamRenderState,
FIXTURE_NOW,
} from "./ChatConversation/storyFixtures";
import { FIXTURE_NOW } from "./ChatConversation/storyFixtures";
import { ChatPageTimeline } from "./ChatPageContent";
const meta = {
@@ -29,48 +26,6 @@ const buildMessage = (
content,
});
const buildRegressionStore = () => {
const store = createChatStore();
store.replaceMessages([
buildMessage(1, "user", [{ type: "text", text: "Read the source files" }]),
buildMessage(2, "assistant", [
{
type: "reasoning",
text: "I should read SKILL.md and main.go to understand the codebase.",
},
{
type: "tool-call",
tool_call_id: "tool-1",
tool_name: "read_file",
args: { path: "SKILL.md" },
},
{
type: "tool-call",
tool_call_id: "tool-2",
tool_name: "read_file",
args: { path: "main.go" },
},
]),
buildMessage(3, "tool", [
{
type: "tool-result",
tool_call_id: "tool-1",
result: { output: "# SKILL.md contents" },
},
]),
buildMessage(4, "tool", [
{
type: "tool-result",
tool_call_id: "tool-2",
result: { output: "package main" },
},
]),
]);
return store;
};
const buildThinkingSpacerStore = () => {
const store = createChatStore();
@@ -87,42 +42,6 @@ const buildThinkingSpacerStore = () => {
return store;
};
export const StreamingToolCallGapRegression: Story = {
render: () => {
const store = buildRegressionStore();
const { streamState } = buildStreamRenderState([
{
type: "tool-call",
tool_call_id: "tool-streaming",
tool_name: "read_file",
args: { path: "types.go" },
},
]);
store.setStreamState(streamState);
store.setChatStatus("pending");
return <ChatPageTimeline store={store} persistedError={undefined} />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByTestId("assistant-bottom-spacer")).toBeNull();
},
};
export const StartingPhaseToolCallGapRegression: Story = {
render: () => {
const store = buildRegressionStore();
store.setChatStatus("running");
return <ChatPageTimeline store={store} persistedError={undefined} />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
canvas.getAllByText("Thinking...");
expect(canvas.queryByTestId("assistant-bottom-spacer")).toBeNull();
},
};
export const SpacerVisibleWhenNotStreaming: Story = {
render: () => {
const store = buildThinkingSpacerStore();
@@ -501,11 +501,9 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
return (
<div>
{inputElement}
{modelSelectorHelp && (
<div className="px-3 pt-1 text-2xs text-content-secondary">
{modelSelectorHelp}
</div>
)}
<div className="px-3 pt-1 text-2xs text-content-secondary">
{modelSelectorHelp}
</div>
</div>
);
};
@@ -99,6 +99,34 @@ const makeLargeRecord = (
type StoryCanvas = ReturnType<typeof within>;
type StoryUser = ReturnType<typeof userEvent.setup>;
const expectVisibleCopyButtonOnHover = async ({
canvas,
label,
}: {
canvas: StoryCanvas;
label: RegExp;
}) => {
const copyButton = canvas.getByRole("button", { name: label });
const groupContainer = copyButton.closest("[data-debug-code-block]");
if (!(groupContainer instanceof HTMLElement)) {
throw new Error("Missing debug-code hover wrapper.");
}
let supportsNativeHover = false;
try {
const { userEvent: browserUserEvent } = await import("vitest/browser");
await browserUserEvent.hover(groupContainer);
supportsNativeHover = true;
} catch {
await userEvent.hover(groupContainer);
}
if (supportsNativeHover) {
await waitFor(() => {
expect(copyButton).toBeVisible();
});
}
return copyButton;
};
// Story fixtures use structured normalized payloads even though the generated
// API type still models them as string records.
const makeNormalizedPayloadFixture = (
@@ -752,12 +780,11 @@ export const SingleStepSuccessfulRun: Story = {
// Request body toggle should be available once the step is open.
expect(canvas.getByText("Request body")).toBeVisible();
// Verify a copy button is reachable for normalized body sections.
// Verify a copy button becomes visible for normalized body sections.
await user.click(canvas.getByText("Request body"));
await waitFor(() => {
expect(
canvas.getByRole("button", { name: /Copy request body JSON/i }),
).toBeVisible();
await expectVisibleCopyButtonOnHover({
canvas,
label: /Copy request body JSON/i,
});
},
};
@@ -1098,16 +1125,17 @@ export const MultiStepRunWithRetries: Story = {
});
await user.click(canvas.getByRole("button", { name: /Attempt 1/i }));
await waitFor(() => {
expect(
canvas.getByRole("button", { name: /Copy raw request JSON/i }),
).toBeVisible();
expect(
canvas.getByRole("button", { name: /Copy raw response JSON/i }),
).toBeVisible();
expect(
canvas.getByRole("button", { name: /Copy raw attempt error/i }),
).toBeVisible();
await expectVisibleCopyButtonOnHover({
canvas,
label: /Copy raw request JSON/i,
});
await expectVisibleCopyButtonOnHover({
canvas,
label: /Copy raw response JSON/i,
});
await expectVisibleCopyButtonOnHover({
canvas,
label: /Copy raw attempt error/i,
});
},
};
@@ -1152,10 +1180,9 @@ export const ErrorStateWithRedactedHeaders: Story = {
// Expand request body to reveal the redacted headers.
await user.click(canvas.getByText("Request body"));
await waitFor(() => {
expect(
canvas.getByRole("button", { name: /Copy request body JSON/i }),
).toBeVisible();
await expectVisibleCopyButtonOnHover({
canvas,
label: /Copy request body JSON/i,
});
// After expanding, verify [REDACTED] markers appear in the
@@ -62,9 +62,13 @@ export const CopyableCodeBlock: FC<CopyableCodeBlockProps> = ({
className,
}) => {
return (
<div className="relative">
<div data-debug-code-block className="group/debug-code relative">
<div className="absolute right-2 top-2 z-10">
<CopyButton text={code} label={label} />
<CopyButton
text={code}
label={label}
className="opacity-0 transition-opacity group-hover/debug-code:opacity-100 focus-visible:opacity-100"
/>
</div>
<DebugCodeBlock code={code} className={cn("pr-10", className)} />
</div>