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

50
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# Electron
/dist/

36
app/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

22
app/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,5 @@
module.exports = {
rules: {
"@typescript-eslint/no-require-imports": "off",
},
};

146
app/electron/main.js Normal file
View File

@@ -0,0 +1,146 @@
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const path = require("path");
const fs = require("fs/promises");
let mainWindow = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a",
});
// Load Next.js dev server in development or production build
const isDev = !app.isPackaged;
if (isDev) {
mainWindow.loadURL("http://localhost:3000");
// mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
}
mainWindow.on("closed", () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
// IPC Handlers
// Dialog handlers
ipcMain.handle("dialog:openDirectory", async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
});
return result;
});
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
...options,
});
return result;
});
// File system handlers
ipcMain.handle("fs:readFile", async (_, filePath) => {
try {
const content = await fs.readFile(filePath, "utf-8");
return { success: true, content };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
try {
await fs.writeFile(filePath, content, "utf-8");
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("fs:mkdir", async (_, dirPath) => {
try {
await fs.mkdir(dirPath, { recursive: true });
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("fs:readdir", async (_, dirPath) => {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const result = entries.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
}));
return { success: true, entries: result };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("fs:exists", async (_, filePath) => {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
});
ipcMain.handle("fs:stat", async (_, filePath) => {
try {
const stats = await fs.stat(filePath);
return {
success: true,
stats: {
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
size: stats.size,
mtime: stats.mtime,
},
};
} catch (error) {
return { success: false, error: error.message };
}
});
// App data path
ipcMain.handle("app:getPath", (_, name) => {
return app.getPath(name);
});
// IPC ping for testing communication
ipcMain.handle("ping", () => {
return "pong";
});

27
app/electron/preload.js Normal file
View File

@@ -0,0 +1,27 @@
const { contextBridge, ipcRenderer } = require("electron");
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("electronAPI", {
// IPC test
ping: () => ipcRenderer.invoke("ping"),
// Dialog APIs
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
// File system APIs
readFile: (filePath) => ipcRenderer.invoke("fs:readFile", filePath),
writeFile: (filePath, content) =>
ipcRenderer.invoke("fs:writeFile", filePath, content),
mkdir: (dirPath) => ipcRenderer.invoke("fs:mkdir", dirPath),
readdir: (dirPath) => ipcRenderer.invoke("fs:readdir", dirPath),
exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath),
stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath),
// App APIs
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
});
// Also expose a flag to detect if we're in Electron
contextBridge.exposeInMainWorld("isElectron", true);

20
app/eslint.config.mjs Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Electron files use CommonJS
"electron/**",
]),
]);
export default eslintConfig;

7
app/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11940
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

63
app/package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "automaker",
"version": "0.1.0",
"private": true,
"main": "electron/main.js",
"scripts": {
"dev": "next dev -p 3000",
"dev:web": "next dev -p 3000",
"dev:electron": "concurrently \"next dev -p 3000\" \"wait-on http://localhost:3000 && electron .\"",
"build": "next build",
"build:electron": "next build && electron-builder",
"start": "next start",
"lint": "eslint",
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"react": "19.2.0",
"react-dom": "19.2.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"directories": {
"output": "dist"
},
"files": [
"electron/**/*",
".next/**/*",
"public/**/*"
]
}
}

28
app/playwright.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 10000,
use: {
baseURL: "http://localhost:3002",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev -- -p 3002",
url: "http://localhost:3002",
reuseExistingServer: !process.env.CI,
timeout: 60000,
},
});

7
app/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
app/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
app/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
app/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
app/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
app/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

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,
}),
}
)
);

View File

