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:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user