mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site/src/pages/AgentsPage): improve live tool activity (#26147)
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
+117
-46
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+25
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user