import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useAppStore, type AgentModel } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ImageDropZone } from '@/components/ui/image-drop-zone'; import { Bot, Send, User, Sparkles, Wrench, Trash2, PanelLeftClose, PanelLeft, Paperclip, X, ImageIcon, ChevronDown, FileText, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useElectronAgent } from '@/hooks/use-electron-agent'; import { SessionManager } from '@/components/session-manager'; import { Markdown } from '@/components/ui/markdown'; import type { ImageAttachment, TextFileAttachment } from '@/store/app-store'; import { fileToBase64, generateImageId, generateFileId, validateImageFile, validateTextFile, isTextFile, isImageFile, fileToText, getTextFileMimeType, formatFileSize, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_FILES, } from '@/lib/image-utils'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, KeyboardShortcut, } from '@/hooks/use-keyboard-shortcuts'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants'; export function AgentView() { const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [input, setInput] = useState(''); const [selectedImages, setSelectedImages] = useState([]); const [selectedTextFiles, setSelectedTextFiles] = useState([]); const [showImageDropZone, setShowImageDropZone] = useState(false); const [currentTool, setCurrentTool] = useState(null); const [currentSessionId, setCurrentSessionId] = useState(null); const [showSessionManager, setShowSessionManager] = useState(true); const [isDragOver, setIsDragOver] = useState(false); const [selectedModel, setSelectedModel] = useState('sonnet'); // Track if initial session has been loaded const initialSessionLoadedRef = useRef(false); // Scroll management for auto-scroll const messagesContainerRef = useRef(null); const [isUserAtBottom, setIsUserAtBottom] = useState(true); // Input ref for auto-focus const inputRef = useRef(null); // Ref for quick create session function from SessionManager const quickCreateSessionRef = useRef<(() => Promise) | null>(null); // Use the Electron agent hook (only if we have a session) const { messages, isProcessing, isConnected, sendMessage, clearHistory, error: agentError, } = useElectronAgent({ sessionId: currentSessionId || '', workingDirectory: currentProject?.path, model: selectedModel, onToolUse: (toolName) => { setCurrentTool(toolName); setTimeout(() => setCurrentTool(null), 2000); }, }); // Handle session selection with persistence const handleSelectSession = useCallback( (sessionId: string | null) => { setCurrentSessionId(sessionId); // Persist the selection for this project if (currentProject?.path) { setLastSelectedSession(currentProject.path, sessionId); } }, [currentProject?.path, setLastSelectedSession] ); // Restore last selected session when switching to Agent view or when project changes useEffect(() => { if (!currentProject?.path) { // No project, reset setCurrentSessionId(null); initialSessionLoadedRef.current = false; return; } // Only restore once per project if (initialSessionLoadedRef.current) return; initialSessionLoadedRef.current = true; const lastSessionId = getLastSelectedSession(currentProject.path); if (lastSessionId) { console.log('[AgentView] Restoring last selected session:', lastSessionId); setCurrentSessionId(lastSessionId); } }, [currentProject?.path, getLastSelectedSession]); // Reset initialSessionLoadedRef when project changes useEffect(() => { initialSessionLoadedRef.current = false; }, [currentProject?.path]); const handleSend = useCallback(async () => { if ( (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) || isProcessing ) return; const messageContent = input; const messageImages = selectedImages; const messageTextFiles = selectedTextFiles; setInput(''); setSelectedImages([]); setSelectedTextFiles([]); setShowImageDropZone(false); await sendMessage(messageContent, messageImages, messageTextFiles); }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]); const handleImagesSelected = useCallback((images: ImageAttachment[]) => { setSelectedImages(images); }, []); const toggleImageDropZone = useCallback(() => { setShowImageDropZone(!showImageDropZone); }, [showImageDropZone]); // Process dropped files (images and text files) const processDroppedFiles = useCallback( async (files: FileList) => { if (isProcessing) return; const newImages: ImageAttachment[] = []; const newTextFiles: TextFileAttachment[] = []; const errors: string[] = []; for (const file of Array.from(files)) { // Check if it's a text file if (isTextFile(file)) { const validation = validateTextFile(file); if (!validation.isValid) { errors.push(validation.error!); continue; } // Check if we've reached max files const totalFiles = newImages.length + selectedImages.length + newTextFiles.length + selectedTextFiles.length; if (totalFiles >= DEFAULT_MAX_FILES) { errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`); break; } try { const content = await fileToText(file); const textFileAttachment: TextFileAttachment = { id: generateFileId(), content, mimeType: getTextFileMimeType(file.name), filename: file.name, size: file.size, }; newTextFiles.push(textFileAttachment); } catch { errors.push(`${file.name}: Failed to read text file.`); } } // Check if it's an image file else if (isImageFile(file)) { const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE); if (!validation.isValid) { errors.push(validation.error!); continue; } // Check if we've reached max files const totalFiles = newImages.length + selectedImages.length + newTextFiles.length + selectedTextFiles.length; if (totalFiles >= DEFAULT_MAX_FILES) { errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`); break; } try { const base64 = await fileToBase64(file); const imageAttachment: ImageAttachment = { id: generateImageId(), data: base64, mimeType: file.type, filename: file.name, size: file.size, }; newImages.push(imageAttachment); } catch { errors.push(`${file.name}: Failed to process image.`); } } else { errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`); } } if (errors.length > 0) { console.warn('File upload errors:', errors); } if (newImages.length > 0) { setSelectedImages((prev) => [...prev, ...newImages]); } if (newTextFiles.length > 0) { setSelectedTextFiles((prev) => [...prev, ...newTextFiles]); } }, [isProcessing, selectedImages, selectedTextFiles] ); // Remove individual image const removeImage = useCallback((imageId: string) => { setSelectedImages((prev) => prev.filter((img) => img.id !== imageId)); }, []); // Remove individual text file const removeTextFile = useCallback((fileId: string) => { setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId)); }, []); // Drag and drop handlers for the input area const handleDragEnter = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (isProcessing || !isConnected) return; // Check if dragged items contain files if (e.dataTransfer.types.includes('Files')) { setIsDragOver(true); } }, [isProcessing, isConnected] ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only set dragOver to false if we're leaving the input container const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX; const y = e.clientY; if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { setIsDragOver(false); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); if (isProcessing || !isConnected) return; // Check if we have files const files = e.dataTransfer.files; if (files && files.length > 0) { processDroppedFiles(files); return; } // Handle file paths (from screenshots or other sources) const items = e.dataTransfer.items; if (items && items.length > 0) { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file) { const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); processDroppedFiles(dataTransfer.files); } } } } }, [isProcessing, isConnected, processDroppedFiles] ); const handlePaste = useCallback( async (e: React.ClipboardEvent) => { // Check if clipboard contains files const items = e.clipboardData?.items; if (items) { const files: File[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file && file.type.startsWith('image/')) { e.preventDefault(); // Prevent default paste of file path files.push(file); } } } if (files.length > 0) { const dataTransfer = new DataTransfer(); files.forEach((file) => dataTransfer.items.add(file)); await processDroppedFiles(dataTransfer.files); } } }, [processDroppedFiles] ); const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleClearChat = async () => { if (!confirm('Are you sure you want to clear this conversation?')) return; await clearHistory(); }; // Scroll position detection const checkIfUserIsAtBottom = useCallback(() => { const container = messagesContainerRef.current; if (!container) return; const threshold = 50; // 50px threshold for "near bottom" const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; setIsUserAtBottom(isAtBottom); }, []); // Scroll to bottom function const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const container = messagesContainerRef.current; if (!container) return; container.scrollTo({ top: container.scrollHeight, behavior: behavior, }); }, []); // Handle scroll events const handleScroll = useCallback(() => { checkIfUserIsAtBottom(); }, [checkIfUserIsAtBottom]); // Auto-scroll effect when messages change useEffect(() => { // Only auto-scroll if user was already at bottom if (isUserAtBottom && messages.length > 0) { // Use a small delay to ensure DOM is updated setTimeout(() => { scrollToBottom('smooth'); }, 100); } }, [messages, isUserAtBottom, scrollToBottom]); // Initial scroll to bottom when session changes useEffect(() => { if (currentSessionId && messages.length > 0) { // Scroll immediately without animation when switching sessions setTimeout(() => { scrollToBottom('auto'); setIsUserAtBottom(true); }, 100); } }, [currentSessionId, scrollToBottom]); // Auto-focus input when session is selected/changed useEffect(() => { if (currentSessionId && inputRef.current) { // Small delay to ensure UI has updated setTimeout(() => { inputRef.current?.focus(); }, 200); } }, [currentSessionId]); // Keyboard shortcuts for agent view const agentShortcuts: KeyboardShortcut[] = useMemo(() => { const shortcutsList: KeyboardShortcut[] = []; // New session shortcut - only when in agent view with a project if (currentProject) { shortcutsList.push({ key: shortcuts.newSession, action: () => { if (quickCreateSessionRef.current) { quickCreateSessionRef.current(); } }, description: 'Create new session', }); } return shortcutsList; }, [currentProject, shortcuts]); // Register keyboard shortcuts useKeyboardShortcuts(agentShortcuts); if (!currentProject) { return (

No Project Selected

Open or create a project to start working with the AI agent.

); } // Show welcome message if no messages yet const displayMessages = messages.length === 0 ? [ { id: 'welcome', role: 'assistant' as const, content: "Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?", timestamp: new Date().toISOString(), }, ] : messages; return (
{/* Session Manager Sidebar */} {showSessionManager && currentProject && (
)} {/* Chat Area */}
{/* Header */}