@@ -0,0 +1,217 @@
import { test, expect } from "@playwright/test";
test.describe("Agent Tools", () => {
test("can navigate to agent tools view when project is open", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Wait for board view to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Verify agent tools view is displayed
await expect(page.getByTestId("agent-tools-view")).toBeVisible();
});
test("agent tools view shows all three tool cards", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Verify all three tool cards are visible
await expect(page.getByTestId("read-file-tool")).toBeVisible();
await expect(page.getByTestId("write-file-tool")).toBeVisible();
await expect(page.getByTestId("terminal-tool")).toBeVisible();
});
test.describe("Read File Tool", () => {
test("agent can request to read file and receive content", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Enter a file path
await page.getByTestId("read-file-path-input").fill("/test/path/feature_list.json");
// Click execute
await page.getByTestId("read-file-button").click();
// Wait for result
await expect(page.getByTestId("read-file-result")).toBeVisible();
// Verify success message
await expect(page.getByTestId("read-file-result")).toContainText("Success");
});
test("read file tool shows input field for file path", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Verify input field exists
await expect(page.getByTestId("read-file-path-input")).toBeVisible();
await expect(page.getByTestId("read-file-button")).toBeVisible();
});
});
test.describe("Write File Tool", () => {
test("agent can request to write file and file is written", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Enter file path and content
await page.getByTestId("write-file-path-input").fill("/test/path/new-file.txt");
await page.getByTestId("write-file-content-input").fill("Hello from agent!");
// Click execute
await page.getByTestId("write-file-button").click();
// Wait for result
await expect(page.getByTestId("write-file-result")).toBeVisible();
// Verify success message
await expect(page.getByTestId("write-file-result")).toContainText("Success");
await expect(page.getByTestId("write-file-result")).toContainText("File written successfully");
});
test("write file tool shows path and content inputs", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Verify input fields exist
await expect(page.getByTestId("write-file-path-input")).toBeVisible();
await expect(page.getByTestId("write-file-content-input")).toBeVisible();
await expect(page.getByTestId("write-file-button")).toBeVisible();
});
});
test.describe("Terminal Tool", () => {
test("agent can request to run terminal command and receive stdout", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Enter command (default is 'ls')
await page.getByTestId("terminal-command-input").fill("ls");
// Click execute
await page.getByTestId("run-terminal-button").click();
// Wait for result
await expect(page.getByTestId("terminal-result")).toBeVisible();
// Verify success and output
await expect(page.getByTestId("terminal-result")).toContainText("Success");
await expect(page.getByTestId("terminal-result")).toContainText("$ ls");
});
test("terminal tool shows command input field", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Verify input field exists
await expect(page.getByTestId("terminal-command-input")).toBeVisible();
await expect(page.getByTestId("run-terminal-button")).toBeVisible();
});
test("terminal tool can run pwd command", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Enter pwd command
await page.getByTestId("terminal-command-input").fill("pwd");
// Click execute
await page.getByTestId("run-terminal-button").click();
// Wait for result
await expect(page.getByTestId("terminal-result")).toBeVisible();
// Verify success
await expect(page.getByTestId("terminal-result")).toContainText("Success");
});
});
test("tool log section is visible", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("Test Project");
await page.getByTestId("project-path-input").fill("/test/path");
await page.getByTestId("confirm-create-project").click();
// Navigate to agent tools
await page.getByTestId("nav-tools").click();
// Verify tool log section is visible
await expect(page.getByTestId("tool-log")).toBeVisible();
});
});

166
app/tests/analysis.spec.ts Normal file
View File

