From 909f6f4e1a62a86f5a9733d83748b24be4c1bed8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 9 Jun 2026 19:50:29 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage): improve live tool activity (#26147) --- site/src/pages/AgentsPage/AgentChatPage.tsx | 2 +- .../ChatConversation/ChatStatusCallout.tsx | 45 +- .../StreamingOutput.stories.tsx | 163 +++++-- .../ChatConversation/StreamingOutput.tsx | 79 ++-- .../chatStore.createStore.test.ts | 3 +- .../ChatConversation/chatStore.test.tsx | 4 +- .../components/ChatConversation/chatStore.ts | 2 +- .../ChatConversation/streamState.test.ts | 114 +++++ .../ChatConversation/streamState.ts | 10 + .../streamingActivity.test.ts | 133 ++++++ .../ChatConversation/streamingActivity.ts | 24 + .../ChatConversation/useChatStore.ts | 2 +- .../ChatElements/tools/AdvisorTool.tsx | 220 ++++----- .../tools/AskUserQuestionTool.stories.tsx | 27 +- .../tools/AskUserQuestionTool.tsx | 65 +-- .../ChatElements/tools/ChatSummarizedTool.tsx | 69 +-- .../ChatElements/tools/ComputerTool.tsx | 99 ++-- .../tools/CreateWorkspaceTool.tsx | 88 ++-- .../ChatElements/tools/EditFilesTool.tsx | 92 ++-- .../ChatElements/tools/ExecuteTool.tsx | 283 ++++++----- .../ChatElements/tools/ListTemplatesTool.tsx | 102 ++-- .../ChatElements/tools/ProcessOutputTool.tsx | 137 +++--- .../tools/ProposePlanTool.stories.tsx | 10 +- .../ChatElements/tools/ProposePlanTool.tsx | 47 +- .../ChatElements/tools/ReadFileTool.tsx | 56 +-- .../ChatElements/tools/ReadFilesTool.tsx | 92 ++-- .../ChatElements/tools/ReadSkillTool.tsx | 69 +-- .../ChatElements/tools/ReadTemplateTool.tsx | 34 +- .../ChatElements/tools/StartWorkspaceTool.tsx | 59 +-- .../ChatElements/tools/SubagentTool.tsx | 174 +++---- .../ChatElements/tools/Tool.stories.tsx | 113 +++-- .../components/ChatElements/tools/Tool.tsx | 117 ++--- .../ChatElements/tools/ToolCall.stories.tsx | 132 ++++++ .../ChatElements/tools/ToolCall.tsx | 439 ++++++++++++++++++ .../ChatElements/tools/ToolCollapsible.tsx | 26 -- .../ChatElements/tools/WriteFileTool.tsx | 82 ++-- .../ChatElements/tools/displayMode.test.ts | 9 - .../ChatElements/tools/displayMode.ts | 7 +- .../ChatElements/tools/utils.test.ts | 42 -- .../components/ChatElements/tools/utils.ts | 20 - .../components/ChatPageContent.stories.tsx | 83 +--- .../AgentsPage/components/ChatPageContent.tsx | 8 +- .../DebugPanel/DebugPanel.stories.tsx | 65 ++- .../DebugPanel/DebugPanelPrimitives.tsx | 8 +- 44 files changed, 1929 insertions(+), 1526 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts create mode 100644 site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index dac70da0ee..9fb29e556c 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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 diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx index adde6c1151..cf7184e981 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx @@ -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; -const StatusPlaceholder: FC<{ - text: string; - shimmer?: boolean; - showThinkingIcon?: boolean; -}> = ({ text, shimmer = false, showThinkingIcon = false }) => { - return ( - - {showThinkingIcon && } - {shimmer ? ( - - {text} - - ) : ( - - {text} - - )} - - ); -}; - /** * 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 ( - - ); + return null; case "retrying": - return ( - <> - - - - ); + return ; case "reconnecting": - return ( - <> - - - - ); + return ; case "failed": return ; } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx index 9fd03871d8..b2324de155 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx @@ -33,23 +33,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** 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 ( +
+
+ +
+
+ +
+
); - 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), + ); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx index 4284394f6d..99cb9b30f4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx @@ -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 = () => ( -
- - - - Thinking - - +const LiveActivitySlot: FC<{ + visible: boolean; + detached: boolean; +}> = ({ visible, detached }) => ( +
+ + + Thinking +
); @@ -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 && } - {!needsStreamingThinking && hasTransientLiveStatus(liveStatus) && ( + {hasCalloutLiveStatus(liveStatus) && ( )} +
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index f812922201..c65b16a09c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -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")); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index e85237fe30..a8ba38f4c2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -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(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 0562e4a5c4..8aca1e0bc2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -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 diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts index 4c8cdc6929..7c2e52ea8b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts @@ -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, { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index 2f2d5e9b9b..aa7b92b650 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -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, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts new file mode 100644 index 0000000000..8d5a04bcca --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts @@ -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); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts new file mode 100644 index 0000000000..7175bccdb1 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts @@ -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)); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index d7fb56f0d3..e2cbe573f1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -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") { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx index eaaca11788..12cf7200c6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx @@ -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 = ({ const showLimitReached = resultType === "limit_reached"; const showError = isError || resultType === "error"; + const headerStatus = showLimitReached ? ( + + ) : showError ? ( + + ) : ( + + ); + return ( - ( -
-
- - - {isRunning && ( - - {RUNNING_MESSAGE} - - )} - {advisorModelText && ( - - {advisorModelText} - - )} - {remainingUses !== undefined && ( - - {remainingUses.toLocaleString("en-US")} uses left - - )} -
- - {questionText} - -
- )} - headerStatus={ - showLimitReached ? ( - - ) : showError ? ( - - ) : isRunning ? ( - - ) : null - } > - -
- {isRunning && adviceText.length === 0 ? ( -
- Reviewing context and preparing guidance. -
- ) : showLimitReached ? ( -
- -
-

Advisor limit reached.

-

- {LIMIT_REACHED_MESSAGE} -

-
-
- ) : showError ? ( -
- -
-

Advisor request failed.

-

- {effectiveErrorMessage} -

-
-
- ) : ( -
-
- - Advice + + + {({ expanded }) => ( + <> +
+
+ + + {isRunning && ( + + {RUNNING_MESSAGE} + + )} + {advisorModelText && ( + + {advisorModelText} + + )} + {remainingUses !== undefined && ( + + {remainingUses.toLocaleString("en-US")} uses left + + )} +
+ + {questionText}
- - {adviceText || EMPTY_ADVICE_MESSAGE} - -
+ {headerStatus} + + )} -
-
-
+ + + + +
+ {isRunning && adviceText.length === 0 ? ( +
+ Reviewing context and preparing guidance. +
+ ) : showLimitReached ? ( +
+ +
+

Advisor limit reached.

+

+ {LIMIT_REACHED_MESSAGE} +

+
+
+ ) : showError ? ( +
+ +
+

Advisor request failed.

+

+ {effectiveErrorMessage} +

+
+
+ ) : ( +
+
+ + Advice + +
+ + {adviceText || EMPTY_ADVICE_MESSAGE} + +
+ )} +
+
+
+ ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx index 9181c7dd13..9e683e4163 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx @@ -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(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx index 0a0386b962..514e6cc84a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx @@ -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 = ({ if (isError) { return ( -
- + - - - {errorMessage || "Failed to ask questions"} - +
); } @@ -557,24 +556,17 @@ export const AskUserQuestionTool: FC = ({ return (
{isRunning ? ( - - - - Asking for clarification... - - - + ) : (

No questions available. @@ -688,24 +680,17 @@ export const AskUserQuestionTool: FC = ({ return (

{isRunning && ( - - - - Asking for clarification... - - - + )} {isInteractive ? ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx index 61a041d890..c6ff53285e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -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 ( - - - - {isRunning ? "Summarizing…" : "Summarized"} - - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to summarize conversation"} - - - )} - {isRunning && ( - - )} - - } > - -
- {summary} -
-
-
+ + + +
+ {summary} +
+
+
+ ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ComputerTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ComputerTool.tsx index 513ee35175..adafee228d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ComputerTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ComputerTool.tsx @@ -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 ( - - - - {isRunning ? "Taking screenshot…" : "Screenshot"} - - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to take screenshot"} - - - )} - {isRunning && ( - - )} - - } > - {hasImage ? ( - <> -
- +
+ {showLightbox && ( + setShowLightbox(false)} /> - + )} + + ) : hasText ? ( +
+
+							{text}
+						
- {showLightbox && ( - setShowLightbox(false)} - /> - )} - - ) : hasText ? ( -
-
-						{text}
-					
-
- ) : null} -
+ ) : null} + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx index 256717ef0d..970ed61de2 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx @@ -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 = ( - <> - - {label} - {workspaceLink && !isRunning && ( - e.stopPropagation()} - className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100" - aria-label="View workspace" - > - - - )} - - ); - const headerStatus = ( - <> - {isError && ( - - - - - - {errorMessage || "Failed to create workspace"} - - - )} - {isRunning && ( - - )} - - ); - return ( -
- + + + + + {label} + + + + {workspaceLink && !isRunning && ( + + + + + + )} + + - -
+ + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index b7f9cf6a64..93b8d5f177 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -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 ( - - - {label} - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to edit files"} - - - )} - {isRunning && ( - - )} - - } + defaultView={displayState} > -
- {diffs.map((diff, i) => - diff ? ( - - - - ) : null, - )} -
-
+ + +
+ {diffs.map((diff, i) => + diff ? ( + + + + ) : null, + )} +
+
+ ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 07d3f4e783..329773e056 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -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 = (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 ( - - ); -}; - -const ExecuteToolInner: React.FC = ({ +export const ExecuteTool: React.FC = ({ command, transcriptBlocks, status, @@ -85,106 +54,117 @@ const ExecuteToolInner: React.FC = ({ 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 ( -
- - - - - {isRunning && ( - - )} - {showFailureIndicator && ( - - - - - - - Command failed - - )} - {isBackgrounded && !isRunning && ( - - - - - - - Running in background - - )} - {killedBySignal && !isRunning && ( - - - - - - {signalTooltipLabel(killedBySignal)} - - - )} - - - {outputOpen && ( + + + - )} -
+ + ); }; -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 ( - <> - - - {commandLabel} - {durationSuffix && ( - {durationSuffix} - )} - - {expanded !== undefined && ( - - )} - - ); + 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 = ( - - ); + let statusIcon: React.ReactNode = isRunning ? ( + + ) : null; if (isError) { label = errorMessage || `Failed while waiting for ${providerLabel} authentication`; - icon = ( - + statusIcon = ( + ); } else if (timedOut) { label = `Timed out waiting for ${providerLabel} authentication`; - icon = ( - + statusIcon = ( + ); } else if (authenticated && !isRunning) { label = `Authenticated with ${providerLabel}`; - icon = ; + statusIcon = ( + + ); } return ( -
-
- {icon} - {label} -
-
+ + + + {statusIcon} + + {label} + + + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListTemplatesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListTemplatesTool.tsx index b8ec34a15f..d5554ec38f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ListTemplatesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListTemplatesTool.tsx @@ -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 ( - - - {label} - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to list templates"} - - - )} - {isRunning && ( - - )} - - } > -
- {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}`; + + +
+ {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 ( +
+ {templateName} +
+ ); + } - if (!name) { return ( -
- {templateName} +
+ + {templateName} + +
); - } - - return ( -
- e.stopPropagation()} - className="flex items-center gap-1.5 text-[13px] text-content-secondary opacity-50 transition-opacity hover:opacity-100" - > - {templateName} - - -
- ); - })} -
- + })} +
+
+ ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 2ab190b020..8b740af4e8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -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 = (props) => { = ({ isRunning, exitCode, isError, + errorMessage, killedBySignal, - shellToolDisplayMode, - autoDisplayState, + defaultView, outputInitiallyFullyExpanded, }) => { const [outputFullyExpanded, setOutputFullyExpanded] = useState( @@ -81,32 +81,26 @@ const ProcessOutputToolInner: React.FC = ({ const hasHeaderActions = Boolean(killedBySignal) || showExitCode || hasOutput; return ( - expanded ? "Collapse process output" : "Expand process output" } - header={ - <> - - Process output - - } - headerStatus={ - isRunning ? ( - - ) : undefined - } - headerActions={ - hasHeaderActions ? ( - <> + > + + + + Process output + + + + {hasHeaderActions && ( + {killedBySignal && !isRunning && ( @@ -126,53 +120,54 @@ const ProcessOutputToolInner: React.FC = ({ )} - - ) : undefined - } - > - -
+				)}
+			
+			
+				
-					{output}
-				
-
- {overflows && ( - - )} -
+ > + {output} + + + {overflows && ( + + )} + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx index 35e74f123c..2f9c0270e2 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx @@ -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(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx index a19a0426b9..e1ee74db1a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx @@ -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 (
- - + - - {isRunning ? `Proposing ${filename}…` : `Proposed ${filename}`} - - {effectiveError && ( - - - - - - {effectiveErrorMessage || "Failed to propose plan"} - - - )} - {isRunning && ( - - )} - + {hasDisplayContent ? ( <> {displayContent} -
- +
+ {canImplementPlan && ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx index 1512df890b..103666e285 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx @@ -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 ( - - - {label} - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to read file"} - - - )} - {isRunning && ( - - )} - - } > - {isError && ( -
- {errorMessage || "Failed to read file"} -
- )} - {content.length > 0 && } -
+ + + {isError && ( +
+ {errorMessage || "Failed to read file"} +
+ )} + {content.length > 0 && ( + + )} +
+ ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx index d157e9592d..cf2cf63990 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx @@ -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 (
- - - {label} - {isError && ( - - - - - - {errorMessage || "Failed to read one or more files"} - - - )} - {isRunning && ( - - )} - - } > -
- {items.map((item) => ( -
- { - setExpandedFileIDs((previous) => { - const next = new Set(previous); - if (nextExpanded) { - next.add(item.id); - } else { - next.delete(item.id); - } - return next; - }); - }} - /> -
- ))} -
-
+ + +
+ {items.map((item) => ( +
+ { + setExpandedFileIDs((previous) => { + const next = new Set(previous); + if (nextExpanded) { + next.add(item.id); + } else { + next.delete(item.id); + } + return next; + }); + }} + /> +
+ ))} +
+
+
); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx index 33f210d7e2..baf32f1738 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx @@ -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 ( - - - - {isRunning ? `Reading ${label}…` : `Read ${label}`} - - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to read skill"} - - - )} - {isRunning && ( - - )} - - } > - {body && ( - -
- {body} -
-
- )} -
+ + + {body && ( + +
+ {body} +
+
+ )} +
+ ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadTemplateTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadTemplateTool.tsx index 70b428cc41..3fc2d48b93 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadTemplateTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadTemplateTool.tsx @@ -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 ( - - - {label} - {isError && ( - - - - - - {errorMessage || "Failed to read template"} - - - )} - {isRunning && ( - - )} - + + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx index 98b1262efc..78e654cb74 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx @@ -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 = ({ ? `Started ${workspaceName}` : "Started workspace"; - const header = ( - <> - - {label} - - ); - const headerStatus = ( - <> - {isError && ( - - - - - - {errorMessage || "Failed to start workspace"} - - - )} - {isRunning && ( - - )} - - ); - - // Show collapsible with build logs when there's a build to show. const hasBuildLogs = (isRunning || Boolean(buildId)) && !noBuild; return ( -
- + + + - -
+ + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index b3f34bbd8e..c64f39fb56 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -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 ( - - Using the computer... - - ); + 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 ( -
- - - + + + + {agentChatPath && ( + + + + + + )} + {showDesktopPreview && desktopChatId && toolStatus !== "completed" && (
@@ -267,41 +241,43 @@ export const SubagentTool: React.FC<{ />
)} - {expanded && hasPrompt && ( - -
- {prompt ?? ""} -
-
- )} + + {hasPrompt && ( + +
+ {prompt ?? ""} +
+
+ )} - {expanded && hasMessage && ( - -
- {message ?? ""} -
-
- )} + {hasMessage && ( + +
+ {message ?? ""} +
+
+ )} - {expanded && hasReport && ( - -
- {report ?? ""} -
-
- )} -
+ {hasReport && ( + +
+ {report ?? ""} +
+
+ )} + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index cd37945445..92fc966d9e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -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", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index e3b15b4466..9aa8c36c1e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -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 = ({ const exitCode = rec ? (asNumber(rec.exit_code, { parseString: true }) ?? null) : null; + const errorMessage = rec ? asString(rec.error || rec.message) : ""; return ( = ({ isRunning={status === "running"} exitCode={exitCode} isError={isError} + errorMessage={errorMessage || undefined} killedBySignal={killedBySignal} shellToolDisplayMode={shellToolDisplayMode} /> @@ -496,9 +492,6 @@ const SubagentRenderer: FC = ({ 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 = ({ 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 = ({ ); }; +const getGenericToolErrorMessage = ({ + name, + mcpSlug, +}: { + name: string; + mcpSlug?: string; +}): string => { + const displayName = humanizeMCPToolName(mcpSlug ?? "", name); + return `${displayName} failed`; +}; + const GenericToolRenderer: FC = ({ name, status, @@ -918,67 +921,47 @@ const GenericToolRenderer: FC = ({ : 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 = ( - <> - - {modelIntent ? ( - - {formatModelIntentLabel(modelIntent)} - - ) : ( - - )} - - ); - const toolHeaderStatus = ( - <> - {isError && ( - - - - - {errorMessage || "Tool call failed"} - - )} - {isRunning && ( - - )} - - ); - - const toolContent = ( - - ); + const fallbackErrorMessage = getGenericToolErrorMessage({ + name, + mcpSlug: mcpServer?.slug, + }); return ( - - {toolContent} - + + ) + } + /> + + + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.stories.tsx new file mode 100644 index 0000000000..707a316880 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Running: Story = { + render: () => ( + + + + ), + 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: () => ( + + + + ), + 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: () => ( + + + + ), + 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: () => ( + + + + ), + 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: () => ( + + expanded ? "Collapse read file" : "Expand read file" + } + > + + +
+ File contents +
+
+
+ ), + 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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx new file mode 100644 index 0000000000..cd51f618b0 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -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(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, "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 = ({ + children, + status, + isError = false, + errorMessage, + hasContent = true, + defaultExpanded = false, + defaultView, + expanded: expandedProp, + onExpandedChange, + onViewChange, + ariaLabel, + className, + view: viewProp, + ...divProps +}) => { + const [uncontrolledView, setUncontrolledView] = useState( + 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 ( + +
+ {children} +
+
+ ); +}; +type ToolCallHeaderRowProps = { + children: ReactNode; + className?: string; +}; + +const HeaderRow: FC = ({ children, className }) => ( + + {children} + +); + +type ToolCallHeaderButtonProps = { + children: ReactNode; + className?: string; + alwaysButton?: boolean; +}; + +const HeaderButton: FC = ({ + children, + className, + alwaysButton = false, +}) => { + const { ariaLabel, collapsible, expanded, onToggle } = useToolCallContext(); + if (!collapsible && !alwaysButton) { + return ( + {children} + ); + } + + return ( + + + + ); +}; + +type ToolCallLeadingIconProps = { + name?: string; + children?: ReactNode; + iconUrl?: string; + serverName?: string; + subagentIconKind?: SubagentIconKind; +}; + +const LeadingIcon: FC = ({ + name, + children, + iconUrl, + serverName, + subagentIconKind, +}) => { + const { active, failed } = useToolCallContext(); + if (children) { + return <>{children}; + } + if (!name) { + return null; + } + + return ( + + ); +}; + +type ToolCallLabelProps = { + children: ReactNode; + className?: string; + shimmerWhenActive?: boolean; +}; + +const Label: FC = ({ + 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 ( + + {children} + + ); + } + + return {children}; +}; + +type ToolCallStatusProps = { + className?: string; + errorMessage?: string; +}; + +const Status: FC = ({ className, errorMessage }) => { + const { + active, + errorMessage: contextErrorMessage, + failed, + } = useToolCallContext(); + const message = errorMessage || contextErrorMessage || "Tool call failed"; + return ( + <> + {active && ( + + )} + {failed && ( + + + + + + + {message} + + )} + + ); +}; + +const Chevron: FC<{ className?: string }> = ({ className }) => { + const { collapsible, expanded } = useToolCallContext(); + if (!collapsible) { + return null; + } + return ( + + ); +}; + +const Actions: FC<{ children: ReactNode; className?: string }> = ({ + children, + className, +}) => ( +
+ {children} +
+); + +const HeaderActions: FC<{ children: ReactNode; className?: string }> = ({ + children, + className, +}) => { + return {children}; +}; + +const HeaderLayout: FC<{ children: ReactNode; className?: string }> = ({ + children, + className, +}) => ( +
+ {children} +
+); + +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 = ({ 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 = ({ + iconName, + label, + iconUrl, + serverName, + subagentIconKind, + secondaryLabel, + trailing, + showStatus = true, + headerClassName, +}) => { + return ( + + + + {secondaryLabel} + {showStatus && } + {trailing} + + + ); +}; +type ToolCallContentProps = { + children: ReactNode; +}; + +const Content: FC = ({ 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, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx index d894f33dec..257af63137 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCollapsible.tsx @@ -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 { - displayMode: AgentDisplayMode | undefined; - autoDisplayState: AgentDisplayState; -} - -export const AgentDisplayModeToolCollapsible: FC< - AgentDisplayModeToolCollapsibleProps -> = ({ displayMode, autoDisplayState, ...props }) => { - const displayState = resolveAgentDisplayState(displayMode, autoDisplayState); - - return ( - - ); -}; - export const ToolCollapsible: FC = ({ children, header, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index e70cf930bf..6923e9b50c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -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 ( - - - {label} - - } - headerStatus={ - <> - {isError && ( - - - - - - {errorMessage || "Failed to write file"} - - - )} - {isRunning && ( - - )} - - } + defaultView={displayState} > - {hasDiff && ( - - - - )} - + + + {hasDiff && ( + + + + )} + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.test.ts index 9b66e3f6ee..893b22e0b8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.test.ts @@ -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); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.ts index 78d7ce6bd3..4bbe574e1d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/displayMode.ts @@ -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 => { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts index b0dd5384d8..a3785989d8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -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(""); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index e8b9d74bb8..2995058fe6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -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 = ( diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 43d8182066..51b1287316 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -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 ; - }, - 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 ; - }, - 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(); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 9599b273d1..135db09f81 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -501,11 +501,9 @@ export const ChatPageInput: FC = ({ return (
{inputElement} - {modelSelectorHelp && ( -
- {modelSelectorHelp} -
- )} +
+ {modelSelectorHelp} +
); }; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx index afd658b926..a932ec76e0 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -99,6 +99,34 @@ const makeLargeRecord = ( type StoryCanvas = ReturnType; type StoryUser = ReturnType; +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 diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx index 9f0b00c014..857a94ff89 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx @@ -62,9 +62,13 @@ export const CopyableCodeBlock: FC = ({ className, }) => { return ( -
+
- +