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
43 changes: 42 additions & 1 deletion packages/client/src/components/ToolCallCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ToolCallCard } from "./ToolCallCard";
import * as rest from "@/api/rest";
Expand All @@ -16,6 +16,47 @@ const call = {
};

describe("ToolCallCard status", () => {
it("shows a friendly label instead of the raw build123d tool name, keeping the raw name in the tooltip", () => {
render(<ToolCallCard call={call} />);

expect(screen.getByText("Executing code")).toBeTruthy();
expect(screen.queryByText("run_build123d")).toBeNull();
// The raw name stays reachable for debugging via the row's title tooltip.
expect(screen.getByTitle("run_build123d")).toBeTruthy();
});

it("summarizes a payload-less tool as a flat row with no empty expander", () => {
render(
<ToolCallCard
call={{ id: "skill-1", name: "load_skill", arguments: { name: "gears" } }}
result={{ content: [{ type: "text", text: "full skill body for the model" }], isError: false, details: { skill: "gears", loaded: true } }}
/>,
);

// The header carries the whole story; there is nothing to expand into.
expect(screen.getByText('Loaded skill “gears”')).toBeTruthy();
expect(screen.queryByRole("button")).toBeNull();
// The model-facing skill body is not dumped into the chat.
expect(screen.queryByText("full skill body for the model")).toBeNull();
});

it("expands a doc lookup to reveal its result text", () => {
render(
<ToolCallCard
call={{ id: "docs-1", name: "search_docs", arguments: { query: "fillet edge" } }}
result={{ content: [{ type: "text", text: "1. fillet(objects, radius)" }], isError: false }}
/>,
);

expect(screen.getByText('Searched docs “fillet edge”')).toBeTruthy();
// Doc lookups start collapsed to keep the chat tidy.
expect(screen.queryByTestId("tool-doc-text")).toBeNull();

fireEvent.click(screen.getByRole("button"));

expect(screen.getByTestId("tool-doc-text").textContent).toContain("fillet(objects, radius)");
});

it("renders every ordered inspect_evidence reference regardless of attachment kind", async () => {
vi.mocked(rest.downloadAttachment).mockImplementation(async (id) => ({
type: "image",
Expand Down
166 changes: 150 additions & 16 deletions packages/client/src/components/ToolCallCard.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,104 @@
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import {
BookOpen,
ChevronDown,
Code2,
GraduationCap,
ListChecks,
type LucideIcon,
NotebookPen,
ScanEye,
Search,
ShieldCheck,
Tags,
Wrench,
} from "lucide-react";
import type { Gate, Measurements } from "@chamfer/shared";
import * as rest from "@/api/rest";
import { cn } from "@/lib/utils";
import { CadCodeBlock } from "./CadCodeBlock";
import { AttachmentImage } from "./AttachmentImage";
import type { AttachmentReferenceBlock } from "@chamfer/shared";

/** Shape of a run_build123d/lookup_docs tool-result as rendered by the card.
/** Humanize a raw snake_case tool name as a last-resort label. */
export function toolDisplayName(name: string): string {
const spaced = name.replace(/_/g, " ").trim();
return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : name;
}

function trimmed(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

/** How a tool call reads in the chat: an icon plus a plain-language, one-line
* summary built from its arguments/details. Keeps implementation names
* (build123d, snake_case tool ids) out of the UI; the raw name still rides in
* the row's `title` tooltip. */
interface ToolPresentation {
Icon: LucideIcon;
summary: string;
}

export function toolPresentation(
call: { name: string; arguments: Record<string, unknown> },
result?: ToolCallCardResult,
): ToolPresentation {
const args = call.arguments ?? {};
const details = (result?.details ?? {}) as Record<string, unknown>;
const quote = (value: unknown): string => {
const text = trimmed(value);
return text ? ` “${text}”` : "";
};

switch (call.name) {
case "run_build123d":
return { Icon: Code2, summary: "Executing code" };
case "search_docs":
return { Icon: Search, summary: `Searched docs${quote(args.query)}` };
case "lookup_docs":
return { Icon: BookOpen, summary: `Looked up${quote(args.topic)}` };
case "load_skill": {
const name = trimmed(args.name) ?? trimmed(details.skill);
const resource = trimmed(args.resource) ?? trimmed(details.resource);
const named = name ? ` “${name}”` : "";
if (resource) return { Icon: GraduationCap, summary: `Loaded “${resource}” from skill${named}` };
if (details.deduped === true) return { Icon: GraduationCap, summary: name ? `Skill “${name}” already loaded` : "Skill already loaded" };
return { Icon: GraduationCap, summary: `Loaded skill${named}` };
}
case "inspect_evidence": {
const count = Array.isArray(args.evidenceIds) ? args.evidenceIds.length : 0;
return { Icon: ScanEye, summary: count ? `Inspected ${count} reference${count === 1 ? "" : "s"}` : "Inspecting evidence" };
}
case "record_inspection_observation":
return { Icon: NotebookPen, summary: "Recorded observation" };
case "record_visual_verification":
case "record_visual_verification_batch": {
const verdict = trimmed(args.finalVerdict);
const label = verdict === "needs-revision" ? "needs revision" : verdict === "match" ? "match" : undefined;
return { Icon: ShieldCheck, summary: label ? `Visual check: ${label}` : "Recorded visual check" };
}
case "classify_reference":
return { Icon: Tags, summary: "Classified reference" };
case "update_plan":
return { Icon: ListChecks, summary: "Updated plan" };
default:
return { Icon: Wrench, summary: toolDisplayName(call.name) };
}
}

/** Shape of a tool-result as rendered by the card. `details` is a per-tool bag:
* run_build123d carries measurements/gate, load_skill carries skill/resource.
* Exported so MessageList can reuse it instead of duplicating the type. */
export interface ToolCallCardResult {
content?: unknown;
details?: { measurements?: Measurements; gate?: Gate };
details?: {
measurements?: Measurements;
gate?: Gate;
skill?: string;
resource?: string;
deduped?: boolean;
loaded?: boolean;
};
isError?: boolean;
}

Expand Down Expand Up @@ -93,8 +180,30 @@ function errorText(content: unknown): string {
return text || "Tool call failed";
}

/** Joined text content of a tool result, or "" when it carries none. */
function resultText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(block): block is { type: "text"; text: string } =>
typeof block === "object" &&
block !== null &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string",
)
.map((block) => block.text)
.join("\n")
.trim();
}

/** Tools whose text result is worth reading inline (doc lookups). Other
* text-only tools are fully summarized by the header, so they get no body. */
const READABLE_TEXT_TOOLS = new Set(["search_docs", "lookup_docs"]);

export function ToolCallCard({ call, result, interrupted = false, resultMessageId, showCadCode = false }: ToolCallCardProps) {
const [expanded, setExpanded] = useState(true);
const heavy = call.name === "run_build123d" || call.name === "inspect_evidence";
const [expanded, setExpanded] = useState(heavy);
const [sheetUrl, setSheetUrl] = useState<string | undefined>(() => inlineImage(result?.content));
const sheetReference = attachmentReference(result?.content);
const inspectedEvidence = call.name === "inspect_evidence" ? inspectionEvidenceBlocks(result?.content) : [];
Expand All @@ -117,25 +226,42 @@ export function ToolCallCard({ call, result, interrupted = false, resultMessageI
};
}, [result?.content, resultMessageId, sheetReference]);

const { Icon, summary } = toolPresentation(call, result);
const code = typeof call.arguments.code === "string" ? call.arguments.code : "";
const measurements = result?.details?.measurements;
const gate = result?.details?.gate;
const gateFailures = gate?.checks.filter((check) => !check.passed) ?? [];
const docText = !result?.isError && READABLE_TEXT_TOOLS.has(call.name) ? resultText(result?.content) : "";
const hasImages = inspectedEvidence.length > 0 || Boolean(sheetReference) || Boolean(sheetUrl);
const hasBody = Boolean(result?.isError || code || measurements || gate || docText || hasImages);

const header = (
<>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span title={call.name} className="min-w-0 flex-1 truncate whitespace-nowrap">{summary}</span>
<span className={cn("shrink-0", result?.isError || interrupted ? "text-destructive" : "text-muted-foreground")}>
{result ? (result.isError ? "Failed" : "Complete") : interrupted ? "Failed" : "Running"}
</span>
{hasBody && (
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", !expanded && "-rotate-90")} />
)}
</>
);

return (
<div data-testid="tool-call-card" className="mt-2 overflow-hidden rounded-md border bg-background text-foreground">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent"
>
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 transition-transform", !expanded && "-rotate-90")} />
<span title={call.name} className="min-w-0 flex-1 truncate whitespace-nowrap font-mono">{call.name}</span>
<span className={cn("shrink-0", result?.isError || interrupted ? "text-destructive" : "text-muted-foreground")}>
{result ? (result.isError ? "Failed" : "Complete") : interrupted ? "Failed" : "Running"}
</span>
</button>
{expanded && (
{hasBody ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent"
>
{header}
</button>
) : (
<div className="flex w-full items-center gap-2 px-3 py-2 text-xs font-medium">{header}</div>
)}
{hasBody && expanded && (
<div className="space-y-3 border-t p-3">
{code && <CadCodeBlock code={code} show={showCadCode} className="bg-muted/30" />}
{result?.isError && (
Expand All @@ -146,6 +272,14 @@ export function ToolCallCard({ call, result, interrupted = false, resultMessageI
{errorText(result.content)}
</pre>
)}
{docText && (
<pre
data-testid="tool-doc-text"
className="max-h-60 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-2 font-mono text-xs text-foreground"
>
{docText}
</pre>
)}
{measurements && (
<div data-testid="tool-measurements" className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">Bounds</span>
Expand Down
Loading