@@ -0,0 +1,166 @@
import { test, expect } from "@playwright/test";
test.describe("Project Analysis", () => {
test("can navigate to analysis view when project is open", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project");
await page.getByTestId("project-path-input").fill("/test/analysis/project");
await page.getByTestId("confirm-create-project").click();
// Wait for board view to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Click on Analysis in sidebar
await page.getByTestId("nav-analysis").click();
// Verify analysis view is displayed
await expect(page.getByTestId("analysis-view")).toBeVisible();
});
test("analysis view shows 'No Analysis Yet' message initially", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project2");
await page.getByTestId("project-path-input").fill("/test/analysis/project2");
await page.getByTestId("confirm-create-project").click();
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to analysis view
await page.getByTestId("nav-analysis").click();
await expect(page.getByTestId("analysis-view")).toBeVisible();
// Verify no analysis message
await expect(page.getByText("No Analysis Yet")).toBeVisible();
await expect(page.getByText('Click "Analyze Project"')).toBeVisible();
});
test("shows 'Analyze Project' button", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project3");
await page.getByTestId("project-path-input").fill("/test/analysis/project3");
await page.getByTestId("confirm-create-project").click();
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to analysis view
await page.getByTestId("nav-analysis").click();
await expect(page.getByTestId("analysis-view")).toBeVisible();
// Verify analyze button is visible
await expect(page.getByTestId("analyze-project-button")).toBeVisible();
});
test("can run project analysis", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project4");
await page.getByTestId("project-path-input").fill("/test/analysis/project4");
await page.getByTestId("confirm-create-project").click();
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to analysis view
await page.getByTestId("nav-analysis").click();
await expect(page.getByTestId("analysis-view")).toBeVisible();
// Click analyze button
await page.getByTestId("analyze-project-button").click();
// Wait for analysis to complete and stats to appear
await expect(page.getByTestId("analysis-stats")).toBeVisible();
// Verify statistics are displayed
await expect(page.getByTestId("total-files")).toBeVisible();
await expect(page.getByTestId("total-directories")).toBeVisible();
});
test("analysis shows file tree after running", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project5");
await page.getByTestId("project-path-input").fill("/test/analysis/project5");
await page.getByTestId("confirm-create-project").click();
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to analysis view
await page.getByTestId("nav-analysis").click();
await expect(page.getByTestId("analysis-view")).toBeVisible();
// Click analyze button
await page.getByTestId("analyze-project-button").click();
// Wait for analysis to complete
await expect(page.getByTestId("analysis-file-tree")).toBeVisible();
// Verify file tree is displayed
await expect(page.getByTestId("analysis-file-tree")).toBeVisible();
});
test("analysis shows files by extension breakdown", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project6");
await page.getByTestId("project-path-input").fill("/test/analysis/project6");
await page.getByTestId("confirm-create-project").click();
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to analysis view
await page.getByTestId("nav-analysis").click();
await expect(page.getByTestId("analysis-view")).toBeVisible();
// Click analyze button
await page.getByTestId("analyze-project-button").click();
// Wait for analysis to complete
await expect(page.getByTestId("files-by-extension")).toBeVisible();
// Verify files by extension card is displayed
await expect(page.getByTestId("files-by-extension")).toBeVisible();
});
test("file tree displays correct structure with directories and files", async ({ page }) => {
await page.goto("/");
// Create a project first
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await page.getByTestId("project-name-input").fill("Analysis Test Project7");
await page.getByTestId("project-path-input").fill("/test/analysis/project7");
await page.getByTestId("confirm-create-project").click();
await expect(page.getByTestId("board-view")).toBeVisible();
// Navigate to analysis view
await page.getByTestId("nav-analysis").click();
await expect(page.getByTestId("analysis-view")).toBeVisible();
// Click analyze button
await page.getByTestId("analyze-project-button").click();
// Wait for file tree to be populated
await expect(page.getByTestId("analysis-file-tree")).toBeVisible();
// Verify src directory is in the tree (mock data provides this)
await expect(page.getByTestId("analysis-node-src")).toBeVisible();
// Verify some files are in the tree
await expect(page.getByTestId("analysis-node-package.json")).toBeVisible();
});
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from "@playwright/test";
test.describe("Application Foundation", () => {
test("loads the application with sidebar and welcome view", async ({ page }) => {
await page.goto("/");
// Verify main container exists
await expect(page.getByTestId("app-container")).toBeVisible();
// Verify sidebar is visible
await expect(page.getByTestId("sidebar")).toBeVisible();
// Verify welcome view is shown by default
await expect(page.getByTestId("welcome-view")).toBeVisible();
});
test("displays Automaker title in sidebar", async ({ page }) => {
await page.goto("/");
// Verify the title is visible in the sidebar (be specific to avoid matching welcome heading)
await expect(page.getByTestId("sidebar").getByRole("heading", { name: "Automaker" })).toBeVisible();
});
test("shows New Project and Open Project buttons", async ({ page }) => {
await page.goto("/");
// Verify project action buttons in welcome view
await expect(page.getByTestId("new-project-card")).toBeVisible();
await expect(page.getByTestId("open-project-card")).toBeVisible();
});
test("sidebar can be collapsed and expanded", async ({ page }) => {
await page.goto("/");
const sidebar = page.getByTestId("sidebar");
const toggleButton = page.getByTestId("toggle-sidebar");
// Initially sidebar should be expanded (width 256px / w-64)
await expect(sidebar).toHaveClass(/w-64/);
// Click to collapse
await toggleButton.click();
await expect(sidebar).toHaveClass(/w-16/);
// Click to expand again
await toggleButton.click();
await expect(sidebar).toHaveClass(/w-64/);
});
test("shows Web Mode indicator when running in browser", async ({ page }) => {
await page.goto("/");
// When running in browser (not Electron), should show mock indicator
await expect(page.getByText("Web Mode (Mock IPC)")).toBeVisible();
});
});
test.describe("Theme Toggle", () => {
test("toggles between dark and light mode", async ({ page }) => {
await page.goto("/");
const themeButton = page.getByTestId("toggle-theme");
const html = page.locator("html");
// Initially should be in dark mode
await expect(html).toHaveClass(/dark/);
// Click to switch to light mode
await themeButton.click();
await expect(html).not.toHaveClass(/dark/);
// Click to switch back to dark mode
await themeButton.click();
await expect(html).toHaveClass(/dark/);
});
});

