From 26236d3d5b0c5fccfd0cb605aab8b5fcfee731f1 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 23:08:08 +0100 Subject: [PATCH] feat: enhance ESLint configuration and improve component error handling - Updated ESLint configuration to include support for `.mjs` and `.cjs` file types, adding necessary global variables for Node.js and browser environments. - Introduced a new `vite-env.d.ts` file to define environment variables for Vite, improving type safety. - Refactored error handling in `file-browser-dialog.tsx`, `description-image-dropzone.tsx`, and `feature-image-upload.tsx` to omit error parameters, simplifying the catch blocks. - Removed unused bug report button functionality from the sidebar, streamlining the component structure. - Adjusted various components to improve code readability and maintainability, including updates to type imports and component props. These changes aim to enhance the development experience by improving linting support and simplifying error handling across components. --- apps/ui/eslint.config.mjs | 76 +++ .../dialogs/file-browser-dialog.tsx | 112 ++-- apps/ui/src/components/layout/sidebar.tsx | 12 +- .../sidebar/components/bug-report-button.tsx | 16 +- .../project-selector-with-options.tsx | 2 +- .../sidebar/components/sidebar-header.tsx | 7 +- .../ui/src/components/layout/sidebar/types.ts | 6 +- apps/ui/src/components/ui/accordion.tsx | 138 +++-- .../ui/description-image-dropzone.tsx | 186 ++++--- .../components/ui/feature-image-upload.tsx | 77 +-- apps/ui/src/components/ui/image-drop-zone.tsx | 244 +++++---- apps/ui/src/components/ui/sheet.tsx | 73 ++- apps/ui/src/components/views/agent-view.tsx | 306 +++++------ .../ui/src/components/views/analysis-view.tsx | 503 +++++++----------- .../views/board-view/board-header.tsx | 26 +- .../kanban-card/agent-info-panel.tsx | 130 ++--- .../board-view/dialogs/agent-output-modal.tsx | 193 ++++--- .../dialogs/edit-feature-dialog.tsx | 179 +++---- .../views/board-view/kanban-board.tsx | 67 +-- apps/ui/src/components/views/context-view.tsx | 255 ++++----- .../authentication-status-display.tsx | 44 +- .../components/delete-project-dialog.tsx | 17 +- .../setup-view/steps/claude-setup-step.tsx | 362 +++++-------- .../views/setup-view/steps/complete-step.tsx | 26 +- .../views/setup-view/steps/welcome-step.tsx | 14 +- .../ui/src/components/views/terminal-view.tsx | 228 ++++---- apps/ui/src/components/views/welcome-view.tsx | 20 +- apps/ui/src/config/api-providers.ts | 34 +- apps/ui/src/lib/file-picker.ts | 109 ++-- apps/ui/src/lib/http-api-client.ts | 385 ++++++-------- apps/ui/src/lib/utils.ts | 18 +- apps/ui/src/lib/workspace-config.ts | 23 +- apps/ui/src/routes/__root.tsx | 2 +- apps/ui/src/vite-env.d.ts | 11 + apps/ui/tests/feature-lifecycle.spec.ts | 225 +++----- apps/ui/tests/spec-editor-persistence.spec.ts | 161 +++--- apps/ui/tests/utils/components/toasts.ts | 24 +- apps/ui/tests/utils/git/worktree.ts | 158 +++--- apps/ui/tests/utils/views/board.ts | 94 +--- apps/ui/tests/utils/views/setup.ts | 37 +- 40 files changed, 2013 insertions(+), 2587 deletions(-) create mode 100644 apps/ui/src/vite-env.d.ts diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 150f0bad..0b7d6f0e 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser"; const eslintConfig = defineConfig([ js.configs.recommended, + { + files: ["**/*.mjs", "**/*.cjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + }, + }, + }, { files: ["**/*.ts", "**/*.tsx"], languageOptions: { @@ -13,6 +25,70 @@ const eslintConfig = defineConfig([ ecmaVersion: "latest", sourceType: "module", }, + globals: { + // Browser/DOM APIs + window: "readonly", + document: "readonly", + navigator: "readonly", + Navigator: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", + fetch: "readonly", + WebSocket: "readonly", + File: "readonly", + FileList: "readonly", + FileReader: "readonly", + Blob: "readonly", + atob: "readonly", + crypto: "readonly", + prompt: "readonly", + confirm: "readonly", + getComputedStyle: "readonly", + requestAnimationFrame: "readonly", + // DOM Element Types + HTMLElement: "readonly", + HTMLInputElement: "readonly", + HTMLDivElement: "readonly", + HTMLButtonElement: "readonly", + HTMLSpanElement: "readonly", + HTMLTextAreaElement: "readonly", + HTMLHeadingElement: "readonly", + HTMLParagraphElement: "readonly", + HTMLImageElement: "readonly", + Element: "readonly", + // Event Types + Event: "readonly", + KeyboardEvent: "readonly", + DragEvent: "readonly", + PointerEvent: "readonly", + CustomEvent: "readonly", + ClipboardEvent: "readonly", + WheelEvent: "readonly", + DataTransfer: "readonly", + // Web APIs + ResizeObserver: "readonly", + AbortSignal: "readonly", + Audio: "readonly", + ScrollBehavior: "readonly", + // Timers + setTimeout: "readonly", + setInterval: "readonly", + clearTimeout: "readonly", + clearInterval: "readonly", + // Node.js (for scripts and Electron) + process: "readonly", + require: "readonly", + __dirname: "readonly", + __filename: "readonly", + NodeJS: "readonly", + // React + React: "readonly", + JSX: "readonly", + // Electron + Electron: "readonly", + // Console + console: "readonly", + }, }, plugins: { "@typescript-eslint": ts, diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index b6a05ab0..dc9c1c2e 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from 'react'; import { FolderOpen, Folder, @@ -9,7 +9,7 @@ import { CornerDownLeft, Clock, X, -} from "lucide-react"; +} from 'lucide-react'; import { Dialog, DialogContent, @@ -17,14 +17,11 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { getJSON, setJSON } from "@/lib/storage"; -import { - getDefaultWorkspaceDirectory, - saveLastProjectDirectory, -} from "@/lib/workspace-config"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { getJSON, setJSON } from '@/lib/storage'; +import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; interface DirectoryEntry { name: string; @@ -50,7 +47,7 @@ interface FileBrowserDialogProps { initialPath?: string; } -const RECENT_FOLDERS_KEY = "file-browser-recent-folders"; +const RECENT_FOLDERS_KEY = 'file-browser-recent-folders'; const MAX_RECENT_FOLDERS = 5; function getRecentFolders(): string[] { @@ -76,18 +73,18 @@ export function FileBrowserDialog({ open, onOpenChange, onSelect, - title = "Select Project Directory", - description = "Navigate to your project folder or paste a path directly", + title = 'Select Project Directory', + description = 'Navigate to your project folder or paste a path directly', initialPath, }: FileBrowserDialogProps) { - const [currentPath, setCurrentPath] = useState(""); - const [pathInput, setPathInput] = useState(""); + const [currentPath, setCurrentPath] = useState(''); + const [pathInput, setPathInput] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [warning, setWarning] = useState(""); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); const [recentFolders, setRecentFolders] = useState([]); const pathInputRef = useRef(null); @@ -98,28 +95,24 @@ export function FileBrowserDialog({ } }, [open]); - const handleRemoveRecent = useCallback( - (e: React.MouseEvent, path: string) => { - e.stopPropagation(); - const updated = removeRecentFolder(path); - setRecentFolders(updated); - }, - [] - ); + const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => { + e.stopPropagation(); + const updated = removeRecentFolder(path); + setRecentFolders(updated); + }, []); const browseDirectory = useCallback(async (dirPath?: string) => { setLoading(true); - setError(""); - setWarning(""); + setError(''); + setWarning(''); try { // Get server URL from environment or default - const serverUrl = - import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const response = await fetch(`${serverUrl}/api/fs/browse`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dirPath }), }); @@ -131,14 +124,12 @@ export function FileBrowserDialog({ setParentPath(result.parentPath); setDirectories(result.directories); setDrives(result.drives || []); - setWarning(result.warning || ""); + setWarning(result.warning || ''); } else { - setError(result.error || "Failed to browse directory"); + setError(result.error || 'Failed to browse directory'); } } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load directories" - ); + setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setLoading(false); } @@ -154,12 +145,12 @@ export function FileBrowserDialog({ // Reset current path when dialog closes useEffect(() => { if (!open) { - setCurrentPath(""); - setPathInput(""); + setCurrentPath(''); + setPathInput(''); setParentPath(null); setDirectories([]); - setError(""); - setWarning(""); + setError(''); + setWarning(''); } }, [open]); @@ -189,7 +180,7 @@ export function FileBrowserDialog({ // No default directory, browse home directory browseDirectory(); } - } catch (err) { + } catch { // If config fetch fails, try initialPath or fall back to home directory if (initialPath) { setPathInput(initialPath); @@ -230,7 +221,7 @@ export function FileBrowserDialog({ }; const handlePathInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + if (e.key === 'Enter') { e.preventDefault(); handleGoToPath(); } @@ -252,7 +243,7 @@ export function FileBrowserDialog({ const handleKeyDown = (e: KeyboardEvent) => { // Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (currentPath && !loading) { handleSelect(); @@ -260,8 +251,8 @@ export function FileBrowserDialog({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [open, currentPath, loading, handleSelect]); // Helper to get folder name from path @@ -326,9 +317,7 @@ export function FileBrowserDialog({ title={folder} > - - {getFolderName(folder)} - + {getFolderName(folder)} ))} @@ -388,7 +375,7 @@ export function FileBrowserDialog({ )}
- {currentPath || "Loading..."} + {currentPath || 'Loading...'}
@@ -396,9 +383,7 @@ export function FileBrowserDialog({
{loading && (
-
- Loading directories... -
+
Loading directories...
)} @@ -416,9 +401,7 @@ export function FileBrowserDialog({ {!loading && !error && !warning && directories.length === 0 && (
-
- No subdirectories found -
+
No subdirectories found
)} @@ -440,8 +423,8 @@ export function FileBrowserDialog({
- Paste a full path above, or click on folders to navigate. Press - Enter or click Go to jump to a path. + Paste a full path above, or click on folders to navigate. Press Enter or click Go to + jump to a path.
@@ -458,10 +441,9 @@ export function FileBrowserDialog({ Select Current Folder - {typeof navigator !== "undefined" && - navigator.platform?.includes("Mac") - ? "⌘" - : "Ctrl"} + {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') + ? '⌘' + : 'Ctrl'} +↵ diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index e59c6744..16b1e5cb 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -155,12 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // Handle bug report button click - const handleBugReportClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); - }, []); - /** * Opens the system folder selection dialog and initializes the selected project. * Used by both the 'O' keyboard shortcut and the folder icon button. @@ -273,11 +267,7 @@ export function Sidebar() { />
- + {/* Project Actions - Moved above project selector */} {sidebarOpen && ( diff --git a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx index 68a413c4..8139dc55 100644 --- a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx @@ -1,11 +1,21 @@ import { Bug } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { BugReportButtonProps } from '../types'; +import { useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; + +interface BugReportButtonProps { + sidebarExpanded: boolean; +} + +export function BugReportButton({ sidebarExpanded }: BugReportButtonProps) { + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); -export function BugReportButton({ sidebarExpanded, onClick }: BugReportButtonProps) { return (
{/* Bug Report Button - Collapsed sidebar version */} {!sidebarOpen && (
- +
)} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index e76e4917..4d9ecc35 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -1,4 +1,5 @@ import type { Project } from '@/lib/electron'; +import type React from 'react'; export interface NavSection { label?: string; @@ -29,8 +30,3 @@ export interface ThemeMenuItemProps { onPreviewEnter: (value: string) => void; onPreviewLeave: (e: React.PointerEvent) => void; } - -export interface BugReportButtonProps { - sidebarExpanded: boolean; - onClick: () => void; -} diff --git a/apps/ui/src/components/ui/accordion.tsx b/apps/ui/src/components/ui/accordion.tsx index 0c8b6101..3cb256b3 100644 --- a/apps/ui/src/components/ui/accordion.tsx +++ b/apps/ui/src/components/ui/accordion.tsx @@ -1,9 +1,10 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ -import * as React from "react"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; +import * as React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; -type AccordionType = "single" | "multiple"; +type AccordionType = 'single' | 'multiple'; interface AccordionContextValue { type: AccordionType; @@ -12,12 +13,10 @@ interface AccordionContextValue { collapsible?: boolean; } -const AccordionContext = React.createContext( - null -); +const AccordionContext = React.createContext(null); interface AccordionProps extends React.HTMLAttributes { - type?: "single" | "multiple"; + type?: 'single' | 'multiple'; value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[]) => void; @@ -27,7 +26,7 @@ interface AccordionProps extends React.HTMLAttributes { const Accordion = React.forwardRef( ( { - type = "single", + type = 'single', value, defaultValue, onValueChange, @@ -38,13 +37,11 @@ const Accordion = React.forwardRef( }, ref ) => { - const [internalValue, setInternalValue] = React.useState( - () => { - if (value !== undefined) return value; - if (defaultValue !== undefined) return defaultValue; - return type === "single" ? "" : []; - } - ); + const [internalValue, setInternalValue] = React.useState(() => { + if (value !== undefined) return value; + if (defaultValue !== undefined) return defaultValue; + return type === 'single' ? '' : []; + }); const currentValue = value !== undefined ? value : internalValue; @@ -52,9 +49,9 @@ const Accordion = React.forwardRef( (itemValue: string) => { let newValue: string | string[]; - if (type === "single") { + if (type === 'single') { if (currentValue === itemValue && collapsible) { - newValue = ""; + newValue = ''; } else if (currentValue === itemValue && !collapsible) { return; } else { @@ -91,27 +88,21 @@ const Accordion = React.forwardRef( return ( -
+
{children}
); } ); -Accordion.displayName = "Accordion"; +Accordion.displayName = 'Accordion'; interface AccordionItemContextValue { value: string; isOpen: boolean; } -const AccordionItemContext = - React.createContext(null); +const AccordionItemContext = React.createContext(null); interface AccordionItemProps extends React.HTMLAttributes { value: string; @@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef( const accordionContext = React.useContext(AccordionContext); if (!accordionContext) { - throw new Error("AccordionItem must be used within an Accordion"); + throw new Error('AccordionItem must be used within an Accordion'); } const isOpen = Array.isArray(accordionContext.value) ? accordionContext.value.includes(value) : accordionContext.value === value; - const contextValue = React.useMemo( - () => ({ value, isOpen }), - [value, isOpen] - ); + const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]); return (
{children} @@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef( ); } ); -AccordionItem.displayName = "AccordionItem"; +AccordionItem.displayName = 'AccordionItem'; -interface AccordionTriggerProps - extends React.ButtonHTMLAttributes {} +interface AccordionTriggerProps extends React.ButtonHTMLAttributes {} -const AccordionTrigger = React.forwardRef< - HTMLButtonElement, - AccordionTriggerProps ->(({ className, children, ...props }, ref) => { - const accordionContext = React.useContext(AccordionContext); - const itemContext = React.useContext(AccordionItemContext); +const AccordionTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => { + const accordionContext = React.useContext(AccordionContext); + const itemContext = React.useContext(AccordionItemContext); - if (!accordionContext || !itemContext) { - throw new Error("AccordionTrigger must be used within an AccordionItem"); + if (!accordionContext || !itemContext) { + throw new Error('AccordionTrigger must be used within an AccordionItem'); + } + + const { onValueChange } = accordionContext; + const { value, isOpen } = itemContext; + + return ( +
+ +
+ ); } - - const { onValueChange } = accordionContext; - const { value, isOpen } = itemContext; - - return ( -
- -
- ); -}); -AccordionTrigger.displayName = "AccordionTrigger"; +); +AccordionTrigger.displayName = 'AccordionTrigger'; interface AccordionContentProps extends React.HTMLAttributes {} @@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef const [height, setHeight] = React.useState(undefined); if (!itemContext) { - throw new Error("AccordionContent must be used within an AccordionItem"); + throw new Error('AccordionContent must be used within an AccordionItem'); } const { isOpen } = itemContext; @@ -220,16 +206,16 @@ const AccordionContent = React.forwardRef return (
-
+
{children}
@@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef ); } ); -AccordionContent.displayName = "AccordionContent"; +AccordionContent.displayName = 'AccordionContent'; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index af3f9019..7020ca75 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -1,10 +1,9 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Loader2 } from "lucide-react"; -import { Textarea } from "@/components/ui/textarea"; -import { getElectronAPI } from "@/lib/electron"; -import { useAppStore, type FeatureImagePath } from "@/store/app-store"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Loader2 } from 'lucide-react'; +import { Textarea } from '@/components/ui/textarea'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore, type FeatureImagePath } from '@/store/app-store'; // Map to store preview data by image ID (persisted across component re-mounts) export type ImagePreviewMap = Map; @@ -26,13 +25,7 @@ interface DescriptionImageDropZoneProps { error?: boolean; // Show error state with red border } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export function DescriptionImageDropZone({ @@ -40,7 +33,7 @@ export function DescriptionImageDropZone({ onChange, images, onImagesChange, - placeholder = "Describe the feature...", + placeholder = 'Describe the feature...', className, disabled = false, maxFiles = 5, @@ -59,71 +52,76 @@ export function DescriptionImageDropZone({ // Determine which preview map to use - prefer parent-controlled state const previewImages = previewMap !== undefined ? previewMap : localPreviewImages; - const setPreviewImages = useCallback((updater: Map | ((prev: Map) => Map)) => { - if (onPreviewMapChange) { - const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; - const newMap = typeof updater === 'function' ? updater(currentMap) : updater; - onPreviewMapChange(newMap); - } else { - setLocalPreviewImages((prev) => { - const newMap = typeof updater === 'function' ? updater(prev) : updater; - return newMap; - }); - } - }, [onPreviewMapChange, previewMap, localPreviewImages]); + const setPreviewImages = useCallback( + (updater: Map | ((prev: Map) => Map)) => { + if (onPreviewMapChange) { + const currentMap = previewMap !== undefined ? previewMap : localPreviewImages; + const newMap = typeof updater === 'function' ? updater(currentMap) : updater; + onPreviewMapChange(newMap); + } else { + setLocalPreviewImages((prev) => { + const newMap = typeof updater === 'function' ? updater(prev) : updater; + return newMap; + }); + } + }, + [onPreviewMapChange, previewMap, localPreviewImages] + ); const fileInputRef = useRef(null); const currentProject = useAppStore((state) => state.currentProject); // Construct server URL for loading saved images - const getImageServerUrl = useCallback((imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; - const projectPath = currentProject?.path || ""; - return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; - }, [currentProject?.path]); + const getImageServerUrl = useCallback( + (imagePath: string): string => { + const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const projectPath = currentProject?.path || ''; + return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; + }, + [currentProject?.path] + ); const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; - const saveImageToTemp = useCallback(async ( - base64Data: string, - filename: string, - mimeType: string - ): Promise => { - try { - const api = getElectronAPI(); - // Check if saveImageToTemp method exists - if (!api.saveImageToTemp) { - // Fallback path when saveImageToTemp is not available - console.log("[DescriptionImageDropZone] Using fallback path for image"); - return `.automaker/images/${Date.now()}_${filename}`; - } + const saveImageToTemp = useCallback( + async (base64Data: string, filename: string, mimeType: string): Promise => { + try { + const api = getElectronAPI(); + // Check if saveImageToTemp method exists + if (!api.saveImageToTemp) { + // Fallback path when saveImageToTemp is not available + console.log('[DescriptionImageDropZone] Using fallback path for image'); + return `.automaker/images/${Date.now()}_${filename}`; + } - // Get projectPath from the store if available - const projectPath = currentProject?.path; - const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); - if (result.success && result.path) { - return result.path; + // Get projectPath from the store if available + const projectPath = currentProject?.path; + const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); + if (result.success && result.path) { + return result.path; + } + console.error('[DescriptionImageDropZone] Failed to save image:', result.error); + return null; + } catch (error) { + console.error('[DescriptionImageDropZone] Error saving image:', error); + return null; } - console.error("[DescriptionImageDropZone] Failed to save image:", result.error); - return null; - } catch (error) { - console.error("[DescriptionImageDropZone] Error saving image:", error); - return null; - } - }, [currentProject?.path]); + }, + [currentProject?.path] + ); const processFiles = useCallback( async (files: FileList) => { @@ -137,18 +135,14 @@ export function DescriptionImageDropZone({ for (const file of Array.from(files)) { // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push( - `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.` - ); + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); continue; } // Validate file size if (file.size > maxFileSize) { const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push( - `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -176,13 +170,13 @@ export function DescriptionImageDropZone({ } else { errors.push(`${file.name}: Failed to save image.`); } - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -192,7 +186,16 @@ export function DescriptionImageDropZone({ setIsProcessing(false); }, - [disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp] + [ + disabled, + isProcessing, + images, + maxFiles, + maxFileSize, + onImagesChange, + previewImages, + saveImageToTemp, + ] ); const handleDrop = useCallback( @@ -236,7 +239,7 @@ export function DescriptionImageDropZone({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -276,17 +279,15 @@ export function DescriptionImageDropZone({ const item = clipboardItems[i]; // Check if the item is an image - if (item.type.startsWith("image/")) { + if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { // Generate a filename for pasted images since they don't have one - const extension = item.type.split("/")[1] || "png"; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const renamedFile = new File( - [file], - `pasted-image-${timestamp}.${extension}`, - { type: file.type } - ); + const extension = item.type.split('/')[1] || 'png'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, { + type: file.type, + }); imageFiles.push(renamedFile); } } @@ -307,13 +308,13 @@ export function DescriptionImageDropZone({ ); return ( -
+
{/* Hidden file input */} {/* Drag overlay */} {isDragOver && !disabled && ( @@ -355,17 +352,14 @@ export function DescriptionImageDropZone({ disabled={disabled} autoFocus={autoFocus} aria-invalid={error} - className={cn( - "min-h-[120px]", - isProcessing && "opacity-50 pointer-events-none" - )} + className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')} data-testid="feature-description-input" />
{/* Hint text */}

- Paste, drag and drop images, or{" "} + Paste, drag and drop images, or{' '} {" "} + {' '} to attach context images

@@ -390,7 +384,7 @@ export function DescriptionImageDropZone({

- {images.length} image{images.length > 1 ? "s" : ""} attached + {images.length} image{images.length > 1 ? 's' : ''} attached

))} diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx index a16dfcb6..0cb5403c 100644 --- a/apps/ui/src/components/ui/feature-image-upload.tsx +++ b/apps/ui/src/components/ui/feature-image-upload.tsx @@ -1,7 +1,6 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; export interface FeatureImage { id: string; @@ -20,13 +19,7 @@ interface FeatureImageUploadProps { disabled?: boolean; } -const ACCEPTED_IMAGE_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export function FeatureImageUpload({ @@ -45,13 +38,13 @@ export function FeatureImageUpload({ return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - if (typeof reader.result === "string") { + if (typeof reader.result === 'string') { resolve(reader.result); } else { - reject(new Error("Failed to read file as base64")); + reject(new Error('Failed to read file as base64')); } }; - reader.onerror = () => reject(new Error("Failed to read file")); + reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsDataURL(file); }); }; @@ -67,18 +60,14 @@ export function FeatureImageUpload({ for (const file of Array.from(files)) { // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push( - `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.` - ); + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); continue; } // Validate file size if (file.size > maxFileSize) { const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push( - `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.` - ); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); continue; } @@ -98,13 +87,13 @@ export function FeatureImageUpload({ size: file.size, }; newImages.push(imageAttachment); - } catch (error) { + } catch { errors.push(`${file.name}: Failed to process image.`); } } if (errors.length > 0) { - console.warn("Image upload errors:", errors); + console.warn('Image upload errors:', errors); } if (newImages.length > 0) { @@ -157,7 +146,7 @@ export function FeatureImageUpload({ } // Reset the input so the same file can be selected again if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = ''; } }, [processFiles] @@ -180,22 +169,14 @@ export function FeatureImageUpload({ onImagesChange([]); }, [onImagesChange]); - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; - }; - return ( -
+
{/* Hidden file input */}
{isProcessing ? ( @@ -237,13 +215,10 @@ export function FeatureImageUpload({ )}

- {isDragOver && !disabled - ? "Drop images here" - : "Click or drag images here"} + {isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}

- Up to {maxFiles} images, max{" "} - {Math.round(maxFileSize / (1024 * 1024))}MB each + Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each

@@ -253,7 +228,7 @@ export function FeatureImageUpload({

- {images.length} image{images.length > 1 ? "s" : ""} selected + {images.length} image{images.length > 1 ? 's' : ''} selected

))} diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx index 5494bdc3..04e53491 100644 --- a/apps/ui/src/components/ui/image-drop-zone.tsx +++ b/apps/ui/src/components/ui/image-drop-zone.tsx @@ -1,8 +1,7 @@ - -import React, { useState, useRef, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { ImageIcon, X, Upload } from "lucide-react"; -import type { ImageAttachment } from "@/store/app-store"; +import React, { useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; +import { ImageIcon, X, Upload } from 'lucide-react'; +import type { ImageAttachment } from '@/store/app-store'; interface ImageDropZoneProps { onImagesSelected: (images: ImageAttachment[]) => void; @@ -35,88 +34,100 @@ export function ImageDropZone({ const selectedImages = images ?? internalImages; // Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state - const updateImages = useCallback((newImages: ImageAttachment[]) => { - if (images === undefined) { - setInternalImages(newImages); - } - onImagesSelected(newImages); - }, [images, onImagesSelected]); + const updateImages = useCallback( + (newImages: ImageAttachment[]) => { + if (images === undefined) { + setInternalImages(newImages); + } + onImagesSelected(newImages); + }, + [images, onImagesSelected] + ); - const processFiles = useCallback(async (files: FileList) => { - if (disabled || isProcessing) return; + const processFiles = useCallback( + async (files: FileList) => { + if (disabled || isProcessing) return; - setIsProcessing(true); - const newImages: ImageAttachment[] = []; - const errors: string[] = []; + setIsProcessing(true); + const newImages: ImageAttachment[] = []; + const errors: string[] = []; - for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; + for (const file of Array.from(files)) { + // Validate file type + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); + continue; + } + + // Validate file size + if (file.size > maxFileSize) { + const maxSizeMB = maxFileSize / (1024 * 1024); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + continue; + } + + // Check if we've reached max files + if (newImages.length + selectedImages.length >= maxFiles) { + errors.push(`Maximum ${maxFiles} images allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const imageAttachment: ImageAttachment = { + id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + data: base64, + mimeType: file.type, + filename: file.name, + size: file.size, + }; + newImages.push(imageAttachment); + } catch { + errors.push(`${file.name}: Failed to process image.`); + } } - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; + if (errors.length > 0) { + console.warn('Image upload errors:', errors); + // You could show these errors to the user via a toast or notification } - // Check if we've reached max files - if (newImages.length + selectedImages.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} images allowed.`); - break; + if (newImages.length > 0) { + const allImages = [...selectedImages, ...newImages]; + updateImages(allImages); } - try { - const base64 = await fileToBase64(file); - const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - data: base64, - mimeType: file.type, - filename: file.name, - size: file.size, - }; - newImages.push(imageAttachment); - } catch (error) { - errors.push(`${file.name}: Failed to process image.`); + setIsProcessing(false); + }, + [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (disabled) return; + + const files = e.dataTransfer.files; + if (files.length > 0) { + processFiles(files); } - } + }, + [disabled, processFiles] + ); - if (errors.length > 0) { - console.warn('Image upload errors:', errors); - // You could show these errors to the user via a toast or notification - } - - if (newImages.length > 0) { - const allImages = [...selectedImages, ...newImages]; - updateImages(allImages); - } - - setIsProcessing(false); - }, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - if (disabled) return; - - const files = e.dataTransfer.files; - if (files.length > 0) { - processFiles(files); - } - }, [disabled, processFiles]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!disabled) { - setIsDragOver(true); - } - }, [disabled]); + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragOver(true); + } + }, + [disabled] + ); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -124,16 +135,19 @@ export function ImageDropZone({ setIsDragOver(false); }, []); - const handleFileSelect = useCallback((e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - processFiles(files); - } - // Reset the input so the same file can be selected again - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }, [processFiles]); + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFiles(files); + } + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + [processFiles] + ); const handleBrowseClick = useCallback(() => { if (!disabled && fileInputRef.current) { @@ -141,17 +155,20 @@ export function ImageDropZone({ } }, [disabled]); - const removeImage = useCallback((imageId: string) => { - const updated = selectedImages.filter(img => img.id !== imageId); - updateImages(updated); - }, [selectedImages, updateImages]); + const removeImage = useCallback( + (imageId: string) => { + const updated = selectedImages.filter((img) => img.id !== imageId); + updateImages(updated); + }, + [selectedImages, updateImages] + ); const clearAllImages = useCallback(() => { updateImages([]); }, [updateImages]); return ( -
+
{/* Hidden file input */} {children || (
-
+
{isProcessing ? ( ) : ( @@ -191,10 +208,13 @@ export function ImageDropZone({ )}

- {isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"} + {isDragOver && !disabled + ? 'Drop your images here' + : 'Drag images here or click to browse'}

- {maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each + {maxFiles > 1 ? `Up to ${maxFiles} images` : '1 image'}, max{' '} + {Math.round(maxFileSize / (1024 * 1024))}MB each

{!disabled && (