|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { useState, useEffect, useRef } from 'react'; |
| 4 | +import { motion, AnimatePresence } from 'motion/react'; |
| 5 | +import { HugeiconsIcon } from "@hugeicons/react"; |
| 6 | +import type { IconSvgElement } from "@hugeicons/react"; |
| 7 | +import { |
| 8 | + File01Icon, |
| 9 | + FileEditIcon, |
| 10 | + CommandLineIcon, |
| 11 | + Search01Icon, |
| 12 | + Wrench01Icon, |
| 13 | + Loading02Icon, |
| 14 | + CheckmarkCircle02Icon, |
| 15 | + CancelCircleIcon, |
| 16 | +} from "@hugeicons/core-free-icons"; |
| 17 | +import { ChevronRightIcon } from 'lucide-react'; |
| 18 | +import { cn } from '@/lib/utils'; |
| 19 | + |
| 20 | +// --------------------------------------------------------------------------- |
| 21 | +// Types |
| 22 | +// --------------------------------------------------------------------------- |
| 23 | + |
| 24 | +export interface ToolAction { |
| 25 | + id?: string; |
| 26 | + name: string; |
| 27 | + input: unknown; |
| 28 | + result?: string; |
| 29 | + isError?: boolean; |
| 30 | +} |
| 31 | + |
| 32 | +interface ToolActionsGroupProps { |
| 33 | + tools: ToolAction[]; |
| 34 | + isStreaming?: boolean; |
| 35 | + streamingToolOutput?: string; |
| 36 | +} |
| 37 | + |
| 38 | +// --------------------------------------------------------------------------- |
| 39 | +// Tool categorisation |
| 40 | +// --------------------------------------------------------------------------- |
| 41 | + |
| 42 | +type ToolCategory = 'read' | 'write' | 'bash' | 'search' | 'other'; |
| 43 | + |
| 44 | +function getToolCategory(name: string): ToolCategory { |
| 45 | + const lower = name.toLowerCase(); |
| 46 | + if (lower === 'read' || lower === 'readfile' || lower === 'read_file') return 'read'; |
| 47 | + if ( |
| 48 | + lower === 'write' || lower === 'edit' || lower === 'writefile' || |
| 49 | + lower === 'write_file' || lower === 'create_file' || lower === 'createfile' || |
| 50 | + lower === 'notebookedit' || lower === 'notebook_edit' |
| 51 | + ) return 'write'; |
| 52 | + if ( |
| 53 | + lower === 'bash' || lower === 'execute' || lower === 'run' || |
| 54 | + lower === 'shell' || lower === 'execute_command' |
| 55 | + ) return 'bash'; |
| 56 | + if ( |
| 57 | + lower === 'search' || lower === 'glob' || lower === 'grep' || |
| 58 | + lower === 'find_files' || lower === 'search_files' || |
| 59 | + lower === 'websearch' || lower === 'web_search' |
| 60 | + ) return 'search'; |
| 61 | + return 'other'; |
| 62 | +} |
| 63 | + |
| 64 | +function getToolIcon(category: ToolCategory): IconSvgElement { |
| 65 | + switch (category) { |
| 66 | + case 'read': return File01Icon; |
| 67 | + case 'write': return FileEditIcon; |
| 68 | + case 'bash': return CommandLineIcon; |
| 69 | + case 'search': return Search01Icon; |
| 70 | + case 'other': return Wrench01Icon; |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +// --------------------------------------------------------------------------- |
| 75 | +// Summary helpers |
| 76 | +// --------------------------------------------------------------------------- |
| 77 | + |
| 78 | +function extractFilename(path: string): string { |
| 79 | + const parts = path.split('/'); |
| 80 | + return parts[parts.length - 1] || path; |
| 81 | +} |
| 82 | + |
| 83 | +function getToolSummary(name: string, input: unknown, category: ToolCategory): string { |
| 84 | + const inp = input as Record<string, unknown> | undefined; |
| 85 | + if (!inp) return name; |
| 86 | + |
| 87 | + switch (category) { |
| 88 | + case 'read': |
| 89 | + case 'write': { |
| 90 | + const path = (inp.file_path || inp.path || inp.filePath || '') as string; |
| 91 | + return path ? extractFilename(path) : name; |
| 92 | + } |
| 93 | + case 'bash': { |
| 94 | + const cmd = (inp.command || inp.cmd || '') as string; |
| 95 | + if (cmd) return cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd; |
| 96 | + return name; |
| 97 | + } |
| 98 | + case 'search': { |
| 99 | + const pattern = (inp.pattern || inp.query || inp.glob || '') as string; |
| 100 | + return pattern ? `"${pattern.length > 50 ? pattern.slice(0, 47) + '...' : pattern}"` : name; |
| 101 | + } |
| 102 | + default: |
| 103 | + return name; |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +function getFilePath(input: unknown): string { |
| 108 | + const inp = input as Record<string, unknown> | undefined; |
| 109 | + if (!inp) return ''; |
| 110 | + return (inp.file_path || inp.path || inp.filePath || '') as string; |
| 111 | +} |
| 112 | + |
| 113 | +function truncatePath(path: string, maxLen = 50): string { |
| 114 | + if (path.length <= maxLen) return path; |
| 115 | + return '...' + path.slice(path.length - maxLen + 3); |
| 116 | +} |
| 117 | + |
| 118 | +// --------------------------------------------------------------------------- |
| 119 | +// Status indicator — running: gray, completed: green, error: red |
| 120 | +// --------------------------------------------------------------------------- |
| 121 | + |
| 122 | +type ToolStatus = 'running' | 'success' | 'error'; |
| 123 | + |
| 124 | +function getStatus(tool: ToolAction): ToolStatus { |
| 125 | + if (tool.result === undefined) return 'running'; |
| 126 | + return tool.isError ? 'error' : 'success'; |
| 127 | +} |
| 128 | + |
| 129 | +function StatusDot({ status }: { status: ToolStatus }) { |
| 130 | + switch (status) { |
| 131 | + case 'running': |
| 132 | + return ( |
| 133 | + <HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/50" /> |
| 134 | + ); |
| 135 | + case 'success': |
| 136 | + return <HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-3.5 w-3.5 shrink-0 text-green-500" />; |
| 137 | + case 'error': |
| 138 | + return <HugeiconsIcon icon={CancelCircleIcon} className="h-3.5 w-3.5 shrink-0 text-red-500" />; |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +// --------------------------------------------------------------------------- |
| 143 | +// Compact row for a single tool action |
| 144 | +// --------------------------------------------------------------------------- |
| 145 | + |
| 146 | +function ToolActionRow({ tool }: { tool: ToolAction }) { |
| 147 | + const category = getToolCategory(tool.name); |
| 148 | + const icon = getToolIcon(category); |
| 149 | + const summary = getToolSummary(tool.name, tool.input, category); |
| 150 | + const filePath = getFilePath(tool.input); |
| 151 | + const status = getStatus(tool); |
| 152 | + |
| 153 | + const label = category === 'bash' ? '' : tool.name; |
| 154 | + |
| 155 | + return ( |
| 156 | + <div className="flex items-center gap-2 px-2 py-1 min-h-[28px] text-xs hover:bg-muted/30 rounded-sm transition-colors"> |
| 157 | + <HugeiconsIcon icon={icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> |
| 158 | + |
| 159 | + {label && ( |
| 160 | + <span className="font-medium text-muted-foreground shrink-0">{label}</span> |
| 161 | + )} |
| 162 | + |
| 163 | + <span className="font-mono text-muted-foreground/60 truncate flex-1"> |
| 164 | + {summary} |
| 165 | + </span> |
| 166 | + |
| 167 | + {filePath && (category === 'read' || category === 'write') && ( |
| 168 | + <span className="text-muted-foreground/40 text-[11px] font-mono truncate max-w-[200px] hidden sm:inline"> |
| 169 | + {truncatePath(filePath)} |
| 170 | + </span> |
| 171 | + )} |
| 172 | + |
| 173 | + <StatusDot status={status} /> |
| 174 | + </div> |
| 175 | + ); |
| 176 | +} |
| 177 | + |
| 178 | +// --------------------------------------------------------------------------- |
| 179 | +// Header summary helper — build running task description |
| 180 | +// --------------------------------------------------------------------------- |
| 181 | + |
| 182 | +function getRunningDescription(tools: ToolAction[]): string { |
| 183 | + const running = tools.filter((t) => t.result === undefined); |
| 184 | + if (running.length === 0) return ''; |
| 185 | + const last = running[running.length - 1]; |
| 186 | + const category = getToolCategory(last.name); |
| 187 | + return getToolSummary(last.name, last.input, category); |
| 188 | +} |
| 189 | + |
| 190 | +// --------------------------------------------------------------------------- |
| 191 | +// Main group component |
| 192 | +// --------------------------------------------------------------------------- |
| 193 | + |
| 194 | +export function ToolActionsGroup({ |
| 195 | + tools, |
| 196 | + isStreaming = false, |
| 197 | + streamingToolOutput: _streamingToolOutput, |
| 198 | +}: ToolActionsGroupProps) { |
| 199 | + const hasRunningTool = tools.some((t) => t.result === undefined); |
| 200 | + |
| 201 | + const [userToggled, setUserToggled] = useState(false); |
| 202 | + const [expanded, setExpanded] = useState(hasRunningTool); |
| 203 | + |
| 204 | + useEffect(() => { |
| 205 | + if (!userToggled) { |
| 206 | + setExpanded(hasRunningTool || isStreaming); |
| 207 | + } |
| 208 | + }, [hasRunningTool, isStreaming, userToggled]); |
| 209 | + |
| 210 | + if (tools.length === 0) return null; |
| 211 | + |
| 212 | + const runningCount = tools.filter((t) => t.result === undefined).length; |
| 213 | + const doneCount = tools.length - runningCount; |
| 214 | + const runningDesc = getRunningDescription(tools); |
| 215 | + |
| 216 | + const handleToggle = () => { |
| 217 | + setUserToggled(true); |
| 218 | + setExpanded((prev) => !prev); |
| 219 | + }; |
| 220 | + |
| 221 | + // Build summary text parts |
| 222 | + const summaryParts: string[] = []; |
| 223 | + if (runningCount > 0) summaryParts.push(`${runningCount} running`); |
| 224 | + if (doneCount > 0) summaryParts.push(`${doneCount} completed`); |
| 225 | + if (summaryParts.length === 0) summaryParts.push(`${tools.length} actions`); |
| 226 | + |
| 227 | + return ( |
| 228 | + <div className="w-[min(100%,48rem)]"> |
| 229 | + {/* Header — minimal: chevron + count + gray summary */} |
| 230 | + <button |
| 231 | + type="button" |
| 232 | + onClick={handleToggle} |
| 233 | + className="flex w-full items-center gap-2 py-1 text-xs rounded-sm hover:bg-muted/30 transition-colors" |
| 234 | + > |
| 235 | + <ChevronRightIcon |
| 236 | + className={cn( |
| 237 | + "h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200", |
| 238 | + expanded && "rotate-90" |
| 239 | + )} |
| 240 | + /> |
| 241 | + |
| 242 | + <span className="inline-flex items-center justify-center rounded bg-muted/80 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground/70 tabular-nums"> |
| 243 | + {tools.length} |
| 244 | + </span> |
| 245 | + |
| 246 | + <span className="text-muted-foreground/60 truncate"> |
| 247 | + {summaryParts.join(' · ')} |
| 248 | + </span> |
| 249 | + |
| 250 | + {/* Show running task description on the right */} |
| 251 | + {runningDesc && ( |
| 252 | + <span className="ml-auto text-muted-foreground/40 text-[11px] font-mono truncate max-w-[40%]"> |
| 253 | + {runningDesc} |
| 254 | + </span> |
| 255 | + )} |
| 256 | + </button> |
| 257 | + |
| 258 | + {/* Expanded list — left vertical line like blockquote */} |
| 259 | + <AnimatePresence initial={false}> |
| 260 | + {expanded && ( |
| 261 | + <motion.div |
| 262 | + initial={{ height: 0 }} |
| 263 | + animate={{ height: 'auto' }} |
| 264 | + exit={{ height: 0 }} |
| 265 | + transition={{ duration: 0.15, ease: 'easeOut' }} |
| 266 | + style={{ overflow: 'hidden', transformOrigin: 'top' }} |
| 267 | + > |
| 268 | + <motion.div |
| 269 | + initial={{ opacity: 0, y: -4 }} |
| 270 | + animate={{ opacity: 1, y: 0 }} |
| 271 | + exit={{ opacity: 0, y: -4 }} |
| 272 | + transition={{ duration: 0.12, ease: 'easeOut' }} |
| 273 | + > |
| 274 | + <div className="ml-1.5 mt-0.5 border-l-2 border-border/50 pl-2"> |
| 275 | + {tools.map((tool, i) => ( |
| 276 | + <ToolActionRow key={tool.id || `tool-${i}`} tool={tool} /> |
| 277 | + ))} |
| 278 | + </div> |
| 279 | + </motion.div> |
| 280 | + </motion.div> |
| 281 | + )} |
| 282 | + </AnimatePresence> |
| 283 | + </div> |
| 284 | + ); |
| 285 | +} |
0 commit comments