View File

@@ -0,0 +1,267 @@
import { test, expect } from "@playwright/test";
test.describe("Kanban Board", () => {
// Helper to set up a mock project in localStorage
async function setupMockProject(page: ReturnType<typeof test.step>) {
await page.addInitScript(() => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
localStorage.setItem("automaker-storage", JSON.stringify({
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
sidebarOpen: true,
theme: "dark",
},
version: 0,
}));
});
}
test("renders Kanban columns when project is open", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Should show the board view
await expect(page.getByTestId("board-view")).toBeVisible();
// Check all columns are visible
await expect(page.getByTestId("kanban-column-backlog")).toBeVisible();
await expect(page.getByTestId("kanban-column-planned")).toBeVisible();
await expect(page.getByTestId("kanban-column-in_progress")).toBeVisible();
await expect(page.getByTestId("kanban-column-review")).toBeVisible();
await expect(page.getByTestId("kanban-column-verified")).toBeVisible();
await expect(page.getByTestId("kanban-column-failed")).toBeVisible();
});
test("shows Add Feature button", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
await expect(page.getByTestId("add-feature-button")).toBeVisible();
});
test("opens add feature dialog", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Click add feature button
await page.getByTestId("add-feature-button").click();
// Dialog should appear
await expect(page.getByTestId("add-feature-dialog")).toBeVisible();
await expect(page.getByTestId("feature-category-input")).toBeVisible();
await expect(page.getByTestId("feature-description-input")).toBeVisible();
});
test("can add a new feature", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Click add feature button
await page.getByTestId("add-feature-button").click();
// Fill in feature details
await page.getByTestId("feature-category-input").fill("Test Category");
await page.getByTestId("feature-description-input").fill("Test Feature Description");
await page.getByTestId("feature-step-0-input").fill("Step 1: First step");
// Submit the form
await page.getByTestId("confirm-add-feature").click();
// Dialog should close
await expect(page.getByTestId("add-feature-dialog")).not.toBeVisible();
});
test("refresh button is visible", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
await expect(page.getByTestId("refresh-board")).toBeVisible();
});
test("loads cards from feature_list.json and displays them in correct columns", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Wait for loading to complete (the mock IPC returns a sample feature)
// The mock returns a feature in "backlog" column (passes: false)
await expect(page.getByTestId("kanban-column-backlog")).toBeVisible();
// After loading, the backlog should show the sample feature from mock data
// Looking at the electron.ts mock, it returns one feature with "Sample Feature"
const backlogColumn = page.getByTestId("kanban-column-backlog");
await expect(backlogColumn.getByText("Sample Feature")).toBeVisible();
});
test("features with passes:true appear in verified column", async ({ page }) => {
// Create a project and add a feature manually
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Add a new feature
await page.getByTestId("add-feature-button").click();
await page.getByTestId("feature-category-input").fill("Core");
await page.getByTestId("feature-description-input").fill("Verified Test Feature");
await page.getByTestId("confirm-add-feature").click();
// The new feature should appear in backlog
await expect(page.getByTestId("kanban-column-backlog").getByText("Verified Test Feature")).toBeVisible();
});
test("can edit feature card details", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Wait for features to load - the mock returns "Sample Feature"
await expect(page.getByTestId("kanban-column-backlog").getByText("Sample Feature")).toBeVisible();
// Find and click the edit button on the card using specific testid pattern
const backlogColumn = page.getByTestId("kanban-column-backlog");
// The edit button has testid "edit-feature-{feature.id}" where feature.id contains "feature-0-"
const editButton = backlogColumn.locator('[data-testid^="edit-feature-feature-0-"]');
await editButton.click();
// Edit dialog should appear
await expect(page.getByTestId("edit-feature-dialog")).toBeVisible();
// Edit the description
await page.getByTestId("edit-feature-description").fill("Updated Feature Description");
// Save the changes
await page.getByTestId("confirm-edit-feature").click();
// Dialog should close
await expect(page.getByTestId("edit-feature-dialog")).not.toBeVisible();
// The updated description should be visible
await expect(page.getByText("Updated Feature Description")).toBeVisible();
});
test("edit dialog shows existing feature data", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Wait for features to load
await expect(page.getByTestId("kanban-column-backlog").getByText("Sample Feature")).toBeVisible();
// Click edit button using specific testid pattern
const backlogColumn = page.getByTestId("kanban-column-backlog");
const editButton = backlogColumn.locator('[data-testid^="edit-feature-feature-0-"]');
await editButton.click();
// Check that the dialog pre-populates with existing data
await expect(page.getByTestId("edit-feature-description")).toHaveValue("Sample Feature");
await expect(page.getByTestId("edit-feature-category")).toHaveValue("Core");
});
test("can drag card from Backlog to In Progress column", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Wait for features to load in Backlog
const backlogColumn = page.getByTestId("kanban-column-backlog");
const inProgressColumn = page.getByTestId("kanban-column-in_progress");
await expect(backlogColumn.getByText("Sample Feature")).toBeVisible();
// Find the drag handle specifically
const dragHandle = backlogColumn.locator('[data-testid^="drag-handle-feature-0-"]');
await expect(dragHandle).toBeVisible();
// Get drag handle and target positions
const handleBox = await dragHandle.boundingBox();
const targetBox = await inProgressColumn.boundingBox();
if (!handleBox || !targetBox) throw new Error("Could not find elements");
// Use mouse events - start from center of drag handle
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
const endX = targetBox.x + targetBox.width / 2;
const endY = targetBox.y + 100;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Move in steps to trigger dnd-kit activation (needs >8px movement)
await page.mouse.move(endX, endY, { steps: 20 });
await page.mouse.up();
// Verify card moved to In Progress column
await expect(inProgressColumn.getByText("Sample Feature")).toBeVisible();
// Verify card is no longer in Backlog
await expect(backlogColumn.getByText("Sample Feature")).not.toBeVisible();
});
test("drag and drop updates feature status and triggers file save", async ({ page }) => {
await setupMockProject(page);
await page.goto("/");
// Wait for board to load
await expect(page.getByTestId("board-view")).toBeVisible();
// Wait for features to load in Backlog
const backlogColumn = page.getByTestId("kanban-column-backlog");
const plannedColumn = page.getByTestId("kanban-column-planned");
await expect(backlogColumn.getByText("Sample Feature")).toBeVisible();
// Find the drag handle specifically
const dragHandle = backlogColumn.locator('[data-testid^="drag-handle-feature-0-"]');
await expect(dragHandle).toBeVisible();
// Get drag handle and target positions (Planned is adjacent to Backlog)
const handleBox = await dragHandle.boundingBox();
const targetBox = await plannedColumn.boundingBox();
if (!handleBox || !targetBox) throw new Error("Could not find elements");
// Use mouse events - start from center of drag handle
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
const endX = targetBox.x + targetBox.width / 2;
const endY = targetBox.y + 100;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Move in steps to trigger dnd-kit activation (needs >8px movement)
await page.mouse.move(endX, endY, { steps: 20 });
await page.mouse.up();
// Verify card moved to Planned column
await expect(plannedColumn.getByText("Sample Feature")).toBeVisible();
// Verify card is no longer in Backlog
await expect(backlogColumn.getByText("Sample Feature")).not.toBeVisible();
// The feature moving to Planned means the feature_list.json would be updated
// with the new status. Since status changed from backlog, passes would remain false
// This confirms the state update and file save workflow works.
const plannedCard = plannedColumn.locator('[data-testid^="kanban-card-feature-0-"]');
await expect(plannedCard).toBeVisible();
});
});

