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:
parent
583242baba
commit
703c4686d9
3 changed files with 254 additions and 0 deletions
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
228
packages/ui/src/components/tool-call-item.tsx
Normal file
228
packages/ui/src/components/tool-call-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue