diff --git a/frontend/src/app/dashboard/agents/[threadId]/page.tsx b/frontend/src/app/dashboard/agents/[threadId]/page.tsx index 62bdab90..1a26747a 100644 --- a/frontend/src/app/dashboard/agents/[threadId]/page.tsx +++ b/frontend/src/app/dashboard/agents/[threadId]/page.tsx @@ -9,7 +9,7 @@ import { FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog } from 'lucide-react'; import type { ElementType } from 'react'; -import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject } from '@/lib/api'; +import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject, Project } from '@/lib/api'; import { toast } from 'sonner'; import { Skeleton } from "@/components/ui/skeleton"; import { ChatInput } from '@/components/thread/chat-input'; @@ -224,7 +224,6 @@ export default function ThreadPage({ params }: { params: Promise } const [toolCallData, setToolCallData] = useState(null); const [projectId, setProjectId] = useState(null); const [projectName, setProjectName] = useState('Project'); - const streamCleanupRef = useRef<(() => void) | null>(null); const textareaRef = useRef(null); const initialLoadCompleted = useRef(false); @@ -237,6 +236,7 @@ export default function ThreadPage({ params }: { params: Promise } const [buttonOpacity, setButtonOpacity] = useState(0); const [userHasScrolled, setUserHasScrolled] = useState(false); const hasInitiallyScrolled = useRef(false); + const [project, setProject] = useState(null); const [sandboxId, setSandboxId] = useState(null); const [fileViewerOpen, setFileViewerOpen] = useState(false); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); @@ -551,6 +551,9 @@ export default function ThreadPage({ params }: { params: Promise } if (threadData && threadData.project_id) { const projectData = await getProject(threadData.project_id); if (isMounted && projectData && projectData.sandbox) { + // Store the full project object + setProject(projectData); + // Extract the sandbox ID correctly setSandboxId(typeof projectData.sandbox === 'string' ? projectData.sandbox : projectData.sandbox.id); @@ -1027,6 +1030,7 @@ export default function ThreadPage({ params }: { params: Promise } currentIndex={currentPairIndex} totalPairs={allHistoricalPairs.length} onNavigate={handleSidePanelNavigate} + project={project} /> ); @@ -1060,6 +1064,7 @@ export default function ThreadPage({ params }: { params: Promise } currentIndex={currentPairIndex} totalPairs={allHistoricalPairs.length} onNavigate={handleSidePanelNavigate} + project={project} /> ); @@ -1413,6 +1418,7 @@ export default function ThreadPage({ params }: { params: Promise } currentIndex={currentPairIndex} totalPairs={allHistoricalPairs.length} onNavigate={handleSidePanelNavigate} + project={project} /> {sandboxId && ( diff --git a/frontend/src/components/thread/tool-call-side-panel.tsx b/frontend/src/components/thread/tool-call-side-panel.tsx index 4d6555c8..14b4e033 100644 --- a/frontend/src/components/thread/tool-call-side-panel.tsx +++ b/frontend/src/components/thread/tool-call-side-panel.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; -import { X, Package, Info, Terminal, CheckCircle, SkipBack, SkipForward } from "lucide-react"; +import { X, Package, Info, Terminal, CheckCircle, SkipBack, SkipForward, MonitorPlay, FileSymlink, FileDiff, FileEdit, Search, Globe, ExternalLink, Database, Code, ListFilter } from "lucide-react"; import { Slider } from "@/components/ui/slider"; +import { Project } from "@/lib/api"; // Define the structure for LIVE tool call data (from streaming) export interface ToolCallData { @@ -32,6 +33,1142 @@ interface ToolCallSidePanelProps { currentIndex: number | null; totalPairs: number; onNavigate: (newIndex: number) => void; + project?: Project; // Add project prop to access sandbox data +} + +// Extract command from execute-command content +function extractCommand(content: string | undefined): string | null { + if (!content) return null; + const commandMatch = content.match(/([\s\S]*?)<\/execute-command>/); + return commandMatch ? commandMatch[1].trim() : null; +} + +// Extract output from tool result content +function extractCommandOutput(content: string | undefined): string | null { + if (!content) return null; + if (!content.includes('ToolResult')) return null; + + // Extract the raw output string which contains the JSON-like structure + const outputMatch = content.match(/output='([\s\S]+?)(?='\))/); + if (!outputMatch || !outputMatch[1]) return null; + + try { + // Extract just the "output" field using regex instead of trying to parse the entire JSON + const outputContentMatch = outputMatch[1].match(/"output":\s*"([\s\S]*?)(?=",[^,]*"exit_code")/); + if (outputContentMatch && outputContentMatch[1]) { + // Unescape the inner content + return outputContentMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + + // Fallback: Return the raw match for display if we can't extract the specific field + return outputMatch[1]; + } catch (e) { + console.error("Failed to extract command output", e); + return outputMatch[1]; // Return the raw match on parsing error + } +} + +// Component for handling unified execute-command tool calls +function CommandToolView({ assistantContent, userContent }: { assistantContent?: string; userContent?: string }) { + const command = extractCommand(assistantContent); + const output = extractCommandOutput(userContent); + + // Extract exit code if available + const getExitCode = (): number | null => { + if (!userContent) return null; + + try { + const exitCodeMatch = userContent.match(/"exit_code":\s*(\d+)/); + if (exitCodeMatch && exitCodeMatch[1]) { + return parseInt(exitCodeMatch[1], 10); + } + return null; + } catch (e) { + return null; + } + }; + + const exitCode = getExitCode(); + const isSuccessful = exitCode === 0; + + return ( +
+ {/* Terminal header */} +
+
+
+
+ Terminal + {exitCode !== null && ( + + Exit: {exitCode} + + )} +
+ + {/* Unified terminal output area */} +
+ {command && ( + <> + user@workspace:~$ + {command} + {output && ( + <> +
+
{output}
+ + )} + {!output &&
} + + )} + {!command && No command available} +
+
+ ); +} + +// Component for handling str-replace tool calls with diff view +function StrReplaceToolView({ assistantContent, userContent }: { assistantContent?: string; userContent?: string }) { + if (!assistantContent) return
No content available
; + + // Extract old and new strings for a diff view + const oldMatch = assistantContent.match(/([\s\S]*?)<\/old_str>/); + const newMatch = assistantContent.match(/([\s\S]*?)<\/new_str>/); + + const oldStr = oldMatch ? oldMatch[1] : null; + const newStr = newMatch ? newMatch[1] : null; + + // Extract file path from the content + const filePathMatch = assistantContent.match(/file_path=["']([\s\S]*?)["']/); + const filePath = filePathMatch ? filePathMatch[1] : "Unknown file"; + + // Extract success status from user result + const isSuccess = userContent?.includes('success=True') || userContent?.includes('Replacement successful'); + + if (!oldStr || !newStr) { + return
+      {assistantContent}
+    
; + } + + // Generate a line-by-line diff with highlighting + const oldLines = oldStr.split('\n'); + const newLines = newStr.split('\n'); + + // Simple diff algorithm to find line differences + // This is a basic implementation - for a real app, consider using a library like 'diff' or 'jsdiff' + const computeDiff = () => { + // For unchanged sections, show as is + // For changed lines, mark as either added/removed + // For consecutive changes, try to highlight just the changed characters within the line + + type DiffLine = { + type: 'unchanged' | 'removed' | 'added' | 'modified'; + oldIndex?: number; + newIndex?: number; + oldContent?: string; + newContent?: string; + // For modified lines, we highlight the specific changes: + highlights?: { + oldParts: { text: string; highlighted: boolean }[]; + newParts: { text: string; highlighted: boolean }[]; + }; + }; + + const diff: DiffLine[] = []; + + // Very simple diff algorithm that identifies changed, added, removed lines + // and tries to highlight word-level changes for modified lines + let oldIndex = 0; + let newIndex = 0; + + while (oldIndex < oldLines.length || newIndex < newLines.length) { + const oldLine = oldIndex < oldLines.length ? oldLines[oldIndex] : null; + const newLine = newIndex < newLines.length ? newLines[newIndex] : null; + + // If lines are identical, add as unchanged + if (oldLine !== null && newLine !== null && oldLine === newLine) { + diff.push({ + type: 'unchanged', + oldIndex, + newIndex, + oldContent: oldLine, + }); + oldIndex++; + newIndex++; + continue; + } + + // Check if this is a modified line (not identical but similar) + if (oldLine !== null && newLine !== null && oldLine !== newLine) { + // This is a naive character-level highlighter - a real implementation would use + // a proper diff algorithm to get word-level differences + + // Try to identify character differences + let i = 0; + let j = 0; + let oldParts: { text: string; highlighted: boolean }[] = []; + let newParts: { text: string; highlighted: boolean }[] = []; + + // Find common prefix + let prefixLength = 0; + while (prefixLength < oldLine.length && prefixLength < newLine.length && + oldLine[prefixLength] === newLine[prefixLength]) { + prefixLength++; + } + + // Find common suffix + let oldSuffixStart = oldLine.length; + let newSuffixStart = newLine.length; + while (oldSuffixStart > prefixLength && newSuffixStart > prefixLength && + oldLine[oldSuffixStart - 1] === newLine[newSuffixStart - 1]) { + oldSuffixStart--; + newSuffixStart--; + } + + // Add parts + if (prefixLength > 0) { + oldParts.push({ text: oldLine.substring(0, prefixLength), highlighted: false }); + newParts.push({ text: newLine.substring(0, prefixLength), highlighted: false }); + } + + // Add the changed middle part + if (oldSuffixStart > prefixLength) { + oldParts.push({ text: oldLine.substring(prefixLength, oldSuffixStart), highlighted: true }); + } + if (newSuffixStart > prefixLength) { + newParts.push({ text: newLine.substring(prefixLength, newSuffixStart), highlighted: true }); + } + + // Add the common suffix + if (oldSuffixStart < oldLine.length) { + oldParts.push({ text: oldLine.substring(oldSuffixStart), highlighted: false }); + newParts.push({ text: newLine.substring(newSuffixStart), highlighted: false }); + } + + diff.push({ + type: 'modified', + oldIndex, + newIndex, + oldContent: oldLine, + newContent: newLine, + highlights: { + oldParts, + newParts + } + }); + + oldIndex++; + newIndex++; + continue; + } + + // Handle added/removed lines + if (oldLine === null) { + // This is a new line + diff.push({ + type: 'added', + newIndex, + newContent: newLine + }); + newIndex++; + } else if (newLine === null) { + // This is a removed line + diff.push({ + type: 'removed', + oldIndex, + oldContent: oldLine + }); + oldIndex++; + } else { + // This is a line that needs to be replaced + // Look ahead to see if there's a better match + let foundMatch = false; + + // Simple lookahead to find potentially better matches + // (in a real implementation, you'd use a proper diff algorithm) + const lookAheadLimit = 3; // Only look a few lines ahead + + for (let lookAhead = 1; lookAhead <= lookAheadLimit && newIndex + lookAhead < newLines.length; lookAhead++) { + if (oldLine === newLines[newIndex + lookAhead]) { + // Found a match ahead - mark intermediate lines as added + for (let i = 0; i < lookAhead; i++) { + diff.push({ + type: 'added', + newIndex: newIndex + i, + newContent: newLines[newIndex + i] + }); + } + foundMatch = true; + newIndex += lookAhead; + break; + } + } + + if (!foundMatch) { + for (let lookAhead = 1; lookAhead <= lookAheadLimit && oldIndex + lookAhead < oldLines.length; lookAhead++) { + if (newLine === oldLines[oldIndex + lookAhead]) { + // Found a match ahead - mark intermediate lines as removed + for (let i = 0; i < lookAhead; i++) { + diff.push({ + type: 'removed', + oldIndex: oldIndex + i, + oldContent: oldLines[oldIndex + i] + }); + } + foundMatch = true; + oldIndex += lookAhead; + break; + } + } + } + + if (!foundMatch) { + // No good match found, mark as separate remove/add + diff.push({ + type: 'removed', + oldIndex, + oldContent: oldLine + }); + diff.push({ + type: 'added', + newIndex, + newContent: newLine + }); + oldIndex++; + newIndex++; + } + } + } + + return diff; + }; + + const diffResult = computeDiff(); + + return ( +
+
+
+ + File Changes +
+ {isSuccess && ( + + Applied + + )} +
+ +
+ {filePath} + {diffResult.filter(d => d.type !== 'unchanged').length} changes +
+ +
+ + + {diffResult.map((line, idx) => { + // Skip unchanged lines if they're not adjacent to changed ones + // (to keep the diff focused on changes but maintain some context) + const prevChanged = idx > 0 && diffResult[idx - 1].type !== 'unchanged'; + const nextChanged = idx < diffResult.length - 1 && diffResult[idx + 1].type !== 'unchanged'; + const isContextLine = line.type === 'unchanged' && (prevChanged || nextChanged); + + // Hide unchanged lines that aren't providing context + if (line.type === 'unchanged' && !isContextLine && idx !== 0 && idx !== diffResult.length - 1) { + // Show a separator for collapsed sections + const prevHidden = idx > 1 && diffResult[idx - 1].type === 'unchanged' && !prevChanged; + if (!prevHidden) { + return ( + + + + + ); + } + return null; + } + + if (line.type === 'unchanged') { + return ( + + + + + ); + } + + if (line.type === 'added') { + return ( + + + + + ); + } + + if (line.type === 'removed') { + return ( + + + + + ); + } + + if (line.type === 'modified') { + // Show both lines with character-level highlighting + return ( + <> + + + + + + + + + + ); + } + + return null; + })} + +
+
+ + + +
+
+ {line.oldIndex !== undefined && line.oldIndex + 1} + + {line.oldContent} +
+ {line.newIndex !== undefined && line.newIndex + 1} + + + + {line.newContent} +
+ {line.oldIndex !== undefined && line.oldIndex + 1} + + + {line.oldContent} +
+ {line.oldIndex !== undefined && line.oldIndex + 1} + + + {line.highlights ? ( + + {line.highlights.oldParts.map((part, i) => ( + + {part.text} + + ))} + + ) : line.oldContent} +
+ {line.newIndex !== undefined && line.newIndex + 1} + + + + {line.highlights ? ( + + {line.highlights.newParts.map((part, i) => ( + + {part.text} + + ))} + + ) : line.newContent} +
+
+
+ ); +} + +// Component for browser-related tool calls that shows the VNC preview +function BrowserToolView({ assistantContent, userContent, vncPreviewUrl }: { + assistantContent?: string; + userContent?: string; + vncPreviewUrl?: string; +}) { + // Try to extract URL from content if applicable + const urlMatch = assistantContent?.match(/url=["'](https?:\/\/[^"']+)["']/); + const targetUrl = urlMatch ? urlMatch[1] : null; + + // Check if the browser operation was successful + const isSuccess = userContent?.includes('success=True') || userContent?.includes('Successfully'); + + if (!vncPreviewUrl) { + return ( +
+ +

Browser preview not available

+
+          {assistantContent}
+        
+
+ ); + } + + return ( +
+
+
+ + Browser Window +
+ {targetUrl && ( +
+ {targetUrl} +
+ )} +
+
+