AI Agent

{currentProject.name} {currentSessionId && !isConnected && ' - Connecting...'}

{/* Status indicators & actions */}
{/* Model Selector */} {CLAUDE_MODELS.map((model) => ( setSelectedModel(model.id)} className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} data-testid={`model-option-${model.id}`} >
{model.label} {model.description}
))}
{currentTool && (
{currentTool}
)} {agentError && ( {agentError} )} {currentSessionId && messages.length > 0 && ( )}
{/* Messages */} {!currentSessionId ? (

No Session Selected

Create or select a session to start chatting with the AI agent

) : (
{displayMessages.map((message) => (
{/* Avatar */}
{message.role === 'assistant' ? ( ) : ( )}
{/* Message Bubble */}
{message.role === 'assistant' ? ( {message.content} ) : (

{message.content}

)} {/* Display attached images for user messages */} {message.role === 'user' && message.images && message.images.length > 0 && (
{message.images.length} image {message.images.length > 1 ? 's' : ''} attached
{message.images.map((image, index) => { // Construct proper data URL from base64 data and mime type const dataUrl = image.data.startsWith('data:') ? image.data : `data:${image.mimeType || 'image/png'};base64,${image.data}`; return (
{image.filename
{image.filename || `Image ${index + 1}`}
); })}
)}

{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', })}

))} {/* Thinking Indicator */} {isProcessing && (
Thinking...
)}
)} {/* Input Area */} {currentSessionId && (
{/* Image Drop Zone (when visible) */} {showImageDropZone && ( )} {/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */} {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (

{selectedImages.length + selectedTextFiles.length} file {selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached

{/* Image attachments */} {selectedImages.map((image) => (
{/* Image thumbnail */}
{image.filename}
{/* Image info */}

{image.filename}

{image.size !== undefined && (

{formatFileSize(image.size)}

)}
{/* Remove button */} {image.id && ( )}
))} {/* Text file attachments */} {selectedTextFiles.map((file) => (
{/* File icon */}
{/* File info */}

{file.filename}

{formatFileSize(file.size)}

{/* Remove button */}
))}
)} {/* Text Input and Controls */}
setInput(e.target.value)} onKeyPress={handleKeyPress} onPaste={handlePaste} disabled={isProcessing || !isConnected} data-testid="agent-input" className={cn( 'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all', 'focus:ring-2 focus:ring-primary/20 focus:border-primary/50', (selectedImages.length > 0 || selectedTextFiles.length > 0) && 'border-primary/30', isDragOver && 'border-primary bg-primary/5' )} /> {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
{selectedImages.length + selectedTextFiles.length} file {selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
)} {isDragOver && (
Drop here
)}
{/* File Attachment Button */} {/* Send Button */}
{/* Keyboard hint */}

Press{' '} Enter to send

)}
); }