| import React, { useState, useEffect } from 'react'; | |
| import { | |
| CheckCircle2, | |
| CheckCircle, | |
| AlertTriangle, | |
| Loader2, | |
| ListChecks, | |
| Sparkles, | |
| Trophy, | |
| Paperclip, | |
| ExternalLink, | |
| } from 'lucide-react'; | |
| import { ToolViewProps } from './types'; | |
| import { | |
| formatTimestamp, | |
| getToolTitle, | |
| normalizeContentToString, | |
| extractToolData, | |
| getFileIconAndColor, | |
| } from './utils'; | |
| import { cn } from '@/lib/utils'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| import { Progress } from '@/components/ui/progress'; | |
| import { Markdown } from '@/components/ui/markdown'; | |
| interface CompleteContent { | |
| summary?: string; | |
| result?: string | null; | |
| tasksCompleted?: string[]; | |
| finalOutput?: string; | |
| attachments?: string[]; | |
| } | |
| interface CompleteToolViewProps extends ToolViewProps { | |
| onFileClick?: (filePath: string) => void; | |
| } | |
| export function CompleteToolView({ | |
| name = 'complete', | |
| assistantContent, | |
| toolContent, | |
| assistantTimestamp, | |
| toolTimestamp, | |
| isSuccess = true, | |
| isStreaming = false, | |
| onFileClick, | |
| }: CompleteToolViewProps) { | |
| const [completeData, setCompleteData] = useState<CompleteContent>({}); | |
| const [progress, setProgress] = useState(0); | |
| useEffect(() => { | |
| if (assistantContent) { | |
| try { | |
| const contentStr = normalizeContentToString(assistantContent); | |
| if (!contentStr) return; | |
| let cleanContent = contentStr | |
| .replace(/<function_calls>[\s\S]*?<\/function_calls>/g, '') | |
| .replace(/<invoke name="complete"[\s\S]*?<\/invoke>/g, '') | |
| .trim(); | |
| const completeMatch = cleanContent.match(/<complete[^>]*>([^<]*)<\/complete>/); | |
| if (completeMatch) { | |
| setCompleteData(prev => ({ ...prev, summary: completeMatch[1].trim() })); | |
| } else if (cleanContent) { | |
| setCompleteData(prev => ({ ...prev, summary: cleanContent })); | |
| } | |
| const attachmentsMatch = contentStr.match(/attachments=["']([^"']*)["']/i); | |
| if (attachmentsMatch) { | |
| const attachments = attachmentsMatch[1].split(',').map(a => a.trim()).filter(a => a.length > 0); | |
| setCompleteData(prev => ({ ...prev, attachments })); | |
| } | |
| const taskMatches = cleanContent.match(/- ([^\n]+)/g); | |
| if (taskMatches) { | |
| const tasks = taskMatches.map(task => task.replace('- ', '').trim()); | |
| setCompleteData(prev => ({ ...prev, tasksCompleted: tasks })); | |
| } | |
| } catch (e) { | |
| console.error('Error parsing complete content:', e); | |
| } | |
| } | |
| }, [assistantContent]); | |
| useEffect(() => { | |
| if (toolContent && !isStreaming) { | |
| try { | |
| const contentStr = normalizeContentToString(toolContent); | |
| if (!contentStr) return; | |
| const toolResultMatch = contentStr.match(/ToolResult\([^)]*output=['"]([^'"]+)['"]/); | |
| if (toolResultMatch) { | |
| setCompleteData(prev => ({ ...prev, result: toolResultMatch[1] })); | |
| } else { | |
| setCompleteData(prev => ({ ...prev, result: contentStr })); | |
| } | |
| } catch (e) { | |
| console.error('Error parsing tool response:', e); | |
| } | |
| } | |
| }, [toolContent, isStreaming]); | |
| useEffect(() => { | |
| if (isStreaming) { | |
| const timer = setInterval(() => { | |
| setProgress((prevProgress) => { | |
| if (prevProgress >= 95) { | |
| clearInterval(timer); | |
| return prevProgress; | |
| } | |
| return prevProgress + 5; | |
| }); | |
| }, 300); | |
| return () => clearInterval(timer); | |
| } else { | |
| setProgress(100); | |
| } | |
| }, [isStreaming]); | |
| const toolTitle = getToolTitle(name) || 'Task Complete'; | |
| const handleFileClick = (filePath: string) => { | |
| if (onFileClick) { | |
| onFileClick(filePath); | |
| } | |
| }; | |
| return ( | |
| <Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card"> | |
| <CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2"> | |
| <div className="flex flex-row items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <div className="relative p-2 rounded-lg bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/20"> | |
| <CheckCircle2 className="w-5 h-5 text-emerald-500 dark:text-emerald-400" /> | |
| </div> | |
| <div> | |
| <CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100"> | |
| {toolTitle} | |
| </CardTitle> | |
| </div> | |
| </div> | |
| {!isStreaming && ( | |
| <Badge | |
| variant="secondary" | |
| className={ | |
| isSuccess | |
| ? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300" | |
| : "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300" | |
| } | |
| > | |
| {isSuccess ? ( | |
| <CheckCircle className="h-3.5 w-3.5 mr-1" /> | |
| ) : ( | |
| <AlertTriangle className="h-3.5 w-3.5 mr-1" /> | |
| )} | |
| {isSuccess ? 'Completed' : 'Failed'} | |
| </Badge> | |
| )} | |
| {isStreaming && ( | |
| <Badge className="bg-gradient-to-b from-blue-200 to-blue-100 text-blue-700 dark:from-blue-800/50 dark:to-blue-900/60 dark:text-blue-300"> | |
| <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> | |
| Completing | |
| </Badge> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-0 flex-1 overflow-hidden relative"> | |
| <ScrollArea className="h-full w-full"> | |
| <div className="p-4 space-y-6"> | |
| {/* Success Animation/Icon - Only show when completed successfully */} | |
| {!isStreaming && isSuccess && !completeData.summary && !completeData.tasksCompleted && !completeData.attachments && ( | |
| <div className="flex justify-center"> | |
| <div className="relative"> | |
| <div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 dark:from-emerald-800/40 dark:to-emerald-900/60 flex items-center justify-center"> | |
| <Trophy className="h-10 w-10 text-emerald-600 dark:text-emerald-400" /> | |
| </div> | |
| <div className="absolute -top-1 -right-1"> | |
| <Sparkles className="h-5 w-5 text-yellow-500 animate-pulse" /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Summary Section */} | |
| {completeData.summary && ( | |
| <div className="space-y-2"> | |
| <div className="bg-muted/50 rounded-2xl p-4 border border-border"> | |
| <Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3"> | |
| {completeData.summary} | |
| </Markdown> | |
| </div> | |
| </div> | |
| )} | |
| {/* Attachments Section */} | |
| {completeData.attachments && completeData.attachments.length > 0 && ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> | |
| <Paperclip className="h-4 w-4" /> | |
| Files ({completeData.attachments.length}) | |
| </div> | |
| <div className="grid grid-cols-1 gap-2"> | |
| {completeData.attachments.map((attachment, index) => { | |
| const { icon: FileIcon, color, bgColor } = getFileIconAndColor(attachment); | |
| const fileName = attachment.split('/').pop() || attachment; | |
| const filePath = attachment.includes('/') ? attachment.substring(0, attachment.lastIndexOf('/')) : ''; | |
| return ( | |
| <button | |
| key={index} | |
| onClick={() => handleFileClick(attachment)} | |
| className="flex items-center gap-3 p-3 bg-muted/30 rounded-lg border border-border/50 hover:bg-muted/50 transition-colors group cursor-pointer text-left" | |
| > | |
| <div className="flex-shrink-0"> | |
| <div className={cn( | |
| "w-10 h-10 rounded-lg bg-gradient-to-br flex items-center justify-center", | |
| bgColor | |
| )}> | |
| <FileIcon className={cn("h-5 w-5", color)} /> | |
| </div> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm font-medium text-foreground truncate"> | |
| {fileName} | |
| </p> | |
| {filePath && ( | |
| <p className="text-xs text-muted-foreground truncate"> | |
| {filePath} | |
| </p> | |
| )} | |
| </div> | |
| <div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <ExternalLink className="h-4 w-4 text-muted-foreground" /> | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Tasks Completed Section */} | |
| {completeData.tasksCompleted && completeData.tasksCompleted.length > 0 && ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> | |
| <ListChecks className="h-4 w-4" /> | |
| Tasks Completed | |
| </div> | |
| <div className="space-y-2"> | |
| {completeData.tasksCompleted.map((task, index) => ( | |
| <div | |
| key={index} | |
| className="flex items-start gap-3 p-3 bg-muted/30 rounded-lg border border-border/50" | |
| > | |
| <div className="mt-1 flex-shrink-0"> | |
| <CheckCircle className="h-4 w-4 text-emerald-500" /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 [&>:last-child]:mb-0"> | |
| {task} | |
| </Markdown> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Progress Section for Streaming */} | |
| {isStreaming && ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between text-sm"> | |
| <span className="text-muted-foreground"> | |
| Completing task... | |
| </span> | |
| <span className="text-muted-foreground text-xs"> | |
| {progress}% | |
| </span> | |
| </div> | |
| <Progress value={progress} className="h-1" /> | |
| </div> | |
| )} | |
| {/* Empty State */} | |
| {!completeData.summary && !completeData.result && !completeData.attachments && !completeData.tasksCompleted && !isStreaming && ( | |
| <div className="flex flex-col items-center justify-center py-8 text-center"> | |
| <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4"> | |
| <CheckCircle2 className="h-8 w-8 text-muted-foreground" /> | |
| </div> | |
| <h3 className="text-lg font-medium text-foreground mb-2"> | |
| Task Completed | |
| </h3> | |
| <p className="text-sm text-muted-foreground"> | |
| No additional details provided | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </ScrollArea> | |
| </CardContent> | |
| {/* Footer */} | |
| <div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4"> | |
| <div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400"> | |
| <Badge className="h-6 py-0.5" variant="outline"> | |
| <CheckCircle2 className="h-3 w-3 mr-1" /> | |
| Task Completion | |
| </Badge> | |
| </div> | |
| <div className="text-xs text-zinc-500 dark:text-zinc-400"> | |
| {toolTimestamp && !isStreaming | |
| ? formatTimestamp(toolTimestamp) | |
| : assistantTimestamp | |
| ? formatTimestamp(assistantTimestamp) | |
| : ''} | |
| </div> | |
| </div> | |
| </Card> | |
| ); | |
| } |