From f496bb825df4e0fced003f7d4e918584858152a5 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 31 Dec 2025 16:58:21 +0100 Subject: [PATCH] feat(agent-view): refactor to folder pattern and add Cursor model support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor agent-view.tsx from 1028 lines to ~215 lines - Create agent-view/ folder with components/, hooks/, input-area/, shared/ - Extract hooks: useAgentScroll, useFileAttachments, useAgentShortcuts, useAgentSession - Extract components: AgentHeader, ChatArea, MessageList, MessageBubble, ThinkingIndicator - Extract input-area: AgentInputArea, FilePreview, QueueDisplay, InputControls - Add AgentModelSelector with Claude and Cursor CLI model support - Update /models/available to use ProviderFactory.getAllAvailableModels() - Update /models/providers to include Cursor CLI status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/routes/models/routes/available.ts | 53 +- .../src/routes/models/routes/providers.ts | 7 + apps/ui/src/components/views/agent-view.tsx | 1007 ++--------------- .../agent-view/components/agent-header.tsx | 80 ++ .../views/agent-view/components/chat-area.tsx | 49 + .../agent-view/components/empty-states.tsx | 49 + .../views/agent-view/components/index.ts | 6 + .../agent-view/components/message-bubble.tsx | 109 ++ .../agent-view/components/message-list.tsx | 41 + .../components/thinking-indicator.tsx | 30 + .../views/agent-view/hooks/index.ts | 4 + .../agent-view/hooks/use-agent-scroll.ts | 78 ++ .../agent-view/hooks/use-agent-session.ts | 61 + .../agent-view/hooks/use-agent-shortcuts.ts | 41 + .../agent-view/hooks/use-file-attachments.ts | 288 +++++ .../input-area/agent-input-area.tsx | 131 +++ .../agent-view/input-area/file-preview.tsx | 103 ++ .../views/agent-view/input-area/index.ts | 4 + .../agent-view/input-area/input-controls.tsx | 185 +++ .../agent-view/input-area/queue-display.tsx | 60 + .../shared/agent-model-selector.tsx | 132 +++ .../views/agent-view/shared/constants.ts | 9 + .../views/agent-view/shared/index.ts | 2 + 23 files changed, 1570 insertions(+), 959 deletions(-) create mode 100644 apps/ui/src/components/views/agent-view/components/agent-header.tsx create mode 100644 apps/ui/src/components/views/agent-view/components/chat-area.tsx create mode 100644 apps/ui/src/components/views/agent-view/components/empty-states.tsx create mode 100644 apps/ui/src/components/views/agent-view/components/index.ts create mode 100644 apps/ui/src/components/views/agent-view/components/message-bubble.tsx create mode 100644 apps/ui/src/components/views/agent-view/components/message-list.tsx create mode 100644 apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx create mode 100644 apps/ui/src/components/views/agent-view/hooks/index.ts create mode 100644 apps/ui/src/components/views/agent-view/hooks/use-agent-scroll.ts create mode 100644 apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts create mode 100644 apps/ui/src/components/views/agent-view/hooks/use-agent-shortcuts.ts create mode 100644 apps/ui/src/components/views/agent-view/hooks/use-file-attachments.ts create mode 100644 apps/ui/src/components/views/agent-view/input-area/agent-input-area.tsx create mode 100644 apps/ui/src/components/views/agent-view/input-area/file-preview.tsx create mode 100644 apps/ui/src/components/views/agent-view/input-area/index.ts create mode 100644 apps/ui/src/components/views/agent-view/input-area/input-controls.tsx create mode 100644 apps/ui/src/components/views/agent-view/input-area/queue-display.tsx create mode 100644 apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx create mode 100644 apps/ui/src/components/views/agent-view/shared/constants.ts create mode 100644 apps/ui/src/components/views/agent-view/shared/index.ts diff --git a/apps/server/src/routes/models/routes/available.ts b/apps/server/src/routes/models/routes/available.ts index 4ac4e0b1..2ebb4992 100644 --- a/apps/server/src/routes/models/routes/available.ts +++ b/apps/server/src/routes/models/routes/available.ts @@ -1,61 +1,16 @@ /** - * GET /available endpoint - Get available models + * GET /available endpoint - Get available models from all providers */ import type { Request, Response } from 'express'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; import { getErrorMessage, logError } from '../common.js'; -interface ModelDefinition { - id: string; - name: string; - provider: string; - contextWindow: number; - maxOutputTokens: number; - supportsVision: boolean; - supportsTools: boolean; -} - export function createAvailableHandler() { return async (_req: Request, res: Response): Promise => { try { - const models: ModelDefinition[] = [ - { - id: 'claude-opus-4-5-20251101', - name: 'Claude Opus 4.5', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 16384, - supportsVision: true, - supportsTools: true, - }, - { - id: 'claude-sonnet-4-20250514', - name: 'Claude Sonnet 4', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 16384, - supportsVision: true, - supportsTools: true, - }, - { - id: 'claude-3-5-sonnet-20241022', - name: 'Claude 3.5 Sonnet', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 8192, - supportsVision: true, - supportsTools: true, - }, - { - id: 'claude-3-5-haiku-20241022', - name: 'Claude 3.5 Haiku', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 8192, - supportsVision: true, - supportsTools: true, - }, - ]; + // Get all models from all registered providers (Claude + Cursor) + const models = ProviderFactory.getAllAvailableModels(); res.json({ success: true, models }); } catch (error) { diff --git a/apps/server/src/routes/models/routes/providers.ts b/apps/server/src/routes/models/routes/providers.ts index b7ef1b85..174a1fac 100644 --- a/apps/server/src/routes/models/routes/providers.ts +++ b/apps/server/src/routes/models/routes/providers.ts @@ -17,6 +17,13 @@ export function createProvidersHandler() { available: statuses.claude?.installed || false, hasApiKey: !!process.env.ANTHROPIC_API_KEY, }, + cursor: { + available: statuses.cursor?.installed || false, + version: statuses.cursor?.version, + path: statuses.cursor?.path, + method: statuses.cursor?.method, + authenticated: statuses.cursor?.authenticated, + }, }; res.json({ success: true, providers }); diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 2a89b07c..3adea4f5 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -1,85 +1,39 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { useAppStore, type ModelAlias } from '@/store/app-store'; import type { CursorModelId } from '@automaker/types'; -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, - Square, - ListOrdered, -} 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'; + +// Extracted hooks 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'; -import { Textarea } from '@/components/ui/textarea'; + useAgentScroll, + useFileAttachments, + useAgentShortcuts, + useAgentSession, +} from './agent-view/hooks'; + +// Extracted components +import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components'; +import { AgentInputArea } from './agent-view/input-area'; export function AgentView() { - const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore(); - const shortcuts = useKeyboardShortcutsConfig(); + const { currentProject } = useAppStore(); 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); + // Session management hook + const { currentSessionId, handleSelectSession } = useAgentSession({ + projectPath: currentProject?.path, + }); + // Use the Electron agent hook (only if we have a session) const { messages, @@ -103,44 +57,34 @@ export function AgentView() { }, }); - // 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] - ); + // File attachments hook + const fileAttachments = useFileAttachments({ + isProcessing, + isConnected, + }); - // 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; - } + // Scroll management hook + const { messagesContainerRef, handleScroll } = useAgentScroll({ + messagesLength: messages.length, + currentSessionId, + }); - // 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]); + // Keyboard shortcuts hook + useAgentShortcuts({ + currentProject, + quickCreateSessionRef, + }); + // Handle send message const handleSend = useCallback(async () => { + const { + selectedImages, + selectedTextFiles, + setSelectedImages, + setSelectedTextFiles, + setShowImageDropZone, + } = fileAttachments; + if (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) return; const messageContent = input; @@ -158,343 +102,22 @@ export function AgentView() { } else { await sendMessage(messageContent, messageImages, messageTextFiles); } - }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage, addToServerQueue]); - - 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 handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - const adjustTextareaHeight = useCallback(() => { - const textarea = inputRef.current; - if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.height = `${textarea.scrollHeight}px`; - }, []); - - useEffect(() => { - adjustTextareaHeight(); - }, [input, adjustTextareaHeight]); + }, [input, fileAttachments, isProcessing, sendMessage, addToServerQueue]); 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 @@ -509,11 +132,15 @@ export function AgentView() { ] : messages; + if (!currentProject) { + return ; + } + return (
{/* Session Manager Sidebar */} {showSessionManager && currentProject && ( -
+
{/* Header */} -
-
- -
- -
-
-

AI Agent

-

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

-
-
- - {/* Status indicators & actions */} -
- {currentTool && ( -
- - {currentTool} -
- )} - {agentError && ( - {agentError} - )} - {currentSessionId && messages.length > 0 && ( - - )} -
-
+ setShowSessionManager(!showSessionManager)} + onClearChat={handleClearChat} + /> {/* 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... -
-
-
- )} -
- )} + setShowSessionManager(true)} + /> {/* Input Area */} {currentSessionId && ( -
- {/* Image Drop Zone (when visible) */} - {showImageDropZone && ( - - )} - - {/* Queued Prompts List */} - {serverQueue.length > 0 && ( -
-
-

- {serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued -

- -
-
- {serverQueue.map((item, index) => ( -
- - {index + 1}. - - {item.message} - {item.imagePaths && item.imagePaths.length > 0 && ( - - +{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''} - - )} - -
- ))} -
-
- )} - - {/* 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 */} -
-
-