From 703c4686d985a415d839717d7c192be11eb14fb9 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:30:46 +0800 Subject: [PATCH] feat(ui): improve tool-call-item with status dots, smart subtitles, and accessibility - Replace hugeicons status icons with CSS colored dots + glow-pulse animation for running state (honors prefers-reduced-motion) - Add smart subtitles from toolArgs: file basename, command preview, search pattern, URL hostname - Add right-aligned stats: line count, match count, file count - Add hover chevron for expand/collapse affordance - Fix accessibility: aria-label, aria-expanded, focus-visible ring - Store toolArgs in Message (was received but discarded) - Extract toolArgs from assistant ToolCall blocks during fetchHistory - Add --tool-running/success/error CSS variables with dark mode support Co-Authored-By: Claude Opus 4.5 --- packages/store/src/connection-store.ts | 14 ++ packages/ui/src/components/tool-call-item.tsx | 228 ++++++++++++++++++ packages/ui/src/styles/globals.css | 12 + 3 files changed, 254 insertions(+) create mode 100644 packages/ui/src/components/tool-call-item.tsx diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index 998a34f9..ea2407c2 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -162,6 +162,18 @@ async function fetchHistory(state: ConnectionStoreState): Promise { hubId, "getAgentMessages", { agentId, limit: 200 }, ) + // Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks + const toolCallArgsMap = new Map }>() + for (const m of result.messages) { + if (m.role === "assistant") { + for (const block of m.content) { + if (block.type === "toolCall") { + toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments }) + } + } + } + } + // Mirror the backend message array directly const messages: Message[] = [] for (const m of result.messages) { @@ -181,6 +193,7 @@ async function fetchHistory(state: ConnectionStoreState): Promise { stopReason: m.stopReason, }) } else if (m.role === "toolResult") { + const callInfo = toolCallArgsMap.get(m.toolCallId) messages.push({ id: uuidv7(), role: "toolResult", @@ -188,6 +201,7 @@ async function fetchHistory(state: ConnectionStoreState): Promise { agentId, toolCallId: m.toolCallId, toolName: m.toolName, + toolArgs: callInfo?.args, toolStatus: m.isError ? "error" : "success", isError: m.isError, }) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx new file mode 100644 index 00000000..eaf97d88 --- /dev/null +++ b/packages/ui/src/components/tool-call-item.tsx @@ -0,0 +1,228 @@ +"use client" + +import { useState } from "react" +import { HugeiconsIcon } from "@hugeicons/react" +import { + File01Icon, + FloppyDiskIcon, + FileEditIcon, + CommandLineIcon, + Search01Icon, + FolderOpenIcon, + GlobeIcon, + DatabaseIcon, + GitBranchIcon, + ArrowRight01Icon, +} from "@hugeicons/core-free-icons" +import { cn } from "@multica/ui/lib/utils" +import type { Message } from "@multica/store" +import type { ContentBlock } from "@multica/sdk" + +// --------------------------------------------------------------------------- +// Tool display config +// --------------------------------------------------------------------------- + +const TOOL_DISPLAY: Record = { + read: { label: "Read", icon: File01Icon }, + write: { label: "Write", icon: FloppyDiskIcon }, + edit: { label: "Edit", icon: FileEditIcon }, + exec: { label: "Exec", icon: CommandLineIcon }, + bash: { label: "Exec", icon: CommandLineIcon }, + process: { label: "Process", icon: CommandLineIcon }, + grep: { label: "Grep", icon: Search01Icon }, + find: { label: "Find", icon: Search01Icon }, + ls: { label: "ListDir", icon: FolderOpenIcon }, + glob: { label: "Glob", icon: Search01Icon }, + web_search: { label: "WebSearch", icon: GlobeIcon }, + web_fetch: { label: "WebFetch", icon: GlobeIcon }, + memory_get: { label: "MemoryGet", icon: DatabaseIcon }, + memory_set: { label: "MemorySet", icon: DatabaseIcon }, + memory_delete: { label: "MemoryDelete", icon: DatabaseIcon }, + memory_list: { label: "MemoryList", icon: DatabaseIcon }, + sessions_spawn: { label: "SpawnSession", icon: GitBranchIcon }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract plain text from ContentBlock[] */ +function getResultText(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join("") +} + +/** Extract a short basename from a file path */ +function basename(path: string): string { + return path.split("/").pop() ?? path +} + +/** Smart subtitle based on tool type and args */ +function getSubtitle(toolName: string, args?: Record): string { + if (!args) return "" + switch (toolName) { + case "read": + case "write": + case "edit": + return args.path ? basename(String(args.path)) : "" + case "exec": + case "bash": + case "process": { + const cmd = String(args.command ?? args.cmd ?? "") + return cmd.length > 60 ? cmd.slice(0, 57) + "…" : cmd + } + case "grep": + case "find": + return args.pattern ? String(args.pattern) : "" + case "glob": + return args.pattern ? String(args.pattern) : "" + case "web_search": + return args.query ? String(args.query) : "" + case "web_fetch": + try { return new URL(String(args.url)).hostname } catch { return String(args.url ?? "") } + default: + return "" + } +} + +/** Running-state label per tool */ +const RUNNING_LABELS: Record = { + read: "reading…", + write: "writing…", + edit: "editing…", + exec: "running…", + bash: "running…", + process: "running…", + grep: "searching…", + find: "searching…", + glob: "searching…", + web_search: "searching…", + web_fetch: "fetching…", +} + +/** Stats derived from tool result content */ +function getStats(toolName: string, toolStatus: string, resultText: string): string { + if (toolStatus === "running") return RUNNING_LABELS[toolName] ?? "working…" + if (toolStatus === "error" || toolStatus === "interrupted" || !resultText) return "" + + switch (toolName) { + case "read": { + const lines = resultText.split("\n").length + return `${lines} lines` + } + case "grep": { + const matches = resultText.split("\n").filter((l) => l.trim()).length + return matches > 0 ? `${matches} matches` : "" + } + case "glob": + case "find": { + const files = resultText.split("\n").filter((l) => l.trim()).length + return files > 0 ? `${files} files` : "" + } + default: + return "" + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ToolCallItem({ message }: { message: Message }) { + const [expanded, setExpanded] = useState(false) + const { toolName = "", toolStatus = "running", toolArgs, content } = message + + const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: CommandLineIcon } + const isFinished = toolStatus !== "running" + const resultText = getResultText(content) + const hasDetails = isFinished && !!resultText + const subtitle = getSubtitle(toolName, toolArgs) + const stats = getStats(toolName, toolStatus, resultText) + + return ( +
+ + + {/* Expanded result */} + {expanded && resultText && ( +
+ {resultText} +
+ )} +
+ ) +} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 7e57c362..cd74a06b 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -86,6 +86,9 @@ --scrollbar-thumb: oklch(0.82 0.003 286); --scrollbar-thumb-hover: oklch(0.705 0.015 286.067); --scrollbar-track: transparent; + --tool-running: oklch(0.6 0.18 250); + --tool-success: oklch(0.72 0.12 145); + --tool-error: oklch(0.65 0.2 25); } .dark { @@ -123,6 +126,9 @@ --scrollbar-thumb: oklch(1 0 0 / 15%); --scrollbar-thumb-hover: oklch(1 0 0 / 30%); --scrollbar-track: transparent; + --tool-running: oklch(0.65 0.2 250); + --tool-success: oklch(0.65 0.15 145); + --tool-error: oklch(0.7 0.2 22); } /* Shiki dual themes: CSS-only light/dark switching via CSS variables */ @@ -213,3 +219,9 @@ opacity: 0.7; } } + +/* Tool status: running glow pulse */ +@keyframes glow-pulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--tool-running); } + 50% { box-shadow: 0 0 0 3px oklch(0.6 0.2 250 / 0); } +}