Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions frontend/src/components/AgentMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useOpenAI } from "@/ai/useOpenAi";
import {
AlertCircle,
ArrowUp,
Blocks,
Brain,
Camera,
Check,
Expand All @@ -20,17 +21,23 @@ import {
ChevronRight,
Circle,
Expand,
FilePenLine,
FileSearch,
FolderOpen,
Globe2,
Loader2,
Lock,
MessageSquarePlus,
MoreHorizontal,
ShieldCheck,
Shrink,
SquareTerminal,
Trash,
Wrench,
X,
Zap
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
Expand Down Expand Up @@ -109,6 +116,11 @@ import {
} from "@/services/agentMcpErrors";
import { reconcileNewChatMcpServerNames } from "@/services/agentMcpServers";
import { agentOperationFence } from "@/services/agentOperationFence";
import {
agentToolKind,
agentToolKindLabel,
type AgentToolKind
} from "@/services/agentToolPresentation";
import {
AgentThoughtLabelFinalRequestRegistry,
AgentThoughtLabelProvisionalScheduler,
Expand Down Expand Up @@ -4061,31 +4073,55 @@ function ToolCallRow({ item }: { item: AgentTimelineItem }) {
const status = item.status || "running";
const failed = status === "failed" || status === "error";
const active = isActiveAgentStatus(status);
const toolKind = agentToolKind(item.id, item.title);
const ToolKindIcon = AGENT_TOOL_KIND_ICONS[toolKind];
const toolKindLabel = agentToolKindLabel(toolKind);
const hasDetails =
Boolean(item.text?.trim()) || item.input !== undefined || item.output !== undefined;
const statusIcon = active ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<Loader2
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground"
/>
) : failed ? (
<X className="h-4 w-4 shrink-0 text-destructive" />
<X aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Check className="h-4 w-4 shrink-0 text-maple-success" />
<Check aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-maple-success" />
);

const summary = (
<div className="flex min-w-0 flex-1 items-center gap-2">
{statusIcon}
<span className="min-w-0 flex-1 truncate text-sm font-medium">{toolTitle(item)}</span>
<span className={cn("shrink-0 text-xs text-muted-foreground", failed && "text-destructive")}>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<span
role="img"
aria-label={toolKindLabel}
title={toolKindLabel}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-background/70 text-muted-foreground"
>
<ToolKindIcon aria-hidden="true" className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 truncate text-[13px] font-medium leading-5"
title={toolTitle(item)}
>
{toolTitle(item)}
</span>
<span
className={cn(
"shrink-0 text-[11px] leading-5 text-muted-foreground",
failed && "text-destructive"
)}
>
{formatStatus(status)}
</span>
{statusIcon}
</div>
);

if (!hasDetails) {
return (
<div
className={cn(
"flex items-center gap-2 rounded-2xl bg-muted/30 px-3 py-2 text-sm",
"flex min-h-8 items-center rounded-xl bg-muted/30 px-2 py-1 text-sm",
failed && "bg-destructive/5"
)}
>
Expand All @@ -4098,15 +4134,18 @@ function ToolCallRow({ item }: { item: AgentTimelineItem }) {
<details
open={failed}
className={cn(
"group rounded-3xl border border-muted/40 bg-muted/20 px-4 py-3 text-sm",
"group rounded-xl border border-muted/40 bg-muted/20 px-2 py-1 text-sm",
failed && "border-destructive/35 bg-destructive/5"
)}
>
<summary className="flex cursor-pointer list-none items-center gap-2">
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
<summary className="flex min-h-6 cursor-pointer list-none items-center gap-1">
<ChevronRight
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-90"
/>
{summary}
</summary>
<div className="mt-2 space-y-2 pl-6">
<div className="mt-1.5 space-y-2 border-t border-muted/40 pb-1 pl-7 pr-1 pt-2">
{item.text ? <ToolDetail label="Summary" value={item.text} /> : null}
{item.input !== undefined ? (
<ToolDetail label="Input" value={formatUnknown(item.input)} />
Expand All @@ -4119,6 +4158,15 @@ function ToolCallRow({ item }: { item: AgentTimelineItem }) {
);
}

const AGENT_TOOL_KIND_ICONS: Record<AgentToolKind, LucideIcon> = {
shell: SquareTerminal,
"file-read": FileSearch,
"file-write": FilePenLine,
web: Globe2,
mcp: Blocks,
generic: Wrench
};

function PermissionRow({
item,
onPermissionDecision
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/services/agentToolPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { agentToolKind, agentToolKindLabel } from "./agentToolPresentation";

describe("agentToolKind", () => {
test.each([
["functions.shell:1", "shell"],
["functions.developer__shell:2", "shell"],
["functions.read_file:3", "file-read"],
["functions.developer__read_image:4", "file-read"],
["functions.read:4", "file-read"],
["functions.edit_file:5", "file-write"],
["functions.write_file:6", "file-write"],
["functions.edit:6", "file-write"],
["functions.write:6", "file-write"],
["functions.web_search:7", "web"],
["functions.developer__open_url:8", "web"],
["functions.github__search_code:9", "mcp"],
["functions.skills__load_skill:10", "mcp"],
["functions.skills__shell:11", "mcp"]
] as const)("classifies %s as %s", (id, expected) => {
expect(agentToolKind(id)).toBe(expected);
});

test.each([
["Terminal: pwd", "shell"],
["Read file: notes.txt", "file-read"],
["read: notes.txt", "file-read"],
["Editor: report.txt", "file-write"],
["edit: report.txt", "file-write"],
["write: report.txt", "file-write"],
["Write file: report.txt", "file-write"],
["Web Search: Example Domain", "web"],
["Open url: https://example.com", "web"]
] as const)(
"uses Maple's stable %s label when a provider ID has no tool name",
(title, expected) => {
expect(agentToolKind("chatcmpl-tool-123", title)).toBe(expected);
}
);

test("uses the generic type when the canonical tool name and stable label are unsupported", () => {
expect(agentToolKind("chatcmpl-tool-123", "Unrecognized action: shell in prose")).toBe(
"generic"
);
expect(agentToolKind("functions.load_skill:10", "Loading skill: example")).toBe("generic");
expect(agentToolKind("functions.unknown_tool:11", "Terminal: ignored fallback")).toBe(
"generic"
);
});
});

describe("agentToolKindLabel", () => {
test("provides a text alternative for every visual type", () => {
expect(agentToolKindLabel("shell")).toBe("Shell command");
expect(agentToolKindLabel("file-read")).toBe("File read");
expect(agentToolKindLabel("file-write")).toBe("File change");
expect(agentToolKindLabel("web")).toBe("Web tool");
expect(agentToolKindLabel("mcp")).toBe("MCP tool");
expect(agentToolKindLabel("generic")).toBe("Tool call");
});
});
87 changes: 87 additions & 0 deletions frontend/src/services/agentToolPresentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export type AgentToolKind = "shell" | "file-read" | "file-write" | "web" | "mcp" | "generic";

const SHELL_TOOL_NAMES = new Set(["shell"]);
const FILE_READ_TOOL_NAMES = new Set([
"read",
"read_file",
"read_image",
"list_files",
"glob",
"grep"
]);
const FILE_WRITE_TOOL_NAMES = new Set([
"edit",
"write",
"edit_file",
"write_file",
"text_editor",
"str_replace_editor",
"str_replace_based_edit_tool",
"apply_patch"
]);
const WEB_TOOL_NAMES = new Set(["web_search", "open_url"]);
const MAPLE_EXTENSION_NAMES = new Set(["developer"]);
const SHELL_TOOL_LABELS = new Set(["terminal", "shell"]);
const FILE_READ_TOOL_LABELS = new Set([
"read",
"read file",
"read image",
"list files",
"find files",
"search"
]);
const FILE_WRITE_TOOL_LABELS = new Set(["edit", "write", "editor", "edit file", "write file"]);
const WEB_TOOL_LABELS = new Set(["web search", "open url"]);

function toolNameFromTimelineId(id: string): string | null {
const encodedName = id.startsWith("functions.") ? id.slice("functions.".length) : null;
if (!encodedName) return null;

const sequenceSeparator = encodedName.lastIndexOf(":");
const name = sequenceSeparator >= 0 ? encodedName.slice(0, sequenceSeparator) : encodedName;
return name.trim() || null;
}

function agentToolKindFromTitle(title: string | null | undefined): AgentToolKind {
const label = title?.split(":", 1)[0]?.trim().toLowerCase();
if (!label) return "generic";
if (SHELL_TOOL_LABELS.has(label)) return "shell";
if (FILE_READ_TOOL_LABELS.has(label)) return "file-read";
if (FILE_WRITE_TOOL_LABELS.has(label)) return "file-write";
if (WEB_TOOL_LABELS.has(label)) return "web";
return "generic";
}

export function agentToolKind(id: string, title?: string | null): AgentToolKind {
const encodedName = toolNameFromTimelineId(id);
if (!encodedName) return agentToolKindFromTitle(title);

const namespaceSeparator = encodedName.indexOf("__");
const namespace = namespaceSeparator >= 0 ? encodedName.slice(0, namespaceSeparator) : null;
const toolName =
namespaceSeparator >= 0 ? encodedName.slice(namespaceSeparator + 2) : encodedName;

if (namespace && !MAPLE_EXTENSION_NAMES.has(namespace)) return "mcp";
if (SHELL_TOOL_NAMES.has(toolName)) return "shell";
if (FILE_READ_TOOL_NAMES.has(toolName)) return "file-read";
if (FILE_WRITE_TOOL_NAMES.has(toolName)) return "file-write";
if (WEB_TOOL_NAMES.has(toolName)) return "web";
return "generic";
}

export function agentToolKindLabel(kind: AgentToolKind): string {
switch (kind) {
case "shell":
return "Shell command";
case "file-read":
return "File read";
case "file-write":
return "File change";
case "web":
return "Web tool";
case "mcp":
return "MCP tool";
default:
return "Tool call";
}
}
Loading