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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-04 17:30:46 +08:00
parent 583242baba
commit 703c4686d9
3 changed files with 254 additions and 0 deletions

View file

@ -162,6 +162,18 @@ async function fetchHistory(state: ConnectionStoreState): Promise<void> {
hubId, "getAgentMessages", { agentId, limit: 200 },
)
// Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>()
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<void> {
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<void> {
agentId,
toolCallId: m.toolCallId,
toolName: m.toolName,
toolArgs: callInfo?.args,
toolStatus: m.isError ? "error" : "success",
isError: m.isError,
})

View file

@ -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<string, { label: string; icon: typeof File01Icon }> = {
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, unknown>): 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<string, string> = {
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 (
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
<button
type="button"
aria-label={`${display.label}${subtitle ? ` ${subtitle}` : ""}${toolStatus}`}
aria-expanded={hasDetails ? expanded : undefined}
onClick={() => hasDetails && setExpanded(!expanded)}
className={cn(
"group flex w-full items-center gap-1.5 rounded px-1.5 py-0.5",
"text-left transition-[color,background-color]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 outline-none",
hasDetails && "hover:bg-muted/30 cursor-pointer",
!hasDetails && "cursor-default",
)}
>
{/* Status dot */}
<span
className={cn(
"size-1.5 rounded-full shrink-0",
toolStatus === "running" && "bg-[var(--tool-running)] motion-safe:animate-[glow-pulse_2s_ease-in-out_infinite]",
toolStatus === "success" && "bg-[var(--tool-success)]",
toolStatus === "error" && "bg-[var(--tool-error)]",
toolStatus === "interrupted" && "bg-[var(--tool-error)]",
)}
/>
{/* Tool icon */}
<HugeiconsIcon
icon={display.icon}
strokeWidth={2}
className={cn("size-3.5 shrink-0", toolStatus === "error" && "text-[var(--tool-error)]")}
/>
{/* Tool label */}
<span className={cn(
"font-medium shrink-0",
toolStatus === "error" && "text-[var(--tool-error)]",
toolStatus === "interrupted" && "text-[var(--tool-error)]",
)}>
{display.label}
</span>
{/* Smart subtitle */}
{subtitle && (
<span className="text-muted-foreground/60 truncate min-w-0">
{subtitle}
</span>
)}
{/* Right-aligned stats */}
{stats && (
<span className={cn(
"ml-auto text-xs text-muted-foreground/60 shrink-0",
"font-[tabular-nums]",
toolStatus === "running" && "motion-safe:animate-pulse",
)}>
{stats}
</span>
)}
{/* Chevron — visible on hover when expandable */}
{hasDetails && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className={cn(
"size-3 text-muted-foreground/40 shrink-0",
"transition-[transform,opacity] duration-150",
!stats && "ml-auto",
"opacity-0 group-hover:opacity-100",
expanded && "rotate-90 opacity-100",
)}
/>
)}
</button>
{/* Expanded result */}
{expanded && resultText && (
<div className="mt-1 ml-7 text-xs bg-muted rounded p-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-all">
{resultText}
</div>
)}
</div>
)
}

View file

@ -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); }
}