feat(agent-view): refactor to folder pattern and add Cursor model support

- 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 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-31 16:58:21 +01:00
parent 9653e2b970
commit f496bb825d
23 changed files with 1570 additions and 959 deletions

View File

@@ -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<void> => {
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) {

View File

@@ -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 });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface AgentHeaderProps {
projectName: string;
currentSessionId: string | null;
isConnected: boolean;
isProcessing: boolean;
currentTool: string | null;
agentError: string | null;
messagesCount: number;
showSessionManager: boolean;
onToggleSessionManager: () => void;
onClearChat: () => void;
}
export function AgentHeader({
projectName,
currentSessionId,
isConnected,
isProcessing,
currentTool,
agentError,
messagesCount,
showSessionManager,
onToggleSessionManager,
onClearChat,
}: AgentHeaderProps) {
return (
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={onToggleSessionManager}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{projectName}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
</div>
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && <span className="text-xs text-destructive font-medium">{agentError}</span>}
{currentSessionId && messagesCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { ImageAttachment } from '@/store/app-store';
import { MessageList } from './message-list';
import { NoSessionState } from './empty-states';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface ChatAreaProps {
currentSessionId: string | null;
messages: Message[];
isProcessing: boolean;
showSessionManager: boolean;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
onShowSessionManager: () => void;
}
export function ChatArea({
currentSessionId,
messages,
isProcessing,
showSessionManager,
messagesContainerRef,
onScroll,
onShowSessionManager,
}: ChatAreaProps) {
if (!currentSessionId) {
return (
<NoSessionState
showSessionManager={showSessionManager}
onShowSessionManager={onShowSessionManager}
/>
);
}
return (
<MessageList
messages={messages}
isProcessing={isProcessing}
messagesContainerRef={messagesContainerRef}
onScroll={onScroll}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Sparkles, Bot, PanelLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function NoProjectState() {
return (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="agent-view-no-project"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
<p className="text-muted-foreground leading-relaxed">
Open or create a project to start working with the AI agent.
</p>
</div>
</div>
);
}
interface NoSessionStateProps {
showSessionManager: boolean;
onShowSessionManager: () => void;
}
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) {
return (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="no-session-placeholder"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { ThinkingIndicator } from './thinking-indicator';
export { NoProjectState, NoSessionState } from './empty-states';
export { MessageBubble } from './message-bubble';
export { MessageList } from './message-list';
export { AgentHeader } from './agent-header';
export { ChatArea } from './chat-area';

View File

@@ -0,0 +1,109 @@
import { Bot, User, ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment } from '@/store/app-store';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
return (
<div
className={cn(
'flex gap-4 max-w-4xl',
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
)}
>
{/* Avatar */}
<div
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
)}
>
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
)}
</div>
{/* Message Bubble */}
<div
className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{message.role === 'assistant' ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{/* Display attached images for user messages */}
{message.role === 'user' && message.images && message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? 's' : ''} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{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 (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={image.filename || `Attached image ${index + 1}`}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
<p
className={cn(
'text-[11px] mt-2 font-medium',
message.role === 'user' ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { ImageAttachment } from '@/store/app-store';
import { MessageBubble } from './message-bubble';
import { ThinkingIndicator } from './thinking-indicator';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface MessageListProps {
messages: Message[];
isProcessing: boolean;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
}
export function MessageList({
messages,
isProcessing,
messagesContainerRef,
onScroll,
}: MessageListProps) {
return (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={onScroll}
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{/* Thinking Indicator */}
{isProcessing && <ThinkingIndicator />}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Bot } from 'lucide-react';
export function ThinkingIndicator() {
return (
<div className="flex gap-4 max-w-4xl">
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4 text-primary" />
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { useAgentScroll } from './use-agent-scroll';
export { useFileAttachments } from './use-file-attachments';
export { useAgentShortcuts } from './use-agent-shortcuts';
export { useAgentSession } from './use-agent-session';

View File

@@ -0,0 +1,78 @@
import { useRef, useState, useCallback, useEffect } from 'react';
interface UseAgentScrollOptions {
messagesLength: number;
currentSessionId: string | null;
}
interface UseAgentScrollResult {
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
isUserAtBottom: boolean;
handleScroll: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
export function useAgentScroll({
messagesLength,
currentSessionId,
}: UseAgentScrollOptions): UseAgentScrollResult {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// 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 && messagesLength > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom('smooth');
}, 100);
}
}, [messagesLength, isUserAtBottom, scrollToBottom]);
// Initial scroll to bottom when session changes
useEffect(() => {
if (currentSessionId && messagesLength > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
}, [currentSessionId, scrollToBottom, messagesLength]);
return {
messagesContainerRef,
isUserAtBottom,
handleScroll,
scrollToBottom,
};
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
interface UseAgentSessionOptions {
projectPath: string | undefined;
}
interface UseAgentSessionResult {
currentSessionId: string | null;
handleSelectSession: (sessionId: string | null) => void;
}
export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
// Handle session selection with persistence
const handleSelectSession = useCallback(
(sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (projectPath) {
setLastSelectedSession(projectPath, sessionId);
}
},
[projectPath, setLastSelectedSession]
);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
if (!projectPath) {
// No project, reset
setCurrentSessionId(null);
initialSessionLoadedRef.current = false;
return;
}
// Only restore once per project
if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(projectPath);
if (lastSessionId) {
console.log('[AgentView] Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [projectPath, getLastSelectedSession]);
// Reset initialSessionLoadedRef when project changes
useEffect(() => {
initialSessionLoadedRef.current = false;
}, [projectPath]);
return {
currentSessionId,
handleSelectSession,
};
}

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
type KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
interface UseAgentShortcutsOptions {
currentProject: { path: string; name: string } | null;
quickCreateSessionRef: React.MutableRefObject<(() => Promise<void>) | null>;
}
export function useAgentShortcuts({
currentProject,
quickCreateSessionRef,
}: UseAgentShortcutsOptions): void {
const shortcuts = useKeyboardShortcutsConfig();
// 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, quickCreateSessionRef]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);
}

View File

@@ -0,0 +1,288 @@
import { useState, useCallback } from 'react';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
interface UseFileAttachmentsOptions {
isProcessing: boolean;
isConnected: boolean;
}
interface UseFileAttachmentsResult {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
handleImagesSelected: (images: ImageAttachment[]) => void;
toggleImageDropZone: () => void;
processDroppedFiles: (files: FileList) => Promise<void>;
removeImage: (imageId: string) => void;
removeTextFile: (fileId: string) => void;
handleDragEnter: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => Promise<void>;
handlePaste: (e: React.ClipboardEvent) => Promise<void>;
clearAllFiles: () => void;
setSelectedImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
setSelectedTextFiles: React.Dispatch<React.SetStateAction<TextFileAttachment[]>>;
setShowImageDropZone: React.Dispatch<React.SetStateAction<boolean>>;
}
export function useFileAttachments({
isProcessing,
isConnected,
}: UseFileAttachmentsOptions): UseFileAttachmentsResult {
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
}, []);
const toggleImageDropZone = useCallback(() => {
setShowImageDropZone((prev) => !prev);
}, []);
// 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));
}, []);
// Clear all files
const clearAllFiles = useCallback(() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}, []);
// Drag and drop handlers
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]
);
return {
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
handleImagesSelected,
toggleImageDropZone,
processDroppedFiles,
removeImage,
removeTextFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
clearAllFiles,
setSelectedImages,
setSelectedTextFiles,
setShowImageDropZone,
};
}

View File

@@ -0,0 +1,131 @@
import { ImageDropZone } from '@/components/ui/image-drop-zone';
import type { ImageAttachment, TextFileAttachment, ModelAlias } from '@/store/app-store';
import type { CursorModelId } from '@automaker/types';
import { FilePreview } from './file-preview';
import { QueueDisplay } from './queue-display';
import { InputControls } from './input-controls';
interface QueueItem {
id: string;
message: string;
imagePaths?: string[];
}
interface AgentInputAreaProps {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
selectedModel: ModelAlias | CursorModelId;
onModelSelect: (model: ModelAlias | CursorModelId) => void;
isProcessing: boolean;
isConnected: boolean;
// File attachments
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
onImagesSelected: (images: ImageAttachment[]) => void;
onToggleImageDropZone: () => void;
onRemoveImage: (imageId: string) => void;
onRemoveTextFile: (fileId: string) => void;
onClearAllFiles: () => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => Promise<void>;
onPaste: (e: React.ClipboardEvent) => Promise<void>;
// Queue
serverQueue: QueueItem[];
onRemoveFromQueue: (id: string) => void;
onClearQueue: () => void;
// Refs
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
}
export function AgentInputArea({
input,
onInputChange,
onSend,
onStop,
selectedModel,
onModelSelect,
isProcessing,
isConnected,
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
onImagesSelected,
onToggleImageDropZone,
onRemoveImage,
onRemoveTextFile,
onClearAllFiles,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onPaste,
serverQueue,
onRemoveFromQueue,
onClearQueue,
inputRef,
}: AgentInputAreaProps) {
const hasFiles = selectedImages.length > 0 || selectedTextFiles.length > 0;
return (
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={onImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={!isConnected}
/>
)}
{/* Queued Prompts List */}
<QueueDisplay
serverQueue={serverQueue}
onRemoveFromQueue={onRemoveFromQueue}
onClearQueue={onClearQueue}
/>
{/* Selected Files Preview - only show when ImageDropZone is hidden */}
{!showImageDropZone && (
<FilePreview
selectedImages={selectedImages}
selectedTextFiles={selectedTextFiles}
isProcessing={isProcessing}
onRemoveImage={onRemoveImage}
onRemoveTextFile={onRemoveTextFile}
onClearAll={onClearAllFiles}
/>
)}
{/* Input Controls */}
<InputControls
input={input}
onInputChange={onInputChange}
onSend={onSend}
onStop={onStop}
onToggleImageDropZone={onToggleImageDropZone}
onPaste={onPaste}
selectedModel={selectedModel}
onModelSelect={onModelSelect}
isProcessing={isProcessing}
isConnected={isConnected}
hasFiles={hasFiles}
isDragOver={isDragOver}
showImageDropZone={showImageDropZone}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
inputRef={inputRef}
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { X, FileText } from 'lucide-react';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import { formatFileSize } from '@/lib/image-utils';
interface FilePreviewProps {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
isProcessing: boolean;
onRemoveImage: (imageId: string) => void;
onRemoveTextFile: (fileId: string) => void;
onClearAll: () => void;
}
export function FilePreview({
selectedImages,
selectedTextFiles,
isProcessing,
onRemoveImage,
onRemoveTextFile,
onClearAll,
}: FilePreviewProps) {
const totalFiles = selectedImages.length + selectedTextFiles.length;
if (totalFiles === 0) {
return null;
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{totalFiles} file{totalFiles > 1 ? 's' : ''} attached
</p>
<button
onClick={onClearAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
<img src={image.data} alt={image.filename} className="w-full h-full object-cover" />
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground">{formatFileSize(image.size)}</p>
)}
</div>
{/* Remove button */}
{image.id && (
<button
onClick={() => onRemoveImage(image.id!)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
{/* Remove button */}
<button
onClick={() => onRemoveTextFile(file.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { AgentInputArea } from './agent-input-area';
export { FilePreview } from './file-preview';
export { QueueDisplay } from './queue-display';
export { InputControls } from './input-controls';

View File

@@ -0,0 +1,185 @@
import { useRef, useCallback, useEffect } from 'react';
import { Send, Paperclip, Square, ListOrdered } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { AgentModelSelector } from '../shared/agent-model-selector';
import type { ModelAlias } from '@/store/app-store';
import type { CursorModelId } from '@automaker/types';
interface InputControlsProps {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
onToggleImageDropZone: () => void;
onPaste: (e: React.ClipboardEvent) => Promise<void>;
selectedModel: ModelAlias | CursorModelId;
onModelSelect: (model: ModelAlias | CursorModelId) => void;
isProcessing: boolean;
isConnected: boolean;
hasFiles: boolean;
isDragOver: boolean;
showImageDropZone: boolean;
// Drag handlers
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => Promise<void>;
// Refs
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
}
export function InputControls({
input,
onInputChange,
onSend,
onStop,
onToggleImageDropZone,
onPaste,
selectedModel,
onModelSelect,
isProcessing,
isConnected,
hasFiles,
isDragOver,
showImageDropZone,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
inputRef: externalInputRef,
}: InputControlsProps) {
const internalInputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = externalInputRef || internalInputRef;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const adjustTextareaHeight = useCallback(() => {
const textarea = inputRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, [inputRef]);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
const canSend = (input.trim() || hasFiles) && isConnected;
return (
<>
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex-1 relative">
<Textarea
ref={inputRef}
placeholder={
isDragOver
? 'Drop your files here...'
: isProcessing
? 'Type to queue another prompt...'
: 'Describe what you want to build...'
}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={onPaste}
disabled={!isConnected}
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
hasFiles && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{hasFiles && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
files attached
</div>
)}
{isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
<Paperclip className="w-3 h-3" />
Drop here
</div>
)}
</div>
{/* Model Selector */}
<AgentModelSelector
selectedModel={selectedModel}
onModelSelect={onModelSelect}
disabled={!isConnected}
/>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
for new line
</p>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { X } from 'lucide-react';
interface QueueItem {
id: string;
message: string;
imagePaths?: string[];
}
interface QueueDisplayProps {
serverQueue: QueueItem[];
onRemoveFromQueue: (id: string) => void;
onClearQueue: () => void;
}
export function QueueDisplay({ serverQueue, onRemoveFromQueue, onClearQueue }: QueueDisplayProps) {
if (serverQueue.length === 0) {
return null;
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
</p>
<button
onClick={onClearQueue}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="space-y-1.5">
{serverQueue.map((item, index) => (
<div
key={item.id}
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
>
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
{index + 1}.
</span>
<span className="flex-1 truncate text-foreground">{item.message}</span>
{item.imagePaths && item.imagePaths.length > 0 && (
<span className="text-xs text-muted-foreground">
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
</span>
)}
<button
onClick={() => onRemoveFromQueue(item.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
title="Remove from queue"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { ChevronDown, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { useAppStore, type ModelAlias } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
import type { CursorModelId } from '@automaker/types';
import { getModelProvider, stripProviderPrefix } from '@automaker/types';
interface AgentModelSelectorProps {
selectedModel: ModelAlias | CursorModelId;
onModelSelect: (model: ModelAlias | CursorModelId) => void;
disabled?: boolean;
}
export function AgentModelSelector({
selectedModel,
onModelSelect,
disabled,
}: AgentModelSelectorProps) {
const { enabledCursorModels } = useAppStore();
const { cursorCliStatus } = useSetupStore();
// Check if Cursor CLI is available
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
// Filter cursor models by enabled settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
const modelId = stripProviderPrefix(model.id) as CursorModelId;
return enabledCursorModels.includes(modelId);
});
// Determine current provider and display label
const currentProvider = getModelProvider(selectedModel);
const currentModel =
currentProvider === 'cursor'
? CURSOR_MODELS.find((m) => m.id === selectedModel)
: CLAUDE_MODELS.find((m) => m.id === selectedModel);
// Get display label (strip "Claude " prefix for brevity)
const displayLabel = currentModel?.label.replace('Claude ', '') || 'Sonnet';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-11 gap-1 text-xs font-medium rounded-xl border-border px-2.5"
disabled={disabled}
data-testid="model-selector"
>
{currentProvider === 'cursor' && (
<span className="w-2 h-2 rounded-full bg-purple-500 mr-1" />
)}
{displayLabel}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 max-h-80 overflow-y-auto">
{/* Claude Models Section */}
<DropdownMenuLabel className="text-xs text-muted-foreground">Claude</DropdownMenuLabel>
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => onModelSelect(model.id as ModelAlias)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
{/* Cursor Models Section */}
{filteredCursorModels.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground flex items-center gap-2">
Cursor CLI
{!isCursorAvailable && (
<span className="text-amber-500 flex items-center gap-1 ml-auto">
<AlertCircle className="w-3 h-3" />
Setup required
</span>
)}
</DropdownMenuLabel>
{filteredCursorModels.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => onModelSelect(model.id as CursorModelId)}
className={cn(
'cursor-pointer',
selectedModel === model.id && 'bg-accent',
!isCursorAvailable && 'opacity-50'
)}
disabled={!isCursorAvailable}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{model.label}</span>
{model.hasThinking && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-600 dark:text-purple-400">
Thinking
</span>
)}
{model.tier === 'pro' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400">
Pro
</span>
)}
</div>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,9 @@
// Agent view constants
export const WELCOME_MESSAGE = {
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(),
};

View File

@@ -0,0 +1,2 @@
export { AgentModelSelector } from './agent-model-selector';
export * from './constants';