View File

@@ -0,0 +1,205 @@
import { test, expect } from "@playwright/test";
test.describe("New Project Workflow", () => {
test("opens new project dialog when clicking Create Project", async ({ page }) => {
await page.goto("/");
// Click the New Project card
await page.getByTestId("new-project-card").click();
// Dialog should appear
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
await expect(page.getByText("Create New Project")).toBeVisible();
});
test("shows project name and directory inputs", async ({ page }) => {
await page.goto("/");
// Open dialog
await page.getByTestId("new-project-card").click();
// Check inputs exist
await expect(page.getByTestId("project-name-input")).toBeVisible();
await expect(page.getByTestId("project-path-input")).toBeVisible();
await expect(page.getByTestId("browse-directory")).toBeVisible();
});
test("create button is disabled without name and path", async ({ page }) => {
await page.goto("/");
// Open dialog
await page.getByTestId("new-project-card").click();
// Create button should be disabled
await expect(page.getByTestId("confirm-create-project")).toBeDisabled();
});
test("can enter project name", async ({ page }) => {
await page.goto("/");
// Open dialog
await page.getByTestId("new-project-card").click();
// Enter project name
await page.getByTestId("project-name-input").fill("my-test-project");
await expect(page.getByTestId("project-name-input")).toHaveValue("my-test-project");
});
test("can close dialog with cancel button", async ({ page }) => {
await page.goto("/");
// Open dialog
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
// Close with cancel
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("new-project-dialog")).not.toBeVisible();
});
test("create button enables when name and path are entered", async ({ page }) => {
await page.goto("/");
// Open dialog
await page.getByTestId("new-project-card").click();
// Create button should be disabled initially
await expect(page.getByTestId("confirm-create-project")).toBeDisabled();
// Enter project name
await page.getByTestId("project-name-input").fill("my-test-project");
// Still disabled (no path)
await expect(page.getByTestId("confirm-create-project")).toBeDisabled();
// Enter path
await page.getByTestId("project-path-input").fill("/Users/test/projects");
// Now should be enabled
await expect(page.getByTestId("confirm-create-project")).toBeEnabled();
});
test("creates project and navigates to board view", async ({ page }) => {
await page.goto("/");
// Open dialog
await page.getByTestId("new-project-card").click();
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
// Enter project details
await page.getByTestId("project-name-input").fill("test-new-project");
await page.getByTestId("project-path-input").fill("/Users/test/projects");
// Click create
await page.getByTestId("confirm-create-project").click();
// Dialog should close
await expect(page.getByTestId("new-project-dialog")).not.toBeVisible();
// Should navigate to board view with the project
await expect(page.getByTestId("board-view")).toBeVisible();
// Project name should be displayed in the board view header
await expect(page.getByTestId("board-view").getByText("test-new-project")).toBeVisible();
// Kanban columns should be visible
await expect(page.getByText("Backlog")).toBeVisible();
await expect(page.getByText("In Progress")).toBeVisible();
await expect(page.getByText("Verified")).toBeVisible();
});
test("created project appears in recent projects on welcome view", async ({ page }) => {
await page.goto("/");
// Create a project
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("recent-project-test");
await page.getByTestId("project-path-input").fill("/Users/test/projects");
await page.getByTestId("confirm-create-project").click();
// Verify we're on board view
await expect(page.getByTestId("board-view")).toBeVisible();
// Go back to welcome view by clicking Automaker title (if there's a way)
// For now, reload the page and check recent projects
await page.goto("/");
// The project should appear in recent projects section (use role to be specific)
await expect(page.getByRole("heading", { name: "Recent Projects" })).toBeVisible();
await expect(page.getByTestId("welcome-view").getByText("recent-project-test", { exact: true })).toBeVisible();
});
});
test.describe("Open Project Workflow", () => {
test("clicking Open Project triggers directory selection", async ({ page }) => {
await page.goto("/");
// In web mode, clicking Open Project card will show a prompt dialog
// We can't fully test native dialogs, but we can verify the click works
await expect(page.getByTestId("open-project-card")).toBeVisible();
});
test("opens existing project and navigates to board view", async ({ page }) => {
await page.goto("/");
// Mock the window.prompt response
await page.evaluate(() => {
window.prompt = () => "/mock/existing-project";
});
// Click Open Project card
await page.getByTestId("open-project-card").click();
// Should navigate to board view
await expect(page.getByTestId("board-view")).toBeVisible();
// Project name should be derived from path
await expect(page.getByTestId("board-view").getByText("existing-project")).toBeVisible();
});
test("opened project loads into dashboard with features", async ({ page }) => {
await page.goto("/");
// Mock the window.prompt response
await page.evaluate(() => {
window.prompt = () => "/mock/existing-project";
});
// Click Open Project
await page.getByTestId("open-project-card").click();
// Should show board view
await expect(page.getByTestId("board-view")).toBeVisible();
// Should have loaded features from the mock feature_list.json
// The mock returns "Sample Feature" in backlog
await expect(page.getByTestId("kanban-column-backlog").getByText("Sample Feature")).toBeVisible();
});
test("can click on recent project to reopen it", async ({ page }) => {
await page.goto("/");
// First, create a project to have it in recent projects
await page.getByTestId("new-project-card").click();
await page.getByTestId("project-name-input").fill("reopenable-project");
await page.getByTestId("project-path-input").fill("/Users/test/projects");
await page.getByTestId("confirm-create-project").click();
// Verify on board view
await expect(page.getByTestId("board-view")).toBeVisible();
// Go back to welcome view
await page.goto("/");
// Wait for recent projects to appear
await expect(page.getByRole("heading", { name: "Recent Projects" })).toBeVisible();
// Click on the recent project
const recentProjectCard = page.getByText("reopenable-project", { exact: true }).first();
await recentProjectCard.click();
// Should navigate to board view with that project
await expect(page.getByTestId("board-view")).toBeVisible();
await expect(page.getByTestId("board-view").getByText("reopenable-project")).toBeVisible();
});
});

