Change description field to textarea in Add New Feature modal

The description field in the Add New Feature modal is now a textarea instead of
an input, allowing users to enter multi-line feature descriptions more easily.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Seibert
2025-12-08 22:53:33 -05:00
parent 9392422d35
commit 7bfc489efa
23 changed files with 1319 additions and 1382 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import Link from "next/link";
@@ -21,8 +21,6 @@ import {
PanelLeft,
PanelLeftClose,
Sparkles,
User,
LogOut,
Cpu,
ChevronDown,
Check,
@@ -57,27 +55,6 @@ export function Sidebar() {
removeProject,
} = useAppStore();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setUserMenuOpen(false);
}
}
if (userMenuOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [userMenuOpen]);
const navSections: NavSection[] = [
{
@@ -113,7 +90,7 @@ export function Sidebar() {
{/* Floating Collapse Toggle Button - Desktop only */}
<button
onClick={toggleSidebar}
className="hidden lg:flex absolute top-20 -right-3 z-50 items-center justify-center w-6 h-6 rounded-full bg-zinc-800 border border-white/10 text-zinc-400 hover:text-white hover:bg-zinc-700 hover:border-white/20 transition-all shadow-lg titlebar-no-drag"
className="hidden lg:flex absolute top-1/2 -translate-y-1/2 -right-3 z-50 items-center justify-center w-6 h-6 rounded-full bg-zinc-800 border border-white/10 text-zinc-400 hover:text-white hover:bg-zinc-700 hover:border-white/20 transition-all shadow-lg titlebar-no-drag"
data-testid="sidebar-collapse-button"
title={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
>
@@ -151,24 +128,26 @@ export function Sidebar() {
</div>
{/* Project Actions */}
<div className="flex items-center gap-1 titlebar-no-drag">
<button
onClick={() => setCurrentView("welcome")}
className="group flex items-center justify-center w-8 h-8 rounded-lg relative overflow-hidden transition-all text-zinc-400 hover:text-white hover:bg-white/5"
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-4 h-4 flex-shrink-0" />
</button>
<button
onClick={() => setCurrentView("welcome")}
className="group flex items-center justify-center w-8 h-8 rounded-lg relative overflow-hidden transition-all text-zinc-400 hover:text-white hover:bg-white/5"
title="Open Project"
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
</button>
</div>
{sidebarOpen && (
<div className="flex items-center gap-1 titlebar-no-drag">
<button
onClick={() => setCurrentView("welcome")}
className="group flex items-center justify-center w-8 h-8 rounded-lg relative overflow-hidden transition-all text-zinc-400 hover:text-white hover:bg-white/5"
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-4 h-4 flex-shrink-0" />
</button>
<button
onClick={() => setCurrentView("welcome")}
className="group flex items-center justify-center w-8 h-8 rounded-lg relative overflow-hidden transition-all text-zinc-400 hover:text-white hover:bg-white/5"
title="Open Project"
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
</button>
</div>
)}
</div>
{/* Project Selector */}
@@ -215,73 +194,86 @@ export function Sidebar() {
{/* Nav Items - Scrollable */}
<nav className="flex-1 overflow-y-auto px-2 mt-4 pb-2">
{navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="hidden lg:block px-4 mb-2">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
{section.label}
</span>
</div>
)}
{section.label && !sidebarOpen && (
<div className="h-px bg-zinc-800 mx-2 mb-2"></div>
)}
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
<p className="text-zinc-500 text-sm text-center">
<span className="hidden lg:block">
Select or create a project above
</span>
</p>
</div>
) : currentProject ? (
// Navigation sections when project is selected
navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="hidden lg:block px-4 mb-2">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
{section.label}
</span>
</div>
)}
{section.label && !sidebarOpen && (
<div className="h-px bg-zinc-800 mx-2 mb-2"></div>
)}
{/* Nav Items */}
<div className="space-y-1">
{section.items.map((item) => {
const isActive = isActiveRoute(item.id);
const Icon = item.icon;
{/* Nav Items */}
<div className="space-y-1">
{section.items.map((item) => {
const isActive = isActiveRoute(item.id);
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setCurrentView(item.id as any)}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActive
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5"
)}
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Icon
return (
<button
key={item.id}
onClick={() => setCurrentView(item.id as any)}
className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActive
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm",
sidebarOpen ? "hidden lg:block" : "hidden"
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5",
!sidebarOpen && "justify-center"
)}
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
{item.label}
</span>
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Icon
className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
isActive
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
className={cn(
"ml-2.5 font-medium text-sm",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
{item.label}
</span>
)}
</button>
);
})}
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
>
{item.label}
</span>
)}
</button>
);
})}
</div>
</div>
</div>
))}
))
) : null}
</nav>
</div>
@@ -328,81 +320,6 @@ export function Sidebar() {
</button>
</div>
{/* User Profile */}
<div className="p-3 border-t border-zinc-800" ref={userMenuRef}>
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className={cn(
"flex items-center p-1.5 rounded-lg transition-colors group relative w-full hover:bg-white/5 titlebar-no-drag",
sidebarOpen ? "lg:space-x-2.5" : "justify-center"
)}
>
<div className="relative">
<div className="w-8 h-8 rounded-full border border-zinc-600 bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<div className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 border-2 border-zinc-900 rounded-full"></div>
</div>
<div
className={cn(
"overflow-hidden",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
<p className="text-xs font-medium text-white truncate">
Developer
</p>
<p className="text-[10px] text-zinc-500 truncate">
Active Session
</p>
</div>
{/* Tooltip for user when collapsed */}
{!sidebarOpen && (
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid="sidebar-tooltip-user"
>
Developer
</span>
)}
</button>
{/* Dropdown Menu */}
{userMenuOpen && (
<div
className={cn(
"absolute bottom-full mb-2 bg-zinc-800 border border-zinc-700 rounded-xl shadow-lg overflow-hidden z-50",
sidebarOpen ? "left-0 right-0" : "left-0"
)}
>
<div className="py-2">
<button
onClick={() => {
setUserMenuOpen(false);
setCurrentView("settings");
}}
className="flex items-center w-full px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors titlebar-no-drag"
>
<Settings className="w-4 h-4 mr-3" />
<span>Settings</span>
</button>
<div className="border-t border-zinc-700 my-2"></div>
<button
onClick={() => {
setUserMenuOpen(false);
// Add logout logic here if needed
}}
className="flex items-center w-full px-4 py-2 text-sm text-red-400 hover:bg-zinc-700/50 hover:text-red-300 transition-colors titlebar-no-drag"
>
<LogOut className="w-4 h-4 mr-3" />
<span>Exit</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</aside>
);

View File

@@ -25,6 +25,26 @@ import {
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
// Random session name generator
const adjectives = [
"Swift", "Bright", "Clever", "Dynamic", "Eager", "Focused", "Gentle", "Happy",
"Inventive", "Jolly", "Keen", "Lively", "Mighty", "Noble", "Optimal", "Peaceful",
"Quick", "Radiant", "Smart", "Tranquil", "Unique", "Vibrant", "Wise", "Zealous"
];
const nouns = [
"Agent", "Builder", "Coder", "Developer", "Explorer", "Forge", "Garden", "Helper",
"Innovator", "Journey", "Kernel", "Lighthouse", "Mission", "Navigator", "Oracle",
"Project", "Quest", "Runner", "Spark", "Task", "Unicorn", "Voyage", "Workshop"
];
function generateRandomSessionName(): string {
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 100);
return `${adjective} ${noun} ${number}`;
}
interface SessionManagerProps {
currentSessionId: string | null;
onSelectSession: (sessionId: string) => void;
@@ -60,12 +80,14 @@ export function SessionManager({
loadSessions();
}, []);
// Create new session
// Create new session with random name
const handleCreateSession = async () => {
if (!newSessionName.trim() || !window.electronAPI?.sessions) return;
if (!window.electronAPI?.sessions) return;
const sessionName = newSessionName.trim() || generateRandomSessionName();
const result = await window.electronAPI.sessions.create(
newSessionName,
sessionName,
projectPath,
projectPath
);
@@ -78,6 +100,24 @@ export function SessionManager({
}
};
// Create new session directly with a random name (one-click)
const handleQuickCreateSession = async () => {
if (!window.electronAPI?.sessions) return;
const sessionName = generateRandomSessionName();
const result = await window.electronAPI.sessions.create(
sessionName,
projectPath,
projectPath
);
if (result.success && result.sessionId) {
await loadSessions();
onSelectSession(result.sessionId);
}
};
// Rename session
const handleRenameSession = async (sessionId: string) => {
if (!editingName.trim() || !window.electronAPI?.sessions) return;
@@ -146,7 +186,8 @@ export function SessionManager({
<Button
variant="default"
size="sm"
onClick={() => setIsCreating(true)}
onClick={handleQuickCreateSession}
data-testid="new-session-button"
>
<Plus className="w-4 h-4 mr-1" />
New
@@ -172,7 +213,7 @@ export function SessionManager({
</Tabs>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-2">
<CardContent className="flex-1 overflow-y-auto space-y-2" data-testid="session-list">
{/* Create new session */}
{isCreating && (
<div className="p-3 border rounded-lg bg-muted/50">
@@ -217,6 +258,7 @@ export function SessionManager({
session.isArchived && "opacity-60"
)}
onClick={() => !session.isArchived && onSelectSession(session.id)}
data-testid={`session-item-${session.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">

View File

@@ -200,16 +200,22 @@ 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 = (nodes: FileTreeNode[], maxDepth: number = 3, currentDepth: number = 0): string[] => {
const collectFilePaths = (
nodes: FileTreeNode[],
maxDepth: number = 3,
currentDepth: number = 0
): string[] => {
const paths: string[] = [];
for (const node of nodes) {
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;
@@ -235,31 +241,40 @@ 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');
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) 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');
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");
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
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)];
@@ -267,9 +282,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
@@ -281,36 +296,43 @@ 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.length > 20) {
if (
trimmed &&
!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
const analyzeStructure = () => {
const structure: string[] = [];
const topLevelDirs = projectAnalysis.fileTree.filter(n => n.isDirectory).map(n => n.name);
const topLevelDirs = projectAnalysis.fileTree
.filter((n) => n.isDirectory)
.map((n) => n.name);
for (const dir of topLevelDirs) {
structure.push(` <directory name="${dir}" />`);
}
return structure.join('\n');
return structure.join("\n");
};
const projectName = getProjectName();
@@ -328,14 +350,18 @@ export function AnalysisView() {
<technology_stack>
<languages>
${Object.entries(projectAnalysis.filesByExtension)
.filter(([ext]) => ['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext))
.filter(([ext]) =>
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
ext
)
)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([ext, count]) => ` <language ext=".${ext}" count="${count}" />`)
.join('\n')}
.join("\n")}
</languages>
<frameworks>
${techStack.map(tech => ` <framework>${tech}</framework>`).join('\n')}
${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
</frameworks>
</technology_stack>
@@ -351,8 +377,13 @@ ${analyzeStructure()}
${Object.entries(projectAnalysis.filesByExtension)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([ext, count]) => ` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`)
.join('\n')}
.map(
([ext, count]) =>
` <extension type="${
ext.startsWith("(") ? ext : "." + ext
}" count="${count}" />`
)
.join("\n")}
</file_breakdown>
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
@@ -366,17 +397,19 @@ ${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);
}
}, [currentProject, projectAnalysis]);
// Generate feature_list.json from analysis
// Generate .automaker/feature_list.json from analysis
const generateFeatureList = useCallback(async () => {
if (!currentProject || !projectAnalysis) return;
@@ -389,7 +422,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) {
@@ -431,14 +464,21 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Detect features based on project structure and files
const detectFeatures = () => {
const extensions = projectAnalysis.filesByExtension;
const topLevelDirs = projectAnalysis.fileTree.filter(n => n.isDirectory).map(n => n.name.toLowerCase());
const topLevelFiles = projectAnalysis.fileTree.filter(n => !n.isDirectory).map(n => n.name.toLowerCase());
const topLevelDirs = projectAnalysis.fileTree
.filter((n) => n.isDirectory)
.map((n) => n.name.toLowerCase());
const topLevelFiles = projectAnalysis.fileTree
.filter((n) => !n.isDirectory)
.map((n) => n.name.toLowerCase());
// 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.'));
const hasTests =
topLevelDirs.includes("tests") ||
topLevelDirs.includes("test") ||
topLevelDirs.includes("__tests__") ||
allFilePaths.some(
(p) => p.includes(".spec.") || p.includes(".test.")
);
if (hasTests) {
detectedFeatures.push({
@@ -447,15 +487,16 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Tests directory exists",
"Step 2: Test files are present",
"Step 3: Run test suite"
"Step 3: Run test suite",
],
passes: true
passes: true,
});
}
// Check for components directory (UI components)
const hasComponents = topLevelDirs.includes('components') ||
allFilePaths.some(p => p.toLowerCase().includes('/components/'));
const hasComponents =
topLevelDirs.includes("components") ||
allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
if (hasComponents) {
detectedFeatures.push({
@@ -464,42 +505,42 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Components directory exists",
"Step 2: UI components are defined",
"Step 3: Components are reusable"
"Step 3: Components are reusable",
],
passes: true
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",
steps: [
"Step 1: Source directory exists",
"Step 2: Code is properly organized",
"Step 3: Follows best practices"
"Step 3: Follows best practices",
],
passes: true
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",
steps: [
"Step 1: React is installed",
"Step 2: Components render correctly",
"Step 3: State management works"
"Step 3: State management works",
],
passes: true
passes: true,
});
}
@@ -510,37 +551,45 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Next.js is configured",
"Step 2: Pages/routes are defined",
"Step 3: Server-side rendering works"
"Step 3: Server-side rendering works",
],
passes: true
passes: true,
});
}
// TypeScript support
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript || extensions['ts'] || extensions['tsx']) {
if (
pkg.devDependencies?.typescript ||
pkg.dependencies?.typescript ||
extensions["ts"] ||
extensions["tsx"]
) {
detectedFeatures.push({
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 3: Code compiles without errors",
],
passes: true
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",
steps: [
"Step 1: Tailwind is configured",
"Step 2: Styles are applied",
"Step 3: Responsive design works"
"Step 3: Responsive design works",
],
passes: true
passes: true,
});
}
@@ -552,9 +601,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Linter is configured",
"Step 2: Code passes lint checks",
"Step 3: Formatting is consistent"
"Step 3: Formatting is consistent",
],
passes: true
passes: true,
});
}
@@ -566,49 +615,55 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Electron is configured",
"Step 2: Main process runs",
"Step 3: Renderer process loads"
"Step 3: Renderer process loads",
],
passes: true
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",
steps: [
"Step 1: Playwright is configured",
"Step 2: E2E tests are defined",
"Step 3: Tests pass successfully"
"Step 3: Tests pass successfully",
],
passes: true
passes: true,
});
}
} catch {
// Ignore JSON parse errors
}
}
// 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",
steps: [
"Step 1: README exists",
"Step 2: Documentation is comprehensive",
"Step 3: Setup instructions are clear"
"Step 3: Setup instructions are clear",
],
passes: true
passes: true,
});
}
// Check for CI/CD configuration
const hasCICD = topLevelDirs.includes('.github') ||
topLevelFiles.includes('.gitlab-ci.yml') ||
topLevelFiles.includes('.travis.yml');
const hasCICD =
topLevelDirs.includes(".github") ||
topLevelFiles.includes(".gitlab-ci.yml") ||
topLevelFiles.includes(".travis.yml");
if (hasCICD) {
detectedFeatures.push({
@@ -617,17 +672,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: CI config exists",
"Step 2: Pipeline runs on push",
"Step 3: Automated checks pass"
"Step 3: Automated checks pass",
],
passes: true
passes: true,
});
}
// Check for API routes (Next.js API or Express)
const hasAPIRoutes = allFilePaths.some(p =>
p.includes('/api/') ||
p.includes('/routes/') ||
p.includes('/endpoints/')
const hasAPIRoutes = allFilePaths.some(
(p) =>
p.includes("/api/") ||
p.includes("/routes/") ||
p.includes("/endpoints/")
);
if (hasAPIRoutes) {
@@ -637,18 +693,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: API routes are defined",
"Step 2: Endpoints respond correctly",
"Step 3: Error handling is implemented"
"Step 3: Error handling is implemented",
],
passes: true
passes: true,
});
}
// Check for state management
const hasStateManagement = allFilePaths.some(p =>
p.includes('/store/') ||
p.includes('/stores/') ||
p.includes('/redux/') ||
p.includes('/context/')
const hasStateManagement = allFilePaths.some(
(p) =>
p.includes("/store/") ||
p.includes("/stores/") ||
p.includes("/redux/") ||
p.includes("/context/")
);
if (hasStateManagement) {
@@ -658,23 +715,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Store is configured",
"Step 2: State updates correctly",
"Step 3: Components access state"
"Step 3: Components access state",
],
passes: true
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",
steps: [
"Step 1: Config files exist",
"Step 2: Configuration is valid",
"Step 3: Build process works"
"Step 3: Build process works",
],
passes: true
passes: true,
});
}
};
@@ -689,9 +749,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
steps: [
"Step 1: Project directory exists",
"Step 2: Files are present",
"Step 3: Project can be loaded"
"Step 3: Project can be loaded",
],
passes: true
passes: true,
});
}
@@ -700,16 +760,25 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Write the feature list file
const featureListPath = `${currentProject.path}/feature_list.json`;
const writeResult = await api.writeFile(featureListPath, featureListContent);
const writeResult = await api.writeFile(
featureListPath,
featureListContent
);
if (writeResult.success) {
setFeatureListGenerated(true);
} else {
setFeatureListError(writeResult.error || 'Failed to write feature list file');
setFeatureListError(
writeResult.error || "Failed to write feature list file"
);
}
} catch (error) {
console.error('Failed to generate feature list:', error);
setFeatureListError(error instanceof Error ? error.message : 'Failed to generate feature list');
console.error("Failed to generate feature list:", error);
setFeatureListError(
error instanceof Error
? error.message
: "Failed to generate feature list"
);
} finally {
setIsGeneratingFeatureList(false);
}
@@ -922,7 +991,8 @@ ${Object.entries(projectAnalysis.filesByExtension)
</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}
@@ -943,13 +1013,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
)}
</Button>
{specGenerated && (
<div className="flex items-center gap-2 text-sm text-green-500" data-testid="spec-generated-success">
<div
className="flex items-center gap-2 text-sm text-green-500"
data-testid="spec-generated-success"
>
<CheckCircle className="w-4 h-4" />
<span>app_spec.txt created successfully!</span>
</div>
)}
{specError && (
<div className="flex items-center gap-2 text-sm text-red-500" data-testid="spec-generated-error">
<div
className="flex items-center gap-2 text-sm text-red-500"
data-testid="spec-generated-error"
>
<AlertCircle className="w-4 h-4" />
<span>{specError}</span>
</div>
@@ -965,12 +1041,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
Generate Feature List
</CardTitle>
<CardDescription>
Create feature_list.json from analysis
Create .automaker/feature_list.json 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}
@@ -991,13 +1069,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
)}
</Button>
{featureListGenerated && (
<div className="flex items-center gap-2 text-sm text-green-500" data-testid="feature-list-generated-success">
<div
className="flex items-center gap-2 text-sm text-green-500"
data-testid="feature-list-generated-success"
>
<CheckCircle className="w-4 h-4" />
<span>feature_list.json created successfully!</span>
</div>
)}
{featureListError && (
<div className="flex items-center gap-2 text-sm text-red-500" data-testid="feature-list-generated-error">
<div
className="flex items-center gap-2 text-sm text-red-500"
data-testid="feature-list-generated-error"
>
<AlertCircle className="w-4 h-4" />
<span>{featureListError}</span>
</div>

View File

@@ -16,7 +16,7 @@ import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useAppStore, Feature } from "@/store/app-store";
import { useAppStore, Feature, FeatureImage } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import {
@@ -28,6 +28,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { FeatureImageUpload } from "@/components/ui/feature-image-upload";
import {
Dialog,
DialogContent,
@@ -69,6 +72,7 @@ export function BoardView() {
category: "",
description: "",
steps: [""],
images: [] as FeatureImage[],
});
const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
@@ -263,8 +267,9 @@ export function BoardView() {
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
status: "backlog",
images: newFeature.images,
});
setNewFeature({ category: "", description: "", steps: [""] });
setNewFeature({ category: "", description: "", steps: [""], images: [] });
setShowAddDialog(false);
};
@@ -549,7 +554,7 @@ export function BoardView() {
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
<Textarea
id="description"
placeholder="Describe the feature..."
value={newFeature.description}

View File

@@ -62,7 +62,8 @@ const INTERVIEW_QUESTIONS = [
];
export function InterviewView() {
const { setCurrentView, addProject, setCurrentProject, setAppSpec } = useAppStore();
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
useAppStore();
const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@@ -130,11 +131,19 @@ export function InterviewView() {
if (currentQuestion) {
setInterviewData((prev) => {
const newData = { ...prev };
if (currentQuestion.field === "techStack" || currentQuestion.field === "features") {
if (
currentQuestion.field === "techStack" ||
currentQuestion.field === "features"
) {
// Parse comma-separated values into array
newData[currentQuestion.field] = input.split(",").map((s) => s.trim()).filter(Boolean);
newData[currentQuestion.field] = input
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
(newData as Record<string, string | string[]>)[
currentQuestion.field
] = input;
}
return newData;
});
@@ -161,16 +170,33 @@ export function InterviewView() {
const summaryMessage: InterviewMessage = {
id: `assistant-summary-${Date.now()}`,
role: "assistant",
content: "Perfect! I have all the information I need. Now let me generate your project specification...",
content:
"Perfect! I have all the information I need. Now let me generate your project specification...",
timestamp: new Date(),
};
setMessages((prev) => [...prev, summaryMessage]);
generateSpec({
...interviewData,
projectDescription: currentQuestionIndex === 0 ? input : interviewData.projectDescription,
techStack: currentQuestionIndex === 1 ? input.split(",").map(s => s.trim()).filter(Boolean) : interviewData.techStack,
features: currentQuestionIndex === 2 ? input.split(",").map(s => s.trim()).filter(Boolean) : interviewData.features,
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
projectDescription:
currentQuestionIndex === 0
? input
: interviewData.projectDescription,
techStack:
currentQuestionIndex === 1
? input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.techStack,
features:
currentQuestionIndex === 2
? input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.features,
additionalNotes:
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
});
}
}, 500);
@@ -215,11 +241,23 @@ export function InterviewView() {
</overview>
<technology_stack>
${data.techStack.length > 0 ? data.techStack.map((tech) => `<technology>${tech}</technology>`).join("\n ") : "<!-- Define your tech stack -->"}
${
data.techStack.length > 0
? data.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")
: "<!-- Define your tech stack -->"
}
</technology_stack>
<core_capabilities>
${data.features.length > 0 ? data.features.map((feature) => `<capability>${feature}</capability>`).join("\n ") : "<!-- List core features -->"}
${
data.features.length > 0
? data.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")
: "<!-- List core features -->"
}
</core_capabilities>
<additional_requirements>
@@ -259,7 +297,7 @@ export function InterviewView() {
// Write app_spec.txt with generated content
await api.writeFile(`${fullProjectPath}/app_spec.txt`, generatedSpec);
// Create initial feature_list.json
// Create initial .automaker/feature_list.json
await api.writeFile(
`${fullProjectPath}/feature_list.json`,
JSON.stringify(
@@ -267,7 +305,11 @@ export function InterviewView() {
{
category: "Core",
description: "Initial project setup",
steps: ["Step 1: Review app_spec.txt", "Step 2: Set up development environment", "Step 3: Start implementing features"],
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
passes: false,
},
],
@@ -307,7 +349,10 @@ export function InterviewView() {
};
return (
<div className="flex-1 flex flex-col content-bg" data-testid="interview-view">
<div
className="flex-1 flex flex-col content-bg"
data-testid="interview-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">
@@ -324,7 +369,11 @@ export function InterviewView() {
<div>
<h1 className="text-xl font-bold">New Project Interview</h1>
<p className="text-sm text-muted-foreground">
{isComplete ? "Specification generated!" : `Question ${currentQuestionIndex + 1} of ${INTERVIEW_QUESTIONS.length}`}
{isComplete
? "Specification generated!"
: `Question ${currentQuestionIndex + 1} of ${
INTERVIEW_QUESTIONS.length
}`}
</p>
</div>
</div>
@@ -344,7 +393,9 @@ export function InterviewView() {
)}
/>
))}
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
{isComplete && (
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
)}
</div>
</div>
@@ -418,7 +469,10 @@ export function InterviewView() {
{/* Project Setup Form */}
{showProjectSetup && (
<div className="mt-6">
<Card className="bg-zinc-900/50 border-white/10" data-testid="project-setup-form">
<Card
className="bg-zinc-900/50 border-white/10"
data-testid="project-setup-form"
>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
@@ -427,7 +481,10 @@ export function InterviewView() {
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
<label
htmlFor="project-name"
className="text-sm font-medium text-zinc-300"
>
Project Name
</label>
<Input
@@ -441,7 +498,10 @@ export function InterviewView() {
</div>
<div className="space-y-2">
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
<label
htmlFor="project-path"
className="text-sm font-medium text-zinc-300"
>
Parent Directory
</label>
<div className="flex gap-2">

View File

@@ -401,6 +401,7 @@ export const useAppStore = create<AppState & AppActions>()(
name: "automaker-storage",
partialize: (state) => ({
projects: state.projects,
currentProject: state.currentProject,
theme: state.theme,
sidebarOpen: state.sidebarOpen,
apiKeys: state.apiKeys,