initial commit

This commit is contained in:
Cody Seibert
2025-12-07 16:43:26 -05:00
commit 3c8e786f29
70 changed files with 21487 additions and 0 deletions

BIN
app/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

150
app/src/app/globals.css Normal file
View File

@@ -0,0 +1,150 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.1 0 0);
--foreground: oklch(0.95 0 0);
--card: oklch(0.13 0 0);
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.13 0 0);
--popover-foreground: oklch(0.95 0 0);
--primary: oklch(0.55 0.25 265);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2 0 0);
--secondary-foreground: oklch(0.95 0 0);
--muted: oklch(0.2 0 0);
--muted-foreground: oklch(0.65 0 0);
--accent: oklch(0.55 0.25 265);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.6 0.25 25);
--border: oklch(0.25 0 0);
--input: oklch(0.2 0 0);
--ring: oklch(0.55 0.25 265);
--chart-1: oklch(0.55 0.25 265);
--chart-2: oklch(0.65 0.2 160);
--chart-3: oklch(0.75 0.2 70);
--chart-4: oklch(0.6 0.25 300);
--chart-5: oklch(0.6 0.25 20);
--sidebar: oklch(0.08 0 0);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.55 0.25 265);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2 0.05 265);
--sidebar-accent-foreground: oklch(0.95 0 0);
--sidebar-border: oklch(0.25 0 0);
--sidebar-ring: oklch(0.55 0.25 265);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom scrollbar for dark mode */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: oklch(0.15 0 0);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.3 0 0);
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0 0);
}
/* Electron title bar drag region */
.titlebar-drag-region {
-webkit-app-region: drag;
}
.titlebar-no-drag {
-webkit-app-region: no-drag;
}

34
app/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Automaker - Autonomous AI Development Studio",
description: "Build software autonomously with intelligent orchestration",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}
>
{children}
</body>
</html>
);
}

