mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal file
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AgentModelSelector } from './agent-model-selector';
|
||||
export * from './constants';
|
||||
Reference in New Issue
Block a user