mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +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 type { Request, Response } from 'express';
|
||||||
|
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||||
import { getErrorMessage, logError } from '../common.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() {
|
export function createAvailableHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const models: ModelDefinition[] = [
|
// Get all models from all registered providers (Claude + Cursor)
|
||||||
{
|
const models = ProviderFactory.getAllAvailableModels();
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
res.json({ success: true, models });
|
res.json({ success: true, models });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export function createProvidersHandler() {
|
|||||||
available: statuses.claude?.installed || false,
|
available: statuses.claude?.installed || false,
|
||||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
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 });
|
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