89
app/src/app/page.tsx Normal file
View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
import { SpecView } from "@/components/views/spec-view";
import { CodeView } from "@/components/views/code-view";
import { AgentView } from "@/components/views/agent-view";
import { SettingsView } from "@/components/views/settings-view";
import { AnalysisView } from "@/components/views/analysis-view";
import { AgentToolsView } from "@/components/views/agent-tools-view";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
export default function Home() {
const { currentView, setIpcConnected, theme } = useAppStore();
// Test IPC connection on mount
useEffect(() => {
const testConnection = async () => {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong" || result === "pong (mock)");
} catch (error) {
console.error("IPC connection failed:", error);
setIpcConnected(false);
}
};
testConnection();
}, [setIpcConnected]);
// Apply theme class to document
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else if (theme === "light") {
root.classList.remove("dark");
} else {
// System theme
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (isDark) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}
}, [theme]);
const renderView = () => {
switch (currentView) {
case "welcome":
return <WelcomeView />;
case "board":
return <BoardView />;
case "spec":
return <SpecView />;
case "code":
return <CodeView />;
case "agent":
return <AgentView />;
case "settings":
return <SettingsView />;
case "analysis":
return <AnalysisView />;
case "tools":
return <AgentToolsView />;
default:
return <WelcomeView />;
}
};
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">{renderView()}</div>
{/* Environment indicator */}
{!isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20">
Web Mode (Mock IPC)
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/store/app-store";
import {
FolderOpen,
Plus,
Settings,
FileText,
LayoutGrid,
Code,
Bot,
ChevronLeft,
ChevronRight,
Folder,
X,
Moon,
Sun,
Search,
Wrench,
} from "lucide-react";
export function Sidebar() {
const {
projects,
currentProject,
currentView,
sidebarOpen,
theme,
setCurrentProject,
setCurrentView,
toggleSidebar,
removeProject,
setTheme,
} = useAppStore();
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const navItems = [
{ id: "spec" as const, label: "Spec Editor", icon: FileText },
{ id: "board" as const, label: "Kanban Board", icon: LayoutGrid },
{ id: "code" as const, label: "Code View", icon: Code },
{ id: "analysis" as const, label: "Analysis", icon: Search },
{ id: "agent" as const, label: "Agent Chat", icon: Bot },
{ id: "tools" as const, label: "Agent Tools", icon: Wrench },
];
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return (
<aside
className={cn(
"flex flex-col h-full bg-sidebar border-r border-sidebar-border transition-all duration-300",
sidebarOpen ? "w-64" : "w-16"
)}
data-testid="sidebar"
>
{/* Header */}
<div className="flex items-center justify-between h-14 px-4 border-b border-sidebar-border titlebar-drag-region">
{sidebarOpen && (
<h1 className="text-lg font-bold text-sidebar-foreground">
Automaker
</h1>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="titlebar-no-drag text-sidebar-foreground hover:bg-sidebar-accent"
data-testid="toggle-sidebar"
>
{sidebarOpen ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
{/* Project Actions */}
<div className="p-2 space-y-1">
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2"
)}
onClick={() => setCurrentView("welcome")}
data-testid="new-project-button"
>
<Plus className="h-4 w-4" />
{sidebarOpen && <span>New Project</span>}
</Button>
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2"
)}
onClick={() => setCurrentView("welcome")}
data-testid="open-project-button"
>
<FolderOpen className="h-4 w-4" />
{sidebarOpen && <span>Open Project</span>}
</Button>
</div>
{/* Projects List */}
{sidebarOpen && projects.length > 0 && (
<div className="flex-1 overflow-y-auto px-2">
<p className="px-2 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Projects
</p>
<div className="space-y-1">
{projects.map((project) => (
<div
key={project.id}
className={cn(
"group flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer transition-colors",
currentProject?.id === project.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50 text-sidebar-foreground"
)}
onClick={() => setCurrentProject(project)}
onMouseEnter={() => setHoveredProject(project.id)}
onMouseLeave={() => setHoveredProject(null)}
data-testid={`project-${project.id}`}
>
<Folder className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-sm">{project.name}</span>
{hoveredProject === project.id && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeProject(project.id);
}}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
</div>
)}
{/* Navigation - Only show when a project is open */}
{currentProject && (
<div className="border-t border-sidebar-border p-2 space-y-1">
{sidebarOpen && (
<p className="px-2 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Views
</p>
)}
{navItems.map((item) => (
<Button
key={item.id}
variant="ghost"
className={cn(
"w-full justify-start gap-3",
!sidebarOpen && "justify-center px-2",
currentView === item.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
)}
onClick={() => setCurrentView(item.id)}
data-testid={`nav-${item.id}`}
>
<item.icon className="h-4 w-4" />
{sidebarOpen && <span>{item.label}</span>}
</Button>
))}
</div>
)}
{/* Bottom Actions */}
<div className="mt-auto border-t border-sidebar-border p-2 space-y-1">
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2"
)}
onClick={toggleTheme}
data-testid="toggle-theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
{sidebarOpen && (
<span>{theme === "dark" ? "Light Mode" : "Dark Mode"}</span>
)}
</Button>
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2",
currentView === "settings" && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
onClick={() => setCurrentView("settings")}
data-testid="settings-button"
>
<Settings className="h-4 w-4" />
{sidebarOpen && <span>Settings</span>}
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-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",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
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>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.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",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.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",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
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)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,465 @@
"use client";
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
FileText,
FolderOpen,
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
interface ToolResult {
success: boolean;
output?: string;
error?: string;
timestamp: Date;
}
interface ToolExecution {
tool: string;
input: string;
result: ToolResult | null;
isRunning: boolean;
}
export function AgentToolsView() {
const { currentProject } = useAppStore();
const api = getElectronAPI();
// Read File Tool State
const [readFilePath, setReadFilePath] = useState("");
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
const [isReadingFile, setIsReadingFile] = useState(false);
// Write File Tool State
const [writeFilePath, setWriteFilePath] = useState("");
const [writeFileContent, setWriteFileContent] = useState("");
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(null);
const [isWritingFile, setIsWritingFile] = useState(false);
// Terminal Tool State
const [terminalCommand, setTerminalCommand] = useState("ls");
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
const [isRunningCommand, setIsRunningCommand] = useState(false);
// Execute Read File
const handleReadFile = useCallback(async () => {
if (!readFilePath.trim()) return;
setIsReadingFile(true);
setReadFileResult(null);
try {
// Simulate agent requesting file read
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
const result = await api.readFile(readFilePath);
if (result.success) {
setReadFileResult({
success: true,
output: result.content,
timestamp: new Date(),
});
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
} else {
setReadFileResult({
success: false,
error: result.error || "Failed to read file",
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
}
} catch (error) {
setReadFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsReadingFile(false);
}
}, [readFilePath, api]);
// Execute Write File
const handleWriteFile = useCallback(async () => {
if (!writeFilePath.trim() || !writeFileContent.trim()) return;
setIsWritingFile(true);
setWriteFileResult(null);
try {
// Simulate agent requesting file write
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
const result = await api.writeFile(writeFilePath, writeFileContent);
if (result.success) {
setWriteFileResult({
success: true,
output: `File written successfully: ${writeFilePath}`,
timestamp: new Date(),
});
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
} else {
setWriteFileResult({
success: false,
error: result.error || "Failed to write file",
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
}
} catch (error) {
setWriteFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsWritingFile(false);
}
}, [writeFilePath, writeFileContent, api]);
// Execute Terminal Command
const handleRunCommand = useCallback(async () => {
if (!terminalCommand.trim()) return;
setIsRunningCommand(true);
setTerminalResult(null);
try {
// Simulate agent requesting terminal command execution
console.log(`[Agent Tool] Requesting to run command: ${terminalCommand}`);
// In mock mode, simulate terminal output
// In real Electron mode, this would use child_process
const mockOutputs: Record<string, string> = {
"ls": "app_spec.txt\nfeature_list.json\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
"pwd": currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
"whoami": "automaker-agent",
"date": new Date().toString(),
"cat package.json": '{\n "name": "demo-project",\n "version": "1.0.0"\n}',
};
// Simulate command execution delay
await new Promise((resolve) => setTimeout(resolve, 500));
const output = mockOutputs[terminalCommand.toLowerCase()] ||
`Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
setTerminalResult({
success: true,
output: output,
timestamp: new Date(),
});
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
} catch (error) {
setTerminalResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsRunningCommand(false);
}
}, [terminalCommand, currentProject]);
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="agent-tools-no-project">
<div className="text-center">
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to test agent tools.
</p>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden" data-testid="agent-tools-view">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b">
<Wrench className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">Agent Tools</h1>
<p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name}
</p>
</div>
</div>
{/* Tools Grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{/* Read File Tool */}
<Card data-testid="read-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-blue-500" />
<CardTitle className="text-lg">Read File</CardTitle>
</div>
<CardDescription>
Agent requests to read a file from the filesystem
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label>
<Input
id="read-file-path"
placeholder="/path/to/file.txt"
value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input"
/>
</div>
<Button
onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()}
className="w-full"
data-testid="read-file-button"
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Read
</>
)}
</Button>
{/* Result */}
{readFileResult && (
<div
className={cn(
"p-3 rounded-md border",
readFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
)}
data-testid="read-file-result"
>
<div className="flex items-center gap-2 mb-2">
{readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? "Success" : "Failed"}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success
? readFileResult.output
: readFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Write File Tool */}
<Card data-testid="write-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-green-500" />
<CardTitle className="text-lg">Write File</CardTitle>
</div>
<CardDescription>
Agent requests to write content to a file
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label>
<Input
id="write-file-path"
placeholder="/path/to/file.txt"
value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="write-file-content">Content</Label>
<textarea
id="write-file-content"
placeholder="File content..."
value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
data-testid="write-file-content-input"
/>
</div>
<Button
onClick={handleWriteFile}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full"
data-testid="write-file-button"
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Write
</>
)}
</Button>
{/* Result */}
{writeFileResult && (
<div
className={cn(
"p-3 rounded-md border",
writeFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
)}
data-testid="write-file-result"
>
<div className="flex items-center gap-2 mb-2">
{writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? "Success" : "Failed"}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success
? writeFileResult.output
: writeFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Terminal Tool */}
<Card data-testid="terminal-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-purple-500" />
<CardTitle className="text-lg">Run Terminal</CardTitle>
</div>
<CardDescription>
Agent requests to execute a terminal command
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label>
<Input
id="terminal-command"
placeholder="ls -la"
value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input"
/>
</div>
<Button
onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full"
data-testid="run-terminal-button"
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Command
</>
)}
</Button>
{/* Result */}
{terminalResult && (
<div
className={cn(
"p-3 rounded-md border",
terminalResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
)}
data-testid="terminal-result"
>
<div className="flex items-center gap-2 mb-2">
{terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? "Success" : "Failed"}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
$ {terminalCommand}
{"\n"}
{terminalResult.success
? terminalResult.output
: terminalResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tool Log Section */}
<Card className="mt-6" data-testid="tool-log">
<CardHeader>
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
<CardDescription>
View agent tool requests and responses
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>
<li>Write File - Agent writes content to specified path</li>
<li>Run Terminal - Agent executes shell commands</li>
</ul>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Bot, Send, User, Loader2, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
let messageCounter = 0;
const generateMessageId = () => `msg-${++messageCounter}`;
const getAgentResponse = (userInput: string): string => {
const lowerInput = userInput.toLowerCase();
if (lowerInput.includes("todo") || lowerInput.includes("task")) {
return "I can help you build a todo application! Let me ask a few questions:\n\n1. What tech stack would you prefer? (React, Vue, plain JavaScript)\n2. Do you need user authentication?\n3. Should tasks be stored locally or in a database?\n\nPlease share your preferences and I'll create a detailed spec.";
}
if (lowerInput.includes("api") || lowerInput.includes("backend")) {
return "Great! For building an API, I'll need to know:\n\n1. What type of data will it handle?\n2. Do you need authentication?\n3. What database would you like to use? (PostgreSQL, MongoDB, SQLite)\n4. Should I generate OpenAPI documentation?\n\nShare your requirements and I'll design the architecture.";
}
if (lowerInput.includes("help") || lowerInput.includes("what can you do")) {
return "I can help you with:\n\n• **Project Planning** - Define your app specification and features\n• **Code Generation** - Write code based on your requirements\n• **Testing** - Create and run tests for your features\n• **Code Review** - Analyze and improve existing code\n\nJust describe what you want to build, and I'll guide you through the process!";
}
return `I understand you want to work on: "${userInput}"\n\nLet me analyze this and create a plan. In the full version, I would:\n\n1. Generate a detailed app_spec.txt\n2. Create feature_list.json with test cases\n3. Start implementing features one by one\n4. Run tests to verify each feature\n\nThis functionality requires API keys to be configured in Settings.`;
};
export function AgentView() {
const { currentProject } = useAppStore();
const [messages, setMessages] = useState<Message[]>(() => [
{
id: "welcome",
role: "assistant",
content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. What would you like to create today?",
timestamp: new Date(),
},
]);
const [input, setInput] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const handleSend = useCallback(() => {
if (!input.trim() || isProcessing) return;
const userMessage: Message = {
id: generateMessageId(),
role: "user",
content: input,
timestamp: new Date(),
};
const currentInput = input;
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsProcessing(true);
// Simulate agent response (in a real implementation, this would call the AI API)
setTimeout(() => {
const assistantMessage: Message = {
id: generateMessageId(),
role: "assistant",
content: getAgentResponse(currentInput),
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
setIsProcessing(false);
}, 1500);
}, [input, isProcessing]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="agent-view-no-project">
<div className="text-center">
<Sparkles className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to start working with the AI agent.
</p>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden" data-testid="agent-view">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b">
<Bot className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">AI Agent</h1>
<p className="text-sm text-muted-foreground">
Autonomous development assistant for {currentProject.name}
</p>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4" data-testid="message-list">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
)}
>
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
)}
</div>
<Card
className={cn(
"max-w-[80%]",
message.role === "user" && "bg-primary text-primary-foreground"
)}
>
<CardContent className="p-3">
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
<p
className={cn(
"text-xs mt-2",
message.role === "user"
? "text-primary-foreground/70"
: "text-muted-foreground"
)}
>
{message.timestamp.toLocaleTimeString()}
</p>
</CardContent>
</Card>
</div>
))}
{isProcessing && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</CardContent>
</Card>
</div>
)}
</div>
{/* Input */}
<div className="border-t p-4">
<div className="flex gap-2">
<Input
placeholder="Describe what you want to build..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isProcessing}
data-testid="agent-input"
/>
<Button
onClick={handleSend}
disabled={!input.trim() || isProcessing}
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,361 @@
"use client";
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,
File,
ChevronRight,
ChevronDown,
Search,
RefreshCw,
BarChart3,
FileCode,
Loader2,
} 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",
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
});
};
const getExtension = (filename: string): string => {
const parts = filename.split(".");
return parts.length > 1 ? parts.pop() || "" : "";
};
export function AnalysisView() {
const {
currentProject,
projectAnalysis,
isAnalyzing,
setProjectAnalysis,
setIsAnalyzing,
clearAnalysis,
} = useAppStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Recursively scan directory
const scanDirectory = useCallback(
async (path: string, depth: number = 0): Promise<FileTreeNode[]> => {
if (depth > 10) return []; // Prevent infinite recursion
const api = getElectronAPI();
try {
const result = await api.readdir(path);
if (!result.success || !result.entries) return [];
const nodes: FileTreeNode[] = [];
const entries = result.entries.filter((e) => !shouldIgnore(e.name));
for (const entry of entries) {
const fullPath = `${path}/${entry.name}`;
const node: FileTreeNode = {
name: entry.name,
path: fullPath,
isDirectory: entry.isDirectory,
extension: entry.isFile ? getExtension(entry.name) : undefined,
};
if (entry.isDirectory) {
// Recursively scan subdirectories
node.children = await scanDirectory(fullPath, depth + 1);
}
nodes.push(node);
}
// Sort: directories first, then files alphabetically
nodes.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
return nodes;
} catch (error) {
console.error("Failed to scan directory:", path, error);
return [];
}
},
[]
);
// Count files and directories
const countNodes = (
nodes: FileTreeNode[]
): { files: number; dirs: number; byExt: Record<string, number> } => {
let files = 0;
let dirs = 0;
const byExt: Record<string, number> = {};
const traverse = (items: FileTreeNode[]) => {
for (const item of items) {
if (item.isDirectory) {
dirs++;
if (item.children) traverse(item.children);
} else {
files++;
if (item.extension) {
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
} else {
byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
}
}
}
};
traverse(nodes);
return { files, dirs, byExt };
};
// Run the analysis
const runAnalysis = useCallback(async () => {
if (!currentProject) return;
setIsAnalyzing(true);
clearAnalysis();
try {
const fileTree = await scanDirectory(currentProject.path);
const counts = countNodes(fileTree);
const analysis: ProjectAnalysis = {
fileTree,
totalFiles: counts.files,
totalDirectories: counts.dirs,
filesByExtension: counts.byExt,
analyzedAt: new Date().toISOString(),
};
setProjectAnalysis(analysis);
} catch (error) {
console.error("Analysis failed:", error);
} finally {
setIsAnalyzing(false);
}
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
// Toggle folder expansion
const toggleFolder = (path: string) => {
const newExpanded = new Set(expandedFolders);
if (expandedFolders.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedFolders(newExpanded);
};
// Render file tree node
const renderNode = (node: FileTreeNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
return (
<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"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (node.isDirectory) {
toggleFolder(node.path);
}
}}
>
{node.isDirectory ? (
<>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
) : (
<Folder className="w-4 h-4 text-primary shrink-0" />
)}
</>
) : (
<>
<span className="w-4" />
<File className="w-4 h-4 text-muted-foreground shrink-0" />
</>
)}
<span className="truncate">{node.name}</span>
{node.extension && (
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
)}
</div>
{node.isDirectory && isExpanded && node.children && (
<div>{node.children.map((child) => renderNode(child, depth + 1))}</div>
)}
</div>
);
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="analysis-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden" data-testid="analysis-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<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>
</div>
</div>
<Button
onClick={runAnalysis}
disabled={isAnalyzing}
data-testid="analyze-project-button"
>
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Analyzing...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Analyze Project
</>
)}
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden p-4">
{!projectAnalysis && !isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Click &quot;Analyze Project&quot; to scan your codebase and get insights about its
structure.
</p>
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
<Search className="w-4 h-4 mr-2" />
Start Analysis
</Button>
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Scanning project files...</p>
</div>
) : projectAnalysis ? (
<div className="flex gap-4 h-full overflow-hidden">
{/* Stats Panel */}
<div className="w-80 shrink-0 overflow-y-auto space-y-4">
<Card data-testid="analysis-stats">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
Statistics
</CardTitle>
<CardDescription>
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="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">
{projectAnalysis.totalDirectories}
</span>
</div>
</CardContent>
</Card>
<Card data-testid="files-by-extension">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileCode className="w-4 h-4" />
Files by Extension
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([ext, count]) => (
<div key={ext} className="flex justify-between text-sm">
<span className="text-muted-foreground font-mono">
{ext.startsWith("(") ? ext : `.${ext}`}
</span>
<span>{count}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* File Tree */}
<Card className="flex-1 overflow-hidden">
<CardHeader className="pb-2 border-b">
<CardTitle className="text-sm flex items-center gap-2">
<Folder className="w-4 h-4" />
File Tree
</CardTitle>
<CardDescription>
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{" "}
directories
</CardDescription>
</CardHeader>
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
<div className="p-2">
{projectAnalysis.fileTree.map((node) => renderNode(node))}
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,410 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card";
import { Plus, RefreshCw } from "lucide-react";
type ColumnId = Feature["status"];
const COLUMNS: { id: ColumnId; title: string; color: string }[] = [
{ id: "backlog", title: "Backlog", color: "bg-zinc-500" },
{ id: "planned", title: "Planned", color: "bg-blue-500" },
{ id: "in_progress", title: "In Progress", color: "bg-yellow-500" },
{ id: "review", title: "Review", color: "bg-purple-500" },
{ id: "verified", title: "Verified", color: "bg-green-500" },
{ id: "failed", title: "Failed", color: "bg-red-500" },
];
export function BoardView() {
const { currentProject, features, setFeatures, addFeature, updateFeature, moveFeature } =
useAppStore();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [newFeature, setNewFeature] = useState({
category: "",
description: "",
steps: [""],
});
const [isLoading, setIsLoading] = useState(true);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
// Load features from file
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const api = getElectronAPI();
const result = await api.readFile(`${currentProject.path}/feature_list.json`);
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
const featuresWithIds = parsed.map(
(f: Omit<Feature, "id" | "status">, index: number) => ({
...f,
id: `feature-${index}-${Date.now()}`,
status: f.passes ? "verified" : ("backlog" as ColumnId),
})
);
setFeatures(featuresWithIds);
}
} catch (error) {
console.error("Failed to load features:", error);
} finally {
setIsLoading(false);
}
}, [currentProject, setFeatures]);
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
// Save features to file
const saveFeatures = useCallback(async () => {
if (!currentProject) return;
try {
const api = getElectronAPI();
const toSave = features.map((f) => ({
category: f.category,
description: f.description,
steps: f.steps,
passes: f.status === "verified",
}));
await api.writeFile(
`${currentProject.path}/feature_list.json`,
JSON.stringify(toSave, null, 2)
);
} catch (error) {
console.error("Failed to save features:", error);
}
}, [currentProject, features]);
// Save when features change
useEffect(() => {
if (features.length > 0) {
saveFeatures();
}
}, [features, saveFeatures]);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const feature = features.find((f) => f.id === active.id);
if (feature) {
setActiveFeature(feature);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveFeature(null);
if (!over) return;
const featureId = active.id as string;
const overId = over.id as string;
// Check if we dropped on a column
const column = COLUMNS.find((c) => c.id === overId);
if (column) {
moveFeature(featureId, column.id);
} else {
// Dropped on another feature - find its column
const overFeature = features.find((f) => f.id === overId);
if (overFeature) {
moveFeature(featureId, overFeature.status);
}
}
};
const handleAddFeature = () => {
addFeature({
category: newFeature.category || "Uncategorized",
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
passes: false,
status: "backlog",
});
setNewFeature({ category: "", description: "", steps: [""] });
setShowAddDialog(false);
};
const handleUpdateFeature = () => {
if (!editingFeature) return;
updateFeature(editingFeature.id, {
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
});
setEditingFeature(null);
};
const getColumnFeatures = (columnId: ColumnId) => {
return features.filter((f) => f.status === columnId);
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden" data-testid="board-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div>
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={loadFeatures} data-testid="refresh-board">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button size="sm" onClick={() => setShowAddDialog(true)} data-testid="add-feature-button">
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
</div>
</div>
{/* Kanban Columns */}
<div className="flex-1 overflow-x-auto p-4">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full min-w-max">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
color={column.color}
count={columnFeatures.length}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature) => (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
/>
))}
</SortableContext>
</KanbanColumn>
);
})}
</div>
<DragOverlay>
{activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
<CardHeader className="p-3">
<CardTitle className="text-sm">{activeFeature.description}</CardTitle>
<CardDescription className="text-xs">{activeFeature.category}</CardDescription>
</CardHeader>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
{/* Add Feature Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-feature-dialog">
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
placeholder="e.g., Core, UI, API"
value={newFeature.category}
onChange={(e) => setNewFeature({ ...newFeature, category: e.target.value })}
data-testid="feature-category-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
placeholder="Describe the feature..."
value={newFeature.description}
onChange={(e) => setNewFeature({ ...newFeature, description: e.target.value })}
data-testid="feature-description-input"
/>
</div>
<div className="space-y-2">
<Label>Steps</Label>
{newFeature.steps.map((step, index) => (
<Input
key={index}
placeholder={`Step ${index + 1}`}
value={step}
onChange={(e) => {
const steps = [...newFeature.steps];
steps[index] = e.target.value;
setNewFeature({ ...newFeature, steps });
}}
data-testid={`feature-step-${index}-input`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
setNewFeature({ ...newFeature, steps: [...newFeature.steps, ""] })
}
data-testid="add-step-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Step
</Button>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowAddDialog(false)}>
Cancel
</Button>
<Button
onClick={handleAddFeature}
disabled={!newFeature.description}
data-testid="confirm-add-feature"
>
Add Feature
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Feature Dialog */}
<Dialog open={!!editingFeature} onOpenChange={() => setEditingFeature(null)}>
<DialogContent data-testid="edit-feature-dialog">
<DialogHeader>
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
</DialogHeader>
{editingFeature && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-category">Category</Label>
<Input
id="edit-category"
value={editingFeature.category}
onChange={(e) =>
setEditingFeature({ ...editingFeature, category: e.target.value })
}
data-testid="edit-feature-category"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Input
id="edit-description"
value={editingFeature.description}
onChange={(e) =>
setEditingFeature({ ...editingFeature, description: e.target.value })
}
data-testid="edit-feature-description"
/>
</div>
<div className="space-y-2">
<Label>Steps</Label>
{editingFeature.steps.map((step, index) => (
<Input
key={index}
value={step}
onChange={(e) => {
const steps = [...editingFeature.steps];
steps[index] = e.target.value;
setEditingFeature({ ...editingFeature, steps });
}}
data-testid={`edit-feature-step-${index}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
setEditingFeature({
...editingFeature,
steps: [...editingFeature.steps, ""],
})
}
>
<Plus className="w-4 h-4 mr-2" />
Add Step
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => setEditingFeature(null)}>
Cancel
</Button>
<Button onClick={handleUpdateFeature} data-testid="confirm-edit-feature">
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,279 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
File,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
RefreshCw,
Code,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileTreeNode[];
isExpanded?: boolean;
}
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
});
};
export function CodeView() {
const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Load directory tree
const loadTree = useCallback(async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const api = getElectronAPI();
const result = await api.readdir(currentProject.path);
if (result.success && result.entries) {
const entries = result.entries
.filter((e) => !shouldIgnore(e.name))
.sort((a, b) => {
// Directories first
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
path: `${currentProject.path}/${e.name}`,
isDirectory: e.isDirectory,
}));
setFileTree(entries);
}
} catch (error) {
console.error("Failed to load file tree:", error);
} finally {
setIsLoading(false);
}
}, [currentProject]);
useEffect(() => {
loadTree();
}, [loadTree]);
// Load subdirectory
const loadSubdirectory = async (path: string): Promise<FileTreeNode[]> => {
try {
const api = getElectronAPI();
const result = await api.readdir(path);
if (result.success && result.entries) {
return result.entries
.filter((e) => !shouldIgnore(e.name))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
path: `${path}/${e.name}`,
isDirectory: e.isDirectory,
}));
}
} catch (error) {
console.error("Failed to load subdirectory:", error);
}
return [];
};
// Load file content
const loadFileContent = async (path: string) => {
try {
const api = getElectronAPI();
const result = await api.readFile(path);
if (result.success && result.content) {
setFileContent(result.content);
setSelectedFile(path);
}
} catch (error) {
console.error("Failed to load file:", error);
}
};
// Toggle folder expansion
const toggleFolder = async (node: FileTreeNode) => {
const newExpanded = new Set(expandedFolders);
if (expandedFolders.has(node.path)) {
newExpanded.delete(node.path);
} else {
newExpanded.add(node.path);
// Load children if not already loaded
if (!node.children) {
const children = await loadSubdirectory(node.path);
// Update the tree with children
const updateTree = (nodes: FileTreeNode[]): FileTreeNode[] => {
return nodes.map((n) => {
if (n.path === node.path) {
return { ...n, children };
}
if (n.children) {
return { ...n, children: updateTree(n.children) };
}
return n;
});
};
setFileTree(updateTree(fileTree));
}
}
setExpandedFolders(newExpanded);
};
// Render file tree node
const renderNode = (node: FileTreeNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFile === node.path;
return (
<div key={node.path}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-muted"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (node.isDirectory) {
toggleFolder(node);
} else {
loadFileContent(node.path);
}
}}
data-testid={`file-tree-item-${node.name}`}
>
{node.isDirectory ? (
<>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
) : (
<Folder className="w-4 h-4 text-primary shrink-0" />
)}
</>
) : (
<>
<span className="w-4" />
<File className="w-4 h-4 text-muted-foreground shrink-0" />
</>
)}
<span className="text-sm truncate">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div>{node.children.map((child) => renderNode(child, depth + 1))}</div>
)}
</div>
);
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="code-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden" data-testid="code-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
<Code className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Code Explorer</h1>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={loadTree} data-testid="refresh-tree">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Split View */}
<div className="flex-1 flex overflow-hidden">
{/* File Tree */}
<div className="w-64 border-r overflow-y-auto" data-testid="file-tree">
<div className="p-2">{fileTree.map((node) => renderNode(node))}</div>
</div>
{/* Code Preview */}
<div className="flex-1 overflow-hidden">
{selectedFile ? (
<div className="h-full flex flex-col">
<div className="px-4 py-2 border-b bg-muted/30">
<p className="text-sm font-mono text-muted-foreground truncate">
{selectedFile.replace(currentProject.path, "")}
</p>
</div>
<Card className="flex-1 m-4 overflow-hidden">
<CardContent className="p-0 h-full">
<pre className="p-4 h-full overflow-auto text-sm font-mono whitespace-pre-wrap">
<code data-testid="code-content">{fileContent}</code>
</pre>
</CardContent>
</Card>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">Select a file to view its contents</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Feature } from "@/store/app-store";
import { GripVertical, Edit, Play, CheckCircle2, Circle } from "lucide-react";
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
}
export function KanbanCard({ feature, onEdit }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Card
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all",
isDragging && "opacity-50 scale-105 shadow-lg"
)}
data-testid={`kanban-card-${feature.id}`}
{...attributes}
>
<CardHeader className="p-3 pb-2">
<div className="flex items-start gap-2">
<div
{...listeners}
className="mt-0.5 cursor-grab touch-none"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-sm leading-tight">{feature.description}</CardTitle>
<CardDescription className="text-xs mt-1">{feature.category}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
{/* Steps Preview */}
{feature.steps.length > 0 && (
<div className="mb-3 space-y-1">
{feature.steps.slice(0, 3).map((step, index) => (
<div key={index} className="flex items-start gap-2 text-xs text-muted-foreground">
{feature.passes ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="truncate">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
<p className="text-xs text-muted-foreground pl-5">
+{feature.steps.length - 3} more steps
</p>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-primary hover:text-primary"
data-testid={`run-feature-${feature.id}`}
>
<Play className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
interface KanbanColumnProps {
id: string;
title: string;
color: string;
count: number;
children: ReactNode;
}
export function KanbanColumn({ id, title, color, count, children }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
"flex flex-col w-72 h-full rounded-lg bg-muted/50 transition-colors",
isOver && "bg-muted"
)}
data-testid={`kanban-column-${id}`}
>
{/* Column Header */}
<div className="flex items-center gap-2 p-3 border-b border-border">
<div className={cn("w-3 h-3 rounded-full", color)} />
<h3 className="font-medium text-sm flex-1">{title}</h3>
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
{count}
</span>
</div>
{/* Column Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,207 @@
"use client";
import { useState, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
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 { Settings, Key, Eye, EyeOff, CheckCircle2, AlertCircle } from "lucide-react";
export function SettingsView() {
const { apiKeys, setApiKeys, setCurrentView } = useAppStore();
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
}, [apiKeys]);
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
google: googleKey,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const maskKey = (key: string) => {
if (!key) return "";
if (key.length <= 8) return "*".repeat(key.length);
return key.slice(0, 4) + "*".repeat(key.length - 8) + key.slice(-4);
};
return (
<div className="flex-1 flex flex-col" data-testid="settings-view">
{/* Header */}
<div className="border-b px-6 py-4">
<div className="flex items-center gap-3">
<Settings className="w-6 h-6" />
<div>
<h1 className="text-xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground">
Configure your API keys and preferences
</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-2xl space-y-6">
{/* API Keys Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
API Keys
</CardTitle>
<CardDescription>
Configure your AI provider API keys. Keys are stored locally in your browser.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Anthropic API Key */}
<div className="space-y-2">
<Label htmlFor="anthropic-key" className="flex items-center gap-2">
Anthropic API Key
{apiKeys.anthropic && (
<CheckCircle2 className="w-4 h-4 text-green-500" />
)}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="anthropic-key"
type={showAnthropicKey ? "text" : "password"}
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
placeholder="sk-ant-..."
className="pr-10"
data-testid="anthropic-api-key-input"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowAnthropicKey(!showAnthropicKey)}
data-testid="toggle-anthropic-visibility"
>
{showAnthropicKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Used for Claude AI features. Get your key at{" "}
<a
href="https://console.anthropic.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
console.anthropic.com
</a>
</p>
</div>
{/* Google API Key */}
<div className="space-y-2">
<Label htmlFor="google-key" className="flex items-center gap-2">
Google API Key (Gemini)
{apiKeys.google && (
<CheckCircle2 className="w-4 h-4 text-green-500" />
)}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="google-key"
type={showGoogleKey ? "text" : "password"}
value={googleKey}
onChange={(e) => setGoogleKey(e.target.value)}
placeholder="AIza..."
className="pr-10"
data-testid="google-api-key-input"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowGoogleKey(!showGoogleKey)}
data-testid="toggle-google-visibility"
>
{showGoogleKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Used for Gemini AI features. Get your key at{" "}
<a
href="https://makersuite.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
makersuite.google.com
</a>
</p>
</div>
{/* Security Notice */}
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 text-yellow-500 border border-yellow-500/20">
<AlertCircle className="w-5 h-5 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium">Security Notice</p>
<p className="text-xs opacity-80 mt-1">
API keys are stored in your browser's local storage. Never share your API keys
or commit them to version control.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex items-center gap-4">
<Button
onClick={handleSave}
data-testid="save-settings"
className="min-w-[100px]"
>
{saved ? (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Saved!
</>
) : (
"Save Settings"
)}
</Button>
<Button
variant="outline"
onClick={() => setCurrentView("welcome")}
data-testid="back-to-home"
>
Back to Home
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Save, RefreshCw, FileText } from "lucide-react";
export function SpecView() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Load spec from file
const loadSpec = useCallback(async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const api = getElectronAPI();
const result = await api.readFile(`${currentProject.path}/app_spec.txt`);
if (result.success && result.content) {
setAppSpec(result.content);
setHasChanges(false);
}
} catch (error) {
console.error("Failed to load spec:", error);
} finally {
setIsLoading(false);
}
}, [currentProject, setAppSpec]);
useEffect(() => {
loadSpec();
}, [loadSpec]);
// Save spec to file
const saveSpec = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(`${currentProject.path}/app_spec.txt`, appSpec);
setHasChanges(false);
} catch (error) {
console.error("Failed to save spec:", error);
} finally {
setIsSaving(false);
}
};
const handleChange = (value: string) => {
setAppSpec(value);
setHasChanges(true);
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="spec-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="spec-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden" data-testid="spec-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">App Specification</h1>
<p className="text-sm text-muted-foreground">
{currentProject.path}/app_spec.txt
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadSpec}
disabled={isLoading}
data-testid="reload-spec"
>
<RefreshCw className="w-4 h-4 mr-2" />
Reload
</Button>
<Button
size="sm"
onClick={saveSpec}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save Changes" : "Saved"}
</Button>
</div>
</div>
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
value={appSpec}
onChange={(e) => handleChange(e.target.value)}
placeholder="Write your app specification here..."
spellCheck={false}
data-testid="spec-editor"
/>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,282 @@
"use client";
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { FolderOpen, Plus, Sparkles, Folder, Clock } from "lucide-react";
export function WelcomeView() {
const { projects, addProject, setCurrentProject } = useAppStore();
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleOpenProject = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split("/").pop() || "Untitled Project";
const project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
}
}, [addProject, setCurrentProject]);
const handleNewProject = () => {
setNewProjectName("");
setNewProjectPath("");
setShowNewProjectDialog(true);
};
const handleSelectDirectory = async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
setNewProjectPath(result.filePaths[0]);
}
};
const handleCreateProject = async () => {
if (!newProjectName || !newProjectPath) return;
setIsCreating(true);
try {
const api = getElectronAPI();
const projectPath = `${newProjectPath}/${newProjectName}`;
// Create project directory
await api.mkdir(projectPath);
// Create initial files
await api.writeFile(
`${projectPath}/app_spec.txt`,
`<project_specification>
<project_name>${newProjectName}</project_name>
<overview>
Describe your project here...
</overview>
<technology_stack>
<!-- Define your tech stack -->
</technology_stack>
<core_capabilities>
<!-- List core features -->
</core_capabilities>
</project_specification>`
);
await api.writeFile(
`${projectPath}/feature_list.json`,
JSON.stringify(
[
{
category: "Core",
description: "First feature to implement",
steps: ["Step 1: Define requirements", "Step 2: Implement", "Step 3: Test"],
passes: false,
},
],
null,
2
)
);
const project = {
id: `project-${Date.now()}`,
name: newProjectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectDialog(false);
} catch (error) {
console.error("Failed to create project:", error);
} finally {
setIsCreating(false);
}
};
const recentProjects = [...projects]
.sort((a, b) => {
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
const dateB = b.lastOpened ? new Date(b.lastOpened).getTime() : 0;
return dateB - dateA;
})
.slice(0, 5);
return (
<div className="flex-1 flex flex-col items-center justify-center p-8" data-testid="welcome-view">
{/* Hero Section */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-primary/10 mb-6">
<Sparkles className="w-10 h-10 text-primary" />
</div>
<h1 className="text-4xl font-bold mb-4">Welcome to Automaker</h1>
<p className="text-lg text-muted-foreground max-w-md">
Your autonomous AI development studio. Build software with intelligent orchestration.
</p>
</div>
{/* Action Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl w-full mb-12">
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={handleNewProject}
data-testid="new-project-card"
>
<CardHeader>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
<Plus className="w-6 h-6 text-primary" />
</div>
<CardTitle>New Project</CardTitle>
<CardDescription>
Create a new project from scratch or use interactive mode
</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full" data-testid="create-new-project">
<Plus className="w-4 h-4 mr-2" />
Create Project
</Button>
</CardContent>
</Card>
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={handleOpenProject}
data-testid="open-project-card"
>
<CardHeader>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
<FolderOpen className="w-6 h-6 text-primary" />
</div>
<CardTitle>Open Project</CardTitle>
<CardDescription>
Open an existing project folder to continue working
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="secondary" className="w-full" data-testid="open-existing-project">
<FolderOpen className="w-4 h-4 mr-2" />
Browse Folder
</Button>
</CardContent>
</Card>
</div>
{/* Recent Projects */}
{recentProjects.length > 0 && (
<div className="max-w-2xl w-full">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Recent Projects
</h2>
<div className="space-y-2">
{recentProjects.map((project) => (
<Card
key={project.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setCurrentProject(project)}
data-testid={`recent-project-${project.id}`}
>
<CardContent className="flex items-center gap-4 p-4">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
<Folder className="w-5 h-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.name}</p>
<p className="text-sm text-muted-foreground truncate">{project.path}</p>
</div>
{project.lastOpened && (
<p className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* New Project Dialog */}
<Dialog open={showNewProjectDialog} onOpenChange={setShowNewProjectDialog}>
<DialogContent data-testid="new-project-dialog">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
Set up a new project directory with initial configuration files.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
placeholder="my-awesome-project"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
data-testid="project-name-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-path">Parent Directory</Label>
<div className="flex gap-2">
<Input
id="project-path"
placeholder="/path/to/projects"
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
className="flex-1"
data-testid="project-path-input"
/>
<Button variant="secondary" onClick={handleSelectDirectory} data-testid="browse-directory">
Browse
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowNewProjectDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreateProject}
disabled={!newProjectName || !newProjectPath || isCreating}
data-testid="confirm-create-project"
>
{isCreating ? "Creating..." : "Create Project"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

309
app/src/lib/electron.ts Normal file
View File

@@ -0,0 +1,309 @@
// Type definitions for Electron IPC API
export interface FileEntry {
name: string;
isDirectory: boolean;
isFile: boolean;
}
export interface FileStats {
isDirectory: boolean;
isFile: boolean;
size: number;
mtime: Date;
}
export interface DialogResult {
canceled: boolean;
filePaths: string[];
}
export interface FileResult {
success: boolean;
content?: string;
error?: string;
}
export interface WriteResult {
success: boolean;
error?: string;
}
export interface ReaddirResult {
success: boolean;
entries?: FileEntry[];
error?: string;
}
export interface StatResult {
success: boolean;
stats?: FileStats;
error?: string;
}
export interface ElectronAPI {
ping: () => Promise<string>;
openDirectory: () => Promise<DialogResult>;
openFile: (options?: object) => Promise<DialogResult>;
readFile: (filePath: string) => Promise<FileResult>;
writeFile: (filePath: string, content: string) => Promise<WriteResult>;
mkdir: (dirPath: string) => Promise<WriteResult>;
readdir: (dirPath: string) => Promise<ReaddirResult>;
exists: (filePath: string) => Promise<boolean>;
stat: (filePath: string) => Promise<StatResult>;
getPath: (name: string) => Promise<string>;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
isElectron?: boolean;
}
}
// Mock data for web development
const mockFeatures = [
{
category: "Core",
description: "Sample Feature",
steps: ["Step 1", "Step 2"],
passes: false,
},
];
// Local storage keys
const STORAGE_KEYS = {
PROJECTS: "automaker_projects",
CURRENT_PROJECT: "automaker_current_project",
} as const;
// Mock file system using localStorage
const mockFileSystem: Record<string, string> = {};
// Check if we're in Electron
export const isElectron = (): boolean => {
return typeof window !== "undefined" && window.isElectron === true;
};
// Get the Electron API or a mock for web development
export const getElectronAPI = (): ElectronAPI => {
if (isElectron() && window.electronAPI) {
return window.electronAPI;
}
// Return mock API for web development
return {
ping: async () => "pong (mock)",
openDirectory: async () => {
// In web mode, we'll use a prompt to simulate directory selection
const path = prompt("Enter project directory path:", "/Users/demo/project");
return {
canceled: !path,
filePaths: path ? [path] : [],
};
},
openFile: async () => {
const path = prompt("Enter file path:");
return {
canceled: !path,
filePaths: path ? [path] : [],
};
},
readFile: async (filePath: string) => {
// Check mock file system
if (mockFileSystem[filePath]) {
return { success: true, content: mockFileSystem[filePath] };
}
// Return mock data based on file type
if (filePath.endsWith("feature_list.json")) {
return { success: true, content: JSON.stringify(mockFeatures, null, 2) };
}
if (filePath.endsWith("app_spec.txt")) {
return {
success: true,
content: "<project_specification>\n <project_name>Demo Project</project_name>\n</project_specification>",
};
}
return { success: false, error: "File not found (mock)" };
},
writeFile: async (filePath: string, content: string) => {
mockFileSystem[filePath] = content;
return { success: true };
},
mkdir: async () => {
return { success: true };
},
readdir: async (dirPath: string) => {
// Return mock directory structure based on path
if (dirPath) {
// Root level
if (!dirPath.includes("/src") && !dirPath.includes("/tests") && !dirPath.includes("/public")) {
return {
success: true,
entries: [
{ name: "src", isDirectory: true, isFile: false },
{ name: "tests", isDirectory: true, isFile: false },
{ name: "public", isDirectory: true, isFile: false },
{ name: "package.json", isDirectory: false, isFile: true },
{ name: "tsconfig.json", isDirectory: false, isFile: true },
{ name: "app_spec.txt", isDirectory: false, isFile: true },
{ name: "feature_list.json", isDirectory: false, isFile: true },
{ name: "README.md", isDirectory: false, isFile: true },
],
};
}
// src directory
if (dirPath.endsWith("/src")) {
return {
success: true,
entries: [
{ name: "components", isDirectory: true, isFile: false },
{ name: "lib", isDirectory: true, isFile: false },
{ name: "app", isDirectory: true, isFile: false },
{ name: "index.ts", isDirectory: false, isFile: true },
{ name: "utils.ts", isDirectory: false, isFile: true },
],
};
}
// src/components directory
if (dirPath.endsWith("/components")) {
return {
success: true,
entries: [
{ name: "Button.tsx", isDirectory: false, isFile: true },
{ name: "Card.tsx", isDirectory: false, isFile: true },
{ name: "Header.tsx", isDirectory: false, isFile: true },
{ name: "Footer.tsx", isDirectory: false, isFile: true },
],
};
}
// src/lib directory
if (dirPath.endsWith("/lib")) {
return {
success: true,
entries: [
{ name: "api.ts", isDirectory: false, isFile: true },
{ name: "helpers.ts", isDirectory: false, isFile: true },
],
};
}
// src/app directory
if (dirPath.endsWith("/app")) {
return {
success: true,
entries: [
{ name: "page.tsx", isDirectory: false, isFile: true },
{ name: "layout.tsx", isDirectory: false, isFile: true },
{ name: "globals.css", isDirectory: false, isFile: true },
],
};
}
// tests directory
if (dirPath.endsWith("/tests")) {
return {
success: true,
entries: [
{ name: "unit.test.ts", isDirectory: false, isFile: true },
{ name: "e2e.spec.ts", isDirectory: false, isFile: true },
],
};
}
// public directory
if (dirPath.endsWith("/public")) {
return {
success: true,
entries: [
{ name: "favicon.ico", isDirectory: false, isFile: true },
{ name: "logo.svg", isDirectory: false, isFile: true },
],
};
}
// Default empty for other paths
return { success: true, entries: [] };
}
return { success: true, entries: [] };
},
exists: async (filePath: string) => {
return mockFileSystem[filePath] !== undefined ||
filePath.endsWith("feature_list.json") ||
filePath.endsWith("app_spec.txt");
},
stat: async () => {
return {
success: true,
stats: {
isDirectory: false,
isFile: true,
size: 1024,
mtime: new Date(),
},
};
},
getPath: async (name: string) => {
if (name === "userData") {
return "/mock/userData";
}
return `/mock/${name}`;
},
};
};
// Utility functions for project management
export interface Project {
id: string;
name: string;
path: string;
lastOpened?: string;
}
export const getStoredProjects = (): Project[] => {
if (typeof window === "undefined") return [];
const stored = localStorage.getItem(STORAGE_KEYS.PROJECTS);
return stored ? JSON.parse(stored) : [];
};
export const saveProjects = (projects: Project[]): void => {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEYS.PROJECTS, JSON.stringify(projects));
};
export const getCurrentProject = (): Project | null => {
if (typeof window === "undefined") return null;
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_PROJECT);
return stored ? JSON.parse(stored) : null;
};
export const setCurrentProject = (project: Project | null): void => {
if (typeof window === "undefined") return;
if (project) {
localStorage.setItem(STORAGE_KEYS.CURRENT_PROJECT, JSON.stringify(project));
} else {
localStorage.removeItem(STORAGE_KEYS.CURRENT_PROJECT);
}
};
export const addProject = (project: Project): void => {
const projects = getStoredProjects();
const existing = projects.findIndex((p) => p.path === project.path);
if (existing >= 0) {
projects[existing] = { ...project, lastOpened: new Date().toISOString() };
} else {
projects.push({ ...project, lastOpened: new Date().toISOString() });
}
saveProjects(projects);
};
export const removeProject = (projectId: string): void => {
const projects = getStoredProjects().filter((p) => p.id !== projectId);
saveProjects(projects);
};

6
app/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

223
app/src/store/app-store.ts Normal file
View File

@@ -0,0 +1,223 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Project } from "@/lib/electron";
export type ViewMode = "welcome" | "spec" | "board" | "code" | "agent" | "settings" | "analysis" | "tools";
export type ThemeMode = "light" | "dark" | "system";
export interface ApiKeys {
anthropic: string;
google: string;
}
export interface Feature {
id: string;
category: string;
description: string;
steps: string[];
passes: boolean;
status: "backlog" | "planned" | "in_progress" | "review" | "verified" | "failed";
}
export interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileTreeNode[];
size?: number;
extension?: string;
}
export interface ProjectAnalysis {
fileTree: FileTreeNode[];
totalFiles: number;
totalDirectories: number;
filesByExtension: Record<string, number>;
analyzedAt: string;
}
export interface AppState {
// Project state
projects: Project[];
currentProject: Project | null;
// View state
currentView: ViewMode;
sidebarOpen: boolean;
// Theme
theme: ThemeMode;
// Features/Kanban
features: Feature[];
// App spec
appSpec: string;
// IPC status
ipcConnected: boolean;
// API Keys
apiKeys: ApiKeys;
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
}
export interface AppActions {
// Project actions
setProjects: (projects: Project[]) => void;
addProject: (project: Project) => void;
removeProject: (projectId: string) => void;
setCurrentProject: (project: Project | null) => void;
// View actions
setCurrentView: (view: ViewMode) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
// Theme actions
setTheme: (theme: ThemeMode) => void;
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
addFeature: (feature: Omit<Feature, "id">) => void;
removeFeature: (id: string) => void;
moveFeature: (id: string, newStatus: Feature["status"]) => void;
// App spec actions
setAppSpec: (spec: string) => void;
// IPC actions
setIpcConnected: (connected: boolean) => void;
// API Keys actions
setApiKeys: (keys: Partial<ApiKeys>) => void;
// Analysis actions
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
setIsAnalyzing: (isAnalyzing: boolean) => void;
clearAnalysis: () => void;
// Reset
reset: () => void;
}
const initialState: AppState = {
projects: [],
currentProject: null,
currentView: "welcome",
sidebarOpen: true,
theme: "dark",
features: [],
appSpec: "",
ipcConnected: false,
apiKeys: {
anthropic: "",
google: "",
},
projectAnalysis: null,
isAnalyzing: false,
};
export const useAppStore = create<AppState & AppActions>()(
persist(
(set, get) => ({
...initialState,
// Project actions
setProjects: (projects) => set({ projects }),
addProject: (project) => {
const projects = get().projects;
const existing = projects.findIndex((p) => p.path === project.path);
if (existing >= 0) {
const updated = [...projects];
updated[existing] = { ...project, lastOpened: new Date().toISOString() };
set({ projects: updated });
} else {
set({
projects: [...projects, { ...project, lastOpened: new Date().toISOString() }],
});
}
},
removeProject: (projectId) => {
set({ projects: get().projects.filter((p) => p.id !== projectId) });
},
setCurrentProject: (project) => {
set({ currentProject: project });
if (project) {
set({ currentView: "board" });
} else {
set({ currentView: "welcome" });
}
},
// View actions
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
// Theme actions
setTheme: (theme) => set({ theme }),
// Feature actions
setFeatures: (features) => set({ features }),
updateFeature: (id, updates) => {
set({
features: get().features.map((f) =>
f.id === id ? { ...f, ...updates } : f
),
});
},
addFeature: (feature) => {
const id = `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
set({ features: [...get().features, { ...feature, id }] });
},
removeFeature: (id) => {
set({ features: get().features.filter((f) => f.id !== id) });
},
moveFeature: (id, newStatus) => {
set({
features: get().features.map((f) =>
f.id === id ? { ...f, status: newStatus } : f
),
});
},
// App spec actions
setAppSpec: (spec) => set({ appSpec: spec }),
// IPC actions
setIpcConnected: (connected) => set({ ipcConnected: connected }),
// API Keys actions
setApiKeys: (keys) => set({ apiKeys: { ...get().apiKeys, ...keys } }),
// Analysis actions
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
setIsAnalyzing: (isAnalyzing) => set({ isAnalyzing }),
clearAnalysis: () => set({ projectAnalysis: null, isAnalyzing: false }),
// Reset
reset: () => set(initialState),
}),
{
name: "automaker-storage",
partialize: (state) => ({
projects: state.projects,
theme: state.theme,
sidebarOpen: state.sidebarOpen,
apiKeys: state.apiKeys,
}),
}
)
);