mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.mjs", "**/*.cjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
require: "readonly",
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
@@ -13,6 +25,70 @@ const eslintConfig = defineConfig([
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
globals: {
|
||||
// Browser/DOM APIs
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
navigator: "readonly",
|
||||
Navigator: "readonly",
|
||||
localStorage: "readonly",
|
||||
sessionStorage: "readonly",
|
||||
fetch: "readonly",
|
||||
WebSocket: "readonly",
|
||||
File: "readonly",
|
||||
FileList: "readonly",
|
||||
FileReader: "readonly",
|
||||
Blob: "readonly",
|
||||
atob: "readonly",
|
||||
crypto: "readonly",
|
||||
prompt: "readonly",
|
||||
confirm: "readonly",
|
||||
getComputedStyle: "readonly",
|
||||
requestAnimationFrame: "readonly",
|
||||
// DOM Element Types
|
||||
HTMLElement: "readonly",
|
||||
HTMLInputElement: "readonly",
|
||||
HTMLDivElement: "readonly",
|
||||
HTMLButtonElement: "readonly",
|
||||
HTMLSpanElement: "readonly",
|
||||
HTMLTextAreaElement: "readonly",
|
||||
HTMLHeadingElement: "readonly",
|
||||
HTMLParagraphElement: "readonly",
|
||||
HTMLImageElement: "readonly",
|
||||
Element: "readonly",
|
||||
// Event Types
|
||||
Event: "readonly",
|
||||
KeyboardEvent: "readonly",
|
||||
DragEvent: "readonly",
|
||||
PointerEvent: "readonly",
|
||||
CustomEvent: "readonly",
|
||||
ClipboardEvent: "readonly",
|
||||
WheelEvent: "readonly",
|
||||
DataTransfer: "readonly",
|
||||
// Web APIs
|
||||
ResizeObserver: "readonly",
|
||||
AbortSignal: "readonly",
|
||||
Audio: "readonly",
|
||||
ScrollBehavior: "readonly",
|
||||
// Timers
|
||||
setTimeout: "readonly",
|
||||
setInterval: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
clearInterval: "readonly",
|
||||
// Node.js (for scripts and Electron)
|
||||
process: "readonly",
|
||||
require: "readonly",
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
NodeJS: "readonly",
|
||||
// React
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
// Electron
|
||||
Electron: "readonly",
|
||||
// Console
|
||||
console: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": ts,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 "Analyze Project" to scan your codebase and get
|
||||
insights about its structure.
|
||||
Click "Analyze Project" 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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't have an API key?{" "}
|
||||
Don'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
|
||||
|
||||
@@ -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're ready to start
|
||||
building with AI-powered assistance.
|
||||
Your development environment is configured. You'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"
|
||||
>
|
||||
|
||||
@@ -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'll need to verify either claude code cli is
|
||||
installed or you have Anthropic api keys
|
||||
To get started, we'll need to verify either claude code cli is installed or you have
|
||||
Anthropic api keys
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
11
apps/ui/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -15,11 +15,11 @@
|
||||
* so it doesn't make real API calls during CI/CD runs.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import {
|
||||
waitForNetworkIdle,
|
||||
@@ -29,15 +29,14 @@ import {
|
||||
setupProjectWithPathNoWorktrees,
|
||||
waitForBoardView,
|
||||
clickAddFeature,
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
dragAndDropWithDndKit,
|
||||
} from "./utils";
|
||||
} from './utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests");
|
||||
const TEST_TEMP_DIR = createTempDirPath('feature-lifecycle-tests');
|
||||
|
||||
interface TestRepo {
|
||||
path: string;
|
||||
@@ -45,9 +44,9 @@ interface TestRepo {
|
||||
}
|
||||
|
||||
// Configure all tests to run serially
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe("Feature Lifecycle Tests", () => {
|
||||
test.describe('Feature Lifecycle Tests', () => {
|
||||
let testRepo: TestRepo;
|
||||
let featureId: string;
|
||||
|
||||
@@ -76,7 +75,7 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
});
|
||||
|
||||
// this one fails in github actions for some reason
|
||||
test.skip("complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete", async ({
|
||||
test.skip('complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Increase timeout for this comprehensive test
|
||||
@@ -87,7 +86,7 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// ==========================================================================
|
||||
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
@@ -98,18 +97,15 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details - requesting a file with "yellow" content
|
||||
const featureDescription =
|
||||
"Create a file named yellow.txt that contains the text yellow";
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
const featureDescription = 'Create a file named yellow.txt that contains the text yellow';
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Debug: Check the filesystem to see if feature was created
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
const featuresDir = path.join(testRepo.path, '.automaker', 'features');
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
await expect(async () => {
|
||||
@@ -131,18 +127,14 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
featureId = featureDirs[0];
|
||||
|
||||
// Now get the actual card element by testid
|
||||
const featureCardByTestId = page.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
const featureCardByTestId = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress and wait for agent to finish
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
const inProgressColumn = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||
|
||||
// Perform the drag and drop using dnd-kit compatible method
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
@@ -151,13 +143,10 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// This helps diagnose if the drag-drop is working or not
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||
);
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
|
||||
expect(['in_progress', 'waiting_approval']).toContain(featureData.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// The mock agent should complete quickly (about 1.3 seconds based on the sleep times)
|
||||
@@ -165,12 +154,9 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// The status changes are: in_progress -> waiting_approval after agent completes
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||
);
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
expect(featureData.status).toBe('waiting_approval');
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Refresh page to ensure UI reflects the status change
|
||||
@@ -181,19 +167,17 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// ==========================================================================
|
||||
// Step 3: Verify feature is in waiting_approval (manual review) column
|
||||
// ==========================================================================
|
||||
const waitingApprovalColumn = page.locator(
|
||||
'[data-testid="kanban-column-waiting_approval"]'
|
||||
);
|
||||
const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||
const cardInWaitingApproval = waitingApprovalColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the mock agent created the yellow.txt file
|
||||
const yellowFilePath = path.join(testRepo.path, "yellow.txt");
|
||||
const yellowFilePath = path.join(testRepo.path, 'yellow.txt');
|
||||
expect(fs.existsSync(yellowFilePath)).toBe(true);
|
||||
const yellowContent = fs.readFileSync(yellowFilePath, "utf-8");
|
||||
expect(yellowContent).toBe("yellow");
|
||||
const yellowContent = fs.readFileSync(yellowFilePath, 'utf-8');
|
||||
expect(yellowContent).toBe('yellow');
|
||||
|
||||
// ==========================================================================
|
||||
// Step 4: Click commit and verify git status shows committed changes
|
||||
@@ -207,18 +191,18 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify git status shows clean (changes committed)
|
||||
const { stdout: gitStatus } = await execAsync("git status --porcelain", {
|
||||
const { stdout: gitStatus } = await execAsync('git status --porcelain', {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
// After commit, the yellow.txt file should be committed, so git status should be clean
|
||||
// (only .automaker directory might have changes)
|
||||
expect(gitStatus.includes("yellow.txt")).toBe(false);
|
||||
expect(gitStatus.includes('yellow.txt')).toBe(false);
|
||||
|
||||
// Verify the commit exists in git log
|
||||
const { stdout: gitLog } = await execAsync("git log --oneline -1", {
|
||||
const { stdout: gitLog } = await execAsync('git log --oneline -1', {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
expect(gitLog.toLowerCase()).toContain("yellow");
|
||||
expect(gitLog.toLowerCase()).toContain('yellow');
|
||||
|
||||
// ==========================================================================
|
||||
// Step 5: Verify feature moved to verified column after commit
|
||||
@@ -228,21 +212,15 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
const verifiedColumn = page.locator(
|
||||
'[data-testid="kanban-column-verified"]'
|
||||
);
|
||||
const cardInVerified = verifiedColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||
const cardInVerified = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 6: Archive (complete) the feature
|
||||
// ==========================================================================
|
||||
// Click the Complete button on the verified card
|
||||
const completeButton = page.locator(
|
||||
`[data-testid="complete-${featureId}"]`
|
||||
);
|
||||
const completeButton = page.locator(`[data-testid="complete-${featureId}"]`);
|
||||
await expect(completeButton).toBeVisible({ timeout: 5000 });
|
||||
await completeButton.click();
|
||||
|
||||
@@ -254,39 +232,28 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
|
||||
// Verify feature status is completed in filesystem
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||
);
|
||||
expect(featureData.status).toBe("completed");
|
||||
expect(featureData.status).toBe('completed');
|
||||
|
||||
// ==========================================================================
|
||||
// Step 7: Open archive modal and restore the feature
|
||||
// ==========================================================================
|
||||
// Click the completed features button to open the archive modal
|
||||
const completedFeaturesButton = page.locator(
|
||||
'[data-testid="completed-features-button"]'
|
||||
);
|
||||
const completedFeaturesButton = page.locator('[data-testid="completed-features-button"]');
|
||||
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
|
||||
await completedFeaturesButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
const completedModal = page.locator(
|
||||
'[data-testid="completed-features-modal"]'
|
||||
);
|
||||
const completedModal = page.locator('[data-testid="completed-features-modal"]');
|
||||
await expect(completedModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the archived feature is shown in the modal
|
||||
const archivedCard = completedModal.locator(
|
||||
`[data-testid="completed-card-${featureId}"]`
|
||||
);
|
||||
const archivedCard = completedModal.locator(`[data-testid="completed-card-${featureId}"]`);
|
||||
await expect(archivedCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the restore button
|
||||
const restoreButton = page.locator(
|
||||
`[data-testid="unarchive-${featureId}"]`
|
||||
);
|
||||
const restoreButton = page.locator(`[data-testid="unarchive-${featureId}"]`);
|
||||
await expect(restoreButton).toBeVisible({ timeout: 5000 });
|
||||
await restoreButton.click();
|
||||
|
||||
@@ -294,47 +261,34 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Close the modal - use first() to select the footer Close button, not the X button
|
||||
const closeButton = completedModal
|
||||
.locator('button:has-text("Close")')
|
||||
.first();
|
||||
const closeButton = completedModal.locator('button:has-text("Close")').first();
|
||||
await closeButton.click();
|
||||
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the feature is back in the verified column
|
||||
const restoredCard = verifiedColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
const restoredCard = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
await expect(restoredCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify feature status is verified in filesystem
|
||||
const restoredFeatureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||
);
|
||||
expect(restoredFeatureData.status).toBe("verified");
|
||||
expect(restoredFeatureData.status).toBe('verified');
|
||||
|
||||
// ==========================================================================
|
||||
// Step 8: Delete the feature and verify it's removed
|
||||
// ==========================================================================
|
||||
// Click the delete button on the verified card
|
||||
const deleteButton = page.locator(
|
||||
`[data-testid="delete-verified-${featureId}"]`
|
||||
);
|
||||
const deleteButton = page.locator(`[data-testid="delete-verified-${featureId}"]`);
|
||||
await expect(deleteButton).toBeVisible({ timeout: 5000 });
|
||||
await deleteButton.click();
|
||||
|
||||
// Wait for the confirmation dialog
|
||||
const confirmDialog = page.locator(
|
||||
'[data-testid="delete-confirmation-dialog"]'
|
||||
);
|
||||
const confirmDialog = page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the confirm delete button
|
||||
const confirmDeleteButton = page.locator(
|
||||
'[data-testid="confirm-delete-button"]'
|
||||
);
|
||||
const confirmDeleteButton = page.locator('[data-testid="confirm-delete-button"]');
|
||||
await confirmDeleteButton.click();
|
||||
|
||||
// Wait for the delete action to complete
|
||||
@@ -361,7 +315,7 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
await page.waitForTimeout(1000);
|
||||
@@ -370,17 +324,15 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details
|
||||
const featureDescription = "Create a file named test-restart.txt";
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
const featureDescription = 'Create a file named test-restart.txt';
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
const featuresDir = path.join(testRepo.path, '.automaker', 'features');
|
||||
await expect(async () => {
|
||||
const dirs = fs.readdirSync(featuresDir);
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
@@ -396,36 +348,26 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the feature card to appear
|
||||
const featureCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
const featureCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress (first start)
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const inProgressColumn = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
|
||||
// Verify feature file still exists and is readable
|
||||
const featureFilePath = path.join(
|
||||
featuresDir,
|
||||
testFeatureId,
|
||||
"feature.json"
|
||||
);
|
||||
const featureFilePath = path.join(featuresDir, testFeatureId, 'feature.json');
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// First verify that the drag succeeded by checking for in_progress status
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
|
||||
expect(['in_progress', 'waiting_approval']).toContain(featureData.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// ==========================================================================
|
||||
@@ -433,19 +375,14 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// ==========================================================================
|
||||
// The mock agent completes quickly, so we wait for it to finish
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
expect(featureData.status).toBe('waiting_approval');
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Verify feature file still exists after completion
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
const featureDataAfterComplete = JSON.parse(
|
||||
fs.readFileSync(featureFilePath, "utf-8")
|
||||
);
|
||||
console.log(
|
||||
"Feature status after first run:",
|
||||
featureDataAfterComplete.status
|
||||
);
|
||||
const featureDataAfterComplete = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
console.log('Feature status after first run:', featureDataAfterComplete.status);
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
@@ -457,12 +394,8 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// ==========================================================================
|
||||
// Feature is in waiting_approval, drag it back to backlog
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const currentCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
const currentDragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const currentCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||
const currentDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||
|
||||
await expect(currentCard).toBeVisible({ timeout: 10000 });
|
||||
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
|
||||
@@ -470,8 +403,8 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
|
||||
// Verify feature is in backlog
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("backlog");
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
expect(data.status).toBe('backlog');
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Reload to ensure clean state
|
||||
@@ -482,55 +415,45 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// ==========================================================================
|
||||
// Step 5: Restart the feature (drag to in_progress again)
|
||||
// ==========================================================================
|
||||
const restartCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
const restartCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||
await expect(restartCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const restartDragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const inProgressColumnRestart = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
const restartDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||
const inProgressColumnRestart = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||
|
||||
// Listen for console errors to catch "Feature not found"
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Drag to in_progress to restart
|
||||
await dragAndDropWithDndKit(
|
||||
page,
|
||||
restartDragHandle,
|
||||
inProgressColumnRestart
|
||||
);
|
||||
await dragAndDropWithDndKit(page, restartDragHandle, inProgressColumnRestart);
|
||||
|
||||
// Verify the feature file still exists
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// First verify that the restart drag succeeded by checking for in_progress status
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(data.status);
|
||||
expect(['in_progress', 'waiting_approval']).toContain(data.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// Verify no "Feature not found" errors in console
|
||||
const featureNotFoundErrors = consoleErrors.filter(
|
||||
(err) => err.includes("not found") || err.includes("Feature")
|
||||
(err) => err.includes('not found') || err.includes('Feature')
|
||||
);
|
||||
expect(featureNotFoundErrors).toEqual([]);
|
||||
|
||||
// Wait for the mock agent to complete and move to waiting_approval
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("waiting_approval");
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
expect(data.status).toBe('waiting_approval');
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
console.log("Feature successfully restarted after stop!");
|
||||
console.log('Feature successfully restarted after stop!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
resetFixtureSpec,
|
||||
setupProjectWithFixture,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
fillInput,
|
||||
waitForNetworkIdle,
|
||||
waitForElement,
|
||||
} from "./utils";
|
||||
} from './utils';
|
||||
|
||||
test.describe("Spec Editor Persistence", () => {
|
||||
test.describe('Spec Editor Persistence', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Reset the fixture spec file to original content before each test
|
||||
resetFixtureSpec();
|
||||
@@ -25,7 +25,7 @@ test.describe("Spec Editor Persistence", () => {
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test("should open project, edit spec, save, and persist changes after refresh", async ({
|
||||
test('should open project, edit spec, save, and persist changes after refresh', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Use the resolved fixture path
|
||||
@@ -35,33 +35,33 @@ test.describe("Spec Editor Persistence", () => {
|
||||
await setupProjectWithFixture(page, fixturePath);
|
||||
|
||||
// Step 2: Navigate to the app
|
||||
await page.goto("/");
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Step 3: Verify we're on the dashboard with the project loaded
|
||||
// The sidebar should show the project selector
|
||||
const sidebar = await getByTestId(page, "sidebar");
|
||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||
const sidebar = await getByTestId(page, 'sidebar');
|
||||
await sidebar.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Step 4: Click on the Spec Editor in the sidebar
|
||||
await navigateToSpecEditor(page);
|
||||
|
||||
// Step 5: Wait for the spec view to load (not empty state)
|
||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||
await waitForElement(page, 'spec-view', { timeout: 10000 });
|
||||
|
||||
// Step 6: Wait for the spec editor to load
|
||||
const specEditor = await getByTestId(page, "spec-editor");
|
||||
await specEditor.waitFor({ state: "visible", timeout: 10000 });
|
||||
const specEditor = await getByTestId(page, 'spec-editor');
|
||||
await specEditor.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Step 7: Wait for CodeMirror to initialize (it has a .cm-content element)
|
||||
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
await specEditor.locator('.cm-content').waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Step 8: Modify the editor content to "hello world"
|
||||
await setEditorContent(page, "hello world");
|
||||
await setEditorContent(page, 'hello world');
|
||||
|
||||
// Verify content was set before saving
|
||||
const contentBeforeSave = await getEditorContent(page);
|
||||
expect(contentBeforeSave.trim()).toBe("hello world");
|
||||
expect(contentBeforeSave.trim()).toBe('hello world');
|
||||
|
||||
// Step 9: Click the save button and wait for save to complete
|
||||
await clickSaveButton(page);
|
||||
@@ -72,14 +72,16 @@ test.describe("Spec Editor Persistence", () => {
|
||||
|
||||
// Step 11: Navigate back to the spec editor
|
||||
// After reload, we need to wait for the app to initialize
|
||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||
await waitForElement(page, 'sidebar', { timeout: 10000 });
|
||||
|
||||
// Navigate to spec editor again
|
||||
await navigateToSpecEditor(page);
|
||||
|
||||
// Wait for CodeMirror to be ready
|
||||
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
const specEditorAfterReload = await getByTestId(page, 'spec-editor');
|
||||
await specEditorAfterReload
|
||||
.locator('.cm-content')
|
||||
.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Wait for CodeMirror content to update with the loaded spec
|
||||
// The spec might need time to load into the editor after page reload
|
||||
@@ -91,11 +93,11 @@ test.describe("Spec Editor Persistence", () => {
|
||||
try {
|
||||
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
|
||||
const text = await contentElement.textContent();
|
||||
if (text && text.trim() === "hello world") {
|
||||
if (text && text.trim() === 'hello world') {
|
||||
contentMatches = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Element might not be ready yet, continue
|
||||
}
|
||||
|
||||
@@ -111,20 +113,20 @@ test.describe("Spec Editor Persistence", () => {
|
||||
(expectedContent) => {
|
||||
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
|
||||
if (!contentElement) return false;
|
||||
const text = (contentElement.textContent || "").trim();
|
||||
const text = (contentElement.textContent || '').trim();
|
||||
return text === expectedContent;
|
||||
},
|
||||
"hello world",
|
||||
'hello world',
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 12: Verify the content was persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
expect(persistedContent.trim()).toBe("hello world");
|
||||
expect(persistedContent.trim()).toBe('hello world');
|
||||
});
|
||||
|
||||
test("should handle opening project via Open Project button and file browser", async ({
|
||||
test('should handle opening project via Open Project button and file browser', async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test covers the flow of:
|
||||
@@ -139,49 +141,47 @@ test.describe("Spec Editor Persistence", () => {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
currentView: 'welcome',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
currentStep: 'complete',
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
});
|
||||
|
||||
// Navigate to the app
|
||||
await page.goto("/");
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Wait for the sidebar to be visible
|
||||
const sidebar = await getByTestId(page, "sidebar");
|
||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||
const sidebar = await getByTestId(page, 'sidebar');
|
||||
await sidebar.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Click the Open Project button
|
||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||
const openProjectButton = await getByTestId(page, 'open-project-button');
|
||||
|
||||
// Check if the button is visible (it might not be in collapsed sidebar)
|
||||
const isButtonVisible = await openProjectButton
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const isButtonVisible = await openProjectButton.isVisible().catch(() => false);
|
||||
|
||||
if (isButtonVisible) {
|
||||
await clickElement(page, "open-project-button");
|
||||
await clickElement(page, 'open-project-button');
|
||||
|
||||
// The file browser dialog should open
|
||||
// Note: In web mode, this might use the FileBrowserDialog component
|
||||
@@ -200,10 +200,10 @@ test.describe("Spec Editor Persistence", () => {
|
||||
|
||||
// For now, let's verify the dialog appeared and close it
|
||||
// A full test would navigate through directories
|
||||
console.log("File browser dialog opened successfully");
|
||||
console.log('File browser dialog opened successfully');
|
||||
|
||||
// Press Escape to close the dialog
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ test.describe("Spec Editor Persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
test.describe('Spec Editor - Full Open Project Flow', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Reset the fixture spec file to original content before each test
|
||||
resetFixtureSpec();
|
||||
@@ -232,11 +232,9 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
});
|
||||
|
||||
// Skip in CI - file browser navigation is flaky in headless environments
|
||||
test.skip("should open project via file browser, edit spec, and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip('should open project via file browser, edit spec, and persist', async ({ page }) => {
|
||||
// Navigate to app first
|
||||
await page.goto("/");
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Set up localStorage state (without a current project, but mark setup complete)
|
||||
@@ -247,29 +245,29 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
currentView: 'welcome',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
currentStep: 'complete',
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
});
|
||||
|
||||
// Reload to apply the localStorage state
|
||||
@@ -277,69 +275,68 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Wait for sidebar
|
||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||
await waitForElement(page, 'sidebar', { timeout: 10000 });
|
||||
|
||||
// Click the Open Project button
|
||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "open-project-button");
|
||||
const openProjectButton = await getByTestId(page, 'open-project-button');
|
||||
await openProjectButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await clickElement(page, 'open-project-button');
|
||||
|
||||
// Wait for the file browser dialog to open
|
||||
const dialogTitle = page.locator('text="Select Project Directory"');
|
||||
await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
|
||||
await dialogTitle.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Wait for the dialog to fully load (loading to complete)
|
||||
await page.waitForFunction(
|
||||
() => !document.body.textContent?.includes("Loading directories..."),
|
||||
() => !document.body.textContent?.includes('Loading directories...'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Use the path input to directly navigate to the fixture directory
|
||||
const pathInput = await getByTestId(page, "path-input");
|
||||
await pathInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
const pathInput = await getByTestId(page, 'path-input');
|
||||
await pathInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Clear the input and type the full path to the fixture
|
||||
await fillInput(page, "path-input", getFixturePath());
|
||||
await fillInput(page, 'path-input', getFixturePath());
|
||||
|
||||
// Click the Go button to navigate to the path
|
||||
await clickElement(page, "go-to-path-button");
|
||||
await clickElement(page, 'go-to-path-button');
|
||||
|
||||
// Wait for loading to complete
|
||||
await page.waitForFunction(
|
||||
() => !document.body.textContent?.includes("Loading directories..."),
|
||||
() => !document.body.textContent?.includes('Loading directories...'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Verify we're in the right directory by checking the path display
|
||||
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
|
||||
await expect(pathDisplay).toContainText("projectA");
|
||||
const pathDisplay = page.locator('.font-mono.text-sm.truncate');
|
||||
await expect(pathDisplay).toContainText('projectA');
|
||||
|
||||
// Click "Select Current Folder" button
|
||||
const selectFolderButton = page.locator(
|
||||
'button:has-text("Select Current Folder")'
|
||||
);
|
||||
const selectFolderButton = page.locator('button:has-text("Select Current Folder")');
|
||||
await selectFolderButton.click();
|
||||
|
||||
// Wait for dialog to close and project to load
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[role="dialog"]'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
await page.waitForFunction(() => !document.querySelector('[role="dialog"]'), {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to spec editor
|
||||
const specNav = await getByTestId(page, "nav-spec");
|
||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "nav-spec");
|
||||
const specNav = await getByTestId(page, 'nav-spec');
|
||||
await specNav.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await clickElement(page, 'nav-spec');
|
||||
|
||||
// Wait for spec view with the editor (not the empty state)
|
||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
|
||||
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
await waitForElement(page, 'spec-view', { timeout: 10000 });
|
||||
const specEditorForOpenFlow = await getByTestId(page, 'spec-editor');
|
||||
await specEditorForOpenFlow
|
||||
.locator('.cm-content')
|
||||
.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the content
|
||||
await setEditorContent(page, "hello world");
|
||||
await setEditorContent(page, 'hello world');
|
||||
|
||||
// Click save button
|
||||
await clickSaveButton(page);
|
||||
@@ -349,15 +346,17 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to spec editor
|
||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "nav-spec");
|
||||
await specNav.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await clickElement(page, 'nav-spec');
|
||||
|
||||
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
const specEditorAfterRefresh = await getByTestId(page, 'spec-editor');
|
||||
await specEditorAfterRefresh
|
||||
.locator('.cm-content')
|
||||
.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the content persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
expect(persistedContent.trim()).toBe("hello world");
|
||||
expect(persistedContent.trim()).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Wait for a toast notification with specific text to appear
|
||||
@@ -12,7 +11,7 @@ export async function waitForToast(
|
||||
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
|
||||
await toast.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
state: 'visible',
|
||||
});
|
||||
return toast;
|
||||
}
|
||||
@@ -32,19 +31,21 @@ export async function waitForErrorToast(
|
||||
|
||||
if (titleText) {
|
||||
// First try specific error type, then fallback to any toast with text
|
||||
const errorToast = page.locator(
|
||||
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
|
||||
).first();
|
||||
const errorToast = page
|
||||
.locator(
|
||||
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
|
||||
)
|
||||
.first();
|
||||
await errorToast.waitFor({
|
||||
timeout,
|
||||
state: "visible",
|
||||
state: 'visible',
|
||||
});
|
||||
return errorToast;
|
||||
} else {
|
||||
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
|
||||
await errorToast.waitFor({
|
||||
timeout,
|
||||
state: "visible",
|
||||
state: 'visible',
|
||||
});
|
||||
return errorToast;
|
||||
}
|
||||
@@ -53,10 +54,7 @@ export async function waitForErrorToast(
|
||||
/**
|
||||
* Check if an error toast is visible
|
||||
*/
|
||||
export async function isErrorToastVisible(
|
||||
page: Page,
|
||||
titleText?: string
|
||||
): Promise<boolean> {
|
||||
export async function isErrorToastVisible(page: Page, titleText?: string): Promise<boolean> {
|
||||
const toastSelector = titleText
|
||||
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
|
||||
: '[data-sonner-toast][data-type="error"]';
|
||||
@@ -81,7 +79,7 @@ export async function waitForSuccessToast(
|
||||
const toast = page.locator(toastSelector).first();
|
||||
await toast.waitFor({
|
||||
timeout: options?.timeout ?? 5000,
|
||||
state: "visible",
|
||||
state: 'visible',
|
||||
});
|
||||
return toast;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* Provides helpers for creating test git repos and managing worktrees
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { Page } from "@playwright/test";
|
||||
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { Page } from '@playwright/test';
|
||||
import { sanitizeBranchName, TIMEOUTS } from '../core/constants';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -40,8 +40,8 @@ export interface FeatureData {
|
||||
*/
|
||||
function getWorkspaceRoot(): string {
|
||||
const cwd = process.cwd();
|
||||
if (cwd.includes("apps/ui")) {
|
||||
return path.resolve(cwd, "../..");
|
||||
if (cwd.includes('apps/ui')) {
|
||||
return path.resolve(cwd, '../..');
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
@@ -49,9 +49,9 @@ function getWorkspaceRoot(): string {
|
||||
/**
|
||||
* Create a unique temp directory path for tests
|
||||
*/
|
||||
export function createTempDirPath(prefix: string = "temp-worktree-tests"): string {
|
||||
export function createTempDirPath(prefix: string = 'temp-worktree-tests'): string {
|
||||
const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`);
|
||||
return path.join(getWorkspaceRoot(), 'test', `${prefix}-${uniqueId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,7 @@ export function createTempDirPath(prefix: string = "temp-worktree-tests"): strin
|
||||
*/
|
||||
export function getWorktreePath(projectPath: string, branchName: string): string {
|
||||
const sanitizedName = sanitizeBranchName(branchName);
|
||||
return path.join(projectPath, ".worktrees", sanitizedName);
|
||||
return path.join(projectPath, '.worktrees', sanitizedName);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -79,25 +79,25 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync("git init", { cwd: tmpDir });
|
||||
await execAsync('git init', { cwd: tmpDir });
|
||||
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||
|
||||
// Create initial commit
|
||||
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
|
||||
await execAsync("git add .", { cwd: tmpDir });
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n');
|
||||
await execAsync('git add .', { cwd: tmpDir });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync("git branch -M main", { cwd: tmpDir });
|
||||
await execAsync('git branch -M main', { cwd: tmpDir });
|
||||
|
||||
// Create .automaker directories
|
||||
const automakerDir = path.join(tmpDir, ".automaker");
|
||||
const featuresDir = path.join(automakerDir, "features");
|
||||
const automakerDir = path.join(tmpDir, '.automaker');
|
||||
const featuresDir = path.join(automakerDir, 'features');
|
||||
fs.mkdirSync(featuresDir, { recursive: true });
|
||||
|
||||
// Create empty categories.json to avoid ENOENT errors in tests
|
||||
fs.writeFileSync(path.join(automakerDir, "categories.json"), "[]");
|
||||
fs.writeFileSync(path.join(automakerDir, 'categories.json'), '[]');
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
@@ -113,16 +113,16 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
|
||||
export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
||||
try {
|
||||
// Remove all worktrees first
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
}).catch(() => ({ stdout: '' }));
|
||||
|
||||
const worktrees = stdout
|
||||
.split("\n\n")
|
||||
.split('\n\n')
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||
return pathLine ? pathLine.replace("worktree ", "") : null;
|
||||
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
|
||||
return pathLine ? pathLine.replace('worktree ', '') : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
||||
// Remove the repository
|
||||
fs.rmSync(repoPath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup test repo:", error);
|
||||
console.error('Failed to cleanup test repo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,18 +171,18 @@ export async function gitExec(
|
||||
*/
|
||||
export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.split("\n\n")
|
||||
.split('\n\n')
|
||||
.slice(1) // Skip main worktree
|
||||
.map((block) => {
|
||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
||||
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
|
||||
if (!pathLine) return null;
|
||||
// Normalize path separators to OS native (git on Windows returns forward slashes)
|
||||
const worktreePath = pathLine.replace("worktree ", "");
|
||||
const worktreePath = pathLine.replace('worktree ', '');
|
||||
return path.normalize(worktreePath);
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
@@ -195,10 +195,10 @@ export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||
* Get list of git branches
|
||||
*/
|
||||
export async function listBranches(repoPath: string): Promise<string[]> {
|
||||
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
|
||||
const { stdout } = await execAsync('git branch --list', { cwd: repoPath });
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/^[*+]\s*/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export async function listBranches(repoPath: string): Promise<string[]> {
|
||||
* Get the current branch name
|
||||
*/
|
||||
export async function getCurrentBranch(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ export async function createWorktreeDirectly(
|
||||
worktreePath?: string
|
||||
): Promise<string> {
|
||||
const sanitizedName = sanitizeBranchName(branchName);
|
||||
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
|
||||
const targetPath = worktreePath || path.join(repoPath, '.worktrees', sanitizedName);
|
||||
|
||||
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
|
||||
return targetPath;
|
||||
@@ -257,7 +257,7 @@ export async function commitFile(
|
||||
* Get the latest commit message
|
||||
*/
|
||||
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
|
||||
const { stdout } = await execAsync('git log --oneline -1', { cwd: repoPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
@@ -268,32 +268,36 @@ export async function getLatestCommitMessage(repoPath: string): Promise<string>
|
||||
/**
|
||||
* Create a feature file in the test repo
|
||||
*/
|
||||
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
|
||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
||||
export function createTestFeature(
|
||||
repoPath: string,
|
||||
featureId: string,
|
||||
featureData: FeatureData
|
||||
): void {
|
||||
const featuresDir = path.join(repoPath, '.automaker', 'features');
|
||||
const featureDir = path.join(featuresDir, featureId);
|
||||
|
||||
fs.mkdirSync(featureDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2));
|
||||
fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a feature file from the test repo
|
||||
*/
|
||||
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
|
||||
const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json");
|
||||
const featureFilePath = path.join(repoPath, '.automaker', 'features', featureId, 'feature.json');
|
||||
|
||||
if (!fs.existsSync(featureFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
return JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all feature directories in the test repo
|
||||
*/
|
||||
export function listTestFeatures(repoPath: string): string[] {
|
||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
||||
const featuresDir = path.join(repoPath, '.automaker', 'features');
|
||||
|
||||
if (!fs.existsSync(featuresDir)) {
|
||||
return [];
|
||||
@@ -312,8 +316,8 @@ export function listTestFeatures(repoPath: string): string[] {
|
||||
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-worktree",
|
||||
name: "Worktree Test Project",
|
||||
id: 'test-project-worktree',
|
||||
name: 'Worktree Test Project',
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
@@ -322,36 +326,36 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
currentView: 'board',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: true, // Enable worktree feature for tests
|
||||
currentWorktreeByProject: {
|
||||
[pathArg]: { path: null, branch: "main" }, // Initialize to main branch
|
||||
[pathArg]: { path: null, branch: 'main' }, // Initialize to main branch
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
currentStep: 'complete',
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
@@ -359,11 +363,14 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
|
||||
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
|
||||
* Use this to test scenarios where the worktree feature flag is off
|
||||
*/
|
||||
export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise<void> {
|
||||
export async function setupProjectWithPathNoWorktrees(
|
||||
page: Page,
|
||||
projectPath: string
|
||||
): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-no-worktree",
|
||||
name: "Test Project (No Worktrees)",
|
||||
id: 'test-project-no-worktree',
|
||||
name: 'Test Project (No Worktrees)',
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
@@ -372,10 +379,10 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
currentView: 'board',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
@@ -387,19 +394,19 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
currentStep: 'complete',
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
@@ -408,11 +415,14 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
|
||||
* The currentWorktreeByProject points to a worktree path that no longer exists
|
||||
* This simulates the scenario where a user previously selected a worktree that was later deleted
|
||||
*/
|
||||
export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise<void> {
|
||||
export async function setupProjectWithStaleWorktree(
|
||||
page: Page,
|
||||
projectPath: string
|
||||
): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-stale-worktree",
|
||||
name: "Stale Worktree Test Project",
|
||||
id: 'test-project-stale-worktree',
|
||||
name: 'Stale Worktree Test Project',
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
@@ -421,10 +431,10 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
currentView: 'board',
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
apiKeys: { anthropic: '', google: '' },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
@@ -432,26 +442,26 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
|
||||
useWorktrees: true, // Enable worktree feature for tests
|
||||
currentWorktreeByProject: {
|
||||
// This is STALE data - pointing to a worktree path that doesn't exist
|
||||
[pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" },
|
||||
[pathArg]: { path: '/non/existent/worktree/path', branch: 'feature/deleted-branch' },
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
currentStep: 'complete',
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 2, // Must match app-store.ts persist version
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
@@ -477,8 +487,6 @@ export async function waitForBoardView(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||
const noProject = document.querySelector('[data-testid="board-view-no-project"]');
|
||||
const loading = document.querySelector('[data-testid="board-view-loading"]');
|
||||
// Return true only when board-view is visible (store hydrated with project)
|
||||
return boardView !== null;
|
||||
},
|
||||
@@ -490,8 +498,10 @@ export async function waitForBoardView(page: Page): Promise<void> {
|
||||
* Wait for the worktree selector to be visible
|
||||
*/
|
||||
export async function waitForWorktreeSelector(page: Page): Promise<void> {
|
||||
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
|
||||
// Fallback: wait for "Branch:" text
|
||||
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
|
||||
});
|
||||
await page
|
||||
.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium })
|
||||
.catch(() => {
|
||||
// Fallback: wait for "Branch:" text
|
||||
return page.getByText('Branch:').waitFor({ timeout: TIMEOUTS.medium });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Get a kanban card by feature ID
|
||||
*/
|
||||
export async function getKanbanCard(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
export async function getKanbanCard(page: Page, featureId: string): Promise<Locator> {
|
||||
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a kanban column by its ID
|
||||
*/
|
||||
export async function getKanbanColumn(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<Locator> {
|
||||
export async function getKanbanColumn(page: Page, columnId: string): Promise<Locator> {
|
||||
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the width of a kanban column
|
||||
*/
|
||||
export async function getKanbanColumnWidth(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<number> {
|
||||
export async function getKanbanColumnWidth(page: Page, columnId: string): Promise<number> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const box = await column.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
@@ -35,19 +26,16 @@ export async function getKanbanColumnWidth(
|
||||
/**
|
||||
* Check if a kanban column has CSS columns (masonry) layout
|
||||
*/
|
||||
export async function hasKanbanColumnMasonryLayout(
|
||||
page: Page,
|
||||
columnId: string
|
||||
): Promise<boolean> {
|
||||
export async function hasKanbanColumnMasonryLayout(page: Page, columnId: string): Promise<boolean> {
|
||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||
const contentDiv = column.locator("> div").nth(1); // Second child is the content area
|
||||
const contentDiv = column.locator('> div').nth(1); // Second child is the content area
|
||||
|
||||
const columnCount = await contentDiv.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.columnCount;
|
||||
});
|
||||
|
||||
return columnCount === "2";
|
||||
return columnCount === '2';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +46,8 @@ export async function dragKanbanCard(
|
||||
featureId: string,
|
||||
targetColumnId: string
|
||||
): Promise<void> {
|
||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
const targetColumn = page.locator(
|
||||
`[data-testid="kanban-column-${targetColumnId}"]`
|
||||
);
|
||||
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
|
||||
|
||||
// Perform drag and drop
|
||||
await dragHandle.dragTo(targetColumn);
|
||||
@@ -71,15 +56,10 @@ export async function dragKanbanCard(
|
||||
/**
|
||||
* Click the view output button on a kanban card
|
||||
*/
|
||||
export async function clickViewOutput(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<void> {
|
||||
export async function clickViewOutput(page: Page, featureId: string): Promise<void> {
|
||||
// Try the running version first, then the in-progress version
|
||||
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
|
||||
const inProgressBtn = page.locator(
|
||||
`[data-testid="view-output-inprogress-${featureId}"]`
|
||||
);
|
||||
const inProgressBtn = page.locator(`[data-testid="view-output-inprogress-${featureId}"]`);
|
||||
|
||||
if (await runningBtn.isVisible()) {
|
||||
await runningBtn.click();
|
||||
@@ -104,10 +84,7 @@ export async function isDragHandleVisibleForFeature(
|
||||
/**
|
||||
* Get the drag handle element for a specific feature card
|
||||
*/
|
||||
export async function getDragHandleForFeature(
|
||||
page: Page,
|
||||
featureId: string
|
||||
): Promise<Locator> {
|
||||
export async function getDragHandleForFeature(page: Page, featureId: string): Promise<Locator> {
|
||||
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
}
|
||||
|
||||
@@ -134,9 +111,7 @@ export async function fillAddFeatureDialog(
|
||||
options?: { branch?: string; category?: string }
|
||||
): Promise<void> {
|
||||
// Fill description (using the dropzone textarea)
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput.fill(description);
|
||||
|
||||
// Fill branch if provided (it's a combobox autocomplete)
|
||||
@@ -145,36 +120,34 @@ export async function fillAddFeatureDialog(
|
||||
const otherBranchRadio = page
|
||||
.locator('[data-testid="feature-radio-group"]')
|
||||
.locator('[id="feature-other"]');
|
||||
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
|
||||
await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await otherBranchRadio.click();
|
||||
// Wait for the branch input to appear
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Now click on the branch input (autocomplete)
|
||||
const branchInput = page.locator('[data-testid="feature-input"]');
|
||||
await branchInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
await branchInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await branchInput.click();
|
||||
// Wait for the popover to open
|
||||
await page.waitForTimeout(300);
|
||||
// Type in the command input
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
const commandInput = page.locator('[cmdk-input]');
|
||||
await commandInput.fill(options.branch);
|
||||
// Press Enter to select/create the branch
|
||||
await commandInput.press("Enter");
|
||||
await commandInput.press('Enter');
|
||||
// Wait for popover to close
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Fill category if provided (it's also a combobox autocomplete)
|
||||
if (options?.category) {
|
||||
const categoryButton = page.locator(
|
||||
'[data-testid="feature-category-input"]'
|
||||
);
|
||||
const categoryButton = page.locator('[data-testid="feature-category-input"]');
|
||||
await categoryButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
const commandInput = page.locator("[cmdk-input]");
|
||||
const commandInput = page.locator('[cmdk-input]');
|
||||
await commandInput.fill(options.category);
|
||||
await commandInput.press("Enter");
|
||||
await commandInput.press('Enter');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
@@ -185,10 +158,9 @@ export async function fillAddFeatureDialog(
|
||||
export async function confirmAddFeature(page: Page): Promise<void> {
|
||||
await page.click('[data-testid="confirm-add-feature"]');
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
await page.waitForFunction(() => !document.querySelector('[data-testid="add-feature-dialog"]'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,12 +190,9 @@ export async function getWorktreeSelector(page: Page): Promise<Locator> {
|
||||
/**
|
||||
* Click on a branch button in the worktree selector
|
||||
*/
|
||||
export async function selectWorktreeBranch(
|
||||
page: Page,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branchButton = page.getByRole("button", {
|
||||
name: new RegExp(branchName, "i"),
|
||||
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
|
||||
const branchButton = page.getByRole('button', {
|
||||
name: new RegExp(branchName, 'i'),
|
||||
});
|
||||
await branchButton.click();
|
||||
await page.waitForTimeout(500); // Wait for UI to update
|
||||
@@ -232,9 +201,7 @@ export async function selectWorktreeBranch(
|
||||
/**
|
||||
* Get the currently selected branch in the worktree selector
|
||||
*/
|
||||
export async function getSelectedWorktreeBranch(
|
||||
page: Page
|
||||
): Promise<string | null> {
|
||||
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
|
||||
// The main branch button has aria-pressed="true" when selected
|
||||
const selectedButton = page.locator(
|
||||
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
|
||||
@@ -246,12 +213,9 @@ export async function getSelectedWorktreeBranch(
|
||||
/**
|
||||
* Check if a branch button is visible in the worktree selector
|
||||
*/
|
||||
export async function isWorktreeBranchVisible(
|
||||
page: Page,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
const branchButton = page.getByRole("button", {
|
||||
name: new RegExp(branchName, "i"),
|
||||
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
|
||||
const branchButton = page.getByRole('button', {
|
||||
name: new RegExp(branchName, 'i'),
|
||||
});
|
||||
return await branchButton.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { getByTestId } from "../core/elements";
|
||||
import { waitForElement } from "../core/waiting";
|
||||
import { setupFirstRun } from "../project/setup";
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { getByTestId } from '../core/elements';
|
||||
import { waitForElement } from '../core/waiting';
|
||||
|
||||
/**
|
||||
* Wait for setup view to be visible
|
||||
*/
|
||||
export async function waitForSetupView(page: Page): Promise<Locator> {
|
||||
return waitForElement(page, "setup-view", { timeout: 10000 });
|
||||
return waitForElement(page, 'setup-view', { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Get Started" button on setup welcome step
|
||||
*/
|
||||
export async function clickSetupGetStarted(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "setup-start-button");
|
||||
const button = await getByTestId(page, 'setup-start-button');
|
||||
await button.click();
|
||||
}
|
||||
|
||||
@@ -22,7 +21,7 @@ export async function clickSetupGetStarted(page: Page): Promise<void> {
|
||||
* Click continue on Claude setup step
|
||||
*/
|
||||
export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "claude-next-button");
|
||||
const button = await getByTestId(page, 'claude-next-button');
|
||||
await button.click();
|
||||
}
|
||||
|
||||
@@ -30,46 +29,40 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||
* Click finish on setup complete step
|
||||
*/
|
||||
export async function clickSetupFinish(page: Page): Promise<void> {
|
||||
const button = await getByTestId(page, "setup-finish-button");
|
||||
const button = await getByTestId(page, 'setup-finish-button');
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter Anthropic API key in setup
|
||||
*/
|
||||
export async function enterAnthropicApiKey(
|
||||
page: Page,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
|
||||
// Click "Use Anthropic API Key Instead" button
|
||||
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
|
||||
const useApiKeyButton = await getByTestId(page, 'use-api-key-button');
|
||||
await useApiKeyButton.click();
|
||||
|
||||
// Enter the API key
|
||||
const input = await getByTestId(page, "anthropic-api-key-input");
|
||||
const input = await getByTestId(page, 'anthropic-api-key-input');
|
||||
await input.fill(apiKey);
|
||||
|
||||
// Click save button
|
||||
const saveButton = await getByTestId(page, "save-anthropic-key-button");
|
||||
const saveButton = await getByTestId(page, 'save-anthropic-key-button');
|
||||
await saveButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter OpenAI API key in setup
|
||||
*/
|
||||
export async function enterOpenAIApiKey(
|
||||
page: Page,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
|
||||
// Click "Enter OpenAI API Key" button
|
||||
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
|
||||
const useApiKeyButton = await getByTestId(page, 'use-openai-key-button');
|
||||
await useApiKeyButton.click();
|
||||
|
||||
// Enter the API key
|
||||
const input = await getByTestId(page, "openai-api-key-input");
|
||||
const input = await getByTestId(page, 'openai-api-key-input');
|
||||
await input.fill(apiKey);
|
||||
|
||||
// Click save button
|
||||
const saveButton = await getByTestId(page, "save-openai-key-button");
|
||||
const saveButton = await getByTestId(page, 'save-openai-key-button');
|
||||
await saveButton.click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user