revert back to old fe

This commit is contained in:
marko-kraemer 2025-04-16 06:01:57 +01:00
parent f9e1f5b1ab
commit 1b9553b865
14 changed files with 1682 additions and 2478 deletions

View File

@ -153,7 +153,7 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread
stream=stream,
llm_model=model_name,
llm_temperature=0,
llm_max_tokens=128000,
llm_max_tokens=1280000,
tool_choice="auto",
max_xml_tool_calls=1,
temporary_message=temporary_message,

View File

@ -187,7 +187,7 @@ class SandboxFilesTool(SandboxToolsBase):
end_line = replacement_line + self.SNIPPET_LINES + new_str.count('\n')
snippet = '\n'.join(new_content.split('\n')[start_line:end_line + 1])
return self.success_response(f"Replacement successful. Snippet of changes:\n{snippet}")
return self.success_response(f"Replacement successful.")
except Exception as e:
return self.fail_response(f"Error replacing string: {str(e)}")

View File

@ -1224,19 +1224,19 @@ class ResponseProcessor:
logger.error(f"Failed even with fallback message: {str(e2)}", exc_info=True)
def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str:
"""Format a tool result as an XML tag or plain text.
"""Format a tool result wrapped in a <tool_result> tag.
Args:
tool_call: The tool call that was executed
result: The result of the tool execution
Returns:
String containing the formatted result
String containing the formatted result wrapped in <tool_result> tag
"""
# Always use xml_tag_name if it exists
if "xml_tag_name" in tool_call:
xml_tag_name = tool_call["xml_tag_name"]
return f"<{xml_tag_name}> {str(result)} </{xml_tag_name}>"
return f"<tool_result><{xml_tag_name}> {str(result)} </{xml_tag_name}></tool_result>"
# Non-XML tool, just return the function result
function_name = tool_call["function_name"]

File diff suppressed because it is too large Load Diff

View File

@ -231,8 +231,8 @@
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(100% 0 0);
--accent-foreground: oklch(96.7% 0.003 264.54);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);

View File

@ -1,6 +1,6 @@
'use client';
import React, { useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Send, Square, Loader2, File, Upload, X } from "lucide-react";
@ -17,6 +17,7 @@ interface ChatInputProps {
disabled?: boolean;
isAgentRunning?: boolean;
onStopAgent?: () => void;
autoFocus?: boolean;
value?: string;
onChange?: (value: string) => void;
onFileBrowse?: () => void;
@ -31,72 +32,102 @@ interface UploadedFile {
export function ChatInput({
onSubmit,
placeholder = "Type your message...",
placeholder = "Type your message... (Enter to send, Shift+Enter for new line)",
loading = false,
disabled = false,
isAgentRunning = false,
onStopAgent,
value = '',
autoFocus = true,
value,
onChange,
onFileBrowse,
sandboxId
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [inputValue, setInputValue] = useState(value || "");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedFiles, setUploadedFiles] = React.useState<UploadedFile[]>([]);
const [isUploading, setIsUploading] = React.useState(false);
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
// Allow controlled or uncontrolled usage
const isControlled = value !== undefined && onChange !== undefined;
// Update local state if controlled and value changes
useEffect(() => {
if (isControlled && value !== inputValue) {
setInputValue(value);
}
}, [value, isControlled, inputValue]);
// Auto-focus on textarea when component loads
useEffect(() => {
if (textareaRef.current) {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, []);
}, [autoFocus]);
// Handle textarea height adjustment
// Adjust textarea height based on content
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const adjustHeight = () => {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
const newHeight = Math.min(textarea.scrollHeight, 200); // Max height of 200px
textarea.style.height = `${newHeight}px`;
};
const observer = new ResizeObserver(adjustHeight);
observer.observe(textarea);
return () => observer.disconnect();
}, []);
adjustHeight();
// Adjust on window resize too
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [inputValue]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading || (disabled && !isAgentRunning)) return;
if ((!inputValue.trim() && uploadedFiles.length === 0) || loading || (disabled && !isAgentRunning)) return;
if (isAgentRunning && onStopAgent) {
onStopAgent();
return;
}
const message = value.trim();
if (!message && uploadedFiles.length === 0) return;
let finalMessage = message;
let message = inputValue;
// Add file information to the message if files were uploaded
if (uploadedFiles.length > 0) {
const fileInfo = uploadedFiles.map(file =>
`[Uploaded file: ${file.name} (${formatFileSize(file.size)}) at ${file.path}]`
).join('\n');
finalMessage = message ? `${message}\n\n${fileInfo}` : fileInfo;
message = message ? `${message}\n\n${fileInfo}` : fileInfo;
}
onSubmit(finalMessage);
onSubmit(message);
if (!isControlled) {
setInputValue("");
}
// Reset the uploaded files after sending
setUploadedFiles([]);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
if (isControlled) {
onChange(newValue);
} else {
setInputValue(newValue);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as React.FormEvent);
if ((inputValue.trim() || uploadedFiles.length > 0) && !loading && (!disabled || isAgentRunning)) {
handleSubmit(e as React.FormEvent);
}
}
};
@ -107,22 +138,27 @@ export function ChatInput({
};
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!sandboxId || !event.target.files?.length) return;
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
try {
setIsUploading(true);
const files = Array.from(event.target.files);
const newUploadedFiles: UploadedFile[] = [];
for (const file of files) {
if (file.size > 50 * 1024 * 1024) {
if (file.size > 50 * 1024 * 1024) { // 50MB limit
toast.error(`File size exceeds 50MB limit: ${file.name}`);
continue;
}
// Create a FormData object
const formData = new FormData();
formData.append('file', file);
formData.append('path', `/workspace/${file.name}`);
// Upload to workspace root by default
const uploadPath = `/workspace/${file.name}`;
formData.append('path', uploadPath);
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
@ -131,9 +167,12 @@ export function ChatInput({
throw new Error('No access token available');
}
// Upload using FormData
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${session.access_token}` },
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
body: formData
});
@ -141,15 +180,17 @@ export function ChatInput({
throw new Error(`Upload failed: ${response.statusText}`);
}
// Add to uploaded files
newUploadedFiles.push({
name: file.name,
path: `/workspace/${file.name}`,
path: uploadPath,
size: file.size
});
toast.success(`File uploaded: ${file.name}`);
}
// Update the uploaded files state
setUploadedFiles(prev => [...prev, ...newUploadedFiles]);
} catch (error) {
@ -157,6 +198,7 @@ export function ChatInput({
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
} finally {
setIsUploading(false);
// Reset the input
event.target.value = '';
}
};
@ -201,16 +243,21 @@ export function ChatInput({
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange?.(e.target.value)}
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={isAgentRunning ? "Agent is thinking..." : placeholder}
placeholder={
isAgentRunning
? "Agent is thinking..."
: placeholder
}
className="min-h-[50px] max-h-[200px] pr-20 resize-none"
disabled={loading || (disabled && !isAgentRunning)}
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center space-x-1">
{/* Upload file button */}
<Button
type="button"
onClick={handleFileUpload}
@ -227,6 +274,7 @@ export function ChatInput({
)}
</Button>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
@ -235,6 +283,7 @@ export function ChatInput({
multiple
/>
{/* File browser button */}
{onFileBrowse && (
<Button
type="button"
@ -255,7 +304,7 @@ export function ChatInput({
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={loading || (!isAgentRunning && (!value.trim() && uploadedFiles.length === 0))}
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
>
{loading ? (

View File

@ -0,0 +1,224 @@
"use client";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { File, Folder, ChevronRight, ChevronUp, FileText, Coffee } from "lucide-react";
import { listSandboxFiles, getSandboxFileContent, type FileInfo } from "@/lib/api";
import { toast } from "sonner";
interface FileBrowserProps {
sandboxId: string;
onSelectFile?: (path: string, content: string) => void;
trigger?: React.ReactNode;
}
export function FileBrowser({ sandboxId, onSelectFile, trigger }: FileBrowserProps) {
const [isOpen, setIsOpen] = useState(false);
const [currentPath, setCurrentPath] = useState("");
const [files, setFiles] = useState<FileInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [fileContent, setFileContent] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
loadFiles("");
} else {
setFileContent(null);
setSelectedFile(null);
}
}, [isOpen, sandboxId]);
// Load files from the current path
const loadFiles = async (path: string) => {
setIsLoading(true);
try {
const files = await listSandboxFiles(sandboxId, path);
setFiles(files);
setCurrentPath(path);
// Update breadcrumbs
if (path === "") {
setBreadcrumbs([]);
} else {
const parts = path.split('/').filter(Boolean);
setBreadcrumbs(parts);
}
} catch (error) {
toast.error("Failed to load files");
console.error("Failed to load files:", error);
} finally {
setIsLoading(false);
}
};
// Load file content
const loadFileContent = async (path: string) => {
setIsLoading(true);
setSelectedFile(path);
try {
const content = await getSandboxFileContent(sandboxId, path);
if (typeof content === 'string') {
setFileContent(content);
} else {
// For binary files, show a message
setFileContent("[Binary file]");
}
} catch (error) {
toast.error("Failed to load file content");
console.error("Failed to load file content:", error);
setFileContent(null);
} finally {
setIsLoading(false);
}
};
// Handle file or folder click
const handleItemClick = (file: FileInfo) => {
if (file.is_dir) {
loadFiles(file.path);
} else {
loadFileContent(file.path);
}
};
// Navigate to a specific breadcrumb
const navigateToBreadcrumb = (index: number) => {
if (index === -1) {
// Root directory
loadFiles("");
} else {
const path = breadcrumbs.slice(0, index + 1).join('/');
loadFiles(path);
}
};
// Handle select button click
const handleSelectFile = () => {
if (selectedFile && fileContent && onSelectFile) {
onSelectFile(selectedFile, fileContent);
setIsOpen(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger || <Button variant="outline">Browse Files</Button>}
</DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Sandbox Files</DialogTitle>
</DialogHeader>
{/* Breadcrumbs */}
<div className="flex items-center space-x-1 text-sm py-2 border-b">
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={() => navigateToBreadcrumb(-1)}
>
<Folder className="h-4 w-4 mr-1" />
root
</Button>
{breadcrumbs.map((part, index) => (
<div key={index} className="flex items-center">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={() => navigateToBreadcrumb(index)}
>
{part}
</Button>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-4 flex-1 overflow-hidden">
{/* File list */}
<div className="border rounded-md overflow-y-auto h-[400px]">
{isLoading && !files.length ? (
<div className="p-4 space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Coffee className="h-8 w-8 mb-2" />
<p>No files found</p>
</div>
) : (
<div className="p-2">
{currentPath !== "" && (
<Button
variant="ghost"
className="w-full justify-start text-sm mb-1"
onClick={() => {
const parentPath = currentPath.split('/').slice(0, -1).join('/');
loadFiles(parentPath);
}}
>
<ChevronUp className="h-4 w-4 mr-2" />
..
</Button>
)}
{files.map((file) => (
<Button
key={file.path}
variant={selectedFile === file.path ? "secondary" : "ghost"}
className="w-full justify-start text-sm mb-1"
onClick={() => handleItemClick(file)}
>
{file.is_dir ? (
<Folder className="h-4 w-4 mr-2" />
) : (
<FileText className="h-4 w-4 mr-2" />
)}
{file.name}
</Button>
))}
</div>
)}
</div>
{/* File preview */}
<div className="border rounded-md overflow-hidden flex flex-col">
<div className="p-2 bg-muted text-sm font-medium border-b">
{selectedFile ? selectedFile.split('/').pop() : "File Preview"}
</div>
<div className="p-2 overflow-y-auto flex-1 h-[360px]">
{isLoading && selectedFile ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : fileContent ? (
<pre className="text-xs whitespace-pre-wrap">{fileContent}</pre>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<File className="h-8 w-8 mb-2" />
<p>Select a file to preview</p>
</div>
)}
</div>
</div>
</div>
{selectedFile && fileContent && onSelectFile && (
<div className="flex justify-end pt-2">
<Button onClick={handleSelectFile}>Select File</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,524 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
File,
Folder,
FolderOpen,
Upload,
Download,
ChevronRight,
Home,
ArrowLeft
} from "lucide-react";
import { listSandboxFiles, getSandboxFileContent, type FileInfo } from "@/lib/api";
import { toast } from "sonner";
import { createClient } from "@/lib/supabase/client";
// Define API_URL
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
interface FileViewerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sandboxId: string;
}
export function FileViewerModal({
open,
onOpenChange,
sandboxId
}: FileViewerModalProps) {
const [workspaceFiles, setWorkspaceFiles] = useState<FileInfo[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [binaryFileUrl, setBinaryFileUrl] = useState<string | null>(null);
const [fileType, setFileType] = useState<'text' | 'image' | 'pdf' | 'binary'>('text');
const [isLoadingContent, setIsLoadingContent] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Navigation state
const [currentPath, setCurrentPath] = useState<string>("/workspace");
const [pathHistory, setPathHistory] = useState<string[]>(["/workspace"]);
const [historyIndex, setHistoryIndex] = useState<number>(0);
// Load files when the modal opens or sandbox ID changes
useEffect(() => {
if (open && sandboxId) {
loadFilesAtPath(currentPath);
}
}, [open, sandboxId, currentPath]);
// Function to load files from a specific path
const loadFilesAtPath = async (path: string) => {
if (!sandboxId) return;
setIsLoadingFiles(true);
try {
const files = await listSandboxFiles(sandboxId, path);
setWorkspaceFiles(files);
} catch (error) {
console.error(`Failed to load files at ${path}:`, error);
toast.error("Failed to load files");
} finally {
setIsLoadingFiles(false);
}
};
// Navigate to a folder
const navigateToFolder = (folderPath: string) => {
// Update current path
setCurrentPath(folderPath);
// Add to navigation history, discarding any forward history if we're not at the end
if (historyIndex < pathHistory.length - 1) {
setPathHistory(prevHistory => [...prevHistory.slice(0, historyIndex + 1), folderPath]);
setHistoryIndex(historyIndex + 1);
} else {
setPathHistory(prevHistory => [...prevHistory, folderPath]);
setHistoryIndex(pathHistory.length);
}
// Reset file selection and content
setSelectedFile(null);
setFileContent(null);
setBinaryFileUrl(null);
};
// Go back in history
const goBack = () => {
if (historyIndex > 0) {
setHistoryIndex(historyIndex - 1);
setCurrentPath(pathHistory[historyIndex - 1]);
}
};
// Go to home directory
const goHome = () => {
setCurrentPath("/workspace");
// Reset file selection and content
setSelectedFile(null);
setFileContent(null);
setBinaryFileUrl(null);
};
// Determine file type based on extension
const getFileType = (filename: string): 'text' | 'image' | 'pdf' | 'binary' => {
const extension = filename.split('.').pop()?.toLowerCase() || '';
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
if (imageExtensions.includes(extension)) {
return 'image';
}
if (extension === 'pdf') {
return 'pdf';
}
const textExtensions = [
'txt', 'md', 'js', 'jsx', 'ts', 'tsx', 'html', 'css', 'json', 'py',
'java', 'c', 'cpp', 'h', 'cs', 'php', 'rb', 'go', 'rs', 'sh', 'yml',
'yaml', 'toml', 'xml', 'csv', 'sql'
];
if (textExtensions.includes(extension)) {
return 'text';
}
return 'binary';
};
// Handle file or folder click
const handleFileClick = async (file: FileInfo) => {
if (file.is_dir) {
// If it's a directory, navigate to it
navigateToFolder(file.path);
return;
}
// Otherwise handle as regular file
setSelectedFile(file.path);
setIsLoadingContent(true);
setFileContent(null);
setBinaryFileUrl(null);
try {
// Determine file type based on extension
const fileType = getFileType(file.path);
setFileType(fileType);
const content = await getSandboxFileContent(sandboxId, file.path);
// Force certain file types to be treated as text
if (fileType === 'text') {
// For text files (including markdown), always try to render as text
if (typeof content === 'string') {
setFileContent(content);
} else if (content instanceof Blob) {
// If we got a Blob for a text file, convert it to text
const text = await content.text();
setFileContent(text);
}
} else if (fileType === 'image' || fileType === 'pdf') {
// For images and PDFs, create a blob URL to render them
if (content instanceof Blob) {
const url = URL.createObjectURL(content);
setBinaryFileUrl(url);
} else if (typeof content === 'string') {
try {
// For base64 content or binary text, create a blob
const blob = new Blob([content]);
const url = URL.createObjectURL(blob);
setBinaryFileUrl(url);
} catch (e) {
console.error("Failed to create blob URL:", e);
setFileType('text');
setFileContent(content);
}
}
} else {
// For other binary files
if (content instanceof Blob) {
const url = URL.createObjectURL(content);
setBinaryFileUrl(url);
} else if (typeof content === 'string') {
setFileContent("[Binary file]");
try {
const blob = new Blob([content]);
const url = URL.createObjectURL(blob);
setBinaryFileUrl(url);
} catch (e) {
console.error("Failed to create blob URL:", e);
setFileContent(content);
}
}
}
} catch (error) {
console.error("Failed to load file content:", error);
toast.error("Failed to load file content");
setFileContent(null);
setBinaryFileUrl(null);
} finally {
setIsLoadingContent(false);
}
};
// Clean up blob URLs on unmount or when they're no longer needed
useEffect(() => {
return () => {
if (binaryFileUrl) {
URL.revokeObjectURL(binaryFileUrl);
}
};
}, [binaryFileUrl]);
// Handle file upload
const handleFileUpload = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Process the file upload - upload to current directory
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
try {
setIsLoadingFiles(true);
const file = event.target.files[0];
if (file.size > 50 * 1024 * 1024) { // 50MB limit
toast.error("File size exceeds 50MB limit");
return;
}
// Create a FormData object
const formData = new FormData();
formData.append('file', file);
formData.append('path', `${currentPath}/${file.name}`);
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
// Upload using FormData - no need for any encoding/decoding
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
// Important: Do NOT set Content-Type header here, let the browser set it with the boundary
},
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
toast.success(`File uploaded: ${file.name}`);
// Refresh file list for current path
loadFilesAtPath(currentPath);
} catch (error) {
console.error("File upload failed:", error);
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
} finally {
setIsLoadingFiles(false);
// Reset the input
event.target.value = '';
}
};
// Render breadcrumb navigation
const renderBreadcrumbs = () => {
if (currentPath === "/workspace") {
return (
<div className="text-sm font-medium">/workspace</div>
);
}
const parts = currentPath.split('/').filter(Boolean);
const isInWorkspace = parts[0] === 'workspace';
const pathParts = isInWorkspace ? parts.slice(1) : parts;
return (
<div className="flex items-center overflow-x-auto whitespace-nowrap py-1 text-sm">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={goHome}
>
<Home className="h-3 w-3 mr-1" />
workspace
</Button>
{pathParts.map((part, index) => {
// Build the path up to this part
const pathUpToHere = isInWorkspace
? `/workspace/${pathParts.slice(0, index + 1).join('/')}`
: `/${pathParts.slice(0, index + 1).join('/')}`;
return (
<div key={index} className="flex items-center">
<ChevronRight className="h-3 w-3 mx-1 text-muted-foreground" />
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => navigateToFolder(pathUpToHere)}
>
{part}
</Button>
</div>
);
})}
</div>
);
};
// Render file content based on type
const renderFileContent = () => {
if (isLoadingContent) {
return (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
);
}
if (!selectedFile) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<File className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm">Select a file to view its contents</p>
</div>
);
}
if (fileType === 'text' && fileContent) {
return (
<div className="flex flex-col h-full">
<pre className="text-xs font-mono whitespace-pre-wrap break-words overflow-auto flex-1 max-h-full">
{fileContent}
</pre>
</div>
);
}
if (fileType === 'image' && binaryFileUrl) {
return (
<div className="flex flex-col items-center justify-center h-full">
<img
src={binaryFileUrl}
alt={selectedFile}
className="max-w-full max-h-full object-contain"
/>
</div>
);
}
if (fileType === 'pdf' && binaryFileUrl) {
return (
<iframe
src={binaryFileUrl}
className="w-full h-full border-0"
title={selectedFile}
/>
);
}
if (binaryFileUrl) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<File className="h-16 w-16 opacity-50" />
<p className="text-sm text-center">This is a binary file and cannot be previewed</p>
<a
href={binaryFileUrl}
download={selectedFile.split('/').pop()}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium"
>
<Download className="h-4 w-4" />
Download File
</a>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<File className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm">No preview available</p>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[900px] h-[80vh] max-h-[700px] flex flex-col p-0">
<DialogHeader className="p-4 border-b">
<DialogTitle>Workspace Files</DialogTitle>
</DialogHeader>
<div className="flex flex-col sm:flex-row h-full overflow-hidden">
{/* File browser sidebar */}
<div className="w-full sm:w-64 border-r flex flex-col h-full">
<div className="p-2 flex items-center justify-between border-b">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={goBack}
disabled={historyIndex === 0}
title="Go back"
>
<ArrowLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={goHome}
title="Home directory"
>
<Home className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleFileUpload}
title="Upload file"
>
<Upload className="h-3.5 w-3.5" />
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={processFileUpload}
/>
</div>
</div>
<div className="px-2 py-1 border-b">
{renderBreadcrumbs()}
</div>
<div className="flex-1 overflow-y-auto">
{isLoadingFiles ? (
<div className="p-4 space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
) : workspaceFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-4">
<Folder className="h-8 w-8 mb-2 opacity-50" />
<p className="text-xs text-center">This folder is empty</p>
</div>
) : (
<div className="p-1">
{workspaceFiles.map((file) => (
<Button
key={file.path}
variant={selectedFile === file.path ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start h-8 mb-1 text-xs"
onClick={() => handleFileClick(file)}
>
{file.is_dir ? (
<Folder className="h-3.5 w-3.5 mr-2 flex-shrink-0" />
) : (
<File className="h-3.5 w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{file.name}</span>
</Button>
))}
</div>
)}
</div>
</div>
{/* File content pane */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between border-b p-2">
<h3 className="text-sm font-medium truncate">
{selectedFile ? selectedFile.split('/').pop() : 'Select a file to view'}
</h3>
{selectedFile && binaryFileUrl && (
<div className="flex gap-1">
<a
href={binaryFileUrl}
download={selectedFile.split('/').pop()}
className="inline-flex items-center justify-center h-8 w-8 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
title="Download file"
>
<Download className="h-4 w-4" />
</a>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-4 bg-muted/30">
{renderFileContent()}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,416 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Maximize2, Terminal, FileText } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ParsedTag, SUPPORTED_XML_TAGS } from "@/lib/types/tool-calls";
import { motion } from "motion/react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
interface MessageDisplayProps {
content: string;
role: 'user' | 'assistant' | 'tool';
isStreaming?: boolean;
showIdentifier?: boolean;
isPartOfChain?: boolean;
}
// Full implementation of XML tag parsing
function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTags: Record<string, ParsedTag> } {
const parts: (string | ParsedTag)[] = [];
const openTags: Record<string, ParsedTag> = {};
const tagStack: Array<{tagName: string, position: number}> = [];
// Find all opening and closing tags
let currentPosition = 0;
// Match opening tags with attributes like <tag-name attr="value">
const openingTagRegex = new RegExp(`<(${SUPPORTED_XML_TAGS.join('|')})\\s*([^>]*)>`, 'g');
// Match closing tags like </tag-name>
const closingTagRegex = new RegExp(`</(${SUPPORTED_XML_TAGS.join('|')})>`, 'g');
let match: RegExpExecArray | null;
let matches: { regex: RegExp, match: RegExpExecArray, isOpening: boolean, position: number }[] = [];
// Find all opening tags
while ((match = openingTagRegex.exec(content)) !== null) {
matches.push({
regex: openingTagRegex,
match,
isOpening: true,
position: match.index
});
}
// Find all closing tags
while ((match = closingTagRegex.exec(content)) !== null) {
matches.push({
regex: closingTagRegex,
match,
isOpening: false,
position: match.index
});
}
// Sort matches by their position in the content
matches.sort((a, b) => a.position - b.position);
// Process matches in order
for (const { match, isOpening, position } of matches) {
const tagName = match[1];
const matchEnd = position + match[0].length;
// Add text before this tag if needed
if (position > currentPosition) {
parts.push(content.substring(currentPosition, position));
}
if (isOpening) {
// Parse attributes for opening tags
const attributesStr = match[2]?.trim();
const attributes: Record<string, string> = {};
if (attributesStr) {
// Match attributes in format: name="value" or name='value'
const attrRegex = /(\w+)=["']([^"']*)["']/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(attributesStr)) !== null) {
attributes[attrMatch[1]] = attrMatch[2];
}
}
// Create tag object with unique ID
const parsedTag: ParsedTag = {
tagName,
attributes,
content: '',
isClosing: false,
id: `${tagName}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
rawMatch: match[0]
};
// Add timestamp if not present
if (!parsedTag.timestamp) {
parsedTag.timestamp = Date.now();
}
// Push to parts and track in stack
parts.push(parsedTag);
tagStack.push({ tagName, position: parts.length - 1 });
openTags[tagName] = parsedTag;
} else {
// Handle closing tag
// Find the corresponding opening tag in the stack (last in, first out)
let foundOpeningTag = false;
for (let i = tagStack.length - 1; i >= 0; i--) {
if (tagStack[i].tagName === tagName) {
const openTagIndex = tagStack[i].position;
const openTag = parts[openTagIndex] as ParsedTag;
// Get content between this opening and closing tag pair
let tagContentStart = openTagIndex + 1;
let tagContentEnd = parts.length;
// Mark that we need to capture content between these positions
let contentToCapture = '';
// Collect all content parts between the opening and closing tags
for (let j = tagContentStart; j < tagContentEnd; j++) {
if (typeof parts[j] === 'string') {
contentToCapture += parts[j];
}
}
// Try getting content directly from original text (most reliable approach)
const openTagMatch = openTag.rawMatch || '';
const openTagPosition = content.indexOf(openTagMatch, Math.max(0, openTagIndex > 0 ? currentPosition - 200 : 0));
if (openTagPosition >= 0) {
const openTagEndPosition = openTagPosition + openTagMatch.length;
// Only use if the positions make sense
if (openTagEndPosition > 0 && position > openTagEndPosition) {
// Get content and clean up excessive whitespace but preserve formatting
let extractedContent = content.substring(openTagEndPosition, position);
// Trim leading newline if present
if (extractedContent.startsWith('\n')) {
extractedContent = extractedContent.substring(1);
}
// Trim trailing newline if present
if (extractedContent.endsWith('\n')) {
extractedContent = extractedContent.substring(0, extractedContent.length - 1);
}
contentToCapture = extractedContent;
}
}
// Update opening tag with collected content
openTag.content = contentToCapture;
openTag.isClosing = true;
// Remove all parts between the opening tag and this position
// because they're now captured in the tag's content
if (tagContentStart < tagContentEnd) {
parts.splice(tagContentStart, tagContentEnd - tagContentStart);
}
// Remove this tag from the stack
tagStack.splice(i, 1);
// Remove from openTags
delete openTags[tagName];
foundOpeningTag = true;
break;
}
}
// If no corresponding opening tag found, add closing tag as text
if (!foundOpeningTag) {
parts.push(match[0]);
}
}
currentPosition = matchEnd;
}
// Add any remaining text
if (currentPosition < content.length) {
parts.push(content.substring(currentPosition));
}
return { parts, openTags };
}
interface MessageContentProps {
content: string;
maxHeight?: number;
role: 'user' | 'assistant' | 'tool';
isPartOfChain?: boolean;
}
function ToolHeader({ icon: Icon, label }: { icon: any, label: string }) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{label}</span>
</div>
);
}
function CodeBlock({ content, filename }: { content: string, filename?: string }) {
return (
<div className="font-mono text-sm">
{filename && (
<div className="flex items-center justify-between px-4 py-2 border-b border-border/30 bg-background/50 rounded-t-xl">
<span className="text-xs text-muted-foreground font-medium">{filename}</span>
<div className="flex gap-1.5">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted/30 font-medium">Diff</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted/30 font-medium">Original</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">Modified</span>
</div>
</div>
)}
<div className="p-4 bg-muted/[0.02] rounded-b-xl">
<pre className="whitespace-pre-wrap text-[13px] leading-relaxed">{content}</pre>
</div>
</div>
);
}
export function MessageContent({ content, maxHeight = 300, role, isPartOfChain }: MessageContentProps) {
const { parts } = parseXMLTags(content);
const [sidebarOpen, setSidebarOpen] = useState(false);
const isTool = role === 'tool';
return (
<>
<div className={cn(
"w-full",
isPartOfChain && "pl-6 border-l border-border/20"
)}>
<div className={cn(
"w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 backdrop-blur-sm shadow-[0_0_15px_rgba(0,0,0,0.03)]",
isTool && "font-mono text-[13px]"
)}>
{parts.map((part, index) => {
if (typeof part === 'string') {
return (
<div key={index} className="p-4">
{part.split('\n').map((line, i) => (
<React.Fragment key={i}>
<span className="leading-relaxed">{line}</span>
{i < part.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
);
} else {
const isCreateFile = part.tagName === 'create-file';
const isExecuteCommand = part.tagName === 'execute-command';
return (
<div key={index}>
<div className="px-4 py-2 border-b border-border/30">
{isCreateFile && (
<ToolHeader
icon={FileText}
label={`Creating file ${part.attributes.file_path || ''}`}
/>
)}
{isExecuteCommand && (
<ToolHeader
icon={Terminal}
label="Executing command"
/>
)}
{!isCreateFile && !isExecuteCommand && (
<ToolHeader
icon={Terminal}
label={part.tagName}
/>
)}
</div>
<CodeBlock
content={part.content}
filename={isCreateFile ? part.attributes.file_path : undefined}
/>
</div>
);
}
})}
</div>
</div>
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="right" className="w-[600px] sm:w-[800px] bg-background">
<SheetHeader>
<SheetTitle className="text-left">
{isTool ? 'Command Output' : 'Message Content'}
</SheetTitle>
</SheetHeader>
<div className="mt-4 overflow-y-auto max-h-[calc(100vh-8rem)] px-2">
<CodeBlock content={content} />
</div>
</SheetContent>
</Sheet>
</>
);
}
export function MessageDisplay({ content, role, isStreaming = false, showIdentifier = false, isPartOfChain = false }: MessageDisplayProps) {
const isUser = role === 'user';
const { theme } = useTheme();
return (
<motion.div
className={cn(
"w-full px-4 py-3 flex flex-col",
isUser ? "bg-transparent" : "bg-muted/[0.03]",
isStreaming && "animate-pulse",
isPartOfChain && "pt-0"
)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
<div className="w-full max-w-5xl mx-auto relative">
{!isUser && showIdentifier && (
<motion.div
className="flex items-center gap-2 mb-2"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<div className="flex items-center bg-background rounded-full size-8 flex-shrink-0 justify-center shadow-[0_0_10px_rgba(0,0,0,0.05)] border border-border">
<Image
src="/kortix-symbol.svg"
alt="Suna"
width={16}
height={16}
className={theme === 'dark' ? 'invert' : ''}
/>
</div>
<span className="text-sm font-medium">Suna</span>
</motion.div>
)}
<MessageContent
content={content}
role={role}
isPartOfChain={isPartOfChain}
/>
</div>
</motion.div>
);
}
export function ThinkingIndicator() {
const { theme } = useTheme();
return (
<motion.div
className="w-full px-4 py-3 flex flex-col bg-muted/[0.03]"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
<div className="w-full max-w-5xl mx-auto">
<motion.div
className="flex items-center gap-2 mb-2"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<div className="flex items-center bg-background rounded-full size-8 flex-shrink-0 justify-center shadow-[0_0_10px_rgba(0,0,0,0.05)] border border-border">
<Image
src="/kortix-symbol.svg"
alt="Suna"
width={16}
height={16}
className={theme === 'dark' ? 'invert' : ''}
/>
</div>
<span className="text-sm font-medium">Suna</span>
</motion.div>
<div className="w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 backdrop-blur-sm shadow-[0_0_15px_rgba(0,0,0,0.03)]">
<div className="p-4">
<div className="flex gap-1.5">
{[0, 1, 2].map((index) => (
<motion.div
key={index}
className="w-2 h-2 bg-primary/40 rounded-full"
animate={{ y: [0, -5, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: index * 0.2,
ease: "easeInOut",
}}
/>
))}
</div>
</div>
</div>
</div>
</motion.div>
);
}
export function EmptyChat({ agentName = "AI assistant" }) {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center px-4">
<h3 className="text-base font-medium mb-1">What can I help you ship?</h3>
<p className="text-sm text-muted-foreground max-w-[300px]">
Send a message to start talking with Suna
</p>
</div>
</div>
);
}

View File

@ -1,109 +0,0 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { Message } from "@/lib/api";
import { MessageDisplay, ThinkingIndicator, EmptyChat } from "@/components/thread/message-display";
import { motion, AnimatePresence } from "motion/react";
type MessageRole = 'user' | 'assistant' | 'tool';
interface MessageListProps {
messages: Message[];
streamContent?: string;
isStreaming?: boolean;
isAgentRunning?: boolean;
agentName?: string;
}
export function MessageList({
messages,
streamContent = "",
isStreaming = false,
isAgentRunning = false,
agentName = "Suna"
}: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streamContent]);
// Filter out invalid messages
const filteredMessages = messages.filter(message =>
message &&
message.content &&
message.role &&
(message.role === 'user' || message.role === 'assistant' || message.role === 'tool')
);
// If no messages and not streaming, show empty state
if (filteredMessages.length === 0 && !streamContent && !isAgentRunning) {
return <EmptyChat agentName={agentName} />;
}
return (
<div className="flex flex-col flex-1 overflow-y-auto overflow-x-hidden bg-background">
<div className="w-full">
<AnimatePresence initial={false}>
{filteredMessages.map((message, index) => {
const prevMessage = index > 0 ? filteredMessages[index - 1] : null;
const nextMessage = index < filteredMessages.length - 1 ? filteredMessages[index + 1] : null;
// Show identifier if this is an AI/tool message that follows a user message
const showIdentifier = message.role !== 'user' &&
(!prevMessage || prevMessage.role === 'user');
// Part of a chain if:
// 1. Current message is tool/assistant AND
// 2. Previous message was also tool/assistant
const isPartOfChain = message.role !== 'user' &&
prevMessage?.role !== 'user';
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.2,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.05
}}
>
<MessageDisplay
content={message.content}
role={message.role as MessageRole}
showIdentifier={showIdentifier}
isPartOfChain={isPartOfChain}
/>
</motion.div>
);
})}
</AnimatePresence>
{streamContent && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<MessageDisplay
content={streamContent}
role="assistant"
isStreaming={isStreaming}
showIdentifier={filteredMessages.length === 0 || filteredMessages[filteredMessages.length - 1].role === 'user'}
isPartOfChain={filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1].role !== 'user'}
/>
</motion.div>
)}
{isAgentRunning && !streamContent && (
<ThinkingIndicator />
)}
<div ref={messagesEndRef} className="h-24" />
</div>
</div>
);
}

View File

@ -1,306 +0,0 @@
'use client';
import React from 'react';
import { useToolsPanel } from '@/hooks/use-tools-panel';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ChevronLeft, ChevronRight, Grid3X3, Maximize2, Minimize2, PanelRightClose, Terminal, FileText, Search, Globe, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getComponentForTag } from '@/components/thread/tool-components';
import { ParsedTag } from '@/lib/types/tool-calls';
import { motion, AnimatePresence } from "framer-motion";
import { Badge } from '@/components/ui/badge';
interface ToolCallsSidebarProps {
className?: string;
}
interface CombinedToolCall {
toolCall: ParsedTag; // From assistant message
toolResult: ParsedTag | null; // From tool message
id: string;
timestamp: number;
}
const ToolIcon = ({ type }: { type: string }) => {
if (type.includes('file')) return <FileText className="h-3.5 w-3.5" />;
if (type.includes('command')) return <Terminal className="h-3.5 w-3.5" />;
if (type.includes('search')) return <Search className="h-3.5 w-3.5" />;
if (type.includes('browser')) return <Globe className="h-3.5 w-3.5" />;
return <Terminal className="h-3.5 w-3.5" />;
};
const ToolStatus = ({ status }: { status: string }) => {
return (
<Badge variant={status === 'completed' ? 'default' : status === 'running' ? 'secondary' : 'destructive'} className="h-5">
{status === 'running' && (
<div className="mr-1 h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
)}
{status === 'completed' ? 'Done' : status === 'running' ? 'Running' : 'Failed'}
</Badge>
);
};
export function ToolCallsSidebar({ className }: ToolCallsSidebarProps) {
const {
toolCalls,
showPanel,
setShowPanel,
currentToolIndex,
setCurrentToolIndex,
nextTool,
prevTool,
} = useToolsPanel();
const [isExpanded, setIsExpanded] = React.useState(false);
const [viewMode, setViewMode] = React.useState<'single' | 'grid'>('single');
// Combine tool calls with their results
const combinedToolCalls = React.useMemo(() => {
const combined: CombinedToolCall[] = [];
const processedIds = new Set<string>();
toolCalls.forEach((tag, index) => {
// Skip if we've already processed this tag
if (processedIds.has(tag.id)) return;
// Look for matching result in subsequent tags
// A result is a tag with the same name in a tool message
const nextTags = toolCalls.slice(index + 1);
const matchingResult = nextTags.find(nextTag =>
nextTag.tagName === tag.tagName &&
!processedIds.has(nextTag.id) &&
// Match attributes if they exist
(!tag.attributes || Object.entries(tag.attributes).every(([key, value]) =>
nextTag.attributes?.[key] === value
))
);
if (matchingResult) {
processedIds.add(matchingResult.id);
}
combined.push({
toolCall: tag,
toolResult: matchingResult || null,
id: tag.id,
timestamp: tag.timestamp || Date.now()
});
processedIds.add(tag.id);
});
return combined;
}, [toolCalls]);
// No need to render if there are no tool calls
if (combinedToolCalls.length === 0) {
return null;
}
const toggleViewMode = () => {
setViewMode(viewMode === 'single' ? 'grid' : 'single');
};
const renderToolCall = (combined: CombinedToolCall, mode: 'compact' | 'detailed') => {
const { toolCall, toolResult } = combined;
const isCompleted = !!toolResult;
if (mode === 'compact') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<ToolIcon type={toolCall.tagName} />
<span className="text-sm font-medium">{toolCall.tagName}</span>
<div className="flex-1" />
<ToolStatus status={isCompleted ? 'completed' : 'running'} />
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
{toolCall.content}
</div>
{toolResult && (
<>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<ArrowRight className="h-3 w-3" />
<span>Result</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">
{toolResult.content}
</div>
</>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card text-card-foreground">
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
<ToolIcon type={toolCall.tagName} />
<span className="text-sm font-medium">{toolCall.tagName}</span>
</div>
<ToolStatus status={isCompleted ? 'completed' : 'running'} />
</div>
<div className="p-4 text-sm font-mono">
{toolCall.content}
</div>
</div>
{toolResult && (
<div className="rounded-lg border bg-card text-card-foreground">
<div className="flex items-center gap-2 border-b px-4 py-2">
<ArrowRight className="h-3.5 w-3.5" />
<span className="text-sm font-medium">Result</span>
</div>
<div className="p-4 text-sm font-mono">
{toolResult.content}
</div>
</div>
)}
</div>
);
};
return (
<AnimatePresence>
{showPanel && (
<motion.aside
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={cn(
"fixed right-0 top-0 z-30 flex h-screen flex-col border-l bg-background/80 backdrop-blur-md",
isExpanded ? "w-[600px]" : "w-[400px]",
className
)}
>
{/* Toggle button */}
<Button
variant="ghost"
size="icon"
className="absolute -left-10 top-4 h-8 w-8 rounded-l-md border-y border-l bg-background/80 backdrop-blur-md shadow-sm hover:bg-background/90 transition-colors"
onClick={() => setShowPanel(!showPanel)}
>
<PanelRightClose className={cn(
"h-4 w-4 transition-transform duration-200",
!showPanel && "rotate-180"
)} />
</Button>
{/* Header */}
<div className="flex items-center justify-between border-b bg-background/50 px-4 py-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-medium">Tool Calls</h3>
<Badge variant="outline" className="h-5">
{currentToolIndex + 1}/{combinedToolCalls.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={toggleViewMode}
title={viewMode === 'single' ? 'Switch to grid view' : 'Switch to single view'}
>
<Grid3X3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
>
{isExpanded ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-4">
{viewMode === 'single' ? (
<div className="space-y-4">
{renderToolCall(combinedToolCalls[currentToolIndex], 'detailed')}
{/* Navigation */}
{combinedToolCalls.length > 1 && (
<div className="flex items-center gap-2 mt-6">
<Button
variant="outline"
size="sm"
onClick={prevTool}
disabled={currentToolIndex === 0}
className="flex-1"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={nextTool}
disabled={currentToolIndex === combinedToolCalls.length - 1}
className="flex-1"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{combinedToolCalls.map((toolCall, index) => (
<motion.div
key={toolCall.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className={cn(
"cursor-pointer transition-all border rounded-lg p-3 hover:shadow-sm",
index === currentToolIndex
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40 hover:bg-muted/40"
)}
onClick={() => {
setCurrentToolIndex(index);
setViewMode('single');
}}
>
{renderToolCall(toolCall, 'compact')}
</motion.div>
))}
</div>
)}
</div>
</ScrollArea>
{/* Progress bar */}
{viewMode === 'single' && combinedToolCalls.length > 1 && (
<div className="px-4 py-3 border-t bg-background/50">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={combinedToolCalls.length - 1}
value={currentToolIndex}
onChange={(e) => setCurrentToolIndex(parseInt(e.target.value))}
className="flex-1"
/>
</div>
</div>
)}
</motion.aside>
)}
</AnimatePresence>
);
}

View File

@ -1,656 +0,0 @@
'use client';
import React from 'react';
import { ParsedTag, ToolComponentProps } from '@/lib/types/tool-calls';
import {
File, FileText, Terminal, FolderPlus, Folder, Code, Search as SearchIcon,
Bell, Replace, Plus, Minus, Globe, Search
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { diffLines } from 'diff';
// Shared compact mode component
const CompactToolDisplay: React.FC<{
icon: React.ReactNode,
name: string,
input: string,
isRunning?: boolean
}> = ({ icon, name, input, isRunning }) => {
return (
<div className="flex items-center gap-2 px-2 py-1 text-xs bg-muted/30 rounded">
{icon}
<span className="font-medium">{name}: {input}</span>
{isRunning && (
<div className="flex items-center gap-1">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
);
};
/**
* Create File Tool Component
*/
export const CreateFileTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const filePath = tag.attributes.file_path || '';
const fileContent = tag.content || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<FileText className="h-4 w-4 mr-2" />}
name={isRunning ? "Creating file" : "Created file"}
input={filePath}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<FileText className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? "Creating file" : "Created file"}: {filePath}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<FileText className="h-3 w-3" />
<span className="font-mono">{filePath}</span>
</div>
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
{fileContent}
</pre>
</div>
</div>
</div>
);
};
/**
* Full File Rewrite Tool Component
*/
export const FullFileRewriteTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const filePath = tag.attributes.file_path || '';
const fileContent = tag.content || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<FileText className="h-4 w-4 mr-2" />}
name={isRunning ? "Rewriting file" : "Rewrote file"}
input={filePath}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<FileText className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? "Rewriting file" : "Rewrote file"}: {filePath}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<FileText className="h-3 w-3" />
<span className="font-mono">{filePath}</span>
</div>
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
{fileContent}
</pre>
</div>
</div>
</div>
);
};
/**
* Read File Tool Component
*/
export const ReadFileTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const filePath = tag.attributes.file_path || '';
const fileContent = tag.content || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<File className="h-4 w-4 mr-2" />}
name={isRunning ? "Reading file" : "Read file"}
input={filePath}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<File className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? "Reading file" : "Read file"}: {filePath}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<File className="h-3 w-3" />
<span className="font-mono">{filePath}</span>
</div>
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
{fileContent}
</pre>
</div>
</div>
</div>
);
};
/**
* Execute Command Tool Component
*/
export const ExecuteCommandTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const output = tag.content || '';
const command = tag.resultTag?.content || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<Terminal className="h-4 w-4 mr-2" />}
name={isRunning ? "Executing" : "Executed"}
input={tag.content} // in compact mode, the command is in the content
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<Terminal className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? `Executing: ${command}` : `Executed: ${command}`}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="font-mono text-xs bg-black text-green-400 p-3 rounded-md">
<div className="flex items-center gap-2 mb-2 text-white/80">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
<div className="flex-1 text-center text-xs">Terminal</div>
</div>
<div className="space-y-1">
<div className="flex items-start gap-2">
<span className="text-blue-400">user@localhost</span>
<span className="text-white/60">:</span>
<span className="text-purple-400">~</span>
<span className="text-white/60">$</span>
<span>{command}</span>
</div>
{output && (
<pre className="whitespace-pre-wrap pl-4 text-white/90">{output}</pre>
)}
{!isRunning && (
<div className="flex items-start gap-2">
<span className="text-blue-400">user@localhost</span>
<span className="text-white/60">:</span>
<span className="text-purple-400">~</span>
<span className="text-white/60">$</span>
<span className="animate-pulse"></span>
</div>
)}
</div>
</div>
</div>
</div>
);
};
/**
* String Replace Tool Component
*/
export const StringReplaceTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const content = tag.content || '';
const isRunning = tag.status === 'running';
// Parse the old and new strings from the content
const oldStrMatch = content.match(/<old_str>([\s\S]*?)<\/old_str>/);
const newStrMatch = content.match(/<new_str>([\s\S]*?)<\/new_str>/);
const oldStr = oldStrMatch ? oldStrMatch[1] : '';
const newStr = newStrMatch ? newStrMatch[1] : '';
// Calculate the diff between old and new strings
const diff = diffLines(oldStr, newStr);
interface DiffPart {
added?: boolean;
removed?: boolean;
value: string;
}
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<Replace className="h-4 w-4 mr-2" />}
name={isRunning ? "Updating file" : "Updated file"}
input=""
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<Replace className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? "Updating file" : "Updated file"}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="border border-subtle dark:border-white/10 rounded-md overflow-hidden font-mono text-xs">
{diff.map((part: DiffPart, index: number) => (
<div
key={index}
className={cn(
"px-2 py-0.5 flex items-start",
part.added ? "bg-green-500/10 text-green-700 dark:text-green-400" :
part.removed ? "bg-red-500/10 text-red-700 dark:text-red-400" :
"text-foreground"
)}
>
<span className="mr-2">
{part.added ? <Plus className="h-3 w-3" /> :
part.removed ? <Minus className="h-3 w-3" /> :
<span className="w-3" />}
</span>
<pre className="whitespace-pre-wrap flex-1">{part.value}</pre>
</div>
))}
</div>
</div>
</div>
</div>
);
};
/**
* Notification Tool Component
*/
export const NotifyTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const message = tag.attributes.message || '';
const type = tag.attributes.type || 'info';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<Bell className="h-4 w-4 mr-2" />}
name={isRunning ? "Sending notification" : "Sent notification"}
input={message.substring(0, 30) + (message.length > 30 ? '...' : '')}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<Bell className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? "Sending notification" : "Sent notification"}: {message.substring(0, 30)}{message.length > 30 ? '...' : ''}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className={cn(
"p-2 rounded-md",
type === 'error' ? 'bg-red-500/10 text-red-600 dark:text-red-400' :
type === 'warning' ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400' :
type === 'success' ? 'bg-green-500/10 text-green-600 dark:text-green-400' :
'bg-blue-500/10 text-blue-600 dark:text-blue-400'
)}>
{message}
</div>
</div>
</div>
);
};
/**
* Directory Tool Component (for create-directory and list-directory)
*/
export const DirectoryTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const path = tag.attributes.path || '';
const content = tag.content || '';
const isListDirectory = tag.tagName === 'list-directory';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={isListDirectory ? <Folder className="h-4 w-4 mr-2" /> : <FolderPlus className="h-4 w-4 mr-2" />}
name={isListDirectory ?
(isRunning ? "Listing directory" : "Listed directory") :
(isRunning ? "Creating directory" : "Created directory")}
input={path}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
{isListDirectory ? <Folder className="h-4 w-4 mr-2" /> : <FolderPlus className="h-4 w-4 mr-2" />}
<div className="flex-1">
{isListDirectory ?
(isRunning ? `Listing directory: ${path}` : `Listed directory: ${path}`) :
(isRunning ? `Creating directory: ${path}` : `Created directory: ${path}`)}
</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Folder className="h-3 w-3" />
<span className="font-mono">{path}</span>
</div>
{isListDirectory && content && (
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
{content}
</pre>
)}
</div>
</div>
</div>
);
};
/**
* Search Code Tool Component
*/
export const SearchCodeTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const query = tag.attributes.query || '';
const content = tag.content || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<SearchIcon className="h-4 w-4 mr-2" />}
name={isRunning ? "Searching code" : "Searched code"}
input={query}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<SearchIcon className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? "Searching code" : "Searched code"}: {query}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs bg-primary/10 p-1 rounded">
<SearchIcon className="h-3 w-3" />
<span className="font-mono">{query}</span>
</div>
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
{content}
</pre>
</div>
</div>
</div>
);
};
/**
* Browser Navigate Tool Component
*/
export const BrowserNavigateTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const url = tag.content || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<Globe className="h-4 w-4 mr-2" />}
name={isRunning ? "Navigating to" : "Navigated to"}
input={url}
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<Globe className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? `Navigating to` : `Navigated to`}: {url}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Globe className="h-3 w-3" />
<span className="font-mono">{url}</span>
</div>
{/* Display VNC preview if available */}
{tag.vncPreview && (
<div className="mt-2 border border-subtle dark:border-white/10 rounded-md overflow-hidden">
<div className="text-xs bg-black text-white p-1">VNC Preview</div>
<div className="relative w-full h-[300px] overflow-hidden">
<iframe
src={tag.vncPreview}
title="Browser preview"
className="absolute top-0 left-0 border-0"
style={{
width: '200%',
height: '200%',
transform: 'scale(0.5)',
transformOrigin: '0 0'
}}
sandbox="allow-same-origin allow-scripts"
/>
</div>
</div>
)}
</div>
</div>
</div>
);
};
/**
* Web Search Tool Component
*/
export const WebSearchTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
const query = tag.attributes.query || '';
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<Search className="h-4 w-4 mr-2" />}
name={isRunning ? "Web search in progress..." : "Web search complete"}
input={query}
isRunning={isRunning}
/>
);
}
const results = tag.result?.output ? JSON.parse(tag.result.output) : [];
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<Search className="h-4 w-4 mr-2" />
<div className="flex-1">Web Search: {query}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Searching</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
{results.length > 0 ? (
<div className="space-y-3">
{results.map((result: any, index: number) => (
<div key={index} className="text-sm">
<a href={result.URL} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 hover:underline">
{result.Title}
</a>
<div className="text-xs text-muted-foreground mt-1">
{result.URL}
{result['Published Date'] && (
<span className="ml-2">
({new Date(result['Published Date']).toLocaleDateString()})
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">No results found</div>
)}
</div>
</div>
);
};
// Tool component registry
export const ToolComponentRegistry: Record<string, React.FC<ToolComponentProps>> = {
'create-file': CreateFileTool,
'read-file': ReadFileTool,
'execute-command': ExecuteCommandTool,
'str-replace': StringReplaceTool,
'create-directory': DirectoryTool,
'list-directory': DirectoryTool,
'search-code': SearchCodeTool,
'notify': NotifyTool,
'ask': NotifyTool, // Handle ask similar to notify for now
'complete': NotifyTool, // Handle complete similar to notify for now
'full-file-rewrite': FullFileRewriteTool,
'browser-navigate-to': BrowserNavigateTool,
'browser-click-element': BrowserNavigateTool,
'browser-input-text': BrowserNavigateTool,
'browser-go-back': BrowserNavigateTool,
'browser-wait': BrowserNavigateTool,
'browser-scroll-down': BrowserNavigateTool,
'browser-scroll-up': BrowserNavigateTool,
'browser-scroll-to-text': BrowserNavigateTool,
'browser-switch-tab': BrowserNavigateTool,
'browser-close-tab': BrowserNavigateTool,
'browser-get-dropdown-options': BrowserNavigateTool,
'browser-select-dropdown-option': BrowserNavigateTool,
'browser-drag-drop': BrowserNavigateTool,
'web-search': WebSearchTool,
};
// Helper function to get the appropriate component for a tag
export function getComponentForTag(tag: ParsedTag): React.FC<ToolComponentProps> {
console.log("getComponentForTag", tag);
if (!tag || !tag?.tagName) {
console.warn(`No tag name for tag: ${tag}`);
}
if (!ToolComponentRegistry[tag.tagName]) {
console.warn(`No component registered for tag type: ${tag.tagName}`);
}
return ToolComponentRegistry[tag.tagName] ||
// Fallback component for unknown tag types
(({tag, mode}) => {
console.log(`Rendering fallback component for tag: ${tag.tagName}`, tag);
const isRunning = tag.status === 'running';
if (mode === 'compact') {
return (
<CompactToolDisplay
icon={<Code className="h-4 w-4 mr-2" />}
name={isRunning ? `${tag.tagName} operation running` : `${tag.tagName} operation complete`}
input=""
isRunning={isRunning}
/>
);
}
return (
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
<Code className="h-4 w-4 mr-2" />
<div className="flex-1">{isRunning ? `${tag.tagName} operation running` : `${tag.tagName} operation complete`}</div>
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-amber-500">Running</span>
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
</div>
)}
</div>
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs">{tag.content || ''}</pre>
</div>
</div>
);
});
}

View File

@ -1,177 +0,0 @@
'use client';
import { useEffect, useState, useContext } from 'react';
import { ToolCallsContext } from '@/app/providers';
import { ParsedTag, ToolDisplayMode } from '@/lib/types/tool-calls';
import { Grid3X3 } from 'lucide-react';
import { getComponentForTag } from '@/components/thread/tool-components';
export function useToolsPanel() {
const { toolCalls, setToolCalls } = useContext(ToolCallsContext);
const [showPanel, setShowPanel] = useState(false);
const [currentToolIndex, setCurrentToolIndex] = useState(0);
const [viewMode, setViewMode] = useState<'single' | 'grid'>('single');
// Update panel visibility when tool calls change
useEffect(() => {
if (toolCalls.length > 0 && !showPanel) {
setShowPanel(true);
}
}, [toolCalls, showPanel]);
// Reset current tool index when tools change
useEffect(() => {
if (toolCalls.length > 0) {
console.log(`[TOOLS PANEL] Has ${toolCalls.length} tools to display:`,
toolCalls.map(t => `${t.tagName}${t.isClosing ? '(completed)' : '(running)'}`).join(', '));
setCurrentToolIndex(state => Math.min(state, toolCalls.length - 1));
} else {
setCurrentToolIndex(0);
}
}, [toolCalls]);
// Clear all tool calls
const clearToolCalls = () => {
setToolCalls([]);
setShowPanel(false);
};
// Navigate to next tool
const nextTool = () => {
if (toolCalls.length > 0) {
setCurrentToolIndex((currentToolIndex + 1) % toolCalls.length);
}
};
// Navigate to previous tool
const prevTool = () => {
if (toolCalls.length > 0) {
setCurrentToolIndex((currentToolIndex - 1 + toolCalls.length) % toolCalls.length);
}
};
// Toggle view mode between single and grid
const toggleViewMode = () => {
setViewMode(viewMode === 'single' ? 'grid' : 'single');
};
// Navigate to next/previous tool with keyboard support
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (viewMode === 'single' && toolCalls.length > 1) {
if (e.key === 'ArrowRight') {
nextTool();
} else if (e.key === 'ArrowLeft') {
prevTool();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [viewMode, toolCalls.length, nextTool, prevTool]);
// Render the tools panel content
const renderToolsPanel = () => {
if (!toolCalls || toolCalls.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-foreground/40">
<p className="text-sm">No tool calls yet</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Panel header */}
<div className="flex justify-between items-center border-b border-subtle p-3">
<h3 className="text-sm font-medium">Tools ({toolCalls.length})</h3>
<div className="flex items-center gap-2">
<button
onClick={toggleViewMode}
className="p-1 rounded hover:bg-background-hover"
title={viewMode === 'single' ? "Grid view" : "Single view"}
>
<Grid3X3 className="h-4 w-4 text-foreground/60" />
</button>
</div>
</div>
{/* Panel content */}
<div className="flex-grow overflow-y-auto p-3">
{viewMode === 'single' ? (
<>
{/* Single tool view */}
{toolCalls.length > 0 && (
<div className="h-full flex flex-col">
{renderToolComponent(toolCalls[currentToolIndex], 'detailed')}
</div>
)}
</>
) : (
<>
{/* Grid view */}
<div className="grid grid-cols-1 gap-4">
{toolCalls.map((tool, index) => (
<div
key={tool.id || index}
className={`cursor-pointer transition-all border rounded-md p-2 ${
index === currentToolIndex
? 'border-primary shadow-sm'
: 'border-subtle hover:border-primary/40'
}`}
onClick={() => {
setCurrentToolIndex(index);
setViewMode('single');
}}
>
{renderToolComponent(tool, 'compact')}
</div>
))}
</div>
</>
)}
</div>
{/* Gallery navigation controls (only in single view with multiple tools) */}
{viewMode === 'single' && toolCalls.length > 1 && (
<div className="border-t border-subtle p-3">
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground text-center">
Tool {currentToolIndex + 1} of {toolCalls.length}
</div>
<input
type="range"
min={0}
max={toolCalls.length - 1}
value={currentToolIndex}
onChange={(e) => setCurrentToolIndex(parseInt(e.target.value))}
className="w-full"
/>
</div>
</div>
)}
</div>
);
};
// Render a tool component based on the tool type
const renderToolComponent = (tag: ParsedTag, mode: ToolDisplayMode) => {
// Get the specialized component from the registry
const ToolComponent = getComponentForTag(tag);
return <ToolComponent tag={tag} mode={mode} />;
};
return {
toolCalls,
setToolCalls,
showPanel,
setShowPanel,
renderToolsPanel,
clearToolCalls,
currentToolIndex,
setCurrentToolIndex,
nextTool,
prevTool,
};
}

View File

@ -575,7 +575,7 @@ export const streamAgent = (agentRunId: string, callbacks: {
if (!session?.access_token) {
console.error('[STREAM] No auth token available');
callbacks.onError(new Error('Authentication required'));
callbacks.onError('Authentication required');
callbacks.onClose();
return;
}
@ -595,50 +595,77 @@ export const streamAgent = (agentRunId: string, callbacks: {
const rawData = event.data;
if (rawData.includes('"type":"ping"')) return;
// Skip empty messages
if (!rawData || rawData.trim() === '') return;
// Log raw data for debugging
console.log(`[STREAM] Received data: ${rawData.substring(0, 100)}${rawData.length > 100 ? '...' : ''}`);
// Skip empty messages
if (!rawData || rawData.trim() === '') {
console.debug('[STREAM] Received empty message, skipping');
let jsonData;
try {
jsonData = JSON.parse(rawData);
} catch (parseError) {
console.error('[STREAM] Failed to parse message:', parseError);
return;
}
// Check if this is a status completion message
if (rawData.includes('"type":"status"') && rawData.includes('"status":"completed"')) {
console.log(`[STREAM] ⚠️ Detected completion status message: ${rawData}`);
// Handle stream errors and failures first
if (jsonData.status === 'error' || (jsonData.type === 'status' && jsonData.status === 'failed')) {
// Get a clean string version of any error message
const errorMessage = typeof jsonData.message === 'object'
? JSON.stringify(jsonData.message)
: String(jsonData.message || 'Stream failed');
try {
// Explicitly call onMessage before closing the stream to ensure the message is processed
callbacks.onMessage(rawData);
// Explicitly close the EventSource connection when we receive a completion message
if (eventSourceInstance && !isClosing) {
console.log(`[STREAM] ⚠️ Closing EventSource due to completion message for ${agentRunId}`);
isClosing = true;
// Only log to console if it's an unexpected error (not a known API error response)
if (jsonData.status !== 'error') {
console.error(`[STREAM] Stream error for ${agentRunId}:`, errorMessage);
}
// Ensure we close the stream and prevent reconnection
if (!isClosing) {
isClosing = true;
if (eventSourceInstance) {
eventSourceInstance.close();
eventSourceInstance = null;
// Explicitly call onClose here to ensure the client knows the stream is closed
setTimeout(() => {
console.log(`[STREAM] 🚨 Explicitly calling onClose after completion for ${agentRunId}`);
callbacks.onClose();
}, 0);
}
// Exit early to prevent duplicate message processing
return;
} catch (closeError) {
console.error(`[STREAM] ❌ Error while closing stream on completion: ${closeError}`);
// Continue with normal processing if there's an error during closure
callbacks.onError(errorMessage);
callbacks.onClose();
}
return;
}
// Handle completion status
if (jsonData.type === 'status' && jsonData.status === 'completed') {
console.log(`[STREAM] Completion message received for ${agentRunId}`);
if (!isClosing) {
isClosing = true;
callbacks.onMessage(rawData);
if (eventSourceInstance) {
eventSourceInstance.close();
eventSourceInstance = null;
}
callbacks.onClose();
}
return;
}
// Pass other messages normally
if (!isClosing) {
callbacks.onMessage(rawData);
}
// Pass the raw data directly to onMessage for handling in the component
callbacks.onMessage(rawData);
} catch (error) {
console.error(`[STREAM] Error handling message:`, error);
callbacks.onError(error instanceof Error ? error : String(error));
console.error(`[STREAM] Error in message handler:`, error);
if (!isClosing) {
isClosing = true;
if (eventSourceInstance) {
eventSourceInstance.close();
eventSourceInstance = null;
}
callbacks.onError(error instanceof Error ? error.message : 'Stream processing error');
callbacks.onClose();
}
}
};
@ -646,7 +673,6 @@ export const streamAgent = (agentRunId: string, callbacks: {
// Add detailed event logging
console.log(`[STREAM] 🔍 EventSource onerror triggered for ${agentRunId}`, event);
// EventSource errors are often just connection closures
// For clean closures (manual or completed), we don't need to log an error
if (isClosing) {
console.log(`[STREAM] EventSource closed as expected for ${agentRunId}`);
@ -654,10 +680,10 @@ export const streamAgent = (agentRunId: string, callbacks: {
}
// Only log as error for unexpected closures
console.log(`[STREAM] EventSource connection closed for ${agentRunId}`);
console.error(`[STREAM] EventSource connection error/closed unexpectedly for ${agentRunId}`);
if (!isClosing) {
console.log(`[STREAM] Handling connection close for ${agentRunId}`);
console.log(`[STREAM] Handling unexpected connection close for ${agentRunId}`);
// Close the connection
if (eventSourceInstance) {
@ -665,8 +691,9 @@ export const streamAgent = (agentRunId: string, callbacks: {
eventSourceInstance = null;
}
// Then notify error (once)
// Then notify error and close (once)
isClosing = true;
callbacks.onError(new Error('Stream connection closed unexpectedly.')); // Add error callback
callbacks.onClose();
}
};