112
app/tests/settings.spec.ts Normal file
View File

@@ -0,0 +1,112 @@
import { test, expect } from "@playwright/test";
test.describe("Settings - API Key Management", () => {
test("can navigate to settings page", async ({ page }) => {
await page.goto("/");
// Click settings button in sidebar
await page.getByTestId("settings-button").click();
// Should show settings view
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await expect(page.getByText("API Keys", { exact: true })).toBeVisible();
});
test("shows Anthropic and Google API key inputs", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Check input fields exist
await expect(page.getByTestId("anthropic-api-key-input")).toBeVisible();
await expect(page.getByTestId("google-api-key-input")).toBeVisible();
});
test("can enter and save Anthropic API key", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Enter API key
await page.getByTestId("anthropic-api-key-input").fill("sk-ant-test-key-123");
// Save
await page.getByTestId("save-settings").click();
// Should show saved confirmation
await expect(page.getByText("Saved!")).toBeVisible();
// Reload page and verify key persists
await page.reload();
await page.getByTestId("settings-button").click();
// Toggle visibility to see the key
await page.getByTestId("toggle-anthropic-visibility").click();
await expect(page.getByTestId("anthropic-api-key-input")).toHaveValue("sk-ant-test-key-123");
});
test("can enter and save Google API key", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Enter API key
await page.getByTestId("google-api-key-input").fill("AIzaSyTest123");
// Save
await page.getByTestId("save-settings").click();
// Reload page and verify key persists
await page.reload();
await page.getByTestId("settings-button").click();
// Toggle visibility
await page.getByTestId("toggle-google-visibility").click();
await expect(page.getByTestId("google-api-key-input")).toHaveValue("AIzaSyTest123");
});
test("API key inputs are password type by default", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Check input types are password
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "password");
await expect(page.getByTestId("google-api-key-input")).toHaveAttribute("type", "password");
});
test("can toggle API key visibility", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Initially password type
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "password");
// Toggle visibility
await page.getByTestId("toggle-anthropic-visibility").click();
// Now should be text type
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "text");
// Toggle back
await page.getByTestId("toggle-anthropic-visibility").click();
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "password");
});
test("can navigate back to home from settings", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Click back to home
await page.getByTestId("back-to-home").click();
// Should be back on welcome view
await expect(page.getByTestId("welcome-view")).toBeVisible();
});
test("shows security notice about local storage", async ({ page }) => {
await page.goto("/");
await page.getByTestId("settings-button").click();
// Should show security notice
await expect(page.getByText("Security Notice")).toBeVisible();
await expect(page.getByText(/stored in your browser's local storage/i)).toBeVisible();
});
});

34
app/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}