From 5715af4a456a6e71df15a830ca0fcb0918f0ec10 Mon Sep 17 00:00:00 2001 From: Min Liu <19516876+SmartAI@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:55:28 -0700 Subject: [PATCH] feat(client): humanize tool-call cards and fix empty expanders Tool-call rows showed the raw tool name (run_build123d) and, for text-only tools like load_skill/search_docs/record_*, expanded into an empty box because the card only rendered code, measurements, the verify gate, or images. Replace the raw name with a per-tool icon and plain-language summary (run_build123d -> "Executing code", load_skill -> "Loaded skill X"). The raw name stays in the row's title tooltip. A row is collapsible only when it has a real body: payload-less tools render as a flat row with no chevron, so nothing opens empty. Doc lookups now render their result text and start collapsed; run_build123d and inspect_evidence keep their code, gate, and view-sheet bodies open as before. --- .../src/components/ToolCallCard.test.tsx | 43 ++++- .../client/src/components/ToolCallCard.tsx | 166 ++++++++++++++++-- 2 files changed, 192 insertions(+), 17 deletions(-) diff --git a/packages/client/src/components/ToolCallCard.test.tsx b/packages/client/src/components/ToolCallCard.test.tsx index c7d5773..e7ec97d 100644 --- a/packages/client/src/components/ToolCallCard.test.tsx +++ b/packages/client/src/components/ToolCallCard.test.tsx @@ -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"; @@ -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(); + + 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( + , + ); + + // 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( + , + ); + + 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", diff --git a/packages/client/src/components/ToolCallCard.tsx b/packages/client/src/components/ToolCallCard.tsx index 72ad33b..c9318c9 100644 --- a/packages/client/src/components/ToolCallCard.tsx +++ b/packages/client/src/components/ToolCallCard.tsx @@ -1,5 +1,18 @@ 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"; @@ -7,11 +20,85 @@ 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 }, + result?: ToolCallCardResult, +): ToolPresentation { + const args = call.arguments ?? {}; + const details = (result?.details ?? {}) as Record; + 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; } @@ -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(() => inlineImage(result?.content)); const sheetReference = attachmentReference(result?.content); const inspectedEvidence = call.name === "inspect_evidence" ? inspectionEvidenceBlocks(result?.content) : []; @@ -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 = ( + <> +