feat(ui): redesign tool call display with Linear-style aesthetics

- Add ToolCallItem component with expandable input/output details
- Add status dot with color indicators (pending/running/completed/failed)
- Add glow-pulse animation for running state
- Use design system colors (secondary-foreground, muted-foreground)
- Extract tool components to separate file for better organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-01-14 18:12:05 +08:00
parent f50761b5ac
commit 6049ba2b76
3 changed files with 159 additions and 161 deletions

View File

@@ -7,6 +7,7 @@ import type { StoredSessionUpdate } from '../../../shared/types'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown } from 'lucide-react'
import { ToolCallItem, type ToolCall } from './ToolCallItem'
interface ChatViewProps {
updates: StoredSessionUpdate[]
@@ -73,15 +74,6 @@ interface Message {
toolCalls: ToolCall[]
}
interface ToolCall {
id: string
title: string
status: string
kind?: string
input?: string
output?: string
}
function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] {
const messages: Message[] = []
let currentAssistantContent = ''
@@ -237,7 +229,7 @@ function MessageBubble({ message }: MessageBubbleProps) {
{/* Tool calls */}
{message.toolCalls.map((tc) => (
<ToolCallLine key={tc.id} toolCall={tc} />
<ToolCallItem key={tc.id} toolCall={tc} />
))}
{/* Text content with markdown */}
@@ -369,157 +361,6 @@ function ThoughtBlock({ text }: { text: string }) {
)
}
// Tool call line - inline display
function ToolCallLine({ toolCall }: { toolCall: ToolCall }) {
const { icon, action, detail } = parseToolCall(toolCall)
const isRunning = toolCall.status === 'running' || toolCall.status === 'in_progress' || toolCall.status === 'pending'
const isFailed = toolCall.status === 'failed'
return (
<div className={`flex items-center gap-2 text-sm ${isFailed ? 'text-red-400' : 'text-muted-foreground'}`}>
{/* Icon */}
<span className="w-4 text-center font-mono opacity-60">{icon}</span>
{/* Action */}
<span>{action}</span>
{/* Detail in code pill */}
{detail && (
<span className="rounded bg-muted px-2 py-0.5 font-mono text-xs truncate max-w-[300px]">
{detail}
</span>
)}
{/* Running indicator */}
{isRunning && <LoadingDots />}
</div>
)
}
interface ParsedToolCall {
icon: string
action: string
detail?: string
}
function parseToolCall(toolCall: ToolCall): ParsedToolCall {
const title = toolCall.title?.toLowerCase() || ''
const kind = toolCall.kind?.toLowerCase() || ''
// Try to extract file path from input
let filePath: string | undefined
if (toolCall.input) {
try {
const parsed = JSON.parse(toolCall.input)
filePath = parsed.file_path || parsed.path || parsed.filePath || parsed.pattern
} catch {
// Not JSON, might be a direct path
if (toolCall.input.startsWith('/') || toolCall.input.includes('.')) {
filePath = toolCall.input.split('\n')[0].trim()
}
}
}
// Determine icon and action based on kind/title
if (kind === 'search' || title.includes('glob') || title.includes('search') || title.includes('grep')) {
return {
icon: '◎',
action: title.includes('glob') ? 'Search files' : 'Search',
detail: filePath || extractPathFromTitle(toolCall.title),
}
}
if (title.includes('list') || title.startsWith('ls')) {
return {
icon: '▤',
action: 'List',
detail: extractPathFromTitle(toolCall.title),
}
}
if (title.includes('read')) {
return {
icon: '◔',
action: 'Read',
detail: filePath || extractPathFromTitle(toolCall.title),
}
}
if (title.includes('write') || title.includes('create')) {
// Try to get line count from output
let lineCount = ''
if (toolCall.output) {
const lines = toolCall.output.split('\n').length
if (lines > 1) lineCount = `${lines} lines`
}
return {
icon: '▤',
action: lineCount ? `Write ${lineCount}` : 'Write',
detail: filePath || extractPathFromTitle(toolCall.title),
}
}
if (title.includes('edit') || title.includes('replace')) {
return {
icon: '✎',
action: 'Edit',
detail: filePath || extractPathFromTitle(toolCall.title),
}
}
if (title.startsWith('run') || kind === 'bash' || kind === 'shell') {
const cmd = extractCommandFromTitle(toolCall.title)
return {
icon: '>_',
action: getCommandDescription(toolCall.title),
detail: cmd,
}
}
// Default
return {
icon: '◇',
action: toolCall.title || toolCall.kind || 'Tool',
detail: filePath,
}
}
function extractPathFromTitle(title?: string): string | undefined {
if (!title) return undefined
// Look for path-like strings
const match = title.match(/\/[\w\-./]+/)
return match ? match[0] : undefined
}
function extractCommandFromTitle(title?: string): string | undefined {
if (!title) return undefined
// Remove "Run " prefix and get the command
if (title.toLowerCase().startsWith('run ')) {
const cmd = title.slice(4).trim()
// Truncate if too long
return cmd.length > 50 ? cmd.slice(0, 47) + '...' : cmd
}
return undefined
}
function getCommandDescription(title?: string): string {
if (!title) return 'Run command'
const lower = title.toLowerCase()
if (lower.includes('mkdir')) return 'Create directory'
if (lower.includes('rm ')) return 'Remove'
if (lower.includes('mv ')) return 'Move'
if (lower.includes('cp ')) return 'Copy'
if (lower.includes('npm') || lower.includes('pnpm') || lower.includes('yarn')) return 'Package manager'
if (lower.includes('git')) return 'Git'
if (lower.includes('python')) return 'Run Python'
if (lower.includes('node')) return 'Run Node'
if (lower.includes('perl')) return 'Run Perl'
return 'Run command'
}
function LoadingDots() {
return (
<span className="inline-flex gap-1">

View File

@@ -0,0 +1,135 @@
/**
* Tool call item component - displays tool calls with expandable details
*/
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export interface ToolCall {
id: string
title: string
status: string
kind?: string
input?: string
output?: string
}
// Status dot component - displays tool call status with color and animation
function StatusDot({ status }: { status: string }) {
const statusStyles: Record<string, string> = {
pending: 'bg-[var(--tool-pending)]',
running: 'bg-[var(--tool-running)] animate-[glow-pulse_2s_ease-in-out_infinite]',
in_progress: 'bg-[var(--tool-running)] animate-[glow-pulse_2s_ease-in-out_infinite]',
completed: 'bg-[var(--tool-success)]',
failed: 'bg-[var(--tool-error)]',
}
return (
<span
className={cn(
'h-1.5 w-1.5 rounded-full flex-shrink-0',
statusStyles[status] || statusStyles.pending
)}
/>
)
}
// Tool call details - shows input and output separated by a line
function ToolCallDetails({ toolCall }: { toolCall: ToolCall }) {
return (
<div className="ml-4 mt-1 mb-2 bg-muted/50 rounded-md p-2">
{/* Input */}
{toolCall.input && (
<div className="overflow-auto max-h-[120px]">
<pre className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all">
{formatJson(toolCall.input)}
</pre>
</div>
)}
{/* Separator */}
{toolCall.input && toolCall.output && (
<div className="my-1.5 border-t border-border/40" />
)}
{/* Output */}
{toolCall.output && (
<div className="overflow-auto max-h-[160px]">
<pre className="text-xs font-mono text-muted-foreground/70 whitespace-pre-wrap break-all">
{toolCall.output}
</pre>
</div>
)}
</div>
)
}
// Format JSON string for display
function formatJson(input: string): string {
try {
const parsed = JSON.parse(input)
return JSON.stringify(parsed, null, 2)
} catch {
return input
}
}
// Tool call item - expandable display with input/output details
export function ToolCallItem({ toolCall }: { toolCall: ToolCall }) {
const [isOpen, setIsOpen] = useState(false)
const hasDetails = toolCall.input || toolCall.output
const isFailed = toolCall.status === 'failed'
const kind = toolCall.kind || 'tool'
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger
className={cn(
'group flex w-full items-center gap-2 rounded px-1.5 py-0.5',
'text-sm transition-colors duration-100',
'hover:bg-muted/20',
hasDetails && 'cursor-pointer',
!hasDetails && 'cursor-default'
)}
disabled={!hasDetails}
>
{/* Status dot */}
<StatusDot status={toolCall.status} />
{/* Kind */}
<span className={cn(
'text-secondary-foreground',
isFailed && 'text-[var(--tool-error)]'
)}>
{kind}
</span>
{/* Title (file path etc) - truncate */}
{toolCall.title && (
<span className="truncate max-w-[400px] text-muted-foreground text-xs">
{toolCall.title}
</span>
)}
{/* Expand indicator */}
{hasDetails && (
<ChevronRight
className={cn(
'ml-auto h-3 w-3 text-muted-foreground/40 transition-all duration-150',
'opacity-0 group-hover:opacity-100',
isOpen && 'rotate-90 opacity-100'
)}
/>
)}
</CollapsibleTrigger>
{hasDetails && (
<CollapsibleContent className="overflow-hidden">
<ToolCallDetails toolCall={toolCall} />
</CollapsibleContent>
)}
</Collapsible>
)
}

View File

@@ -122,6 +122,12 @@ body {
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
/* Tool call status colors */
--tool-pending: oklch(0.55 0.05 250);
--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 {
@@ -156,6 +162,12 @@ body {
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
/* Tool call status colors */
--tool-pending: oklch(0.5 0.08 250);
--tool-running: oklch(0.65 0.2 250);
--tool-success: oklch(0.65 0.15 145);
--tool-error: oklch(0.7 0.2 22);
}
@layer base {
@@ -166,3 +178,13 @@ body {
@apply bg-background text-foreground;
}
}
/* Tool call status glow animation */
@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);
}
}