diff options
| author | Adam Malczewski <[email protected]> | 2026-10-15 09:31:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-15 09:31:00 +0900 |
| commit | 0a76db397e92c945fefabcf08ff89dcc97f6e6c3 (patch) | |
| tree | 13fa75ee23f187d1a48beb83b9c7f73d9a7a9f41 /src | |
| parent | 633b94326ec4b33fe19d65d02ef78d8a6b14e4e9 (diff) | |
| download | dispatch-web-0a76db397e92c945fefabcf08ff89dcc97f6e6c3.tar.gz dispatch-web-0a76db397e92c945fefabcf08ff89dcc97f6e6c3.zip | |
feat(tool-call-cards): distinct Phosphor icon per tool (phosphor-svelte, deep imports)
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/chat/logic/tool-summary.test.ts | 41 | ||||
| -rw-r--r-- | src/features/chat/logic/tool-summary.ts | 57 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.test.ts | 27 | ||||
| -rw-r--r-- | src/features/chat/ui/ToolCard.svelte | 19 | ||||
| -rw-r--r-- | src/features/chat/ui/tool-icons.ts | 52 |
5 files changed, 180 insertions, 16 deletions
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts index 31c7002..b6c7e0b 100644 --- a/src/features/chat/logic/tool-summary.test.ts +++ b/src/features/chat/logic/tool-summary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ToolEntry } from "../../../core/chunks"; -import { toolLabel, viewToolEntry } from "./tool-summary"; +import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary"; function entry( name: string, @@ -61,6 +61,45 @@ describe("tool labels", () => { }); }); +describe("tool icons", () => { + const builtIns = [ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "read_image", + "youtube_transcript", + "summon_agent", + "send_agent_message", + "read_agent", + "open_agent", + "cancel_agent_message", + "list_agents", + "list_agent_models", + ]; + it("gives every built-in tool its own distinct icon", () => { + const icons = builtIns.map(toolIcon); + expect(icons).not.toContain("tool"); + expect(icons).not.toContain("mcp"); + expect(new Set(icons).size).toBe(builtIns.length); + expect(view("run_shell", {}).icon).toBe("terminal"); + }); + it.each([ + ["github__create_issue", "mcp"], + ["mcp__github__create_issue", "mcp"], + ["scaffold_echo", "tool"], + ["__proto__", "tool"], + ["constructor", "tool"], + ])("%s → %s", (name, icon) => { + expect(toolIcon(name)).toBe(icon); + }); +}); + describe("tool summaries", () => { it.each([ ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"], diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts index 75682de..566e40f 100644 --- a/src/features/chat/logic/tool-summary.ts +++ b/src/features/chat/logic/tool-summary.ts @@ -5,6 +5,8 @@ export interface ToolCardView { readonly name: string; /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */ readonly label: string; + /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */ + readonly icon: ToolIconKey; readonly status: "pending" | "ok" | "error"; readonly callSummary: string; readonly callBadges: readonly { @@ -157,6 +159,60 @@ const TOOL_LABELS: Readonly<Record<string, string>> = { list_agent_models: "List agent models", }; +/** + * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor + * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`, + * else the generic `tool` wrench. + */ +export type ToolIconKey = + | "terminal" + | "file-read" + | "file-edit" + | "file-write" + | "search" + | "code" + | "todos" + | "skill" + | "vision" + | "image" + | "youtube" + | "agent-summon" + | "agent-send" + | "agent-read" + | "agent-open" + | "agent-cancel" + | "agents" + | "models" + | "mcp" + | "tool"; + +const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = { + run_shell: "terminal", + read_file: "file-read", + edit_file: "file-edit", + write_file: "file-write", + web_search: "search", + lsp: "code", + todo_write: "todos", + load_skill: "skill", + consult_vision: "vision", + read_image: "image", + youtube_transcript: "youtube", + summon_agent: "agent-summon", + send_agent_message: "agent-send", + read_agent: "agent-read", + open_agent: "agent-open", + cancel_agent_message: "agent-cancel", + list_agents: "agents", + list_agent_models: "models", +}; + +/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */ +export function toolIcon(name: string): ToolIconKey { + if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool"; + return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool"; +} + /** `create_issue` / `createIssue` / `create-issue` → "Create issue". */ function sentenceCase(raw: string): string { const words = raw @@ -352,6 +408,7 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView { return { name: entry.toolName, label: toolLabel(entry.toolName), + icon: toolIcon(entry.toolName), status: result === null ? "pending" : result.isError ? "error" : "ok", callSummary, callBadges, diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts index 4b28b48..77f832d 100644 --- a/src/features/chat/ui/ChatView.test.ts +++ b/src/features/chat/ui/ChatView.test.ts @@ -470,3 +470,30 @@ it("shows an argument-less call without `{}` — empty header summary, 'No argum expect(screen.getByText("No arguments")).toBeInTheDocument(); expect(container).not.toHaveTextContent("{}"); }); + +it("renders a per-tool Phosphor icon in the header", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "s", + toolName: "run_shell", + input: { command: "ls" }, + }, + }, + { + seq: 2, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']")); + expect(icons).toHaveLength(2); + // Different tools → different glyphs. + expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML); +}); diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte index c3da723..0a72bb5 100644 --- a/src/features/chat/ui/ToolCard.svelte +++ b/src/features/chat/ui/ToolCard.svelte @@ -2,9 +2,11 @@ import type { ToolEntry } from "../index"; import { viewToolEntry } from "../logic/tool-summary"; import LazyCollapse from "./LazyCollapse.svelte"; + import { TOOL_ICON_COMPONENTS } from "./tool-icons"; let { entry }: { entry: ToolEntry } = $props(); const card = $derived(viewToolEntry(entry)); + const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]); let showAll = $state(false); </script> @@ -25,21 +27,8 @@ aria-label="Succeeded">✓</span > {/if} - <svg - class="h-4 w-4 shrink-0 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - > - <path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /> - </svg> + <!-- Per-tool Phosphor icon (key chosen by the pure view-model). --> + <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" /> <span class="tool-name truncate" title={card.name}>{card.label}</span> <span class="tool-summary truncate font-normal text-base-content/60" diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts new file mode 100644 index 0000000..1d68ebe --- /dev/null +++ b/src/features/chat/ui/tool-icons.ts @@ -0,0 +1,52 @@ +// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see +// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the +// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on +// purpose: the package root re-exports ~1,500 components, and Svelte libraries +// are not prebundled in dev, so a root import would load every one of them. + +import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon"; +import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon"; +import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon"; +import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon"; +import CpuIcon from "phosphor-svelte/lib/CpuIcon"; +import EyeIcon from "phosphor-svelte/lib/EyeIcon"; +import FileTextIcon from "phosphor-svelte/lib/FileTextIcon"; +import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon"; +import ImageIcon from "phosphor-svelte/lib/ImageIcon"; +import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon"; +import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon"; +import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon"; +import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon"; +import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon"; +import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; +import RobotIcon from "phosphor-svelte/lib/RobotIcon"; +import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon"; +import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon"; +import WrenchIcon from "phosphor-svelte/lib/WrenchIcon"; +import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon"; +import type { Component } from "svelte"; +import type { ToolIconKey } from "../logic/tool-summary"; + +// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class. +export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = { + terminal: TerminalWindowIcon, + "file-read": FileTextIcon, + "file-edit": PencilSimpleIcon, + "file-write": FloppyDiskIcon, + search: MagnifyingGlassIcon, + code: BracketsCurlyIcon, + todos: ListChecksIcon, + skill: BookOpenIcon, + vision: EyeIcon, + image: ImageIcon, + youtube: YoutubeLogoIcon, + "agent-summon": RobotIcon, + "agent-send": PaperPlaneTiltIcon, + "agent-read": ChatCircleTextIcon, + "agent-open": ArrowSquareOutIcon, + "agent-cancel": ProhibitIcon, + agents: UsersThreeIcon, + models: CpuIcon, + mcp: PuzzlePieceIcon, + tool: WrenchIcon, +}; |
