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.
This commit is contained in:
Kacper
2025-12-21 23:08:08 +01:00
parent 43c93fe19a
commit 26236d3d5b
40 changed files with 2013 additions and 2587 deletions

View File

@@ -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<string>("");
const [pathInput, setPathInput] = useState<string>("");
const [currentPath, setCurrentPath] = useState<string>('');
const [pathInput, setPathInput] = useState<string>('');
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
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<string[]>([]);
const pathInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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}
>
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate max-w-[120px]">
{getFolderName(folder)}
</span>
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
<button
onClick={(e) => handleRemoveRecent(e, folder)}
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
@@ -351,15 +340,13 @@ export function FileBrowserDialog({
{drives.map((drive) => (
<Button
key={drive}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
variant={currentPath.startsWith(drive) ? 'default' : 'outline'}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-6 px-2 text-xs"
disabled={loading}
>
{drive.replace("\\", "")}
{drive.replace('\\', '')}
</Button>
))}
</div>
@@ -388,7 +375,7 @@ export function FileBrowserDialog({
</Button>
)}
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
{currentPath || "Loading..."}
{currentPath || 'Loading...'}
</div>
</div>
@@ -396,9 +383,7 @@ export function FileBrowserDialog({
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
{loading && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
Loading directories...
</div>
<div className="text-xs text-muted-foreground">Loading directories...</div>
</div>
)}
@@ -416,9 +401,7 @@ export function FileBrowserDialog({
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
No subdirectories found
</div>
<div className="text-xs text-muted-foreground">No subdirectories found</div>
</div>
)}
@@ -440,8 +423,8 @@ export function FileBrowserDialog({
</div>
<div className="text-[10px] text-muted-foreground">
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.
</div>
</div>
@@ -458,10 +441,9 @@ export function FileBrowserDialog({
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
{typeof navigator !== "undefined" &&
navigator.platform?.includes("Mac")
? "⌘"
: "Ctrl"}
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
? '⌘'
: 'Ctrl'}
+
</kbd>
</Button>

View File

@@ -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() {
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader
sidebarOpen={sidebarOpen}
navigate={navigate}
handleBugReportClick={handleBugReportClick}
/>
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (

View File

@@ -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 (
<button
onClick={onClick}
onClick={handleBugReportClick}
className={cn(
'titlebar-no-drag px-3 py-2.5 rounded-xl',
'text-muted-foreground hover:text-foreground hover:bg-accent/80',

View File

@@ -37,7 +37,7 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
interface ProjectSelectorWithOptionsProps {
sidebarOpen: boolean;
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean) => void;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setShowDeleteProjectDialog: (show: boolean) => void;
}

View File

@@ -6,10 +6,9 @@ import { BugReportButton } from './bug-report-button';
interface SidebarHeaderProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
handleBugReportClick: () => void;
}
export function SidebarHeader({ sidebarOpen, navigate, handleBugReportClick }: SidebarHeaderProps) {
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
return (
<>
{/* Logo */}
@@ -26,13 +25,13 @@ export function SidebarHeader({ sidebarOpen, navigate, handleBugReportClick }: S
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded onClick={handleBugReportClick} />}
{sidebarOpen && <BugReportButton sidebarExpanded />}
</div>
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} onClick={handleBugReportClick} />
<BugReportButton sidebarExpanded={false} />
</div>
)}
</>

View File

@@ -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;
}

View File

@@ -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<AccordionContextValue | null>(
null
);
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
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<HTMLDivElement> {
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(
{
type = "single",
type = 'single',
value,
defaultValue,
onValueChange,
@@ -38,13 +37,11 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
},
ref
) => {
const [internalValue, setInternalValue] = React.useState<string | string[]>(
() => {
if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue;
return type === "single" ? "" : [];
}
);
const [internalValue, setInternalValue] = React.useState<string | string[]>(() => {
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<HTMLDivElement, AccordionProps>(
(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<HTMLDivElement, AccordionProps>(
return (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion"
className={cn("w-full", className)}
{...props}
>
<div ref={ref} data-slot="accordion" className={cn('w-full', className)} {...props}>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = "Accordion";
Accordion.displayName = 'Accordion';
interface AccordionItemContextValue {
value: string;
isOpen: boolean;
}
const AccordionItemContext =
React.createContext<AccordionItemContextValue | null>(null);
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
@@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
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 (
<AccordionItemContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion-item"
data-state={isOpen ? "open" : "closed"}
className={cn("border-b border-border", className)}
data-state={isOpen ? 'open' : 'closed'}
className={cn('border-b border-border', className)}
{...props}
>
{children}
@@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
);
}
);
AccordionItem.displayName = "AccordionItem";
AccordionItem.displayName = 'AccordionItem';
interface AccordionTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const AccordionTrigger = React.forwardRef<
HTMLButtonElement,
AccordionTriggerProps
>(({ className, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext);
const itemContext = React.useContext(AccordionItemContext);
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ 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 (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? 'open' : 'closed'}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
}
const { onValueChange } = accordionContext;
const { value, isOpen } = itemContext;
return (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? "open" : "closed"}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
});
AccordionTrigger.displayName = "AccordionTrigger";
);
AccordionTrigger.displayName = 'AccordionTrigger';
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
@@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
const [height, setHeight] = React.useState<number | undefined>(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<HTMLDivElement, AccordionContentProps>
return (
<div
data-slot="accordion-content"
data-state={isOpen ? "open" : "closed"}
data-state={isOpen ? 'open' : 'closed'}
className="overflow-hidden text-sm transition-all duration-200 ease-out"
style={{
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
height: isOpen ? (height !== undefined ? `${height}px` : 'auto') : 0,
opacity: isOpen ? 1 : 0,
}}
{...props}
>
<div ref={contentRef}>
<div ref={ref} className={cn("pb-4 pt-0", className)}>
<div ref={ref} className={cn('pb-4 pt-0', className)}>
{children}
</div>
</div>
@@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
);
}
);
AccordionContent.displayName = "AccordionContent";
AccordionContent.displayName = 'AccordionContent';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -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<string, string>;
@@ -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<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
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<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
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<HTMLInputElement>(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<string> => {
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<string | null> => {
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<string | null> => {
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 (
<div className={cn("relative", className)}>
<div className={cn('relative', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
@@ -325,13 +326,9 @@ export function DescriptionImageDropZone({
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-md transition-all duration-200",
{
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
isDragOver && !disabled,
}
)}
className={cn('relative rounded-md transition-all duration-200', {
'ring-2 ring-blue-400 ring-offset-2 ring-offset-background': isDragOver && !disabled,
})}
>
{/* 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"
/>
</div>
{/* Hint text */}
<p className="text-xs text-muted-foreground mt-1">
Paste, drag and drop images, or{" "}
Paste, drag and drop images, or{' '}
<button
type="button"
onClick={handleBrowseClick}
@@ -373,7 +367,7 @@ export function DescriptionImageDropZone({
disabled={disabled || isProcessing}
>
browse
</button>{" "}
</button>{' '}
to attach context images
</p>
@@ -390,7 +384,7 @@ export function DescriptionImageDropZone({
<div className="mt-3 space-y-2" data-testid="description-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} attached
{images.length} image{images.length > 1 ? 's' : ''} attached
</p>
<button
type="button"
@@ -447,9 +441,7 @@ export function DescriptionImageDropZone({
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
<p className="text-[10px] text-white truncate">{image.filename}</p>
</div>
</div>
))}

View File

@@ -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 (
<div className={cn("relative", className)}>
<div className={cn('relative', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
@@ -209,13 +190,12 @@ export function FeatureImageUpload({
onDragLeave={handleDragLeave}
onClick={handleBrowseClick}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
'relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer',
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20":
isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
'border-muted-foreground/25': !isDragOver && !disabled,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled,
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10':
!disabled && !isDragOver,
}
)}
@@ -224,10 +204,8 @@ export function FeatureImageUpload({
<div className="flex flex-col items-center justify-center p-4 text-center">
<div
className={cn(
"rounded-full p-2 mb-2",
isDragOver && !disabled
? "bg-blue-100 dark:bg-blue-900/30"
: "bg-muted"
'rounded-full p-2 mb-2',
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
)}
>
{isProcessing ? (
@@ -237,13 +215,10 @@ export function FeatureImageUpload({
)}
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !disabled
? "Drop images here"
: "Click or drag images here"}
{isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Up to {maxFiles} images, max{" "}
{Math.round(maxFileSize / (1024 * 1024))}MB each
Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
</div>
</div>
@@ -253,7 +228,7 @@ export function FeatureImageUpload({
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} selected
{images.length} image{images.length > 1 ? 's' : ''} selected
</p>
<button
type="button"
@@ -295,9 +270,7 @@ export function FeatureImageUpload({
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
<p className="text-[10px] text-white truncate">{image.filename}</p>
</div>
</div>
))}

View File

@@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className={cn("relative", className)}>
<div className={cn('relative', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
@@ -168,22 +185,22 @@ export function ImageDropZone({
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
}
)}
className={cn('relative rounded-lg border-2 border-dashed transition-all duration-200', {
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
'border-muted-foreground/25': !isDragOver && !disabled,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled,
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10':
!disabled && !isDragOver,
})}
>
{children || (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className={cn(
"rounded-full p-3 mb-4",
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
)}>
<div
className={cn(
'rounded-full p-3 mb-4',
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
@@ -191,10 +208,13 @@ export function ImageDropZone({
)}
</div>
<p className="text-sm font-medium text-foreground mb-1">
{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'}
</p>
<p className="text-xs text-muted-foreground">
{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
</p>
{!disabled && (
<button
@@ -231,7 +251,7 @@ export function ImageDropZone({
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
<div className="w-8 h-8 rounded overflow-hidden bg-muted shrink-0">
<img
src={image.data}
alt={image.filename}
@@ -240,13 +260,9 @@ export function ImageDropZone({
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{image.filename}
</p>
<p className="text-xs font-medium text-foreground truncate">{image.filename}</p>
{image.size !== undefined && (
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size)}
</p>
<p className="text-xs text-muted-foreground">{formatFileSize(image.size)}</p>
)}
</div>
{/* Remove button */}
@@ -288,4 +304,4 @@ function formatFileSize(bytes: number): string {
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];
}
}

View File

@@ -1,29 +1,24 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
@@ -33,13 +28,13 @@ interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
SheetOverlayProps & { "data-slot": string }
SheetOverlayProps & { 'data-slot': string }
>;
return (
<Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
@@ -48,21 +43,16 @@ const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
};
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: "top" | "right" | "bottom" | "left";
side?: 'top' | 'right' | 'bottom' | 'left';
forceMount?: true;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: PointerEvent) => void;
onInteractOutside?: (event: Event) => void;
}
const SheetContent = ({
className,
children,
side = "right",
...props
}: SheetContentProps) => {
const SheetContent = ({ className, children, side = 'right', ...props }: SheetContentProps) => {
const Content = SheetPrimitive.Content as React.ComponentType<
SheetContentProps & { "data-slot": string }
SheetContentProps & { 'data-slot': string }
>;
const Close = SheetPrimitive.Close as React.ComponentType<{
className: string;
@@ -75,15 +65,15 @@ const SheetContent = ({
<Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className
)}
{...props}
@@ -98,21 +88,21 @@ const SheetContent = ({
);
};
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
@@ -122,28 +112,27 @@ interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
const Title = SheetPrimitive.Title as React.ComponentType<
SheetTitleProps & { "data-slot": string }
SheetTitleProps & { 'data-slot': string }
>;
return (
<Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
};
interface SheetDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
interface SheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
const Description = SheetPrimitive.Description as React.ComponentType<
SheetDescriptionProps & { "data-slot": string }
SheetDescriptionProps & { 'data-slot': string }
>;
return (
<Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);

View File

@@ -1,14 +1,12 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore, type AgentModel } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useAppStore, type AgentModel } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ImageDropZone } from '@/components/ui/image-drop-zone';
import {
Bot,
Send,
User,
Loader2,
Sparkles,
Wrench,
Trash2,
@@ -18,37 +16,36 @@ import {
X,
ImageIcon,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
import { SessionManager } from "@/components/session-manager";
import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useElectronAgent } from '@/hooks/use-electron-agent';
import { SessionManager } from '@/components/session-manager';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment } from '@/store/app-store';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} from '@/hooks/use-keyboard-shortcuts';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
} from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
useAppStore();
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState("");
const [input, setInput] = useState('');
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
@@ -72,7 +69,7 @@ export function AgentView() {
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || "",
sessionId: currentSessionId || '',
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
@@ -108,10 +105,7 @@ export function AgentView() {
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log(
"[AgentView] Restoring last selected session:",
lastSessionId
);
console.log('[AgentView] Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
@@ -127,7 +121,7 @@ export function AgentView() {
const messageContent = input;
const messageImages = selectedImages;
setInput("");
setInput('');
setSelectedImages([]);
setShowImageDropZone(false);
@@ -147,13 +141,13 @@ export function AgentView() {
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);
});
}, []);
@@ -164,11 +158,11 @@ export function AgentView() {
if (isProcessing) return;
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
@@ -179,18 +173,14 @@ export function AgentView() {
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 > MAX_FILE_SIZE) {
const maxSizeMB = MAX_FILE_SIZE / (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;
}
@@ -216,7 +206,7 @@ export function AgentView() {
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
console.warn('Image upload errors:', errors);
}
if (newImages.length > 0) {
@@ -239,7 +229,7 @@ export function AgentView() {
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes("Files")) {
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
},
@@ -285,7 +275,7 @@ export function AgentView() {
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
@@ -309,9 +299,9 @@ export function AgentView() {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) {
if (file && file.type.startsWith('image/')) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
@@ -329,14 +319,14 @@ export function AgentView() {
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClearChat = async () => {
if (!confirm("Are you sure you want to clear this conversation?")) return;
if (!confirm('Are you sure you want to clear this conversation?')) return;
await clearHistory();
};
@@ -347,14 +337,13 @@ export function AgentView() {
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const container = messagesContainerRef.current;
if (!container) return;
@@ -375,7 +364,7 @@ export function AgentView() {
if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom("smooth");
scrollToBottom('smooth');
}, 100);
}
}, [messages, isUserAtBottom, scrollToBottom]);
@@ -385,7 +374,7 @@ export function AgentView() {
if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom("auto");
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
@@ -414,7 +403,7 @@ export function AgentView() {
quickCreateSessionRef.current();
}
},
description: "Create new session",
description: 'Create new session',
});
}
@@ -434,9 +423,7 @@ export function AgentView() {
<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>
<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>
@@ -450,8 +437,8 @@ export function AgentView() {
messages.length === 0
? [
{
id: "welcome",
role: "assistant" as const,
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(),
@@ -460,10 +447,7 @@ export function AgentView() {
: messages;
return (
<div
className="flex-1 flex overflow-hidden bg-background"
data-testid="agent-view"
>
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
@@ -498,12 +482,10 @@ export function AgentView() {
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">
AI Agent
</h1>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && " - Connecting..."}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
</div>
@@ -521,7 +503,10 @@ export function AgentView() {
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
@@ -530,17 +515,12 @@ export function AgentView() {
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
"cursor-pointer",
selectedModel === model.id && "bg-accent"
)}
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>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
@@ -554,9 +534,7 @@ export function AgentView() {
</div>
)}
{agentError && (
<span className="text-xs text-destructive font-medium">
{agentError}
</span>
<span className="text-xs text-destructive font-medium">{agentError}</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
@@ -583,9 +561,7 @@ export function AgentView() {
<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>
<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>
@@ -595,7 +571,7 @@ export function AgentView() {
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? "View" : "Show"} Sessions
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
@@ -610,20 +586,20 @@ export function AgentView() {
<div
key={message.id}
className={cn(
"flex gap-4 max-w-4xl",
message.role === "user" ? "flex-row-reverse ml-auto" : ""
'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"
'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" ? (
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
@@ -633,76 +609,67 @@ export function AgentView() {
{/* 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"
'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" ? (
{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>
<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>
{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"
'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",
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
@@ -720,20 +687,18 @@ export function AgentView() {
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "0ms" }}
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "150ms" }}
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "300ms" }}
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">
Thinking...
</span>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
@@ -761,7 +726,7 @@ export function AgentView() {
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
{selectedImages.length > 1 ? 's' : ''} attached
</p>
<button
onClick={() => setSelectedImages([])}
@@ -815,8 +780,8 @@ export function AgentView() {
{/* 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"
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -827,9 +792,7 @@ export function AgentView() {
<Input
ref={inputRef}
placeholder={
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
isDragOver ? 'Drop your images here...' : 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -838,16 +801,16 @@ export function AgentView() {
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
selectedImages.length > 0 && "border-primary/30",
isDragOver && "border-primary bg-primary/5"
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
selectedImages.length > 0 && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{selectedImages.length > 0 && !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">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""}
{selectedImages.length > 1 ? 's' : ''}
</div>
)}
{isDragOver && (
@@ -865,10 +828,9 @@ export function AgentView() {
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
"h-11 w-11 rounded-xl border-border",
showImageDropZone &&
"bg-primary/10 text-primary border-primary/30",
selectedImages.length > 0 && "border-primary/30 text-primary"
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
selectedImages.length > 0 && 'border-primary/30 text-primary'
)}
title="Attach images"
>
@@ -879,9 +841,7 @@ export function AgentView() {
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) ||
isProcessing ||
!isConnected
(!input.trim() && selectedImages.length === 0) || isProcessing || !isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
@@ -892,11 +852,9 @@ export function AgentView() {
{/* 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
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send
</p>
</div>
)}
@@ -907,9 +865,9 @@ export function AgentView() {
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
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 parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

View File

@@ -1,20 +1,8 @@
import { useCallback, useState } from "react";
import {
useAppStore,
FileTreeNode,
ProjectAnalysis,
Feature,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from 'react';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Folder,
FolderOpen,
@@ -30,29 +18,29 @@ import {
CheckCircle,
AlertCircle,
ListChecks,
} from "lucide-react";
import { cn } from "@/lib/utils";
} from 'lucide-react';
import { cn } from '@/lib/utils';
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
".cache",
"coverage",
"__pycache__",
".pytest_cache",
".venv",
"venv",
".env",
'node_modules',
'.git',
'.next',
'dist',
'build',
'.DS_Store',
'*.log',
'.cache',
'coverage',
'__pycache__',
'.pytest_cache',
'.venv',
'venv',
'.env',
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
if (pattern.startsWith('*')) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
@@ -60,8 +48,8 @@ const shouldIgnore = (name: string) => {
};
const getExtension = (filename: string): string => {
const parts = filename.split(".");
return parts.length > 1 ? parts.pop() || "" : "";
const parts = filename.split('.');
return parts.length > 1 ? parts.pop() || '' : '';
};
export function AnalysisView() {
@@ -74,9 +62,7 @@ export function AnalysisView() {
clearAnalysis,
} = useAppStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
const [specGenerated, setSpecGenerated] = useState(false);
const [specError, setSpecError] = useState<string | null>(null);
@@ -123,7 +109,7 @@ export function AnalysisView() {
return nodes;
} catch (error) {
console.error("Failed to scan directory:", path, error);
console.error('Failed to scan directory:', path, error);
return [];
}
},
@@ -148,7 +134,7 @@ export function AnalysisView() {
if (item.extension) {
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
} else {
byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
byExt['(no extension)'] = (byExt['(no extension)'] || 0) + 1;
}
}
}
@@ -179,17 +165,11 @@ export function AnalysisView() {
setProjectAnalysis(analysis);
} catch (error) {
console.error("Analysis failed:", error);
console.error('Analysis failed:', error);
} finally {
setIsAnalyzing(false);
}
}, [
currentProject,
setIsAnalyzing,
clearAnalysis,
scanDirectory,
setProjectAnalysis,
]);
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
// Generate app_spec.txt from analysis
const generateSpec = useCallback(async () => {
@@ -204,7 +184,7 @@ export function AnalysisView() {
// Read key files to understand the project better
const fileContents: Record<string, string> = {};
const keyFiles = ["package.json", "README.md", "tsconfig.json"];
const keyFiles = ['package.json', 'README.md', 'tsconfig.json'];
// Collect file paths from analysis
const collectFilePaths = (
@@ -217,15 +197,13 @@ export function AnalysisView() {
if (!node.isDirectory) {
paths.push(node.path);
} else if (node.children && currentDepth < maxDepth) {
paths.push(
...collectFilePaths(node.children, maxDepth, currentDepth + 1)
);
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
}
}
return paths;
};
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
collectFilePaths(projectAnalysis.fileTree);
// Try to read key configuration files
for (const keyFile of keyFiles) {
@@ -245,40 +223,34 @@ export function AnalysisView() {
const extensions = projectAnalysis.filesByExtension;
// Check package.json for dependencies
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"])
stack.push("React");
if (pkg.dependencies?.next) stack.push("Next.js");
if (pkg.dependencies?.vue) stack.push("Vue");
if (pkg.dependencies?.angular) stack.push("Angular");
if (pkg.dependencies?.express) stack.push("Express");
if (pkg.dependencies?.electron) stack.push("Electron");
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React');
if (pkg.dependencies?.next) stack.push('Next.js');
if (pkg.dependencies?.vue) stack.push('Vue');
if (pkg.dependencies?.angular) stack.push('Angular');
if (pkg.dependencies?.express) stack.push('Express');
if (pkg.dependencies?.electron) stack.push('Electron');
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
stack.push("TypeScript");
if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
)
stack.push("Tailwind CSS");
stack.push('TypeScript');
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss)
stack.push('Tailwind CSS');
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
stack.push("Playwright");
if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
stack.push("Jest");
stack.push('Playwright');
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest');
} catch {
// Ignore JSON parse errors
}
}
// Detect by file extensions
if (extensions["ts"] || extensions["tsx"]) stack.push("TypeScript");
if (extensions["py"]) stack.push("Python");
if (extensions["go"]) stack.push("Go");
if (extensions["rs"]) stack.push("Rust");
if (extensions["java"]) stack.push("Java");
if (extensions["css"] || extensions["scss"] || extensions["sass"])
stack.push("CSS/SCSS");
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript');
if (extensions['py']) stack.push('Python');
if (extensions['go']) stack.push('Go');
if (extensions['rs']) stack.push('Rust');
if (extensions['java']) stack.push('Java');
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS');
// Remove duplicates
return [...new Set(stack)];
@@ -286,9 +258,9 @@ export function AnalysisView() {
// Get project name from package.json or folder name
const getProjectName = () => {
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.name) return pkg.name;
} catch {
// Ignore JSON parse errors
@@ -300,30 +272,30 @@ export function AnalysisView() {
// Get project description from package.json or README
const getProjectDescription = () => {
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.description) return pkg.description;
} catch {
// Ignore JSON parse errors
}
}
if (fileContents["README.md"]) {
if (fileContents['README.md']) {
// Extract first paragraph from README
const lines = fileContents["README.md"].split("\n");
const lines = fileContents['README.md'].split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (
trimmed &&
!trimmed.startsWith("#") &&
!trimmed.startsWith("!") &&
!trimmed.startsWith('#') &&
!trimmed.startsWith('!') &&
trimmed.length > 20
) {
return trimmed.substring(0, 200);
}
}
}
return "A software project";
return 'A software project';
};
// Group files by directory for structure analysis
@@ -336,7 +308,7 @@ export function AnalysisView() {
for (const dir of topLevelDirs) {
structure.push(` <directory name="${dir}" />`);
}
return structure.join("\n");
return structure.join('\n');
};
const projectName = getProjectName();
@@ -356,20 +328,15 @@ export function AnalysisView() {
<languages>
${Object.entries(projectAnalysis.filesByExtension)
.filter(([ext]: [string, number]) =>
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
ext
)
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext)
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5)
.map(
([ext, count]: [string, number]) =>
` <language ext=".${ext}" count="${count}" />`
)
.join("\n")}
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
.join('\n')}
</languages>
<frameworks>
${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')}
</frameworks>
</technology_stack>
@@ -387,11 +354,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
.slice(0, 10)
.map(
([ext, count]: [string, number]) =>
` <extension type="${
ext.startsWith("(") ? ext : "." + ext
}" count="${count}" />`
` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`
)
.join("\n")}
.join('\n')}
</file_breakdown>
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
@@ -405,13 +370,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (writeResult.success) {
setSpecGenerated(true);
} else {
setSpecError(writeResult.error || "Failed to write spec file");
setSpecError(writeResult.error || 'Failed to write spec file');
}
} catch (error) {
console.error("Failed to generate spec:", error);
setSpecError(
error instanceof Error ? error.message : "Failed to generate spec"
);
console.error('Failed to generate spec:', error);
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
} finally {
setIsGeneratingSpec(false);
}
@@ -430,7 +393,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Read key files to understand the project
const fileContents: Record<string, string> = {};
const keyFiles = ["package.json", "README.md"];
const keyFiles = ['package.json', 'README.md'];
// Try to read key configuration files
for (const keyFile of keyFiles) {
@@ -481,21 +444,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for test directories and files
const hasTests =
topLevelDirs.includes("tests") ||
topLevelDirs.includes("test") ||
topLevelDirs.includes("__tests__") ||
allFilePaths.some(
(p) => p.includes(".spec.") || p.includes(".test.")
);
topLevelDirs.includes('tests') ||
topLevelDirs.includes('test') ||
topLevelDirs.includes('__tests__') ||
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.'));
if (hasTests) {
detectedFeatures.push({
category: "Testing",
description: "Automated test suite",
category: 'Testing',
description: 'Automated test suite',
steps: [
"Step 1: Tests directory exists",
"Step 2: Test files are present",
"Step 3: Run test suite",
'Step 1: Tests directory exists',
'Step 2: Test files are present',
'Step 3: Run test suite',
],
passes: true,
});
@@ -503,50 +464,50 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for components directory (UI components)
const hasComponents =
topLevelDirs.includes("components") ||
allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
topLevelDirs.includes('components') ||
allFilePaths.some((p) => p.toLowerCase().includes('/components/'));
if (hasComponents) {
detectedFeatures.push({
category: "UI/Design",
description: "Component-based UI architecture",
category: 'UI/Design',
description: 'Component-based UI architecture',
steps: [
"Step 1: Components directory exists",
"Step 2: UI components are defined",
"Step 3: Components are reusable",
'Step 1: Components directory exists',
'Step 2: UI components are defined',
'Step 3: Components are reusable',
],
passes: true,
});
}
// Check for src directory (organized source code)
if (topLevelDirs.includes("src")) {
if (topLevelDirs.includes('src')) {
detectedFeatures.push({
category: "Project Structure",
description: "Organized source code structure",
category: 'Project Structure',
description: 'Organized source code structure',
steps: [
"Step 1: Source directory exists",
"Step 2: Code is properly organized",
"Step 3: Follows best practices",
'Step 1: Source directory exists',
'Step 2: Code is properly organized',
'Step 3: Follows best practices',
],
passes: true,
});
}
// Check package.json for dependencies and detect features
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
// React/Next.js app detection
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"]) {
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
detectedFeatures.push({
category: "Frontend",
description: "React-based user interface",
category: 'Frontend',
description: 'React-based user interface',
steps: [
"Step 1: React is installed",
"Step 2: Components render correctly",
"Step 3: State management works",
'Step 1: React is installed',
'Step 2: Components render correctly',
'Step 3: State management works',
],
passes: true,
});
@@ -554,12 +515,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (pkg.dependencies?.next) {
detectedFeatures.push({
category: "Framework",
description: "Next.js framework integration",
category: 'Framework',
description: 'Next.js framework integration',
steps: [
"Step 1: Next.js is configured",
"Step 2: Pages/routes are defined",
"Step 3: Server-side rendering works",
'Step 1: Next.js is configured',
'Step 2: Pages/routes are defined',
'Step 3: Server-side rendering works',
],
passes: true,
});
@@ -569,33 +530,30 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (
pkg.devDependencies?.typescript ||
pkg.dependencies?.typescript ||
extensions["ts"] ||
extensions["tsx"]
extensions['ts'] ||
extensions['tsx']
) {
detectedFeatures.push({
category: "Developer Experience",
description: "TypeScript type safety",
category: 'Developer Experience',
description: 'TypeScript type safety',
steps: [
"Step 1: TypeScript is configured",
"Step 2: Type definitions exist",
"Step 3: Code compiles without errors",
'Step 1: TypeScript is configured',
'Step 2: Type definitions exist',
'Step 3: Code compiles without errors',
],
passes: true,
});
}
// Tailwind CSS
if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
) {
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) {
detectedFeatures.push({
category: "UI/Design",
description: "Tailwind CSS styling",
category: 'UI/Design',
description: 'Tailwind CSS styling',
steps: [
"Step 1: Tailwind is configured",
"Step 2: Styles are applied",
"Step 3: Responsive design works",
'Step 1: Tailwind is configured',
'Step 2: Styles are applied',
'Step 3: Responsive design works',
],
passes: true,
});
@@ -604,12 +562,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
// ESLint/Prettier (code quality)
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
detectedFeatures.push({
category: "Developer Experience",
description: "Code quality tools",
category: 'Developer Experience',
description: 'Code quality tools',
steps: [
"Step 1: Linter is configured",
"Step 2: Code passes lint checks",
"Step 3: Formatting is consistent",
'Step 1: Linter is configured',
'Step 2: Code passes lint checks',
'Step 3: Formatting is consistent',
],
passes: true,
});
@@ -618,29 +576,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Electron (desktop app)
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
detectedFeatures.push({
category: "Platform",
description: "Electron desktop application",
category: 'Platform',
description: 'Electron desktop application',
steps: [
"Step 1: Electron is configured",
"Step 2: Main process runs",
"Step 3: Renderer process loads",
'Step 1: Electron is configured',
'Step 2: Main process runs',
'Step 3: Renderer process loads',
],
passes: true,
});
}
// Playwright testing
if (
pkg.devDependencies?.playwright ||
pkg.devDependencies?.["@playwright/test"]
) {
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) {
detectedFeatures.push({
category: "Testing",
description: "Playwright end-to-end testing",
category: 'Testing',
description: 'Playwright end-to-end testing',
steps: [
"Step 1: Playwright is configured",
"Step 2: E2E tests are defined",
"Step 3: Tests pass successfully",
'Step 1: Playwright is configured',
'Step 2: E2E tests are defined',
'Step 3: Tests pass successfully',
],
passes: true,
});
@@ -651,17 +606,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
// Check for documentation
if (
topLevelFiles.includes("readme.md") ||
topLevelDirs.includes("docs")
) {
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
detectedFeatures.push({
category: "Documentation",
description: "Project documentation",
category: 'Documentation',
description: 'Project documentation',
steps: [
"Step 1: README exists",
"Step 2: Documentation is comprehensive",
"Step 3: Setup instructions are clear",
'Step 1: README exists',
'Step 2: Documentation is comprehensive',
'Step 3: Setup instructions are clear',
],
passes: true,
});
@@ -669,18 +621,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for CI/CD configuration
const hasCICD =
topLevelDirs.includes(".github") ||
topLevelFiles.includes(".gitlab-ci.yml") ||
topLevelFiles.includes(".travis.yml");
topLevelDirs.includes('.github') ||
topLevelFiles.includes('.gitlab-ci.yml') ||
topLevelFiles.includes('.travis.yml');
if (hasCICD) {
detectedFeatures.push({
category: "DevOps",
description: "CI/CD pipeline configuration",
category: 'DevOps',
description: 'CI/CD pipeline configuration',
steps: [
"Step 1: CI config exists",
"Step 2: Pipeline runs on push",
"Step 3: Automated checks pass",
'Step 1: CI config exists',
'Step 2: Pipeline runs on push',
'Step 3: Automated checks pass',
],
passes: true,
});
@@ -688,20 +640,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for API routes (Next.js API or Express)
const hasAPIRoutes = allFilePaths.some(
(p) =>
p.includes("/api/") ||
p.includes("/routes/") ||
p.includes("/endpoints/")
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/')
);
if (hasAPIRoutes) {
detectedFeatures.push({
category: "Backend",
description: "API endpoints",
category: 'Backend',
description: 'API endpoints',
steps: [
"Step 1: API routes are defined",
"Step 2: Endpoints respond correctly",
"Step 3: Error handling is implemented",
'Step 1: API routes are defined',
'Step 2: Endpoints respond correctly',
'Step 3: Error handling is implemented',
],
passes: true,
});
@@ -710,37 +659,34 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for state management
const hasStateManagement = allFilePaths.some(
(p) =>
p.includes("/store/") ||
p.includes("/stores/") ||
p.includes("/redux/") ||
p.includes("/context/")
p.includes('/store/') ||
p.includes('/stores/') ||
p.includes('/redux/') ||
p.includes('/context/')
);
if (hasStateManagement) {
detectedFeatures.push({
category: "Architecture",
description: "State management system",
category: 'Architecture',
description: 'State management system',
steps: [
"Step 1: Store is configured",
"Step 2: State updates correctly",
"Step 3: Components access state",
'Step 1: Store is configured',
'Step 2: State updates correctly',
'Step 3: Components access state',
],
passes: true,
});
}
// Check for configuration files
if (
topLevelFiles.includes("tsconfig.json") ||
topLevelFiles.includes("package.json")
) {
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) {
detectedFeatures.push({
category: "Configuration",
description: "Project configuration files",
category: 'Configuration',
description: 'Project configuration files',
steps: [
"Step 1: Config files exist",
"Step 2: Configuration is valid",
"Step 3: Build process works",
'Step 1: Config files exist',
'Step 2: Configuration is valid',
'Step 3: Build process works',
],
passes: true,
});
@@ -752,12 +698,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
// If no features were detected, add a default feature
if (detectedFeatures.length === 0) {
detectedFeatures.push({
category: "Core",
description: "Basic project structure",
category: 'Core',
description: 'Basic project structure',
steps: [
"Step 1: Project directory exists",
"Step 2: Files are present",
"Step 3: Project can be loaded",
'Step 1: Project directory exists',
'Step 2: Files are present',
'Step 3: Project can be loaded',
],
passes: true,
});
@@ -765,7 +711,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Create each feature using the features API
if (!api.features) {
throw new Error("Features API not available");
throw new Error('Features API not available');
}
for (const detectedFeature of detectedFeatures) {
@@ -774,17 +720,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
category: detectedFeature.category,
description: detectedFeature.description,
steps: detectedFeature.steps,
status: "backlog",
status: 'backlog',
});
}
setFeatureListGenerated(true);
} catch (error) {
console.error("Failed to generate feature list:", error);
console.error('Failed to generate feature list:', error);
setFeatureListError(
error instanceof Error
? error.message
: "Failed to generate feature list"
error instanceof Error ? error.message : 'Failed to generate feature list'
);
} finally {
setIsGeneratingFeatureList(false);
@@ -810,7 +754,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm"
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm'
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
@@ -840,17 +784,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
)}
<span className="truncate">{node.name}</span>
{node.extension && (
<span className="text-xs text-muted-foreground ml-auto">
.{node.extension}
</span>
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
)}
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child: FileTreeNode) =>
renderNode(child, depth + 1)
)}
</div>
<div>{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}</div>
)}
</div>
);
@@ -868,26 +806,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="analysis-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="analysis-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3">
<Search className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Project Analysis</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
</div>
<Button
onClick={runAnalysis}
disabled={isAnalyzing}
data-testid="analyze-project-button"
>
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -909,13 +838,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Click &quot;Analyze Project&quot; to scan your codebase and get
insights about its structure.
Click &quot;Analyze Project&quot; to scan your codebase and get insights about its
structure.
</p>
<Button
onClick={runAnalysis}
data-testid="analyze-project-button-empty"
>
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
<Search className="w-4 h-4 mr-2" />
Start Analysis
</Button>
@@ -936,27 +862,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
Statistics
</CardTitle>
<CardDescription>
Analyzed{" "}
{new Date(projectAnalysis.analyzedAt).toLocaleString()}
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Total Files
</span>
<span className="text-sm text-muted-foreground">Total Files</span>
<span className="font-medium" data-testid="total-files">
{projectAnalysis.totalFiles}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Total Directories
</span>
<span
className="font-medium"
data-testid="total-directories"
>
<span className="text-sm text-muted-foreground">Total Directories</span>
<span className="font-medium" data-testid="total-directories">
{projectAnalysis.totalDirectories}
</span>
</div>
@@ -973,15 +891,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort(
(a: [string, number], b: [string, number]) =>
b[1] - a[1]
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 15)
.map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm">
<span className="text-muted-foreground font-mono">
{ext.startsWith("(") ? ext : `.${ext}`}
{ext.startsWith('(') ? ext : `.${ext}`}
</span>
<span>{count}</span>
</div>
@@ -997,14 +912,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<FileText className="w-4 h-4" />
Generate Specification
</CardTitle>
<CardDescription>
Create app_spec.txt from analysis
</CardDescription>
<CardDescription>Create app_spec.txt from analysis</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Generate a project specification file based on the analyzed
codebase structure and detected technologies.
Generate a project specification file based on the analyzed codebase structure
and detected technologies.
</p>
<Button
onClick={generateSpec}
@@ -1052,15 +965,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<ListChecks className="w-4 h-4" />
Generate Feature List
</CardTitle>
<CardDescription>
Create features from analysis
</CardDescription>
<CardDescription>Create features from analysis</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Automatically detect and generate a feature list based on
the analyzed codebase structure, dependencies, and project
configuration.
Automatically detect and generate a feature list based on the analyzed codebase
structure, dependencies, and project configuration.
</p>
<Button
onClick={generateFeatureList}
@@ -1110,18 +1020,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
File Tree
</CardTitle>
<CardDescription>
{projectAnalysis.totalFiles} files in{" "}
{projectAnalysis.totalDirectories} directories
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '}
directories
</CardDescription>
</CardHeader>
<CardContent
className="p-0 overflow-y-auto h-full"
data-testid="analysis-file-tree"
>
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
<div className="p-2">
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
renderNode(node)
)}
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
</div>
</CardContent>
</Card>

View File

@@ -1,13 +1,11 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Bot } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { ClaudeUsagePopover } from "@/components/claude-usage-popover";
import { useAppStore } from "@/store/app-store";
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
interface BoardHeaderProps {
projectName: string;
@@ -36,7 +34,8 @@ export function BoardHeader({
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
return (
@@ -78,10 +77,7 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label
htmlFor="auto-mode-toggle"
className="text-sm font-medium cursor-pointer"
>
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import {
AgentTaskInfo,
parseAgentContext,
formatModelName,
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { cn } from "@/lib/utils";
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
@@ -17,21 +17,21 @@ import {
Circle,
Loader2,
Wrench,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { SummaryDialog } from "./summary-dialog";
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === "none") return "";
if (!level || level === 'none') return '';
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: '',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
return labels[level];
}
@@ -53,7 +53,7 @@ export function AgentInfoPanel({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
useEffect(() => {
const loadContext = async () => {
@@ -63,22 +63,18 @@ export function AgentInfoPanel({
return;
}
if (feature.status === "backlog") {
if (feature.status === 'backlog') {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
@@ -94,68 +90,61 @@ export function AgentInfoPanel({
}
}
} catch {
// eslint-disable-next-line no-undef
console.debug("[KanbanCard] No context file for feature:", feature.id);
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
if (isCurrentAutoTask) {
// eslint-disable-next-line no-undef
const interval = setInterval(loadContext, 3000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === "backlog") {
if (showAgentInfo && feature.status === 'backlog') {
return (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel)}
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
</span>
</div>
)}
) : null}
</div>
</div>
);
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
return (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
agentInfo.currentPhase === "action" &&
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
agentInfo.currentPhase === "verification" &&
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
@@ -169,31 +158,26 @@ export function AgentInfoPanel({
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === "completed").length}
/{agentInfo.todos.length} tasks
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === "in_progress" ? (
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground/60 line-through",
todo.status === "in_progress" &&
"text-[var(--status-warning)]",
todo.status === "pending" && "text-muted-foreground/80"
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
@@ -210,8 +194,7 @@ export function AgentInfoPanel({
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
@@ -238,27 +221,20 @@ export function AgentInfoPanel({
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{
agentInfo.todos.filter((t) => t.status === "completed")
.length
}{" "}
tasks done
</span>
)}
</div>
)}
)}
</div>
)}
</>
)}
</div>

View File

@@ -1,19 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { useAppStore } from '@/store/app-store';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
open: boolean;
@@ -26,7 +25,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw" | "changes";
type ViewMode = 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -36,13 +35,13 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [projectPath, setProjectPath] = useState<string>('');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -75,22 +74,19 @@ export function AgentOutputModal({
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
const result = await api.features.getAgentOutput(currentProject.path, featureId);
if (result.success) {
setOutput(result.content || "");
setOutput(result.content || '');
} else {
setOutput("");
setOutput('');
}
} else {
setOutput("");
setOutput('');
}
} catch (error) {
console.error("Failed to load output:", error);
setOutput("");
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
}
@@ -108,38 +104,32 @@ export function AgentOutputModal({
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
if ('featureId' in event && event.featureId !== featureId) {
return;
}
let newContent = "";
let newContent = '';
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
case 'auto_mode_progress':
newContent = event.content || '';
break;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}\n` : ""
}`;
case 'auto_mode_tool': {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
break;
case "auto_mode_phase":
}
case 'auto_mode_phase': {
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
}
case 'auto_mode_error':
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
case 'auto_mode_ultrathink_preparation': {
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
@@ -169,66 +159,74 @@ export function AgentOutputModal({
newContent = prepContent;
break;
case "planning_started":
}
case 'planning_started': {
// Show when planning mode begins
if ("mode" in event && "message" in event) {
if ('mode' in event && 'message' in event) {
const modeLabel =
event.mode === "lite"
? "Lite"
: event.mode === "spec"
? "Spec"
: "Full";
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
case "plan_approval_required":
}
case 'plan_approval_required':
// Show when plan requires approval
if ("planningMode" in event) {
if ('planningMode' in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case "plan_approved":
case 'plan_approved':
// Show when plan is manually approved
if ("hasEdits" in event) {
if ('hasEdits' in event) {
newContent = event.hasEdits
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
: `\n✅ Plan approved - continuing to implementation...\n`;
}
break;
case "plan_auto_approved":
case 'plan_auto_approved':
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case "plan_revision_requested":
case 'plan_revision_requested': {
// Show when user requests plan revision
if ("planVersion" in event) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
if ('planVersion' in event) {
const revisionEvent = event as Extract<
AutoModeEvent,
{ type: 'plan_revision_requested' }
>;
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
}
break;
case "auto_mode_task_started":
}
case 'auto_mode_task_started': {
// Show when a task starts
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
if ('taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
}
break;
case "auto_mode_task_complete":
}
case 'auto_mode_task_complete': {
// Show task completion progress
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
if ('taskId' in event && 'tasksCompleted' in event && 'tasksTotal' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
}
break;
case "auto_mode_phase_complete":
}
case 'auto_mode_phase_complete': {
// Show phase completion for full mode
if ("phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
if ('phaseNumber' in event) {
const phaseEvent = event as Extract<
AutoModeEvent,
{ type: 'auto_mode_phase_complete' }
>;
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
}
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
}
case 'auto_mode_feature_complete': {
const emoji = event.passes ? '✅' : '⚠️';
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
@@ -239,6 +237,7 @@ export function AgentOutputModal({
}, 1500);
}
break;
}
}
if (newContent) {
@@ -267,20 +266,15 @@ export function AgentOutputModal({
const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers
if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
/^[0-9]$/.test(event.key)
) {
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onNumberKeyPress]);
@@ -293,19 +287,18 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "parsed"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
@@ -313,11 +306,11 @@ export function AgentOutputModal({
Logs
</button>
<button
onClick={() => setViewMode("changes")}
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-changes"
>
@@ -325,11 +318,11 @@ export function AgentOutputModal({
Changes
</button>
<button
onClick={() => setViewMode("raw")}
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "raw"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
@@ -353,7 +346,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
{viewMode === "changes" ? (
{viewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -386,19 +379,17 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
) : viewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}
</div>
</>
)}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,30 +6,29 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
Feature,
AgentModel,
@@ -38,7 +36,7 @@ import {
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -47,14 +45,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
} from '@/components/ui/dropdown-menu';
import { DependencyTreeDialog } from './dependency-tree-dialog';
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -104,16 +102,19 @@ export function EditFeatureDialog({
// If feature has no branchName, default to using current branch
return !feature?.branchName;
});
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
@@ -135,33 +136,31 @@ export function EditFeatureDialog({
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
const isBranchSelectorEnabled = editingFeature.status === "backlog";
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
: 'none';
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? (currentBranch || "")
: editingFeature.branchName || "";
? currentBranch || ''
: editingFeature.branchName || '';
const updates = {
title: editingFeature.title ?? "",
title: editingFeature.title ?? '',
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
@@ -192,16 +191,11 @@ export function EditFeatureDialog({
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -224,16 +218,14 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
toast.success('Description enhanced!');
} else {
toast.error(result?.error || "Failed to enhance description");
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
console.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
@@ -267,10 +259,7 @@ export function EditFeatureDialog({
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="prompt"
className="py-4 flex-1 min-h-0 flex flex-col"
>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
@@ -287,10 +276,7 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -318,7 +304,7 @@ export function EditFeatureDialog({
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
id="edit-title"
value={editingFeature.title ?? ""}
value={editingFeature.title ?? ''}
onChange={(e) =>
setEditingFeature({
...editingFeature,
@@ -332,38 +318,25 @@ export function EditFeatureDialog({
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<Button variant="outline" size="sm" className="w-[180px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -400,7 +373,7 @@ export function EditFeatureDialog({
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
@@ -410,7 +383,7 @@ export function EditFeatureDialog({
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
)}
@@ -429,17 +402,12 @@ export function EditFeatureDialog({
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Simple Mode Active
</p>
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
@@ -447,13 +415,11 @@ export function EditFeatureDialog({
<Button
variant="outline"
size="sm"
onClick={() =>
setShowEditAdvancedOptions(!showEditAdvancedOptions)
}
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
data-testid="edit-show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -461,29 +427,28 @@ export function EditFeatureDialog({
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={editingFeature.model ?? "opus"}
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
selectedModel={editingFeature.model ?? 'opus'}
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>
{editModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={editingFeature.thinkingLevel ?? "none"}
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
onLevelSelect={(level) =>
setEditingFeature({
...editingFeature,
@@ -515,13 +480,9 @@ export function EditFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
setEditingFeature({ ...editingFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
steps={editingFeature.steps}
onStepsChange={(steps) =>
setEditingFeature({ ...editingFeature, steps })
}
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
testIdPrefix="edit"
/>
</TabsContent>
@@ -541,12 +502,12 @@ export function EditFeatureDialog({
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
editingFeature.status === 'backlog' &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}

View File

@@ -1,22 +1,14 @@
import {
DndContext,
DragOverlay,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
import { COLUMNS, ColumnId } from "./constants";
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
interface KanbanBoardProps {
sensors: any;
@@ -93,10 +85,7 @@ export function KanbanBoard({
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -118,8 +107,7 @@ export function KanbanBoard({
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
column.id === 'verified' && columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
@@ -130,7 +118,7 @@ export function KanbanBoard({
<Archive className="w-3 h-3 mr-1" />
Archive All
</Button>
) : column.id === "backlog" ? (
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -175,9 +163,8 @@ export function KanbanBoard({
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
@@ -190,29 +177,19 @@ export function KanbanBoard({
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() =>
onMoveBackToInProgress(feature)
}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
);
})}
@@ -225,7 +202,7 @@ export function KanbanBoard({
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (

View File

@@ -1,10 +1,9 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Card } from '@/components/ui/card';
import {
Plus,
RefreshCw,
@@ -14,17 +13,16 @@ import {
Save,
Upload,
File,
X,
BookOpen,
EditIcon,
Eye,
Pencil,
} from "lucide-react";
} from 'lucide-react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} from '@/hooks/use-keyboard-shortcuts';
import {
Dialog,
DialogContent,
@@ -32,15 +30,15 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Markdown } from "../ui/markdown";
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { Markdown } from '../ui/markdown';
interface ContextFile {
name: string;
type: "text" | "image";
type: 'text' | 'image';
content?: string;
path: string;
}
@@ -53,17 +51,15 @@ export function ContextView() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [editedContent, setEditedContent] = useState('');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
null
);
const [newFileContent, setNewFileContent] = useState("");
const [renameFileName, setRenameFileName] = useState('');
const [newFileName, setNewFileName] = useState('');
const [newFileType, setNewFileType] = useState<'text' | 'image'>('text');
const [uploadedImageData, setUploadedImageData] = useState<string | null>(null);
const [newFileContent, setNewFileContent] = useState('');
const [isDropHovering, setIsDropHovering] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
@@ -73,7 +69,7 @@ export function ContextView() {
{
key: shortcuts.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
description: 'Add new context file',
},
],
[shortcuts]
@@ -87,22 +83,14 @@ export function ContextView() {
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return ext === ".md" || ext === ".markdown";
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
// Determine if a file is an image based on extension
const isImageFile = (filename: string): boolean => {
const imageExtensions = [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
];
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return imageExtensions.includes(ext);
};
@@ -125,13 +113,13 @@ export function ContextView() {
.filter((entry) => entry.isFile)
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? "image" : "text",
type: isImageFile(entry.name) ? 'image' : 'text',
path: `${contextPath}/${entry.name}`,
}));
setContextFiles(files);
}
} catch (error) {
console.error("Failed to load context files:", error);
console.error('Failed to load context files:', error);
} finally {
setIsLoading(false);
}
@@ -152,7 +140,7 @@ export function ContextView() {
setHasChanges(false);
}
} catch (error) {
console.error("Failed to load file content:", error);
console.error('Failed to load file content:', error);
}
}, []);
@@ -176,7 +164,7 @@ export function ContextView() {
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
console.error("Failed to save file:", error);
console.error('Failed to save file:', error);
} finally {
setIsSaving(false);
}
@@ -198,32 +186,32 @@ export function ContextView() {
let filename = newFileName.trim();
// Add default extension if not provided
if (newFileType === "text" && !filename.includes(".")) {
filename += ".md";
if (newFileType === 'text' && !filename.includes('.')) {
filename += '.md';
}
const filePath = `${contextPath}/${filename}`;
if (newFileType === "image" && uploadedImageData) {
if (newFileType === 'image' && uploadedImageData) {
// Write image data
await api.writeFile(filePath, uploadedImageData);
} else {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
// Only reload files on success
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
console.error('Failed to add file:', error);
// Optionally show error toast to user here
} finally {
// Close dialog and reset state
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setNewFileName('');
setNewFileType('text');
setUploadedImageData(null);
setNewFileContent("");
setNewFileContent('');
setIsDropHovering(false);
}
};
@@ -238,11 +226,11 @@ export function ContextView() {
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent("");
setEditedContent('');
setHasChanges(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to delete file:", error);
console.error('Failed to delete file:', error);
}
};
@@ -264,14 +252,14 @@ export function ContextView() {
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
console.error("A file with this name already exists");
console.error('A file with this name already exists');
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
console.error("Failed to read file for rename");
console.error('Failed to read file for rename');
return;
}
@@ -282,7 +270,7 @@ export function ContextView() {
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName("");
setRenameFileName('');
// Reload files and select the renamed file
await loadContextFiles();
@@ -290,13 +278,13 @@ export function ContextView() {
// Update selected file with new name and path
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? "image" : "text",
type: isImageFile(newName) ? 'image' : 'text',
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
console.error("Failed to rename file:", error);
console.error('Failed to rename file:', error);
}
};
@@ -352,9 +340,7 @@ export function ContextView() {
};
// Handle drag and drop for .txt and .md files in the add context dialog textarea
const handleTextAreaDrop = async (
e: React.DragEvent<HTMLTextAreaElement>
) => {
const handleTextAreaDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
@@ -366,8 +352,8 @@ export function ContextView() {
const fileName = file.name.toLowerCase();
// Only accept .txt and .md files
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
console.warn("Only .txt and .md files are supported for drag and drop");
if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) {
console.warn('Only .txt and .md files are supported for drag and drop');
return;
}
@@ -409,20 +395,14 @@ export function ContextView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-loading"
>
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="context-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="context-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
@@ -462,10 +442,7 @@ export function ContextView() {
Context Files ({contextFiles.length})
</h2>
</div>
<div
className="flex-1 overflow-y-auto p-2"
data-testid="context-file-list"
>
<div className="flex-1 overflow-y-auto p-2" data-testid="context-file-list">
{contextFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
@@ -481,10 +458,10 @@ export function ContextView() {
<div
key={file.path}
className={cn(
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors',
selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
? 'bg-primary/20 text-foreground border border-primary/30'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<button
@@ -492,7 +469,7 @@ export function ContextView() {
className="flex-1 flex items-center gap-2 text-left min-w-0"
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
{file.type === 'image' ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
@@ -525,38 +502,35 @@ export function ContextView() {
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
{selectedFile.type === "image" ? (
{selectedFile.type === 'image' ? (
<ImageIcon className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{selectedFile.name}
</span>
<span className="text-sm font-medium">{selectedFile.name}</span>
</div>
<div className="flex gap-2">
{selectedFile.type === "text" &&
isMarkdownFile(selectedFile.name) && (
<Button
variant={"outline"}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<EditIcon className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
)}
{selectedFile.type === "text" && (
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
<Button
variant={'outline'}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<EditIcon className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
)}
{selectedFile.type === 'text' && (
<Button
size="sm"
onClick={saveFile}
@@ -564,7 +538,7 @@ export function ContextView() {
data-testid="save-context-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</Button>
)}
<Button
@@ -581,7 +555,7 @@ export function ContextView() {
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{selectedFile.type === "image" ? (
{selectedFile.type === 'image' ? (
<div
className="h-full flex items-center justify-center bg-card rounded-lg"
data-testid="image-preview"
@@ -614,12 +588,8 @@ export function ContextView() {
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">
Select a file to view or edit
</p>
<p className="text-muted-foreground text-sm mt-1">
Or drop files here to add them
</p>
<p className="text-foreground-secondary">Select a file to view or edit</p>
<p className="text-muted-foreground text-sm mt-1">Or drop files here to add them</p>
</div>
</div>
)}
@@ -634,25 +604,23 @@ export function ContextView() {
>
<DialogHeader>
<DialogTitle>Add Context File</DialogTitle>
<DialogDescription>
Add a new text or image file to the context.
</DialogDescription>
<DialogDescription>Add a new text or image file to the context.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={newFileType === "text" ? "default" : "outline"}
variant={newFileType === 'text' ? 'default' : 'outline'}
size="sm"
onClick={() => setNewFileType("text")}
onClick={() => setNewFileType('text')}
data-testid="add-text-type"
>
<FileText className="w-4 h-4 mr-2" />
Text
</Button>
<Button
variant={newFileType === "image" ? "default" : "outline"}
variant={newFileType === 'image' ? 'default' : 'outline'}
size="sm"
onClick={() => setNewFileType("image")}
onClick={() => setNewFileType('image')}
data-testid="add-image-type"
>
<ImageIcon className="w-4 h-4 mr-2" />
@@ -666,20 +634,18 @@ export function ContextView() {
id="filename"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={
newFileType === "text" ? "context.md" : "image.png"
}
placeholder={newFileType === 'text' ? 'context.md' : 'image.png'}
data-testid="new-file-name"
/>
</div>
{newFileType === "text" && (
{newFileType === 'text' && (
<div className="space-y-2">
<Label htmlFor="context-content">Context Content</Label>
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-primary"
'relative rounded-lg transition-colors',
isDropHovering && 'ring-2 ring-primary'
)}
>
<textarea
@@ -691,8 +657,8 @@ export function ContextView() {
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
isDropHovering && "border-primary bg-primary/10"
'w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent',
isDropHovering && 'border-primary bg-primary/10'
)}
spellCheck={false}
data-testid="new-file-content"
@@ -701,9 +667,7 @@ export function ContextView() {
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">
Drop .txt or .md file here
</span>
<span className="text-sm font-medium">Drop .txt or .md file here</span>
</div>
</div>
)}
@@ -714,7 +678,7 @@ export function ContextView() {
</div>
)}
{newFileType === "image" && (
{newFileType === 'image' && (
<div className="space-y-2">
<Label>Upload Image</Label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
@@ -740,9 +704,7 @@ export function ContextView() {
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
)}
<span className="text-sm text-muted-foreground">
{uploadedImageData
? "Click to change"
: "Click to upload"}
{uploadedImageData ? 'Click to change' : 'Click to upload'}
</span>
</label>
</div>
@@ -754,9 +716,9 @@ export function ContextView() {
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileName('');
setUploadedImageData(null);
setNewFileContent("");
setNewFileContent('');
setIsDropHovering(false);
}}
>
@@ -764,11 +726,8 @@ export function ContextView() {
</Button>
<HotkeyButton
onClick={handleAddFile}
disabled={
!newFileName.trim() ||
(newFileType === "image" && !uploadedImageData)
}
hotkey={{ key: "Enter", cmdCtrl: true }}
disabled={!newFileName.trim() || (newFileType === 'image' && !uploadedImageData)}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={isAddDialogOpen}
data-testid="confirm-add-file"
>
@@ -784,15 +743,11 @@ export function ContextView() {
<DialogHeader>
<DialogTitle>Delete Context File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This
action cannot be undone.
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
@@ -812,9 +767,7 @@ export function ContextView() {
<DialogContent data-testid="rename-context-dialog">
<DialogHeader>
<DialogTitle>Rename Context File</DialogTitle>
<DialogDescription>
Enter a new name for "{selectedFile?.name}".
</DialogDescription>
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
@@ -826,7 +779,7 @@ export function ContextView() {
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === "Enter" && renameFileName.trim()) {
if (e.key === 'Enter' && renameFileName.trim()) {
handleRenameFile();
}
}}
@@ -838,7 +791,7 @@ export function ContextView() {
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName("");
setRenameFileName('');
}}
>
Cancel

View File

@@ -1,12 +1,6 @@
import { Label } from "@/components/ui/label";
import {
CheckCircle2,
AlertCircle,
Info,
Terminal,
Sparkles,
} from "lucide-react";
import type { ClaudeAuthStatus } from "@/store/setup-store";
import { Label } from '@/components/ui/label';
import { CheckCircle2, AlertCircle, Info, Terminal } from 'lucide-react';
import type { ClaudeAuthStatus } from '@/store/setup-store';
interface AuthenticationStatusDisplayProps {
claudeAuthStatus: ClaudeAuthStatus | null;
@@ -39,35 +33,29 @@ export function AuthenticationStatusDisplay({
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Terminal className="w-4 h-4 text-brand-500" />
<span className="text-sm font-medium text-foreground">
Claude (Anthropic)
</span>
<span className="text-sm font-medium text-foreground">Claude (Anthropic)</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{claudeAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">
Authenticated
</span>
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token (subscription)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
: claudeAuthStatus.method === "credentials_file"
? "Using credentials file"
: claudeAuthStatus.method === "cli_authenticated"
? "Using Claude CLI authentication"
: `Using ${
claudeAuthStatus.method || "detected"
} authentication`}
{claudeAuthStatus.method === 'oauth_token'
? 'Using stored OAuth token (subscription)'
: claudeAuthStatus.method === 'api_key_env'
? 'Using ANTHROPIC_API_KEY'
: claudeAuthStatus.method === 'api_key'
? 'Using stored API key'
: claudeAuthStatus.method === 'credentials_file'
? 'Using credentials file'
: claudeAuthStatus.method === 'cli_authenticated'
? 'Using Claude CLI authentication'
: `Using ${claudeAuthStatus.method || 'detected'} authentication`}
</span>
</div>
</>

View File

@@ -1,6 +1,6 @@
import { Folder, Trash2 } from "lucide-react";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Project } from "@/lib/electron";
import { Folder } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import type { Project } from '@/lib/electron';
interface DeleteProjectDialogProps {
open: boolean;
@@ -39,18 +39,13 @@ export function DeleteProjectDialog({
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{project.path}
</p>
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
The folder will remain on disk until you permanently delete it from
Trash.
The folder will remain on disk until you permanently delete it from Trash.
</p>
</>
)}

View File

@@ -1,24 +1,17 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
} from '@/components/ui/accordion';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import {
CheckCircle2,
Loader2,
@@ -31,14 +24,13 @@ import {
RefreshCw,
Download,
Info,
AlertTriangle,
ShieldCheck,
XCircle,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge, TerminalOutput } from "../components";
import { useCliStatus, useCliInstallation, useTokenSave } from "../hooks";
} from 'lucide-react';
import { toast } from 'sonner';
import { StatusBadge, TerminalOutput } from '../components';
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
interface ClaudeSetupStepProps {
onNext: () => void;
@@ -46,17 +38,13 @@ interface ClaudeSetupStepProps {
onSkip: () => void;
}
type VerificationStatus = "idle" | "verifying" | "verified" | "error";
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
// Claude Setup Step
// Users can either:
// 1. Have Claude CLI installed and authenticated (verified by running a test query)
// 2. Provide an Anthropic API key manually
export function ClaudeSetupStep({
onNext,
onBack,
onSkip,
}: ClaudeSetupStepProps) {
export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps) {
const {
claudeCliStatus,
claudeAuthStatus,
@@ -66,21 +54,16 @@ export function ClaudeSetupStep({
} = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [apiKey, setApiKey] = useState("");
const [apiKey, setApiKey] = useState('');
// CLI Verification state
const [cliVerificationStatus, setCliVerificationStatus] =
useState<VerificationStatus>("idle");
const [cliVerificationError, setCliVerificationError] = useState<
string | null
>(null);
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
// API Key Verification state
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
useState<VerificationStatus>("idle");
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<
string | null
>(null);
useState<VerificationStatus>('idle');
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(null);
// Delete API Key state
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
@@ -96,14 +79,11 @@ export function ClaudeSetupStep({
[]
);
const getStoreState = useCallback(
() => useSetupStore.getState().claudeCliStatus,
[]
);
const getStoreState = useCallback(() => useSetupStore.getState().claudeCliStatus, []);
// Use custom hooks
const { isChecking, checkStatus } = useCliStatus({
cliType: "claude",
cliType: 'claude',
statusApi,
setCliStatus: setClaudeCliStatus,
setAuthStatus: setClaudeAuthStatus,
@@ -114,120 +94,114 @@ export function ClaudeSetupStep({
}, [checkStatus]);
const { isInstalling, installProgress, install } = useCliInstallation({
cliType: "claude",
cliType: 'claude',
installApi,
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
onSuccess: onInstallSuccess,
getStoreState,
});
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave(
{
provider: "anthropic",
onSuccess: () => {
setClaudeAuthStatus({
authenticated: true,
method: "api_key",
hasCredentialsFile: false,
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, anthropic: apiKey });
toast.success("API key saved successfully!");
},
}
);
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
provider: 'anthropic',
onSuccess: () => {
setClaudeAuthStatus({
authenticated: true,
method: 'api_key',
hasCredentialsFile: false,
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, anthropic: apiKey });
toast.success('API key saved successfully!');
},
});
// Verify CLI authentication by running a test query (uses CLI credentials only, not API key)
const verifyCliAuth = useCallback(async () => {
setCliVerificationStatus("verifying");
setCliVerificationStatus('verifying');
setCliVerificationError(null);
try {
const api = getElectronAPI();
if (!api.setup?.verifyClaudeAuth) {
setCliVerificationStatus("error");
setCliVerificationError("Verification API not available");
setCliVerificationStatus('error');
setCliVerificationError('Verification API not available');
return;
}
// Pass "cli" to verify CLI authentication only (ignores any API key)
const result = await api.setup.verifyClaudeAuth("cli");
const result = await api.setup.verifyClaudeAuth('cli');
// Check for "Limit reached" error - treat as unverified
const hasLimitReachedError =
result.error?.toLowerCase().includes("limit reached") ||
result.error?.toLowerCase().includes("rate limit");
result.error?.toLowerCase().includes('limit reached') ||
result.error?.toLowerCase().includes('rate limit');
if (result.authenticated && !hasLimitReachedError) {
setCliVerificationStatus("verified");
setCliVerificationStatus('verified');
setClaudeAuthStatus({
authenticated: true,
method: "cli_authenticated",
method: 'cli_authenticated',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("Claude CLI authentication verified!");
toast.success('Claude CLI authentication verified!');
} else {
setCliVerificationStatus("error");
setCliVerificationStatus('error');
setCliVerificationError(
hasLimitReachedError
? "Rate limit reached. Please try again later."
: result.error || "Authentication failed"
? 'Rate limit reached. Please try again later.'
: result.error || 'Authentication failed'
);
setClaudeAuthStatus({
authenticated: false,
method: "none",
method: 'none',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Verification failed";
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
// Also check for limit reached in caught errors
const isLimitError =
errorMessage.toLowerCase().includes("limit reached") ||
errorMessage.toLowerCase().includes("rate limit");
setCliVerificationStatus("error");
errorMessage.toLowerCase().includes('limit reached') ||
errorMessage.toLowerCase().includes('rate limit');
setCliVerificationStatus('error');
setCliVerificationError(
isLimitError
? "Rate limit reached. Please try again later."
: errorMessage
isLimitError ? 'Rate limit reached. Please try again later.' : errorMessage
);
}
}, [claudeAuthStatus, setClaudeAuthStatus]);
// Verify API Key authentication (uses API key only)
const verifyApiKeyAuth = useCallback(async () => {
setApiKeyVerificationStatus("verifying");
setApiKeyVerificationStatus('verifying');
setApiKeyVerificationError(null);
try {
const api = getElectronAPI();
if (!api.setup?.verifyClaudeAuth) {
setApiKeyVerificationStatus("error");
setApiKeyVerificationError("Verification API not available");
setApiKeyVerificationStatus('error');
setApiKeyVerificationError('Verification API not available');
return;
}
// Pass "api_key" to verify API key authentication only
const result = await api.setup.verifyClaudeAuth("api_key");
const result = await api.setup.verifyClaudeAuth('api_key');
if (result.authenticated) {
setApiKeyVerificationStatus("verified");
setApiKeyVerificationStatus('verified');
setClaudeAuthStatus({
authenticated: true,
method: "api_key",
method: 'api_key',
hasCredentialsFile: false,
apiKeyValid: true,
});
toast.success("API key authentication verified!");
toast.success('API key authentication verified!');
} else {
setApiKeyVerificationStatus("error");
setApiKeyVerificationError(result.error || "Authentication failed");
setApiKeyVerificationStatus('error');
setApiKeyVerificationError(result.error || 'Authentication failed');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Verification failed";
setApiKeyVerificationStatus("error");
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
setApiKeyVerificationStatus('error');
setApiKeyVerificationError(errorMessage);
}
}, [setClaudeAuthStatus]);
@@ -238,29 +212,28 @@ export function ClaudeSetupStep({
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error("Delete API not available");
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey("anthropic");
const result = await api.setup.deleteApiKey('anthropic');
if (result.success) {
// Clear local state
setApiKey("");
setApiKeys({ ...apiKeys, anthropic: "" });
setApiKeyVerificationStatus("idle");
setApiKey('');
setApiKeys({ ...apiKeys, anthropic: '' });
setApiKeyVerificationStatus('idle');
setApiKeyVerificationError(null);
setClaudeAuthStatus({
authenticated: false,
method: "none",
method: 'none',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("API key deleted successfully");
toast.success('API key deleted successfully');
} else {
toast.error(result.error || "Failed to delete API key");
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to delete API key";
const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key';
toast.error(errorMessage);
} finally {
setIsDeletingApiKey(false);
@@ -282,30 +255,30 @@ export function ClaudeSetupStep({
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
toast.success('Command copied to clipboard');
};
// User is ready if either method is verified
const hasApiKey =
!!apiKeys.anthropic ||
claudeAuthStatus?.method === "api_key" ||
claudeAuthStatus?.method === "api_key_env";
const isCliVerified = cliVerificationStatus === "verified";
const isApiKeyVerified = apiKeyVerificationStatus === "verified";
claudeAuthStatus?.method === 'api_key' ||
claudeAuthStatus?.method === 'api_key_env';
const isCliVerified = cliVerificationStatus === 'verified';
const isApiKeyVerified = apiKeyVerificationStatus === 'verified';
const isReady = isCliVerified || isApiKeyVerified;
const getAuthMethodLabel = () => {
if (isApiKeyVerified) return "API Key";
if (isCliVerified) return "Claude CLI";
if (isApiKeyVerified) return 'API Key';
if (isCliVerified) return 'Claude CLI';
return null;
};
// Helper to get status badge for CLI
const getCliStatusBadge = () => {
if (cliVerificationStatus === "verified") {
if (cliVerificationStatus === 'verified') {
return <StatusBadge status="authenticated" label="Verified" />;
}
if (cliVerificationStatus === "error") {
if (cliVerificationStatus === 'error') {
return <StatusBadge status="error" label="Error" />;
}
if (isChecking) {
@@ -320,10 +293,10 @@ export function ClaudeSetupStep({
// Helper to get status badge for API Key
const getApiKeyStatusBadge = () => {
if (apiKeyVerificationStatus === "verified") {
if (apiKeyVerificationStatus === 'verified') {
return <StatusBadge status="authenticated" label="Verified" />;
}
if (apiKeyVerificationStatus === "error") {
if (apiKeyVerificationStatus === 'error') {
return <StatusBadge status="error" label="Error" />;
}
if (hasApiKey) {
@@ -339,9 +312,7 @@ export function ClaudeSetupStep({
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-brand-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
API Key Setup
</h2>
<h2 className="text-2xl font-bold text-foreground mb-2">API Key Setup</h2>
<p className="text-muted-foreground">Configure for code generation</p>
</div>
@@ -353,15 +324,8 @@ export function ClaudeSetupStep({
<Info className="w-5 h-5" />
Authentication Methods
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
</Button>
</div>
<CardDescription>
@@ -377,16 +341,14 @@ export function ClaudeSetupStep({
<div className="flex items-center gap-3">
<Terminal
className={`w-5 h-5 ${
cliVerificationStatus === "verified"
? "text-green-500"
: "text-muted-foreground"
cliVerificationStatus === 'verified'
? 'text-green-500'
: 'text-muted-foreground'
}`}
/>
<div className="text-left">
<p className="font-medium text-foreground">Claude CLI</p>
<p className="text-sm text-muted-foreground">
Use Claude Code subscription
</p>
<p className="text-sm text-muted-foreground">Use Claude Code subscription</p>
</div>
</div>
{getCliStatusBadge()}
@@ -398,15 +360,11 @@ export function ClaudeSetupStep({
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-muted-foreground" />
<p className="font-medium text-foreground">
Install Claude CLI
</p>
<p className="font-medium text-foreground">Install Claude CLI</p>
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
macOS / Linux
</Label>
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
curl -fsSL https://claude.ai/install.sh | bash
@@ -415,9 +373,7 @@ export function ClaudeSetupStep({
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
"curl -fsSL https://claude.ai/install.sh | bash"
)
copyCommand('curl -fsSL https://claude.ai/install.sh | bash')
}
>
<Copy className="w-4 h-4" />
@@ -426,9 +382,7 @@ export function ClaudeSetupStep({
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
Windows
</Label>
<Label className="text-sm text-muted-foreground">Windows</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
irm https://claude.ai/install.ps1 | iex
@@ -436,20 +390,14 @@ export function ClaudeSetupStep({
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
"irm https://claude.ai/install.ps1 | iex"
)
}
onClick={() => copyCommand('irm https://claude.ai/install.ps1 | iex')}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{isInstalling && (
<TerminalOutput lines={installProgress.output} />
)}
{isInstalling && <TerminalOutput lines={installProgress.output} />}
<Button
onClick={install}
@@ -480,27 +428,21 @@ export function ClaudeSetupStep({
)}
{/* CLI Verification Status */}
{cliVerificationStatus === "verifying" && (
{cliVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Verifying CLI authentication...
</p>
<p className="text-sm text-muted-foreground">
Running a test query
</p>
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
</div>
</div>
)}
{cliVerificationStatus === "verified" && (
{cliVerificationStatus === 'verified' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
CLI Authentication verified!
</p>
<p className="font-medium text-foreground">CLI Authentication verified!</p>
<p className="text-sm text-muted-foreground">
Your Claude CLI is working correctly.
</p>
@@ -508,17 +450,13 @@ export function ClaudeSetupStep({
</div>
)}
{cliVerificationStatus === "error" && cliVerificationError && (
{cliVerificationStatus === 'error' && cliVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1">
<p className="font-medium text-foreground">
Verification failed
</p>
<p className="text-sm text-red-400 mt-1">
{cliVerificationError}
</p>
{cliVerificationError.includes("login") && (
<p className="font-medium text-foreground">Verification failed</p>
<p className="text-sm text-red-400 mt-1">{cliVerificationError}</p>
{cliVerificationError.includes('login') && (
<div className="mt-3 p-3 rounded bg-muted/50">
<p className="text-sm text-muted-foreground mb-2">
Run this command in your terminal:
@@ -530,7 +468,7 @@ export function ClaudeSetupStep({
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("claude login")}
onClick={() => copyCommand('claude login')}
>
<Copy className="w-4 h-4" />
</Button>
@@ -542,22 +480,19 @@ export function ClaudeSetupStep({
)}
{/* CLI Verify Button - Hide if CLI is verified */}
{cliVerificationStatus !== "verified" && (
{cliVerificationStatus !== 'verified' && (
<Button
onClick={verifyCliAuth}
disabled={
cliVerificationStatus === "verifying" ||
!claudeCliStatus?.installed
}
disabled={cliVerificationStatus === 'verifying' || !claudeCliStatus?.installed}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid="verify-cli-button"
>
{cliVerificationStatus === "verifying" ? (
{cliVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : cliVerificationStatus === "error" ? (
) : cliVerificationStatus === 'error' ? (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Verification
@@ -580,15 +515,13 @@ export function ClaudeSetupStep({
<div className="flex items-center gap-3">
<Key
className={`w-5 h-5 ${
apiKeyVerificationStatus === "verified"
? "text-green-500"
: "text-muted-foreground"
apiKeyVerificationStatus === 'verified'
? 'text-green-500'
: 'text-muted-foreground'
}`}
/>
<div className="text-left">
<p className="font-medium text-foreground">
Anthropic API Key
</p>
<p className="font-medium text-foreground">Anthropic API Key</p>
<p className="text-sm text-muted-foreground">
Pay-per-use with your own API key
</p>
@@ -614,7 +547,7 @@ export function ClaudeSetupStep({
data-testid="anthropic-api-key-input"
/>
<p className="text-xs text-muted-foreground">
Don&apos;t have an API key?{" "}
Don&apos;t have an API key?{' '}
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
@@ -640,7 +573,7 @@ export function ClaudeSetupStep({
Saving...
</>
) : (
"Save API Key"
'Save API Key'
)}
</Button>
{hasApiKey && (
@@ -662,27 +595,21 @@ export function ClaudeSetupStep({
</div>
{/* API Key Verification Status */}
{apiKeyVerificationStatus === "verifying" && (
{apiKeyVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Verifying API key...
</p>
<p className="text-sm text-muted-foreground">
Running a test query
</p>
<p className="font-medium text-foreground">Verifying API key...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
</div>
</div>
)}
{apiKeyVerificationStatus === "verified" && (
{apiKeyVerificationStatus === 'verified' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
API Key verified!
</p>
<p className="font-medium text-foreground">API Key verified!</p>
<p className="text-sm text-muted-foreground">
Your API key is working correctly.
</p>
@@ -690,37 +617,30 @@ export function ClaudeSetupStep({
</div>
)}
{apiKeyVerificationStatus === "error" &&
apiKeyVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1">
<p className="font-medium text-foreground">
Verification failed
</p>
<p className="text-sm text-red-400 mt-1">
{apiKeyVerificationError}
</p>
</div>
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1">
<p className="font-medium text-foreground">Verification failed</p>
<p className="text-sm text-red-400 mt-1">{apiKeyVerificationError}</p>
</div>
)}
</div>
)}
{/* API Key Verify Button - Hide if API key is verified */}
{apiKeyVerificationStatus !== "verified" && (
{apiKeyVerificationStatus !== 'verified' && (
<Button
onClick={verifyApiKeyAuth}
disabled={
apiKeyVerificationStatus === "verifying" || !hasApiKey
}
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid="verify-api-key-button"
>
{apiKeyVerificationStatus === "verifying" ? (
{apiKeyVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : apiKeyVerificationStatus === "error" ? (
) : apiKeyVerificationStatus === 'error' ? (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Verification
@@ -741,20 +661,12 @@ export function ClaudeSetupStep({
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
Skip for now
</Button>
<Button

View File

@@ -1,40 +1,28 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { Button } from '@/components/ui/button';
import { CheckCircle2, Sparkles } from 'lucide-react';
interface CompleteStepProps {
onFinish: () => void;
}
export function CompleteStep({ onFinish }: CompleteStepProps) {
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
const { apiKeys } = useAppStore();
const claudeReady =
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
apiKeys.anthropic;
return (
<div className="text-center space-y-6">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
<div className="w-20 h-20 rounded-full bg-linear-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
<CheckCircle2 className="w-10 h-10 text-white" />
</div>
<div>
<h2 className="text-3xl font-bold text-foreground mb-3">
Setup Complete!
</h2>
<h2 className="text-3xl font-bold text-foreground mb-3">Setup Complete!</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Your development environment is configured. You&apos;re ready to start
building with AI-powered assistance.
Your development environment is configured. You&apos;re ready to start building with
AI-powered assistance.
</p>
</div>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
onClick={onFinish}
data-testid="setup-finish-button"
>

View File

@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Terminal, ArrowRight } from "lucide-react";
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
interface WelcomeStepProps {
onNext: () => void;
@@ -10,17 +9,14 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
return (
<div className="text-center space-y-6">
<div className="flex items-center justify-center mx-auto">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
</div>
<div>
<h2 className="text-3xl font-bold text-foreground mb-3">
Welcome to Automaker
</h2>
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
<p className="text-muted-foreground max-w-md mx-auto">
To get started, we&apos;ll need to verify either claude code cli is
installed or you have Anthropic api keys
To get started, we&apos;ll need to verify either claude code cli is installed or you have
Anthropic api keys
</p>
</div>

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Terminal as TerminalIcon,
Plus,
@@ -12,17 +11,13 @@ import {
RefreshCw,
X,
SquarePlus,
} from "lucide-react";
import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store";
import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Panel,
PanelGroup,
PanelResizeHandle,
} from "react-resizable-panels";
import { TerminalPanel } from "./terminal-view/terminal-panel";
} from 'lucide-react';
import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { TerminalPanel } from './terminal-view/terminal-panel';
import {
DndContext,
DragEndEvent,
@@ -34,8 +29,8 @@ import {
closestCenter,
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { cn } from "@/lib/utils";
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
interface TerminalStatus {
enabled: boolean;
@@ -64,18 +59,18 @@ function TerminalTabButton({
}) {
const { setNodeRef, isOver } = useDroppable({
id: `tab-${tab.id}`,
data: { type: "tab", tabId: tab.id },
data: { type: 'tab', tabId: tab.id },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors",
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors',
isActive
? "bg-background border-brand-500 text-foreground"
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
isOver && isDropTarget && "ring-2 ring-green-500"
? 'bg-background border-brand-500 text-foreground'
: 'bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent',
isOver && isDropTarget && 'ring-2 ring-green-500'
)}
onClick={onClick}
>
@@ -97,18 +92,18 @@ function TerminalTabButton({
// New tab drop zone
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: "new-tab-zone",
data: { type: "new-tab" },
id: 'new-tab-zone',
data: { type: 'new-tab' },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all",
'flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all',
isOver && isDropTarget
? "border-green-500 bg-green-500/10 text-green-500"
: "border-transparent text-muted-foreground hover:border-border"
? 'border-green-500 bg-green-500/10 text-green-500'
: 'border-transparent text-muted-foreground hover:border-border'
)}
>
<SquarePlus className="h-4 w-4" />
@@ -135,7 +130,7 @@ export function TerminalView() {
const [status, setStatus] = useState<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [password, setPassword] = useState('');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
@@ -143,7 +138,7 @@ export function TerminalView() {
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(false);
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
@@ -159,7 +154,7 @@ export function TerminalView() {
};
// Get active tab
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
const activeTab = terminalState.tabs.find((t) => t.id === terminalState.activeTabId);
// DnD sensors with activation constraint to avoid accidental drags
const sensors = useSensors(
@@ -178,43 +173,46 @@ export function TerminalView() {
// Handle drag over - track which tab we're hovering
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
if (over?.data?.current?.type === "tab") {
if (over?.data?.current?.type === 'tab') {
setDragOverTabId(over.data.current.tabId);
} else if (over?.data?.current?.type === "new-tab") {
setDragOverTabId("new");
} else if (over?.data?.current?.type === 'new-tab') {
setDragOverTabId('new');
} else {
setDragOverTabId(null);
}
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
setDragOverTabId(null);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
setDragOverTabId(null);
if (!over) return;
if (!over) return;
const activeId = active.id as string;
const overData = over.data?.current;
const activeId = active.id as string;
const overData = over.data?.current;
// If dropped on a tab, move terminal to that tab
if (overData?.type === "tab") {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on a tab, move terminal to that tab
if (overData?.type === 'tab') {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === "new-tab") {
moveTerminalToTab(activeId, "new");
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === 'new-tab') {
moveTerminalToTab(activeId, 'new');
return;
}
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
}, [swapTerminals, moveTerminalToTab]);
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
},
[swapTerminals, moveTerminalToTab]
);
// Fetch terminal status
const fetchStatus = useCallback(async () => {
@@ -229,11 +227,11 @@ export function TerminalView() {
setTerminalUnlocked(true);
}
} else {
setError(data.error || "Failed to get terminal status");
setError(data.error || 'Failed to get terminal status');
}
} catch (err) {
setError("Failed to connect to server");
console.error("[Terminal] Status fetch error:", err);
setError('Failed to connect to server');
console.error('[Terminal] Status fetch error:', err);
} finally {
setLoading(false);
}
@@ -251,21 +249,21 @@ export function TerminalView() {
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await response.json();
if (data.success) {
setTerminalUnlocked(true, data.data.token);
setPassword("");
setPassword('');
} else {
setAuthError(data.error || "Authentication failed");
setAuthError(data.error || 'Authentication failed');
}
} catch (err) {
setAuthError("Failed to authenticate");
console.error("[Terminal] Auth error:", err);
setAuthError('Failed to authenticate');
console.error('[Terminal] Auth error:', err);
} finally {
setAuthLoading(false);
}
@@ -273,21 +271,24 @@ export function TerminalView() {
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
if (!canCreateTerminal("[Terminal] Debounced terminal creation")) {
const createTerminal = async (
direction?: 'horizontal' | 'vertical',
targetSessionId?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
return;
}
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
@@ -300,10 +301,10 @@ export function TerminalView() {
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
} else {
console.error("[Terminal] Failed to create session:", data.error);
console.error('[Terminal] Failed to create session:', data.error);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
console.error('[Terminal] Create session error:', err);
} finally {
isCreatingRef.current = false;
}
@@ -311,21 +312,21 @@ export function TerminalView() {
// Create terminal in new tab
const createTerminalInNewTab = async () => {
if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return;
}
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
@@ -341,7 +342,7 @@ export function TerminalView() {
addTerminalToTab(data.data.id, tabId);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
console.error('[Terminal] Create session error:', err);
} finally {
isCreatingRef.current = false;
}
@@ -352,16 +353,16 @@ export function TerminalView() {
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
headers['X-Terminal-Token'] = terminalState.authToken;
}
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: "DELETE",
method: 'DELETE',
headers,
});
removeTerminalFromLayout(sessionId);
} catch (err) {
console.error("[Terminal] Kill session error:", err);
console.error('[Terminal] Kill session error:', err);
}
};
@@ -391,25 +392,20 @@ export function TerminalView() {
const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey;
const altMatches = needsAlt ? e.altKey : !e.altKey;
return (
e.key.toLowerCase() === key &&
cmdMatches &&
shiftMatches &&
altMatches
);
return e.key.toLowerCase() === key && cmdMatches && shiftMatches && altMatches;
};
// Split terminal right (Cmd+D / Ctrl+D)
if (matchesShortcut(shortcuts.splitTerminalRight)) {
e.preventDefault();
createTerminal("horizontal", terminalState.activeSessionId);
createTerminal('horizontal', terminalState.activeSessionId);
return;
}
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
if (matchesShortcut(shortcuts.splitTerminalDown)) {
e.preventDefault();
createTerminal("vertical", terminalState.activeSessionId);
createTerminal('vertical', terminalState.activeSessionId);
return;
}
@@ -427,7 +423,7 @@ export function TerminalView() {
// Collect all terminal IDs from a panel tree in order
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
if (panel.type === "terminal") {
if (panel.type === 'terminal') {
return [panel.sessionId];
}
return panel.panels.flatMap(getTerminalIds);
@@ -436,16 +432,16 @@ export function TerminalView() {
// Get a STABLE key for a panel - based only on terminal IDs, not tree structure
// This prevents unnecessary remounts when layout structure changes
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") {
if (panel.type === 'terminal') {
return panel.sessionId;
}
// Use joined terminal IDs - stable regardless of nesting depth
return `group-${getTerminalIds(panel).join("-")}`;
return `group-${getTerminalIds(panel).join('-')}`;
};
// Render panel content recursively
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
if (content.type === "terminal") {
if (content.type === 'terminal') {
// Use per-terminal fontSize or fall back to default
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
return (
@@ -456,8 +452,8 @@ export function TerminalView() {
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)}
onSplitVertical={() => createTerminal('vertical', content.sessionId)}
isDragging={activeDragId === content.sessionId}
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
@@ -466,15 +462,14 @@ export function TerminalView() {
);
}
const isHorizontal = content.direction === "horizontal";
const isHorizontal = content.direction === 'horizontal';
const defaultSizePerPanel = 100 / content.panels.length;
return (
<PanelGroup direction={content.direction}>
{content.panels.map((panel, index) => {
const panelSize = panel.type === "terminal" && panel.size
? panel.size
: defaultSizePerPanel;
const panelSize =
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return (
@@ -484,8 +479,8 @@ export function TerminalView() {
key={`handle-${panelKey}`}
className={
isHorizontal
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
: "h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
? 'w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
: 'h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
}
/>
)}
@@ -534,7 +529,9 @@ export function TerminalView() {
</div>
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
<p className="text-muted-foreground max-w-md">
Terminal access has been disabled. Set <code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your server .env file to enable it.
Terminal access has been disabled. Set{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your
server .env file to enable it.
</p>
</div>
);
@@ -561,9 +558,7 @@ export function TerminalView() {
disabled={authLoading}
autoFocus
/>
{authError && (
<p className="text-sm text-destructive">{authError}</p>
)}
{authError && <p className="text-sm text-destructive">{authError}</p>}
<Button type="submit" className="w-full" disabled={authLoading || !password}>
{authLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -577,8 +572,8 @@ export function TerminalView() {
{status.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
@@ -597,7 +592,8 @@ export function TerminalView() {
Create a new terminal session to start executing commands.
{currentProject && (
<span className="block mt-2 text-sm">
Working directory: <code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
Working directory:{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
</span>
)}
</p>
@@ -610,8 +606,8 @@ export function TerminalView() {
{status?.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
@@ -644,9 +640,7 @@ export function TerminalView() {
))}
{/* New tab drop zone (visible when dragging) */}
{activeDragId && (
<NewTabDropZone isDropTarget={true} />
)}
{activeDragId && <NewTabDropZone isDropTarget={true} />}
{/* New tab button */}
<button
@@ -664,7 +658,7 @@ export function TerminalView() {
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("horizontal")}
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
@@ -673,7 +667,7 @@ export function TerminalView() {
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("vertical")}
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
@@ -688,11 +682,7 @@ export function TerminalView() {
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button
variant="outline"
size="sm"
onClick={() => createTerminal()}
>
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
@@ -707,11 +697,7 @@ export function TerminalView() {
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === "new"
? "New tab"
: dragOverTabId
? "Move to tab"
: "Terminal"}
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
</span>
</div>
) : null}

View File

@@ -9,7 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { getElectronAPI, type Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import {
FolderOpen,
@@ -112,7 +112,7 @@ export function WelcomeView() {
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Show initialization dialog if files were created
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
@@ -241,7 +241,7 @@ export function WelcomeView() {
// Verify parent is actually a directory
const parentStat = await api.stat(parentDir);
if (parentStat && !parentStat.isDirectory) {
if (parentStat && !parentStat.stats?.isDirectory) {
toast.error('Parent path is not a directory', {
description: `${parentDir} is not a directory`,
});
@@ -538,7 +538,7 @@ export function WelcomeView() {
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
<img src="/logo.png" alt="Automaker Logo" className="w-8 h-8" />
</div>
<div>
@@ -563,10 +563,10 @@ export function WelcomeView() {
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
data-testid="new-project-card"
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
@@ -579,7 +579,7 @@ export function WelcomeView() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full mt-5 bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
className="w-full mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
data-testid="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
@@ -610,7 +610,7 @@ export function WelcomeView() {
onClick={handleOpenProject}
data-testid="open-project-card"
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
@@ -653,7 +653,7 @@ export function WelcomeView() {
data-testid={`recent-project-${project.id}`}
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
@@ -767,7 +767,7 @@ export function WelcomeView() {
<DialogFooter>
<Button
onClick={() => setShowInitDialog(false)}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
data-testid="close-init-dialog"
>
Get Started

View File

@@ -1,8 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import type { LucideIcon } from "lucide-react";
import type { ApiKeys } from "@/store/app-store";
import type { Dispatch, SetStateAction } from 'react';
import type { ApiKeys } from '@/store/app-store';
export type ProviderKey = "anthropic" | "google";
export type ProviderKey = 'anthropic' | 'google';
export interface ProviderConfig {
key: ProviderKey;
@@ -56,33 +55,32 @@ export interface ProviderConfigParams {
export const buildProviderConfigs = ({
apiKeys,
anthropic,
google,
}: ProviderConfigParams): ProviderConfig[] => [
{
key: "anthropic",
label: "Anthropic API Key",
inputId: "anthropic-key",
placeholder: "sk-ant-...",
key: 'anthropic',
label: 'Anthropic API Key',
inputId: 'anthropic-key',
placeholder: 'sk-ant-...',
value: anthropic.value,
setValue: anthropic.setValue,
showValue: anthropic.show,
setShowValue: anthropic.setShow,
hasStoredKey: apiKeys.anthropic,
inputTestId: "anthropic-api-key-input",
toggleTestId: "toggle-anthropic-visibility",
inputTestId: 'anthropic-api-key-input',
toggleTestId: 'toggle-anthropic-visibility',
testButton: {
onClick: anthropic.onTest,
disabled: !anthropic.value || anthropic.testing,
loading: anthropic.testing,
testId: "test-claude-connection",
testId: 'test-claude-connection',
},
result: anthropic.result,
resultTestId: "test-connection-result",
resultMessageTestId: "test-connection-message",
descriptionPrefix: "Used for Claude AI features. Get your key at",
descriptionLinkHref: "https://console.anthropic.com/account/keys",
descriptionLinkText: "console.anthropic.com",
descriptionSuffix: ".",
resultTestId: 'test-connection-result',
resultMessageTestId: 'test-connection-message',
descriptionPrefix: 'Used for Claude AI features. Get your key at',
descriptionLinkHref: 'https://console.anthropic.com/account/keys',
descriptionLinkText: 'console.anthropic.com',
descriptionSuffix: '.',
},
// {
// key: "google",

View File

@@ -1,10 +1,10 @@
/**
* File Picker Utility for Web Browsers
*
*
* Provides cross-platform file and directory selection using:
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
*
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This implementation extracts directory information and may require
* user confirmation or server-side path resolution.
@@ -22,7 +22,7 @@ export interface DirectoryPickerResult {
/**
* Opens a directory picker dialog
* @returns Promise resolving to directory information, or null if canceled
*
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This function returns directory structure information that the server
* can use to locate the actual directory path.
@@ -31,10 +31,10 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Use webkitdirectory (works on Windows and all modern browsers)
return new Promise<DirectoryPickerResult | null>((resolve) => {
let resolved = false;
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.style.display = "none";
input.style.display = 'none';
const cleanup = () => {
if (input.parentNode) {
@@ -58,62 +58,59 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
}
};
input.addEventListener("change", (e) => {
input.addEventListener('change', (e) => {
changeEventFired = true;
if (focusTimeout) {
clearTimeout(focusTimeout);
focusTimeout = null;
}
console.log("[FilePicker] Change event fired");
console.log('[FilePicker] Change event fired');
const files = input.files;
console.log("[FilePicker] Files selected:", files?.length || 0);
console.log('[FilePicker] Files selected:', files?.length || 0);
if (!files || files.length === 0) {
console.log("[FilePicker] No files selected");
console.log('[FilePicker] No files selected');
safeResolve(null);
return;
}
const firstFile = files[0];
console.log("[FilePicker] First file:", {
console.log('[FilePicker] First file:', {
name: firstFile.name,
webkitRelativePath: firstFile.webkitRelativePath,
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
path: firstFile.path,
});
// Extract directory name from webkitRelativePath
// webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
let directoryName = "Selected Directory";
let directoryName = 'Selected Directory';
// Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium)
// @ts-expect-error - path property is non-standard but available in some browsers
if (firstFile.path) {
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
const filePath = firstFile.path as string;
console.log("[FilePicker] Found file.path:", filePath);
console.log('[FilePicker] Found file.path:', filePath);
// Extract directory path (remove filename)
const lastSeparator = Math.max(
filePath.lastIndexOf("\\"),
filePath.lastIndexOf("/")
);
const lastSeparator = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
if (lastSeparator > 0) {
const absolutePath = filePath.substring(0, lastSeparator);
console.log("[FilePicker] Found absolute path:", absolutePath);
console.log('[FilePicker] Found absolute path:', absolutePath);
// Return as directory name for now - server can validate it directly
directoryName = absolutePath;
}
}
// Method 2: Extract directory name from webkitRelativePath
if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
if (directoryName === 'Selected Directory' && firstFile.webkitRelativePath) {
const relativePath = firstFile.webkitRelativePath;
console.log("[FilePicker] Using webkitRelativePath:", relativePath);
const pathParts = relativePath.split("/");
console.log('[FilePicker] Using webkitRelativePath:', relativePath);
const pathParts = relativePath.split('/');
if (pathParts.length > 0) {
directoryName = pathParts[0]; // Top-level directory name
console.log("[FilePicker] Extracted directory name:", directoryName);
console.log('[FilePicker] Extracted directory name:', directoryName);
}
}
@@ -130,7 +127,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
}
}
console.log("[FilePicker] Directory info:", {
console.log('[FilePicker] Directory info:', {
directoryName,
fileCount: files.length,
sampleFiles: sampleFiles.slice(0, 5), // Log first 5
@@ -150,7 +147,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Only resolve as canceled if change event hasn't fired after a delay
focusTimeout = setTimeout(() => {
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
console.log("[FilePicker] Dialog canceled (no files after focus and no change event)");
console.log('[FilePicker] Dialog canceled (no files after focus and no change event)');
safeResolve(null);
}
}, 2000); // Increased timeout for Windows - give it time
@@ -158,33 +155,37 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Add to DOM temporarily
document.body.appendChild(input);
console.log("[FilePicker] Opening directory picker...");
console.log('[FilePicker] Opening directory picker...');
// Try to show picker programmatically
if ("showPicker" in HTMLInputElement.prototype) {
if ('showPicker' in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
console.log("[FilePicker] Using showPicker()");
console.log('[FilePicker] Using showPicker()');
} catch (error) {
console.log("[FilePicker] showPicker() failed, using click()", error);
console.log('[FilePicker] showPicker() failed, using click()', error);
input.click();
}
} else {
console.log("[FilePicker] Using click()");
console.log('[FilePicker] Using click()');
input.click();
}
// Set up cancellation detection with longer delay
// Only add focus listener if we're not already resolved
window.addEventListener("focus", handleFocus, { once: true });
window.addEventListener('focus', handleFocus, { once: true });
// Also handle blur as a cancellation signal (but with delay)
window.addEventListener("blur", () => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener("focus", handleFocus, { once: true });
}, 100);
}, { once: true });
window.addEventListener(
'blur',
() => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener('focus', handleFocus, { once: true });
}, 100);
},
{ once: true }
);
});
}
@@ -193,21 +194,19 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
* @param options Optional configuration (multiple files, file types, etc.)
* @returns Promise resolving to selected file path(s), or null if canceled
*/
export async function openFilePicker(
options?: {
multiple?: boolean;
accept?: string;
}
): Promise<string | string[] | null> {
export async function openFilePicker(options?: {
multiple?: boolean;
accept?: string;
}): Promise<string | string[] | null> {
// Use standard file input (works on all browsers including Windows)
return new Promise<string | string[] | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.multiple = options?.multiple ?? false;
if (options?.accept) {
input.accept = options.accept;
}
input.style.display = "none";
input.style.display = 'none';
const cleanup = () => {
if (input.parentNode) {
@@ -215,7 +214,7 @@ export async function openFilePicker(
}
};
input.addEventListener("change", () => {
input.addEventListener('change', () => {
const files = input.files;
if (!files || files.length === 0) {
cleanup();
@@ -228,7 +227,7 @@ export async function openFilePicker(
// Try to get path from File object (non-standard, but available in some browsers)
// @ts-expect-error - path property is non-standard
if (file.path) {
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
return file.path as string;
}
// Fallback to filename (server will need to resolve)
@@ -262,7 +261,7 @@ export async function openFilePicker(
// Try to show picker programmatically
// Note: showPicker() is available in modern browsers but TypeScript types it as void
// In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch
if ("showPicker" in HTMLInputElement.prototype) {
if ('showPicker' in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
} catch {
@@ -274,6 +273,6 @@ export async function openFilePicker(
}
// Set up cancellation detection
window.addEventListener("focus", handleFocus, { once: true });
window.addEventListener('focus', handleFocus, { once: true });
});
}

View File

@@ -20,41 +20,35 @@ import type {
AutoModeEvent,
SuggestionsEvent,
SpecRegenerationEvent,
FeatureSuggestion,
SuggestionType,
} from "./electron";
import type { Message, SessionListItem } from "@/types/electron";
import type { Feature, ClaudeUsageResponse } from "@/store/app-store";
import type {
WorktreeAPI,
GitAPI,
ModelDefinition,
ProviderStatus,
} from "@/types/electron";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
} from './electron';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return "http://localhost:3008";
return 'http://localhost:3008';
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
}
return null;
};
type EventType =
| "agent:stream"
| "auto-mode:event"
| "suggestions:event"
| "spec-regeneration:event";
| 'agent:stream'
| 'auto-mode:event'
| 'suggestions:event'
| 'spec-regeneration:event';
type EventCallback = (payload: unknown) => void;
@@ -80,21 +74,18 @@ export class HttpApiClient implements ElectronAPI {
}
private connectWebSocket(): void {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
this.isConnecting = true;
try {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log("[HttpApiClient] WebSocket connected");
console.log('[HttpApiClient] WebSocket connected');
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -110,15 +101,12 @@ export class HttpApiClient implements ElectronAPI {
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
console.error(
"[HttpApiClient] Failed to parse WebSocket message:",
error
);
console.error('[HttpApiClient] Failed to parse WebSocket message:', error);
}
};
this.ws.onclose = () => {
console.log("[HttpApiClient] WebSocket disconnected");
console.log('[HttpApiClient] WebSocket disconnected');
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
@@ -131,19 +119,16 @@ export class HttpApiClient implements ElectronAPI {
};
this.ws.onerror = (error) => {
console.error("[HttpApiClient] WebSocket error:", error);
console.error('[HttpApiClient] WebSocket error:', error);
this.isConnecting = false;
};
} catch (error) {
console.error("[HttpApiClient] Failed to create WebSocket:", error);
console.error('[HttpApiClient] Failed to create WebSocket:', error);
this.isConnecting = false;
}
}
private subscribeToEvent(
type: EventType,
callback: EventCallback
): () => void {
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
if (!this.eventCallbacks.has(type)) {
this.eventCallbacks.set(type, new Set());
}
@@ -162,18 +147,18 @@ export class HttpApiClient implements ElectronAPI {
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
const apiKey = getApiKey();
if (apiKey) {
headers["X-API-Key"] = apiKey;
headers['X-API-Key'] = apiKey;
}
return headers;
}
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "POST",
method: 'POST',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
@@ -188,7 +173,7 @@ export class HttpApiClient implements ElectronAPI {
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "PUT",
method: 'PUT',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
@@ -197,7 +182,7 @@ export class HttpApiClient implements ElectronAPI {
private async httpDelete<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "DELETE",
method: 'DELETE',
headers: this.getHeaders(),
});
return response.json();
@@ -205,15 +190,13 @@ export class HttpApiClient implements ElectronAPI {
// Basic operations
async ping(): Promise<string> {
const result = await this.get<{ status: string }>("/api/health");
return result.status === "ok" ? "pong" : "error";
const result = await this.get<{ status: string }>('/api/health');
return result.status === 'ok' ? 'pong' : 'error';
}
async openExternalLink(
url: string
): Promise<{ success: boolean; error?: string }> {
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
// Open in new tab
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, '_blank', 'noopener,noreferrer');
return { success: true };
}
@@ -222,7 +205,7 @@ export class HttpApiClient implements ElectronAPI {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
console.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -237,21 +220,21 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: path });
}>('/api/fs/validate-path', { filePath: path });
if (result.success && result.path) {
return { canceled: false, filePaths: [result.path] };
}
console.error("Invalid directory:", result.error);
console.error('Invalid directory:', result.error);
return { canceled: true, filePaths: [] };
}
async openFile(options?: object): Promise<DialogResult> {
async openFile(_options?: object): Promise<DialogResult> {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
console.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -262,50 +245,48 @@ export class HttpApiClient implements ElectronAPI {
return { canceled: true, filePaths: [] };
}
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: path }
);
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
filePath: path,
});
if (result.success && result.exists) {
return { canceled: false, filePaths: [path] };
}
console.error("File not found");
console.error('File not found');
return { canceled: true, filePaths: [] };
}
// File system operations
async readFile(filePath: string): Promise<FileResult> {
return this.post("/api/fs/read", { filePath });
return this.post('/api/fs/read', { filePath });
}
async writeFile(filePath: string, content: string): Promise<WriteResult> {
return this.post("/api/fs/write", { filePath, content });
return this.post('/api/fs/write', { filePath, content });
}
async mkdir(dirPath: string): Promise<WriteResult> {
return this.post("/api/fs/mkdir", { dirPath });
return this.post('/api/fs/mkdir', { dirPath });
}
async readdir(dirPath: string): Promise<ReaddirResult> {
return this.post("/api/fs/readdir", { dirPath });
return this.post('/api/fs/readdir', { dirPath });
}
async exists(filePath: string): Promise<boolean> {
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath }
);
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
filePath,
});
return result.exists;
}
async stat(filePath: string): Promise<StatResult> {
return this.post("/api/fs/stat", { filePath });
return this.post('/api/fs/stat', { filePath });
}
async deleteFile(filePath: string): Promise<WriteResult> {
return this.post("/api/fs/delete", { filePath });
return this.post('/api/fs/delete', { filePath });
}
async trashItem(filePath: string): Promise<WriteResult> {
@@ -315,11 +296,9 @@ export class HttpApiClient implements ElectronAPI {
async getPath(name: string): Promise<string> {
// Server provides data directory
if (name === "userData") {
const result = await this.get<{ dataDir: string }>(
"/api/health/detailed"
);
return result.dataDir || "/data";
if (name === 'userData') {
const result = await this.get<{ dataDir: string }>('/api/health/detailed');
return result.dataDir || '/data';
}
return `/data/${name}`;
}
@@ -330,7 +309,7 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath?: string
): Promise<SaveImageResult> {
return this.post("/api/fs/save-image", {
return this.post('/api/fs/save-image', {
data,
filename,
mimeType,
@@ -344,7 +323,7 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath: string
): Promise<{ success: boolean; path?: string; error?: string }> {
return this.post("/api/fs/save-board-background", {
return this.post('/api/fs/save-board-background', {
data,
filename,
mimeType,
@@ -352,10 +331,8 @@ export class HttpApiClient implements ElectronAPI {
});
}
async deleteBoardBackground(
projectPath: string
): Promise<{ success: boolean; error?: string }> {
return this.post("/api/fs/delete-board-background", { projectPath });
async deleteBoardBackground(projectPath: string): Promise<{ success: boolean; error?: string }> {
return this.post('/api/fs/delete-board-background', { projectPath });
}
// CLI checks - server-side
@@ -374,7 +351,7 @@ export class HttpApiClient implements ElectronAPI {
};
error?: string;
}> {
return this.get("/api/setup/claude-status");
return this.get('/api/setup/claude-status');
}
// Model API
@@ -384,14 +361,14 @@ export class HttpApiClient implements ElectronAPI {
models?: ModelDefinition[];
error?: string;
}> => {
return this.get("/api/models/available");
return this.get('/api/models/available');
},
checkProviders: async (): Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}> => {
return this.get("/api/models/providers");
return this.get('/api/models/providers');
},
};
@@ -417,13 +394,13 @@ export class HttpApiClient implements ElectronAPI {
hasRecentActivity?: boolean;
};
error?: string;
}> => this.get("/api/setup/claude-status"),
}> => this.get('/api/setup/claude-status'),
installClaude: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-claude"),
}> => this.post('/api/setup/install-claude'),
authClaude: (): Promise<{
success: boolean;
@@ -434,7 +411,7 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
message?: string;
output?: string;
}> => this.post("/api/setup/auth-claude"),
}> => this.post('/api/setup/auth-claude'),
storeApiKey: (
provider: string,
@@ -442,7 +419,7 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
}> => this.post('/api/setup/store-api-key', { provider, apiKey }),
deleteApiKey: (
provider: string
@@ -450,13 +427,13 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
error?: string;
message?: string;
}> => this.post("/api/setup/delete-api-key", { provider }),
}> => this.post('/api/setup/delete-api-key', { provider }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
}> => this.get('/api/setup/api-keys'),
getPlatform: (): Promise<{
success: boolean;
@@ -466,15 +443,15 @@ export class HttpApiClient implements ElectronAPI {
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}> => this.get("/api/setup/platform"),
}> => this.get('/api/setup/platform'),
verifyClaudeAuth: (
authMethod?: "cli" | "api_key"
authMethod?: 'cli' | 'api_key'
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
}> => this.post('/api/setup/verify-claude-auth', { authMethod }),
getGhStatus: (): Promise<{
success: boolean;
@@ -484,76 +461,65 @@ export class HttpApiClient implements ElectronAPI {
path: string | null;
user: string | null;
error?: string;
}> => this.get("/api/setup/gh-status"),
}> => this.get('/api/setup/gh-status'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
return this.subscribeToEvent('agent:stream', callback);
},
onAuthProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
return this.subscribeToEvent('agent:stream', callback);
},
};
// Features API
features: FeaturesAPI = {
getAll: (projectPath: string) =>
this.post("/api/features/list", { projectPath }),
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
get: (projectPath: string, featureId: string) =>
this.post("/api/features/get", { projectPath, featureId }),
this.post('/api/features/get', { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
) => this.post("/api/features/update", { projectPath, featureId, updates }),
this.post('/api/features/create', { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post('/api/features/update', { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
this.post('/api/features/delete', { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post("/api/features/agent-output", { projectPath, featureId }),
this.post('/api/features/agent-output', { projectPath, featureId }),
generateTitle: (description: string) =>
this.post("/api/features/generate-title", { description }),
this.post('/api/features/generate-title', { description }),
};
// Auto Mode API
autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) =>
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
stop: (projectPath: string) =>
this.post("/api/auto-mode/stop", { projectPath }),
stopFeature: (featureId: string) =>
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean,
worktreePath?: string
) =>
this.post("/api/auto-mode/run-feature", {
this.post('/api/auto-mode/run-feature', {
projectPath,
featureId,
useWorktrees,
worktreePath,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/resume-feature", {
this.post('/api/auto-mode/verify-feature', { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
this.post('/api/auto-mode/resume-feature', {
projectPath,
featureId,
useWorktrees,
}),
contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
this.post('/api/auto-mode/context-exists', { projectPath, featureId }),
analyzeProject: (projectPath: string) =>
this.post("/api/auto-mode/analyze-project", { projectPath }),
this.post('/api/auto-mode/analyze-project', { projectPath }),
followUpFeature: (
projectPath: string,
featureId: string,
@@ -561,19 +527,15 @@ export class HttpApiClient implements ElectronAPI {
imagePaths?: string[],
worktreePath?: string
) =>
this.post("/api/auto-mode/follow-up-feature", {
this.post('/api/auto-mode/follow-up-feature', {
projectPath,
featureId,
prompt,
imagePaths,
worktreePath,
}),
commitFeature: (
projectPath: string,
featureId: string,
worktreePath?: string
) =>
this.post("/api/auto-mode/commit-feature", {
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
this.post('/api/auto-mode/commit-feature', {
projectPath,
featureId,
worktreePath,
@@ -585,7 +547,7 @@ export class HttpApiClient implements ElectronAPI {
editedPlan?: string,
feedback?: string
) =>
this.post("/api/auto-mode/approve-plan", {
this.post('/api/auto-mode/approve-plan', {
projectPath,
featureId,
approved,
@@ -593,10 +555,7 @@ export class HttpApiClient implements ElectronAPI {
feedback,
}),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",
callback as EventCallback
);
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
},
};
@@ -607,7 +566,7 @@ export class HttpApiClient implements ElectronAPI {
enhancementMode: string,
model?: string
): Promise<EnhancePromptResult> =>
this.post("/api/enhance-prompt", {
this.post('/api/enhance-prompt', {
originalText,
enhancementMode,
model,
@@ -617,86 +576,74 @@ export class HttpApiClient implements ElectronAPI {
// Worktree API
worktree: WorktreeAPI = {
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }),
this.post('/api/worktree/merge', { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) =>
this.post("/api/worktree/info", { projectPath, featureId }),
this.post('/api/worktree/info', { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>
this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }),
this.post('/api/worktree/status', { projectPath, featureId }),
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
listAll: (projectPath: string, includeDetails?: boolean) =>
this.post("/api/worktree/list", { projectPath, includeDetails }),
this.post('/api/worktree/list', { projectPath, includeDetails }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post("/api/worktree/create", {
this.post('/api/worktree/create', {
projectPath,
branchName,
baseBranch,
}),
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) =>
this.post("/api/worktree/delete", {
delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) =>
this.post('/api/worktree/delete', {
projectPath,
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post("/api/worktree/commit", { worktreePath, message }),
this.post('/api/worktree/commit', { worktreePath, message }),
push: (worktreePath: string, force?: boolean) =>
this.post("/api/worktree/push", { worktreePath, force }),
this.post('/api/worktree/push', { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
this.post('/api/worktree/diffs', { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
this.post("/api/worktree/file-diff", {
this.post('/api/worktree/file-diff', {
projectPath,
featureId,
filePath,
}),
pull: (worktreePath: string) =>
this.post("/api/worktree/pull", { worktreePath }),
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post("/api/worktree/list-branches", { worktreePath }),
this.post('/api/worktree/list-branches', { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
this.post('/api/worktree/open-in-editor', { worktreePath }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
};
// Git API
git: GitAPI = {
getDiffs: (projectPath: string) =>
this.post("/api/git/diffs", { projectPath }),
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
getFileDiff: (projectPath: string, filePath: string) =>
this.post("/api/git/file-diff", { projectPath, filePath }),
this.post('/api/git/file-diff', { projectPath, filePath }),
};
// Suggestions API
suggestions: SuggestionsAPI = {
generate: (projectPath: string, suggestionType?: SuggestionType) =>
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
this.post('/api/suggestions/generate', { projectPath, suggestionType }),
stop: () => this.post('/api/suggestions/stop'),
status: () => this.get('/api/suggestions/status'),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent(
"suggestions:event",
callback as EventCallback
);
return this.subscribeToEvent('suggestions:event', callback as EventCallback);
},
};
@@ -709,7 +656,7 @@ export class HttpApiClient implements ElectronAPI {
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/create", {
this.post('/api/spec-regeneration/create', {
projectPath,
projectOverview,
generateFeatures,
@@ -723,7 +670,7 @@ export class HttpApiClient implements ElectronAPI {
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/generate", {
this.post('/api/spec-regeneration/generate', {
projectPath,
projectDefinition,
generateFeatures,
@@ -731,17 +678,14 @@ export class HttpApiClient implements ElectronAPI {
maxFeatures,
}),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", {
this.post('/api/spec-regeneration/generate-features', {
projectPath,
maxFeatures,
}),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
stop: () => this.post('/api/spec-regeneration/stop'),
status: () => this.get('/api/spec-regeneration/status'),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent(
"spec-regeneration:event",
callback as EventCallback
);
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
},
};
@@ -757,7 +701,7 @@ export class HttpApiClient implements ElectronAPI {
}>;
totalCount?: number;
error?: string;
}> => this.get("/api/running-agents"),
}> => this.get('/api/running-agents'),
};
// Workspace API
@@ -768,13 +712,13 @@ export class HttpApiClient implements ElectronAPI {
workspaceDir?: string;
defaultDir?: string | null;
error?: string;
}> => this.get("/api/workspace/config"),
}> => this.get('/api/workspace/config'),
getDirectories: (): Promise<{
success: boolean;
directories?: Array<{ name: string; path: string }>;
error?: string;
}> => this.get("/api/workspace/directories"),
}> => this.get('/api/workspace/directories'),
};
// Agent API
@@ -786,7 +730,7 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
messages?: Message[];
error?: string;
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
}> => this.post('/api/agent/start', { sessionId, workingDirectory }),
send: (
sessionId: string,
@@ -795,7 +739,7 @@ export class HttpApiClient implements ElectronAPI {
imagePaths?: string[],
model?: string
): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", {
this.post('/api/agent/send', {
sessionId,
message,
workingDirectory,
@@ -810,16 +754,16 @@ export class HttpApiClient implements ElectronAPI {
messages?: Message[];
isRunning?: boolean;
error?: string;
}> => this.post("/api/agent/history", { sessionId }),
}> => this.post('/api/agent/history', { sessionId }),
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/stop", { sessionId }),
this.post('/api/agent/stop', { sessionId }),
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/clear", { sessionId }),
this.post('/api/agent/clear', { sessionId }),
onStream: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent("agent:stream", callback as EventCallback);
return this.subscribeToEvent('agent:stream', callback as EventCallback);
},
};
@@ -834,8 +778,7 @@ export class HttpApiClient implements ElectronAPI {
projectPath?: string;
projectName?: string;
error?: string;
}> =>
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
}> => this.post('/api/templates/clone', { repoUrl, projectName, parentDir }),
};
// Settings API - persistent file-based settings
@@ -847,7 +790,7 @@ export class HttpApiClient implements ElectronAPI {
hasCredentials: boolean;
dataDir: string;
needsMigration: boolean;
}> => this.get("/api/settings/status"),
}> => this.get('/api/settings/status'),
// Global settings
getGlobal: (): Promise<{
@@ -880,13 +823,15 @@ export class HttpApiClient implements ElectronAPI {
lastSelectedSessionByProject: Record<string, string>;
};
error?: string;
}> => this.get("/api/settings/global"),
}> => this.get('/api/settings/global'),
updateGlobal: (updates: Record<string, unknown>): Promise<{
updateGlobal: (
updates: Record<string, unknown>
): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/global", updates),
}> => this.put('/api/settings/global', updates),
// Credentials (masked for security)
getCredentials: (): Promise<{
@@ -897,7 +842,7 @@ export class HttpApiClient implements ElectronAPI {
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.get("/api/settings/credentials"),
}> => this.get('/api/settings/credentials'),
updateCredentials: (updates: {
apiKeys?: { anthropic?: string; google?: string; openai?: string };
@@ -909,10 +854,12 @@ export class HttpApiClient implements ElectronAPI {
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.put("/api/settings/credentials", updates),
}> => this.put('/api/settings/credentials', updates),
// Project settings
getProject: (projectPath: string): Promise<{
getProject: (
projectPath: string
): Promise<{
success: boolean;
settings?: {
version: number;
@@ -940,7 +887,7 @@ export class HttpApiClient implements ElectronAPI {
lastSelectedSessionId?: string;
};
error?: string;
}> => this.post("/api/settings/project", { projectPath }),
}> => this.post('/api/settings/project', { projectPath }),
updateProject: (
projectPath: string,
@@ -949,22 +896,22 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/project", { projectPath, updates }),
}> => this.put('/api/settings/project', { projectPath, updates }),
// Migration from localStorage
migrate: (data: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
'automaker-storage'?: string;
'automaker-setup'?: string;
'worktree-panel-collapsed'?: string;
'file-browser-recent-folders'?: string;
'automaker:lastProjectDir'?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> => this.post("/api/settings/migrate", { data }),
}> => this.post('/api/settings/migrate', { data }),
};
// Sessions API
@@ -992,7 +939,7 @@ export class HttpApiClient implements ElectronAPI {
updatedAt: string;
};
error?: string;
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
}> => this.post('/api/sessions', { name, projectPath, workingDirectory }),
update: (
sessionId: string,
@@ -1001,25 +948,19 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; error?: string }> =>
this.put(`/api/sessions/${sessionId}`, { name, tags }),
archive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/archive`, {}),
unarchive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
delete: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.httpDelete(`/api/sessions/${sessionId}`),
};
// Claude API
claude = {
getUsage: (): Promise<ClaudeUsageResponse> => this.get("/api/claude/usage"),
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
};
}

View File

@@ -1,15 +1,15 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AgentModel } from '@/store/app-store';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
export function modelSupportsThinking(_model?: AgentModel | string): boolean {
// All Claude models support thinking
return true;
}
@@ -19,9 +19,9 @@ export function modelSupportsThinking(model?: AgentModel | string): boolean {
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
};
return displayNames[model] || model;
}
@@ -41,7 +41,7 @@ export function truncateDescription(description: string, maxLength = 50): string
* This is important for cross-platform compatibility (Windows uses backslashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
return p.replace(/\\/g, '/');
}
/**

View File

@@ -3,13 +3,12 @@
* Centralizes the logic for determining where projects should be created/opened
*/
/* eslint-disable no-undef */
import { getHttpApiClient } from "./http-api-client";
import { getElectronAPI } from "./electron";
import { getItem, setItem } from "./storage";
import path from "path";
import { getHttpApiClient } from './http-api-client';
import { getElectronAPI } from './electron';
import { getItem, setItem } from './storage';
import path from 'path';
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
/**
* Gets the default Documents/Automaker directory path
@@ -18,11 +17,11 @@ const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
async function getDefaultDocumentsPath(): Promise<string | null> {
try {
const api = getElectronAPI();
const documentsPath = await api.getPath("documents");
return path.join(documentsPath, "Automaker");
const documentsPath = await api.getPath('documents');
return path.join(documentsPath, 'Automaker');
} catch (error) {
if (typeof window !== "undefined" && window.console) {
window.console.error("Failed to get documents path:", error);
if (typeof window !== 'undefined' && window.console) {
window.console.error('Failed to get documents path:', error);
}
return null;
}
@@ -82,8 +81,8 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
const documentsPath = await getDefaultDocumentsPath();
return documentsPath;
} catch (error) {
if (typeof window !== "undefined" && window.console) {
window.console.error("Failed to get default workspace directory:", error);
if (typeof window !== 'undefined' && window.console) {
window.console.error('Failed to get default workspace directory:', error);
}
// On error, try last used dir and Documents

View File

@@ -14,7 +14,7 @@ import { ThemeOption, themeOptions } from '@/config/theme-options';
function RootLayoutContent() {
const location = useLocation();
const { setIpcConnected, theme, currentProject, previewTheme, getEffectiveTheme } = useAppStore();
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
const { setupComplete } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);

11
apps/ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SERVER_URL?: string;
// Add other VITE_ prefixed env vars here as needed
}
// Extend ImportMeta to include env property
interface ImportMeta {
readonly env: ImportMetaEnv;
}