mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): soften tool failure display and improve subagent timeout UX (#23617)
## Summary
Tool call failures in `/agents` previously displayed alarming red
styling (red icons, red text, red alert icons) that made it look like
the user did something wrong. This PR replaces the scary error
presentation with a calm, unified style and adds a dedicated timeout
display for subagent tools.
## Changes
### Unified failure style (all tools)
- Replace red `CircleAlertIcon` + `text-content-destructive` with a
muted `TriangleAlertIcon` in `text-content-secondary` across **all 11
tool renderers**.
- Remove red icon/label recoloring on error from `ToolIcon` and all
specialized tool components.
- Error details remain accessible via tooltip on hover.
### Subagent timeout display
- `ClockIcon` with "Timed out waiting for [Title]" instead of a generic
error display.
- `CircleXIcon` for non-timeout subagent errors with proper error verbs
("Failed to spawn", "Failed waiting for", etc.) instead of the
misleading running verb ("Waiting for").
- Timeout detection from result string/error field containing "timed
out".
### Title resolution for historical messages
- `ConversationTimeline` now computes `subagentTitles` via
`useMemo(buildSubagentTitles(...))` and passes it to historical
`ChatMessageItem` rendering, so `wait_agent` can resolve the actual
agent title from a prior `spawn_agent` result even outside streaming
mode.
### Stories
8 new stories: `GenericToolFailed`, `GenericToolFailedNoResult`,
`SubagentWaitTimedOut`, `SubagentWaitTimedOutWithTitle`,
`SubagentWaitTimedOutTitleFromMap`, `SubagentSpawnError`,
`SubagentWaitError`, `MCPToolFailedUnifiedStyle`.
## Files changed (15)
- `tool/Tool.tsx` — GenericToolRenderer + SubagentRenderer
- `tool/SubagentTool.tsx` — timeout/error verbs, icon changes
- `tool/ToolIcon.tsx` — remove destructive recoloring
- `tool/*.tsx` (10 specialized tools) — unified warning icon
- `ConversationTimeline.tsx` — pass subagentTitles to historical
rendering
- `tool.stories.tsx` — 8 new stories, updated existing assertions
This commit is contained in:
@@ -559,13 +559,12 @@ export const MCPToolError: Story = {
|
||||
mcpServers: sampleMCPServers,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Error alert icon should be present.
|
||||
expect(canvasElement.querySelector(".lucide-circle-alert")).not.toBeNull();
|
||||
// Label text should use the destructive color.
|
||||
const label = canvasElement.querySelector(
|
||||
".\\[\\&\\>\\*\\]\\:text-content-destructive",
|
||||
);
|
||||
expect(label).not.toBeNull();
|
||||
// Warning triangle icon should be present.
|
||||
expect(
|
||||
canvasElement.querySelector(".lucide-triangle-alert"),
|
||||
).not.toBeNull();
|
||||
// Label text should NOT use the destructive color.
|
||||
expect(canvasElement.querySelector(".text-content-destructive")).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -905,9 +904,10 @@ export const ComputerError: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Screenshot")).toBeInTheDocument();
|
||||
// Icon and label should have the destructive color class.
|
||||
const label = canvas.getByText("Screenshot");
|
||||
expect(label.className).toContain("text-content-destructive");
|
||||
// Warning icon should be present, not the old destructive style.
|
||||
expect(
|
||||
canvasElement.querySelector(".lucide-triangle-alert"),
|
||||
).not.toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -933,3 +933,164 @@ export const ComputerArrayResult: Story = {
|
||||
expect(img.getAttribute("src")).toContain("data:image/jpeg;base64,");
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool failure display stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GenericToolFailed: Story = {
|
||||
args: {
|
||||
name: "some_custom_tool",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { input: "test data" },
|
||||
result: { error: "Connection refused: could not reach upstream service" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Should show "Failed" badge instead of scary red alert.
|
||||
expect(
|
||||
canvasElement.querySelector(".lucide-triangle-alert"),
|
||||
).not.toBeNull();
|
||||
// Label should NOT have destructive color.
|
||||
const label = canvas.getByText("some_custom_tool");
|
||||
expect(label.className).not.toContain("text-content-destructive");
|
||||
// Error icon should not be present (replaced by warning triangle).
|
||||
expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const GenericToolFailedNoResult: Story = {
|
||||
args: {
|
||||
name: "web_search",
|
||||
status: "error",
|
||||
isError: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
expect(
|
||||
canvasElement.querySelector(".lucide-triangle-alert"),
|
||||
).not.toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const SubagentWaitTimedOut: Story = {
|
||||
args: {
|
||||
name: "wait_agent",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { chat_id: "timed-out-child" },
|
||||
result: "timed out waiting for delegated subagent completion",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Should show clock icon for timeout.
|
||||
expect(canvasElement.querySelector(".lucide-clock")).not.toBeNull();
|
||||
// Should NOT show red alert icon.
|
||||
expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull();
|
||||
// Should show timeout verb.
|
||||
expect(canvas.getByText(/Timed out waiting for/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SubagentWaitTimedOutWithTitle: Story = {
|
||||
args: {
|
||||
name: "wait_agent",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { chat_id: "timed-out-child" },
|
||||
result: {
|
||||
chat_id: "timed-out-child",
|
||||
error: "timed out waiting for delegated subagent completion",
|
||||
title: "Fix login bug",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvasElement.querySelector(".lucide-clock")).not.toBeNull();
|
||||
expect(canvas.getByText(/Timed out waiting for/)).toBeInTheDocument();
|
||||
expect(canvas.getByText("Fix login bug")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SubagentWaitTimedOutTitleFromMap: Story = {
|
||||
args: {
|
||||
name: "wait_agent",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { chat_id: "timed-out-child" },
|
||||
result: "timed out waiting for delegated subagent completion",
|
||||
subagentTitles: new Map([["timed-out-child", "Refactor auth module"]]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Refactor auth module")).toBeInTheDocument();
|
||||
expect(canvas.getByText(/Timed out waiting for/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SubagentSpawnError: Story = {
|
||||
args: {
|
||||
name: "spawn_agent",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: {
|
||||
title: "Database migration",
|
||||
prompt: "Run the pending migrations.",
|
||||
},
|
||||
result: {
|
||||
chat_id: "failed-child",
|
||||
error: "workspace not found",
|
||||
status: "error",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Should show the muted X icon, not the red alert.
|
||||
expect(canvasElement.querySelector(".lucide-circle-x")).not.toBeNull();
|
||||
expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull();
|
||||
// Should show error verb.
|
||||
expect(canvas.getByText(/Failed to spawn/)).toBeInTheDocument();
|
||||
expect(canvas.getByText("Database migration")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SubagentWaitError: Story = {
|
||||
args: {
|
||||
name: "wait_agent",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { chat_id: "error-child" },
|
||||
result: {
|
||||
chat_id: "error-child",
|
||||
error: "subagent crashed unexpectedly",
|
||||
status: "error",
|
||||
title: "Lint codebase",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvasElement.querySelector(".lucide-circle-x")).not.toBeNull();
|
||||
expect(canvas.getByText(/Failed waiting for/)).toBeInTheDocument();
|
||||
expect(canvas.getByText("Lint codebase")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const MCPToolFailedUnifiedStyle: Story = {
|
||||
args: {
|
||||
name: "linear__list_issues",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { project: "backend" },
|
||||
result: { error: "Authentication token expired" },
|
||||
mcpServerConfigId: "mcp-server-1",
|
||||
mcpServers: sampleMCPServers,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Should show warning triangle icon.
|
||||
expect(
|
||||
canvasElement.querySelector(".lucide-triangle-alert"),
|
||||
).not.toBeNull();
|
||||
// Icon should NOT be red.
|
||||
expect(canvasElement.querySelector(".text-content-destructive")).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
@@ -30,18 +30,13 @@ export const ChatSummarizedTool: React.FC<{
|
||||
hasContent={hasSummary}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
{isRunning ? "Summarizing…" : "Summarized"}
|
||||
</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to summarize chat"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
@@ -40,18 +40,13 @@ export const ComputerTool: React.FC<{
|
||||
defaultExpanded={hasImage}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
{isRunning ? "Taking screenshot…" : "Screenshot"}
|
||||
</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to take screenshot"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlertIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
@@ -46,18 +46,11 @@ export const CreateWorkspaceTool: React.FC<{
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>{label}</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to create workspace"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import { FileDiff } from "@pierre/diffs/react";
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
@@ -61,18 +61,13 @@ export const EditFilesTool: React.FC<{
|
||||
defaultExpanded
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
{label}
|
||||
</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to edit files"}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
LoaderIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
@@ -27,7 +28,7 @@ export const ExecuteTool: React.FC<{
|
||||
output: string;
|
||||
status: ToolStatus;
|
||||
isError: boolean;
|
||||
}> = ({ command, output, status, isError }) => {
|
||||
}> = ({ command, output, status }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
||||
const hasOutput = output.length > 0;
|
||||
@@ -83,7 +84,7 @@ export const ExecuteTool: React.FC<{
|
||||
}
|
||||
className={cn(
|
||||
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-2.5 py-2 font-mono text-xs",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
"text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
@@ -199,7 +200,7 @@ export const WaitForExternalAuthTool: React.FC<{
|
||||
errorMessage ||
|
||||
`Failed while waiting for ${providerLabel} authentication`;
|
||||
icon = (
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
);
|
||||
} else if (timedOut) {
|
||||
label = `Timed out waiting for ${providerLabel} authentication`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlertIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
@@ -37,18 +37,13 @@ export const ListTemplatesTool: React.FC<{
|
||||
hasContent={hasContent}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
{label}
|
||||
</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to list templates"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API } from "api/api";
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
@@ -57,22 +57,15 @@ export const ProposePlanTool: React.FC<{
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
effectiveError
|
||||
? "text-content-destructive"
|
||||
: "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
{isRunning ? `Proposing ${filename}…` : `Proposed ${filename}`}
|
||||
</span>
|
||||
{effectiveError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon
|
||||
<TriangleAlertIcon
|
||||
aria-label="Error"
|
||||
className="h-3.5 w-3.5 shrink-0 text-content-destructive"
|
||||
className="h-3.5 w-3.5 shrink-0 text-content-secondary"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { File as FileViewer } from "@pierre/diffs/react";
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
@@ -38,18 +38,13 @@ export const ReadFileTool: React.FC<{
|
||||
hasContent={hasContent}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
Read {path.split("/").pop() || path}
|
||||
</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to read file"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import {
|
||||
@@ -28,18 +28,11 @@ export const ReadTemplateTool: React.FC<{
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>{label}</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to read template"}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
BotIcon,
|
||||
ChevronDownIcon,
|
||||
CircleAlertIcon,
|
||||
CircleXIcon,
|
||||
ClockIcon,
|
||||
ExternalLinkIcon,
|
||||
LoaderIcon,
|
||||
} from "lucide-react";
|
||||
@@ -17,11 +18,34 @@ import {
|
||||
type ToolStatus,
|
||||
} from "./utils";
|
||||
|
||||
const SUBAGENT_VERBS: Record<string, { completed: string; running: string }> = {
|
||||
spawn_agent: { completed: "Spawned ", running: "Spawning " },
|
||||
wait_agent: { completed: "Waited for ", running: "Waiting for " },
|
||||
message_agent: { completed: "Messaged ", running: "Messaging " },
|
||||
close_agent: { completed: "Terminated ", running: "Terminating " },
|
||||
const SUBAGENT_VERBS: Record<
|
||||
string,
|
||||
{ completed: string; running: string; error: string; timeout: string }
|
||||
> = {
|
||||
spawn_agent: {
|
||||
completed: "Spawned ",
|
||||
running: "Spawning ",
|
||||
error: "Failed to spawn ",
|
||||
timeout: "Timed out spawning ",
|
||||
},
|
||||
wait_agent: {
|
||||
completed: "Waited for ",
|
||||
running: "Waiting for ",
|
||||
error: "Failed waiting for ",
|
||||
timeout: "Timed out waiting for ",
|
||||
},
|
||||
message_agent: {
|
||||
completed: "Messaged ",
|
||||
running: "Messaging ",
|
||||
error: "Failed to message ",
|
||||
timeout: "Timed out messaging ",
|
||||
},
|
||||
close_agent: {
|
||||
completed: "Terminated ",
|
||||
running: "Terminating ",
|
||||
error: "Failed to terminate ",
|
||||
timeout: "Timed out terminating ",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,17 +59,14 @@ const SubagentStatusIcon: React.FC<{
|
||||
subagentStatus: string;
|
||||
toolStatus: ToolStatus;
|
||||
isError: boolean;
|
||||
}> = ({ subagentStatus, toolStatus, isError }) => {
|
||||
isTimeout: boolean;
|
||||
}> = ({ subagentStatus, toolStatus, isError, isTimeout }) => {
|
||||
const subagentCompleted = isSubagentSuccessStatus(subagentStatus);
|
||||
if (isError && !subagentCompleted) {
|
||||
return (
|
||||
<CircleAlertIcon className="h-4 w-4 shrink-0 text-content-destructive" />
|
||||
);
|
||||
if (isTimeout && !subagentCompleted) {
|
||||
return <ClockIcon className="h-4 w-4 shrink-0 text-content-secondary" />;
|
||||
}
|
||||
if (toolStatus === "error") {
|
||||
return (
|
||||
<CircleAlertIcon className="h-4 w-4 shrink-0 text-content-destructive" />
|
||||
);
|
||||
if ((isError && !subagentCompleted) || toolStatus === "error") {
|
||||
return <CircleXIcon className="h-4 w-4 shrink-0 text-content-secondary" />;
|
||||
}
|
||||
if (toolStatus === "running") {
|
||||
return (
|
||||
@@ -72,6 +93,7 @@ export const SubagentTool: React.FC<{
|
||||
report?: string;
|
||||
toolStatus: ToolStatus;
|
||||
isError: boolean;
|
||||
isTimeout?: boolean;
|
||||
}> = ({
|
||||
toolName,
|
||||
title,
|
||||
@@ -83,6 +105,7 @@ export const SubagentTool: React.FC<{
|
||||
report,
|
||||
toolStatus,
|
||||
isError,
|
||||
isTimeout = false,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasPrompt = Boolean(prompt?.trim());
|
||||
@@ -107,10 +130,17 @@ export const SubagentTool: React.FC<{
|
||||
subagentStatus={subagentStatus}
|
||||
toolStatus={toolStatus}
|
||||
isError={isError}
|
||||
isTimeout={isTimeout}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-content-secondary">
|
||||
{SUBAGENT_VERBS[toolName]?.[
|
||||
toolStatus === "completed" ? "completed" : "running"
|
||||
isTimeout
|
||||
? "timeout"
|
||||
: toolStatus === "completed"
|
||||
? "completed"
|
||||
: toolStatus === "error"
|
||||
? "error"
|
||||
: "running"
|
||||
] ?? ""}
|
||||
<span className="text-content-secondary opacity-60">{title}</span>
|
||||
{chatId && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { type ComponentPropsWithRef, type FC, memo } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
@@ -303,6 +303,16 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
|
||||
subagentToolStatus === "error" ||
|
||||
((status === "error" || isError) && !subagentCompleted);
|
||||
|
||||
// Detect timeout from the result. A timed-out wait_agent
|
||||
// typically returns an error string or an object with an
|
||||
// error field containing "timed out".
|
||||
const resultStr = typeof result === "string" ? result : "";
|
||||
const errorStr = rec ? asString(rec.error) : "";
|
||||
const isTimeout =
|
||||
subagentIsError &&
|
||||
(resultStr.toLowerCase().includes("timed out") ||
|
||||
errorStr.toLowerCase().includes("timed out"));
|
||||
|
||||
return (
|
||||
<SubagentTool
|
||||
toolName={name}
|
||||
@@ -315,6 +325,7 @@ const SubagentRenderer: FC<ToolRendererProps> = ({
|
||||
report={chatId ? report || undefined : undefined}
|
||||
toolStatus={subagentToolStatus}
|
||||
isError={subagentIsError}
|
||||
isTimeout={isTimeout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -504,18 +515,16 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||
isRunning={isRunning}
|
||||
serverName={mcpServer?.display_name}
|
||||
/>
|
||||
<span className={cn(isError && "[&>*]:text-content-destructive")}>
|
||||
<ToolLabel
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
mcpSlug={mcpServer?.slug}
|
||||
/>
|
||||
</span>
|
||||
<ToolLabel
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
mcpSlug={mcpServer?.slug}
|
||||
/>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Tool call failed"}
|
||||
|
||||
@@ -24,9 +24,9 @@ export const ToolIcon: React.FC<{
|
||||
iconUrl?: string;
|
||||
isRunning?: boolean;
|
||||
serverName?: string;
|
||||
}> = ({ name, isError, iconUrl, isRunning, serverName }) => {
|
||||
}> = ({ name, iconUrl, isRunning, serverName }) => {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const color = isError ? "text-content-destructive" : "text-content-secondary";
|
||||
const color = "text-content-secondary";
|
||||
const base = cn("h-4 w-4 shrink-0", color, isRunning && "grayscale");
|
||||
|
||||
// If an MCP icon URL is provided and hasn't failed, render it.
|
||||
@@ -35,13 +35,6 @@ export const ToolIcon: React.FC<{
|
||||
// mode we invert to white and tune opacity to approximate
|
||||
// content-secondary (light ≈ 34% lightness, dark ≈ 65%).
|
||||
if (iconUrl && !imgError) {
|
||||
// Always render the same DOM shape so React never unmounts
|
||||
// the <img> when isError changes (avoids a reload flicker).
|
||||
//
|
||||
// The wrapper clips a translated copy of the image so the
|
||||
// drop-shadow trick can work on error (see image classes).
|
||||
// In the normal state the image is not translated and the
|
||||
// wrapper's overflow-hidden is a harmless no-op.
|
||||
const img = (
|
||||
<div className="h-4 w-4 shrink-0 overflow-hidden">
|
||||
<ExternalImage
|
||||
@@ -49,19 +42,11 @@ export const ToolIcon: React.FC<{
|
||||
alt={`${name} icon`}
|
||||
className={cn(
|
||||
"block h-4 w-4",
|
||||
isError
|
||||
? // Drop-shadow recolor: brightness-0 makes every
|
||||
// pixel black, drop-shadow casts an exact-color
|
||||
// copy 16px to the right following the alpha
|
||||
// channel, -translate-x-4 shifts the colored copy
|
||||
// into the original position, and the wrapper's
|
||||
// overflow-hidden clips the black original.
|
||||
"-translate-x-4 [filter:brightness(0)_drop-shadow(16px_0_0_hsl(var(--content-destructive)))]"
|
||||
: // Monochrome: brightness-0 strips colour to black,
|
||||
// dark:invert flips to white for dark backgrounds,
|
||||
// opacity tuned per-theme to match content-secondary
|
||||
// (light ~35% lightness, dark ~65%).
|
||||
"brightness-0 opacity-[0.35] dark:invert dark:opacity-[0.65]",
|
||||
// Monochrome: brightness-0 strips colour to black,
|
||||
// dark:invert flips to white for dark backgrounds,
|
||||
// opacity tuned per-theme to match content-secondary
|
||||
// (light ~35% lightness, dark ~65%).
|
||||
"brightness-0 opacity-[0.35] dark:invert dark:opacity-[0.65]",
|
||||
)}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import { FileDiff } from "@pierre/diffs/react";
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
@@ -43,18 +43,13 @@ export const WriteFileTool: React.FC<{
|
||||
hasContent={hasDiff}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-sm", "text-content-secondary")}>
|
||||
{label}
|
||||
</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to write file"}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { ImageLightbox } from "../ImageLightbox";
|
||||
import { TextPreviewDialog } from "../TextPreviewDialog";
|
||||
import { ChatStatusCallout } from "./ChatStatusCallout";
|
||||
import type { LiveStatusModel } from "./liveStatusModel";
|
||||
import { buildSubagentTitles } from "./messageParsing";
|
||||
import { useSmoothStreamingText } from "./SmoothText";
|
||||
import type {
|
||||
MergedTool,
|
||||
@@ -398,6 +399,7 @@ const ChatMessageItem = memo<{
|
||||
fadeFromBottom?: boolean;
|
||||
urlTransform?: UrlTransform;
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
subagentTitles?: Map<string, string>;
|
||||
}>(
|
||||
({
|
||||
message,
|
||||
@@ -409,6 +411,7 @@ const ChatMessageItem = memo<{
|
||||
fadeFromBottom = false,
|
||||
urlTransform,
|
||||
mcpServers,
|
||||
subagentTitles,
|
||||
}) => {
|
||||
const isUser = message.role === "user";
|
||||
const isSavingMessage = savingMessageId === message.id;
|
||||
@@ -475,6 +478,7 @@ const ChatMessageItem = memo<{
|
||||
blocks: parsed.blocks,
|
||||
toolByID,
|
||||
keyPrefix: String(message.id),
|
||||
subagentTitles,
|
||||
onImageClick: setPreviewImage,
|
||||
onTextFileClick: (content) => setPreviewText(content),
|
||||
urlTransform,
|
||||
@@ -620,6 +624,7 @@ const ChatMessageItem = memo<{
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
subagentTitles={subagentTitles}
|
||||
mcpServerConfigId={tool.mcpServerConfigId}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
@@ -1049,6 +1054,8 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
|
||||
urlTransform,
|
||||
mcpServers,
|
||||
}) => {
|
||||
const subagentTitles = buildSubagentTitles(parsedMessages);
|
||||
|
||||
if (parsedMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -1091,6 +1098,7 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
|
||||
urlTransform={urlTransform}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
|
||||
mcpServers={mcpServers}
|
||||
subagentTitles={subagentTitles}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user