Spaces:
Paused
Paused
| import React, { useState } from 'react'; | |
| import { | |
| FileDiff, | |
| CheckCircle, | |
| AlertTriangle, | |
| CircleDashed, | |
| Loader2, | |
| File, | |
| ChevronDown, | |
| ChevronUp, | |
| Minus, | |
| Plus, | |
| } from 'lucide-react'; | |
| 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; | |
| import { Button } from '@/components/ui/button'; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { | |
| LineDiff, | |
| DiffStats, | |
| extractFromNewFormat, | |
| extractFromLegacyFormat, | |
| generateLineDiff, | |
| generateCharDiff, | |
| calculateDiffStats | |
| } from './_utils'; | |
| import { extractFilePath, extractStrReplaceContent, extractToolData, formatTimestamp, getToolTitle } from '../utils'; | |
| import { ToolViewProps } from '../types'; | |
| import { LoadingState } from '../shared/LoadingState'; | |
| const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => ( | |
| <div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -mt-2"> | |
| <table className="w-full border-collapse"> | |
| <tbody> | |
| {lineDiff.map((line, i) => ( | |
| <tr | |
| key={i} | |
| className={cn( | |
| "hover:bg-zinc-50 dark:hover:bg-zinc-900", | |
| line.type === 'removed' && "bg-red-50 dark:bg-red-950/30", | |
| line.type === 'added' && "bg-emerald-50 dark:bg-emerald-950/30", | |
| )} | |
| > | |
| <td className="w-10 text-right select-none py-0.5 pr-1 pl-4 text-xs text-zinc-500 dark:text-zinc-400 border-r border-zinc-200 dark:border-zinc-800"> | |
| {line.lineNumber} | |
| </td> | |
| <td className="pl-2 py-0.5 w-6 select-none"> | |
| {line.type === 'removed' && <Minus className="h-3.5 w-3.5 text-red-500" />} | |
| {line.type === 'added' && <Plus className="h-3.5 w-3.5 text-emerald-500" />} | |
| </td> | |
| <td className="w-full px-3 py-0.5"> | |
| <div className="overflow-x-auto max-w-full text-xs"> | |
| {line.type === 'removed' && <span className="text-red-700 dark:text-red-400">{line.oldLine}</span>} | |
| {line.type === 'added' && <span className="text-emerald-700 dark:text-emerald-400">{line.newLine}</span>} | |
| {line.type === 'unchanged' && <span className="text-zinc-700 dark:text-zinc-300">{line.oldLine}</span>} | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ); | |
| const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => ( | |
| <div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -my-2"> | |
| <table className="w-full border-collapse"> | |
| <thead> | |
| <tr className="border-b border-zinc-200 dark:border-zinc-800 text-xs"> | |
| <th className="p-2 text-left text-zinc-500 dark:text-zinc-400 w-1/2">Removed</th> | |
| <th className="p-2 text-left text-zinc-500 dark:text-zinc-400 w-1/2">Added</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {lineDiff.map((line, i) => ( | |
| <tr key={i}> | |
| <td | |
| className={cn( | |
| "p-2 align-top", | |
| line.type === 'removed' ? 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400' : '', | |
| line.oldLine === null ? 'bg-zinc-100 dark:bg-zinc-900' : '' | |
| )} | |
| > | |
| {line.oldLine !== null ? ( | |
| <div className="flex"> | |
| <div className="w-8 text-right pr-2 select-none text-xs text-zinc-500 dark:text-zinc-400"> | |
| {line.lineNumber} | |
| </div> | |
| {line.type === 'removed' && | |
| <Minus className="h-3.5 w-3.5 text-red-500 mt-0.5 mr-2 flex-shrink-0" /> | |
| } | |
| <div className="overflow-x-auto"> | |
| <span className="break-all">{line.oldLine}</span> | |
| </div> | |
| </div> | |
| ) : null} | |
| </td> | |
| <td | |
| className={cn( | |
| "p-2 align-top", | |
| line.type === 'added' ? 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400' : '', | |
| line.newLine === null ? 'bg-zinc-100 dark:bg-zinc-900' : '' | |
| )} | |
| > | |
| {line.newLine !== null ? ( | |
| <div className="flex"> | |
| <div className="w-8 text-right pr-2 select-none text-xs text-zinc-500 dark:text-zinc-400"> | |
| {line.lineNumber} | |
| </div> | |
| {line.type === 'added' && | |
| <Plus className="h-3.5 w-3.5 text-emerald-500 mt-0.5 mr-2 flex-shrink-0" /> | |
| } | |
| <div className="overflow-x-auto"> | |
| <span className="break-all">{line.newLine}</span> | |
| </div> | |
| </div> | |
| ) : null} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ); | |
| const ErrorState: React.FC = () => ( | |
| <div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900"> | |
| <div className="text-center w-full max-w-xs"> | |
| <AlertTriangle className="h-16 w-16 mx-auto mb-6 text-amber-500" /> | |
| <h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2"> | |
| Invalid String Replacement | |
| </h3> | |
| <p className="text-sm text-zinc-500 dark:text-zinc-400"> | |
| Could not extract the old string and new string from the request. | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| export function StrReplaceToolView({ | |
| name = 'str-replace', | |
| assistantContent, | |
| toolContent, | |
| assistantTimestamp, | |
| toolTimestamp, | |
| isSuccess = true, | |
| isStreaming = false, | |
| }: ToolViewProps): JSX.Element { | |
| const [expanded, setExpanded] = useState<boolean>(true); | |
| const [viewMode, setViewMode] = useState<'unified' | 'split'>('unified'); | |
| let filePath: string | null = null; | |
| let oldStr: string | null = null; | |
| let newStr: string | null = null; | |
| let actualIsSuccess = isSuccess; | |
| let actualToolTimestamp = toolTimestamp; | |
| let actualAssistantTimestamp = assistantTimestamp; | |
| const assistantNewFormat = extractFromNewFormat(assistantContent); | |
| const toolNewFormat = extractFromNewFormat(toolContent); | |
| if (assistantNewFormat.filePath || assistantNewFormat.oldStr || assistantNewFormat.newStr) { | |
| filePath = assistantNewFormat.filePath; | |
| oldStr = assistantNewFormat.oldStr; | |
| newStr = assistantNewFormat.newStr; | |
| if (assistantNewFormat.success !== undefined) { | |
| actualIsSuccess = assistantNewFormat.success; | |
| } | |
| if (assistantNewFormat.timestamp) { | |
| actualAssistantTimestamp = assistantNewFormat.timestamp; | |
| } | |
| } else if (toolNewFormat.filePath || toolNewFormat.oldStr || toolNewFormat.newStr) { | |
| filePath = toolNewFormat.filePath; | |
| oldStr = toolNewFormat.oldStr; | |
| newStr = toolNewFormat.newStr; | |
| if (toolNewFormat.success !== undefined) { | |
| actualIsSuccess = toolNewFormat.success; | |
| } | |
| if (toolNewFormat.timestamp) { | |
| actualToolTimestamp = toolNewFormat.timestamp; | |
| } | |
| } else { | |
| // Fall back to legacy format extraction | |
| const assistantLegacy = extractFromLegacyFormat(assistantContent, extractToolData, extractFilePath, extractStrReplaceContent); | |
| const toolLegacy = extractFromLegacyFormat(toolContent, extractToolData, extractFilePath, extractStrReplaceContent); | |
| // Use assistant content first, then tool content as fallback | |
| filePath = assistantLegacy.filePath || toolLegacy.filePath; | |
| oldStr = assistantLegacy.oldStr || toolLegacy.oldStr; | |
| newStr = assistantLegacy.newStr || toolLegacy.newStr; | |
| } | |
| // Additional legacy extraction for edge cases | |
| if (!filePath) { | |
| filePath = extractFilePath(assistantContent) || extractFilePath(toolContent); | |
| } | |
| if (!oldStr || !newStr) { | |
| const assistantStrReplace = extractStrReplaceContent(assistantContent); | |
| const toolStrReplace = extractStrReplaceContent(toolContent); | |
| oldStr = oldStr || assistantStrReplace.oldStr || toolStrReplace.oldStr; | |
| newStr = newStr || assistantStrReplace.newStr || toolStrReplace.newStr; | |
| } | |
| const toolTitle = getToolTitle(name); | |
| // Generate diff data (only if we have both strings) | |
| const lineDiff = oldStr && newStr ? generateLineDiff(oldStr, newStr) : []; | |
| const charDiff = oldStr && newStr ? generateCharDiff(oldStr, newStr) : []; | |
| // Calculate stats on changes | |
| const stats: DiffStats = calculateDiffStats(lineDiff); | |
| // Check if we should show error state (only when not streaming and we have content but can't extract strings) | |
| const shouldShowError = !isStreaming && (!oldStr || !newStr) && (assistantContent || toolContent); | |
| 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-purple-500/20 to-purple-600/10 border border-purple-500/20"> | |
| <FileDiff className="w-5 h-5 text-purple-500 dark:text-purple-400" /> | |
| </div> | |
| <CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100"> | |
| {toolTitle} | |
| </CardTitle> | |
| </div> | |
| {!isStreaming && ( | |
| <Badge | |
| variant="secondary" | |
| className={ | |
| actualIsSuccess | |
| ? "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" | |
| } | |
| > | |
| {actualIsSuccess ? ( | |
| <CheckCircle className="h-3.5 w-3.5 mr-1" /> | |
| ) : ( | |
| <AlertTriangle className="h-3.5 w-3.5 mr-1" /> | |
| )} | |
| {actualIsSuccess ? 'Replacement completed' : 'Replacement 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" /> | |
| Processing replacement | |
| </Badge> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-0 h-full flex-1 overflow-hidden relative"> | |
| {isStreaming ? ( | |
| <LoadingState | |
| icon={FileDiff} | |
| iconColor="text-purple-500 dark:text-purple-400" | |
| bgColor="bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20" | |
| title="Processing String Replacement" | |
| filePath={filePath || 'Processing file...'} | |
| progressText="Analyzing text patterns" | |
| subtitle="Please wait while the replacement is being processed" | |
| /> | |
| ) : shouldShowError ? ( | |
| <ErrorState /> | |
| ) : ( | |
| <ScrollArea className="h-full w-full"> | |
| <div className="p-4"> | |
| <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden mb-4"> | |
| <div className="p-3 border-b border-zinc-200 dark:border-zinc-800 bg-accent flex items-center justify-between"> | |
| <div className="flex items-center"> | |
| <File className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" /> | |
| <code className="text-xs font-mono text-zinc-700 dark:text-zinc-300"> | |
| {filePath || 'Unknown file'} | |
| </code> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 gap-3"> | |
| <div className="flex items-center"> | |
| <Plus className="h-3.5 w-3.5 text-emerald-500 mr-1" /> | |
| <span>{stats.additions}</span> | |
| </div> | |
| <div className="flex items-center"> | |
| <Minus className="h-3.5 w-3.5 text-red-500 mr-1" /> | |
| <span>{stats.deletions}</span> | |
| </div> | |
| </div> | |
| <TooltipProvider> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-7 w-7 p-0" | |
| onClick={() => setExpanded(!expanded)} | |
| > | |
| {expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p>{expanded ? 'Collapse' : 'Expand'}</p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| </div> | |
| </div> | |
| {expanded && ( | |
| <div> | |
| <Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'unified' | 'split')} className="w-auto"> | |
| <div className="border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 p-2 flex justify-end"> | |
| <TabsList className="h-7 p-0.5"> | |
| <TabsTrigger value="unified" className="text-xs h-6 px-2">Unified</TabsTrigger> | |
| <TabsTrigger value="split" className="text-xs h-6 px-2">Split</TabsTrigger> | |
| </TabsList> | |
| </div> | |
| <TabsContent value="unified" className="m-0 pb-4"> | |
| <UnifiedDiffView lineDiff={lineDiff} /> | |
| </TabsContent> | |
| <TabsContent value="split" className="m-0"> | |
| <SplitDiffView lineDiff={lineDiff} /> | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </ScrollArea> | |
| )} | |
| </CardContent> | |
| <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"> | |
| <div className="h-full flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400"> | |
| {!isStreaming && ( | |
| <div className="flex items-center gap-1"> | |
| {actualIsSuccess ? ( | |
| <CheckCircle className="h-3.5 w-3.5 text-emerald-500 mr-1" /> | |
| ) : ( | |
| <AlertTriangle className="h-3.5 w-3.5 text-red-500 mr-1" /> | |
| )} | |
| <span> | |
| {actualIsSuccess | |
| ? 'String replacement successful' | |
| : 'String replacement failed'} | |
| </span> | |
| </div> | |
| )} | |
| {isStreaming && ( | |
| <div className="flex items-center gap-1"> | |
| <CircleDashed className="h-3.5 w-3.5 text-blue-500 animate-spin mr-1" /> | |
| <span>Processing replacement...</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="text-xs text-zinc-500 dark:text-zinc-400"> | |
| {actualToolTimestamp && !isStreaming | |
| ? formatTimestamp(actualToolTimestamp) | |
| : actualAssistantTimestamp | |
| ? formatTimestamp(actualAssistantTimestamp) | |
| : ''} | |
| </div> | |
| </div> | |
| </Card> | |
| ); | |
| } |