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

- Refactor agent-view.tsx from 1028 lines to ~215 lines
- Create agent-view/ folder with components/, hooks/, input-area/, shared/
- Extract hooks: useAgentScroll, useFileAttachments, useAgentShortcuts, useAgentSession
- Extract components: AgentHeader, ChatArea, MessageList, MessageBubble, ThinkingIndicator
- Extract input-area: AgentInputArea, FilePreview, QueueDisplay, InputControls
- Add AgentModelSelector with Claude and Cursor CLI model support
- Update /models/available to use ProviderFactory.getAllAvailableModels()
- Update /models/providers to include Cursor CLI status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-31 16:58:21 +01:00
parent 9653e2b970
commit f496bb825d
23 changed files with 1570 additions and 959 deletions

View File

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

View File

@@ -0,0 +1,78 @@
import { useRef, useState, useCallback, useEffect } from 'react';
interface UseAgentScrollOptions {
messagesLength: number;
currentSessionId: string | null;
}
interface UseAgentScrollResult {
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
isUserAtBottom: boolean;
handleScroll: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
export function useAgentScroll({
messagesLength,
currentSessionId,
}: UseAgentScrollOptions): UseAgentScrollResult {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// Scroll position detection
const checkIfUserIsAtBottom = useCallback(() => {
const container = messagesContainerRef.current;
if (!container) return;
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const container = messagesContainerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: behavior,
});
}, []);
// Handle scroll events
const handleScroll = useCallback(() => {
checkIfUserIsAtBottom();
}, [checkIfUserIsAtBottom]);
// Auto-scroll effect when messages change
useEffect(() => {
// Only auto-scroll if user was already at bottom
if (isUserAtBottom && messagesLength > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom('smooth');
}, 100);
}
}, [messagesLength, isUserAtBottom, scrollToBottom]);
// Initial scroll to bottom when session changes
useEffect(() => {
if (currentSessionId && messagesLength > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
}, [currentSessionId, scrollToBottom, messagesLength]);
return {
messagesContainerRef,
isUserAtBottom,
handleScroll,
scrollToBottom,
};
}

View File

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

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
type KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
interface UseAgentShortcutsOptions {
currentProject: { path: string; name: string } | null;
quickCreateSessionRef: React.MutableRefObject<(() => Promise<void>) | null>;
}
export function useAgentShortcuts({
currentProject,
quickCreateSessionRef,
}: UseAgentShortcutsOptions): void {
const shortcuts = useKeyboardShortcutsConfig();
// Keyboard shortcuts for agent view
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// New session shortcut - only when in agent view with a project
if (currentProject) {
shortcutsList.push({
key: shortcuts.newSession,
action: () => {
if (quickCreateSessionRef.current) {
quickCreateSessionRef.current();
}
},
description: 'Create new session',
});
}
return shortcutsList;
}, [currentProject, shortcuts, quickCreateSessionRef]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);
}

View File

@@ -0,0 +1,288 @@
import { useState, useCallback } from 'react';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
interface UseFileAttachmentsOptions {
isProcessing: boolean;
isConnected: boolean;
}
interface UseFileAttachmentsResult {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
handleImagesSelected: (images: ImageAttachment[]) => void;
toggleImageDropZone: () => void;
processDroppedFiles: (files: FileList) => Promise<void>;
removeImage: (imageId: string) => void;
removeTextFile: (fileId: string) => void;
handleDragEnter: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => Promise<void>;
handlePaste: (e: React.ClipboardEvent) => Promise<void>;
clearAllFiles: () => void;
setSelectedImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
setSelectedTextFiles: React.Dispatch<React.SetStateAction<TextFileAttachment[]>>;
setShowImageDropZone: React.Dispatch<React.SetStateAction<boolean>>;
}
export function useFileAttachments({
isProcessing,
isConnected,
}: UseFileAttachmentsOptions): UseFileAttachmentsResult {
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
}, []);
const toggleImageDropZone = useCallback(() => {
setShowImageDropZone((prev) => !prev);
}, []);
// Process dropped files (images and text files)
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const newImages: ImageAttachment[] = [];
const newTextFiles: TextFileAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const content = await fileToText(file);
const textFileAttachment: TextFileAttachment = {
id: generateFileId(),
content,
mimeType: getTextFileMimeType(file.name),
filename: file.name,
size: file.size,
};
newTextFiles.push(textFileAttachment);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
}
// Check if it's an image file
else if (isImageFile(file)) {
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
}
if (errors.length > 0) {
console.warn('File upload errors:', errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
},
[isProcessing, selectedImages, selectedTextFiles]
);
// Remove individual image
const removeImage = useCallback((imageId: string) => {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Remove individual text file
const removeTextFile = useCallback((fileId: string) => {
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
}, []);
// Clear all files
const clearAllFiles = useCallback(() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}, []);
// Drag and drop handlers
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
},
[isProcessing, isConnected]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set dragOver to false if we're leaving the input container
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (isProcessing || !isConnected) return;
// Check if we have files
const files = e.dataTransfer.files;
if (files && files.length > 0) {
processDroppedFiles(files);
return;
}
// Handle file paths (from screenshots or other sources)
const items = e.dataTransfer.items;
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
processDroppedFiles(dataTransfer.files);
}
}
}
}
},
[isProcessing, isConnected, processDroppedFiles]
);
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
// Check if clipboard contains files
const items = e.clipboardData?.items;
if (items) {
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type.startsWith('image/')) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
}
}
if (files.length > 0) {
const dataTransfer = new DataTransfer();
files.forEach((file) => dataTransfer.items.add(file));
await processDroppedFiles(dataTransfer.files);
}
}
},
[processDroppedFiles]
);
return {
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
handleImagesSelected,
toggleImageDropZone,
processDroppedFiles,
removeImage,
removeTextFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
clearAllFiles,
setSelectedImages,
setSelectedTextFiles,
setShowImageDropZone,
};
}