mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
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:
@@ -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">
|
||||
|
||||
135
src/renderer/src/components/ToolCallItem.tsx
Normal file
135
src/renderer/src/components/ToolCallItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user