Compare commits

..

14 Commits

Author SHA1 Message Date
Web Dev Cody
e608f46a49 Merge pull request #52 from AutoMaker-Org/new-project-from-template
feat: Add new project from template feature and UI enhancements
2025-12-13 00:32:56 -05:00
Cody Seibert
8de4056417 fix: update release URL in marketing pages
- Changed the default release URL from 'https://releases.automaker.dev/releases.json' to 'https://releases.automaker.app/releases.json' in both index.html and releases.html files to ensure correct resource loading.
2025-12-13 00:29:03 -05:00
Cody Seibert
9196a1afb4 feat: enhance board background settings and introduce animated borders
- Added default background settings to streamline background management across components.
- Implemented animated border styles for in-progress cards to improve visual feedback.
- Refactored BoardBackgroundModal and BoardView components to utilize the new default settings, ensuring consistent background behavior.
- Updated KanbanCard to support animated borders, enhancing the user experience during task progress.
- Improved Sidebar component by optimizing the fetching of running agents count with a more efficient use of hooks.
2025-12-13 00:25:16 -05:00
Cody Seibert
eaef95c4a3 chore: clean up .gitignore by removing redundant node_modules entry
- Removed duplicate entry for node_modules from the .gitignore file to streamline ignored files and improve clarity.
2025-12-12 23:56:33 -05:00
Cody Seibert
3dd10aa8c7 feat: add project management actions to WelcomeView
- Introduced `addProject` and `setCurrentProject` actions to the WelcomeView component for enhanced project management capabilities.
- Updated the component's state management to support these new actions, improving user experience in project handling.
2025-12-12 23:45:36 -05:00
Cody Seibert
104f478f89 feat: enhance background image handling with cache-busting
- Added a cache-busting query parameter to the background image URL to ensure the browser reloads the image when updated.
- Updated the AppState to include an optional imageVersion property for managing image updates.
- Modified the BoardBackgroundModal and BoardView components to utilize the new imageVersion for dynamic image loading.
2025-12-12 23:09:51 -05:00
Cody Seibert
b32af0c86b feat: implement upsert project functionality in sidebar and welcome view
- Refactored project handling in Sidebar and WelcomeView components to use a new `upsertAndSetCurrentProject` action for creating or updating projects.
- Enhanced theme preservation logic during project creation and updates by integrating theme management directly into the store action.
- Cleaned up redundant code related to project existence checks and state updates, improving maintainability and readability.
2025-12-12 23:06:22 -05:00
Cody Seibert
c991d5f2f7 feat: add video demo section to marketing page
- Introduced a new video demo section to showcase features with an embedded video player.
- Styled the video container for responsive design and improved aesthetics.
- Added media queries for better display on smaller screens.
2025-12-12 22:51:39 -05:00
Cody Seibert
b3a4fd2be1 feat: introduce marketing mode and update sidebar display
- Added a new configuration flag `IS_MARKETING` to toggle marketing mode.
- Updated the sidebar component to conditionally display the marketing URL when in marketing mode.
- Refactored event type naming for consistency in the sidebar logic.
- Cleaned up formatting in the HttpApiClient for improved readability.
2025-12-12 22:42:43 -05:00
Cody Seibert
28328d7d1e feat: add red theme and board background modal
- Introduced a new red theme with custom color variables for a bold aesthetic.
- Updated the theme management to include the new red theme option.
- Added a BoardBackgroundModal component for managing board background settings, including image uploads and opacity controls.
- Enhanced KanbanCard and KanbanColumn components to support new background settings such as opacity and border visibility.
- Updated API client to handle saving and deleting board backgrounds.
- Refactored theme application logic to accommodate the new preview theme functionality.
2025-12-12 22:05:16 -05:00
Cody Seibert
346c38d6da Merge branch 'main' into new-project-from-template 2025-12-12 20:51:23 -05:00
Cody Seibert
ca4809ca06 various fixes 2025-12-12 20:51:01 -05:00
Cody Seibert
5544031164 Merge branch 'main' into new-project-from-template 2025-12-12 18:31:09 -05:00
Cody Seibert
be4aadb632 adding new project from template 2025-12-12 17:14:31 -05:00
28 changed files with 3342 additions and 712 deletions

View File

@@ -1,10 +1,14 @@
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('fs');
const path = require('path');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
} = require("@aws-sdk/client-s3");
const fs = require("fs");
const path = require("path");
const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
@@ -18,15 +22,17 @@ const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
async function fetchExistingReleases() {
try {
const response = await s3Client.send(new GetObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
}));
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: "releases.json",
})
);
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
console.log('No existing releases.json found, creating new one');
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
console.log("No existing releases.json found, creating new one");
return { latestVersion: null, releases: [] };
}
throw error;
@@ -37,12 +43,14 @@ async function uploadFile(localPath, r2Key, contentType) {
const fileBuffer = fs.readFileSync(localPath);
const stats = fs.statSync(localPath);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: r2Key,
Body: fileBuffer,
ContentType: contentType,
}));
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: r2Key,
Body: fileBuffer,
ContentType: contentType,
})
);
console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`);
return stats.size;
@@ -51,44 +59,44 @@ async function uploadFile(localPath, r2Key, contentType) {
function findArtifacts(dir, pattern) {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir);
return files.filter(f => pattern.test(f)).map(f => path.join(dir, f));
return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f));
}
async function main() {
const artifactsDir = 'artifacts';
const artifactsDir = "artifacts";
// Find all artifacts
const artifacts = {
windows: findArtifacts(
path.join(artifactsDir, 'windows-builds'),
/\.exe$/
),
macos: findArtifacts(
path.join(artifactsDir, 'macos-builds'),
/-x64\.dmg$/
),
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
macosArm: findArtifacts(
path.join(artifactsDir, 'macos-builds'),
path.join(artifactsDir, "macos-builds"),
/-arm64\.dmg$/
),
linux: findArtifacts(
path.join(artifactsDir, 'linux-builds'),
path.join(artifactsDir, "linux-builds"),
/\.AppImage$/
),
};
console.log('Found artifacts:');
console.log("Found artifacts:");
for (const [platform, files] of Object.entries(artifacts)) {
console.log(` ${platform}: ${files.length > 0 ? files.map(f => path.basename(f)).join(', ') : 'none'}`);
console.log(
` ${platform}: ${
files.length > 0
? files.map((f) => path.basename(f)).join(", ")
: "none"
}`
);
}
// Upload each artifact to R2
const assets = {};
const contentTypes = {
windows: 'application/x-msdownload',
macos: 'application/x-apple-diskimage',
macosArm: 'application/x-apple-diskimage',
linux: 'application/x-executable',
windows: "application/x-msdownload",
macos: "application/x-apple-diskimage",
macosArm: "application/x-apple-diskimage",
linux: "application/x-executable",
};
for (const [platform, files] of Object.entries(artifacts)) {
@@ -107,7 +115,7 @@ async function main() {
url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`,
filename,
size,
arch: platform === 'macosArm' ? 'arm64' : 'x64',
arch: platform === "macosArm" ? "arm64" : "x64",
};
}
@@ -122,27 +130,31 @@ async function main() {
};
// Remove existing entry for this version if re-running
releasesData.releases = releasesData.releases.filter(r => r.version !== VERSION);
releasesData.releases = releasesData.releases.filter(
(r) => r.version !== VERSION
);
// Prepend new release
releasesData.releases.unshift(newRelease);
releasesData.latestVersion = VERSION;
// Upload updated releases.json
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
Body: JSON.stringify(releasesData, null, 2),
ContentType: 'application/json',
CacheControl: 'public, max-age=60',
}));
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: "releases.json",
Body: JSON.stringify(releasesData, null, 2),
ContentType: "application/json",
CacheControl: "public, max-age=60",
})
);
console.log('Successfully updated releases.json');
console.log("Successfully updated releases.json");
console.log(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`);
}
main().catch(err => {
console.error('Failed to upload to R2:', err);
main().catch((err) => {
console.error("Failed to upload to R2:", err);
process.exit(1);
});

View File

@@ -129,7 +129,7 @@ jobs:
- name: Upload to R2 and update releases.json
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}

1
.gitignore vendored
View File

@@ -7,7 +7,6 @@ node_modules/
# Build outputs
dist/
.next/
node_modules
.automaker/images/
.automaker/
/.automaker/*

View File

@@ -12,6 +12,7 @@
@custom-variant catppuccin (&:is(.catppuccin *));
@custom-variant onedark (&:is(.onedark *));
@custom-variant synthwave (&:is(.synthwave *));
@custom-variant red (&:is(.red *));
@theme inline {
--color-background: var(--background);
@@ -1072,6 +1073,75 @@
--running-indicator-text: oklch(0.75 0.26 350);
}
/* Red Theme - Bold crimson/red aesthetic */
.red {
--background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */
--background-50: oklch(0.12 0.03 15 / 0.5);
--background-80: oklch(0.12 0.03 15 / 0.8);
--foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */
--foreground-secondary: oklch(0.7 0.02 15);
--foreground-muted: oklch(0.5 0.03 15);
--card: oklch(0.18 0.04 15); /* Slightly lighter dark red */
--card-foreground: oklch(0.95 0.01 15);
--popover: oklch(0.15 0.035 15);
--popover-foreground: oklch(0.95 0.01 15);
--primary: oklch(0.55 0.25 25); /* Vibrant crimson red */
--primary-foreground: oklch(0.98 0 0);
--brand-400: oklch(0.6 0.23 25);
--brand-500: oklch(0.55 0.25 25); /* Crimson */
--brand-600: oklch(0.5 0.27 25);
--secondary: oklch(0.22 0.05 15);
--secondary-foreground: oklch(0.95 0.01 15);
--muted: oklch(0.22 0.05 15);
--muted-foreground: oklch(0.5 0.03 15);
--accent: oklch(0.28 0.06 15);
--accent-foreground: oklch(0.95 0.01 15);
--destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */
--border: oklch(0.35 0.08 15);
--border-glass: oklch(0.55 0.25 25 / 0.3);
--input: oklch(0.18 0.04 15);
--ring: oklch(0.55 0.25 25);
--chart-1: oklch(0.55 0.25 25); /* Crimson */
--chart-2: oklch(0.7 0.2 50); /* Orange */
--chart-3: oklch(0.8 0.18 80); /* Gold */
--chart-4: oklch(0.6 0.22 0); /* Pure red */
--chart-5: oklch(0.65 0.2 350); /* Pink-red */
--sidebar: oklch(0.1 0.025 15);
--sidebar-foreground: oklch(0.95 0.01 15);
--sidebar-primary: oklch(0.55 0.25 25);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.22 0.05 15);
--sidebar-accent-foreground: oklch(0.95 0.01 15);
--sidebar-border: oklch(0.35 0.08 15);
--sidebar-ring: oklch(0.55 0.25 25);
/* Action button colors - Red theme */
--action-view: oklch(0.55 0.25 25); /* Crimson */
--action-view-hover: oklch(0.5 0.27 25);
--action-followup: oklch(0.7 0.2 50); /* Orange */
--action-followup-hover: oklch(0.65 0.22 50);
--action-commit: oklch(0.6 0.2 140); /* Green for positive actions */
--action-commit-hover: oklch(0.55 0.22 140);
--action-verify: oklch(0.6 0.2 140); /* Green */
--action-verify-hover: oklch(0.55 0.22 140);
/* Running indicator - Crimson */
--running-indicator: oklch(0.55 0.25 25);
--running-indicator-text: oklch(0.6 0.23 25);
}
@layer base {
* {
@apply border-border outline-ring/50;
@@ -1327,6 +1397,39 @@
.text-running-indicator {
color: var(--running-indicator-text);
}
/* Animated border for in-progress cards */
@keyframes border-rotate {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animated-border-wrapper {
position: relative;
border-radius: 0.75rem;
padding: 2px;
background: linear-gradient(
90deg,
var(--running-indicator),
color-mix(in oklch, var(--running-indicator), transparent 50%),
var(--running-indicator),
color-mix(in oklch, var(--running-indicator), transparent 50%),
var(--running-indicator)
);
background-size: 200% 100%;
animation: border-rotate 3s ease infinite;
}
.animated-border-wrapper > * {
border-radius: calc(0.75rem - 2px);
}
}
/* Retro Overrides for Utilities */

View File

@@ -15,7 +15,11 @@ import { RunningAgentsView } from "@/components/views/running-agents-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
import {
FileBrowserProvider,
useFileBrowser,
setGlobalFileBrowser,
} from "@/contexts/file-browser-context";
function HomeContent() {
const {
@@ -24,6 +28,8 @@ function HomeContent() {
setIpcConnected,
theme,
currentProject,
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
@@ -72,9 +78,9 @@ function HomeContent() {
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: project theme takes priority over global theme
// This is reactive because it depends on currentProject and theme from the store
const effectiveTheme = currentProject?.theme || theme;
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
const effectiveTheme = getEffectiveTheme();
// Prevent hydration issues
useEffect(() => {
@@ -122,7 +128,7 @@ function HomeContent() {
testConnection();
}, [setIpcConnected]);
// Apply theme class to document (uses effective theme - project-specific or global)
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
useEffect(() => {
const root = document.documentElement;
root.classList.remove(
@@ -137,7 +143,8 @@ function HomeContent() {
"gruvbox",
"catppuccin",
"onedark",
"synthwave"
"synthwave",
"red"
);
if (effectiveTheme === "dark") {
@@ -162,6 +169,8 @@ function HomeContent() {
root.classList.add("onedark");
} else if (effectiveTheme === "synthwave") {
root.classList.add("synthwave");
} else if (effectiveTheme === "red") {
root.classList.add("red");
} else if (effectiveTheme === "light") {
root.classList.add("light");
} else if (effectiveTheme === "system") {
@@ -173,7 +182,7 @@ function HomeContent() {
root.classList.add("light");
}
}
}, [effectiveTheme]);
}, [effectiveTheme, previewTheme, currentProject, theme]);
const renderView = () => {
switch (currentView) {

View File

@@ -0,0 +1,520 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
import { getHttpApiClient } from "@/lib/http-api-client";
import { toast } from "sonner";
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
interface BoardBackgroundModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function BoardBackgroundModal({
open,
onOpenChange,
}: BoardBackgroundModalProps) {
const {
currentProject,
boardBackgroundByProject,
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
clearBoardBackground,
} = useAppStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// Get current background settings (live from store)
const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings;
const cardOpacity = backgroundSettings.cardOpacity;
const columnOpacity = backgroundSettings.columnOpacity;
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
const hideScrollbar = backgroundSettings.hideScrollbar;
const imageVersion = backgroundSettings.imageVersion;
// Update preview image when background settings change
useEffect(() => {
if (currentProject && backgroundSettings.imagePath) {
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
// Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion
? `&v=${imageVersion}`
: `&v=${Date.now()}`;
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
setPreviewImage(imagePath);
} else {
setPreviewImage(null);
}
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const processFile = useCallback(
async (file: File) => {
if (!currentProject) {
toast.error("No project selected");
return;
}
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
toast.error(
"Unsupported file type. Please use JPG, PNG, GIF, or WebP."
);
return;
}
// Validate file size
if (file.size > DEFAULT_MAX_FILE_SIZE) {
const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024);
toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`);
return;
}
setIsProcessing(true);
try {
const base64 = await fileToBase64(file);
// Set preview immediately
setPreviewImage(base64);
// Save to server
const httpClient = getHttpApiClient();
const result = await httpClient.saveBoardBackground(
base64,
file.name,
file.type,
currentProject.path
);
if (result.success && result.path) {
// Update store with the relative path (live update)
setBoardBackground(currentProject.path, result.path);
toast.success("Background image saved");
} else {
toast.error(result.error || "Failed to save background image");
setPreviewImage(null);
}
} catch (error) {
console.error("Failed to process image:", error);
toast.error("Failed to process image");
setPreviewImage(null);
} finally {
setIsProcessing(false);
}
},
[currentProject, setBoardBackground]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
processFile(files[0]);
}
},
[processFile]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFile(files[0]);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[processFile]
);
const handleBrowseClick = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const handleClear = useCallback(async () => {
if (!currentProject) return;
try {
setIsProcessing(true);
const httpClient = getHttpApiClient();
const result = await httpClient.deleteBoardBackground(
currentProject.path
);
if (result.success) {
clearBoardBackground(currentProject.path);
setPreviewImage(null);
toast.success("Background image cleared");
} else {
toast.error(result.error || "Failed to clear background image");
}
} catch (error) {
console.error("Failed to clear background:", error);
toast.error("Failed to clear background");
} finally {
setIsProcessing(false);
}
}, [currentProject, clearBoardBackground]);
// Live update opacity when sliders change
const handleCardOpacityChange = useCallback(
(value: number[]) => {
if (!currentProject) return;
setCardOpacity(currentProject.path, value[0]);
},
[currentProject, setCardOpacity]
);
const handleColumnOpacityChange = useCallback(
(value: number[]) => {
if (!currentProject) return;
setColumnOpacity(currentProject.path, value[0]);
},
[currentProject, setColumnOpacity]
);
const handleColumnBorderToggle = useCallback(
(checked: boolean) => {
if (!currentProject) return;
setColumnBorderEnabled(currentProject.path, checked);
},
[currentProject, setColumnBorderEnabled]
);
const handleCardGlassmorphismToggle = useCallback(
(checked: boolean) => {
if (!currentProject) return;
setCardGlassmorphism(currentProject.path, checked);
},
[currentProject, setCardGlassmorphism]
);
const handleCardBorderToggle = useCallback(
(checked: boolean) => {
if (!currentProject) return;
setCardBorderEnabled(currentProject.path, checked);
},
[currentProject, setCardBorderEnabled]
);
const handleCardBorderOpacityChange = useCallback(
(value: number[]) => {
if (!currentProject) return;
setCardBorderOpacity(currentProject.path, value[0]);
},
[currentProject, setCardBorderOpacity]
);
const handleHideScrollbarToggle = useCallback(
(checked: boolean) => {
if (!currentProject) return;
setHideScrollbar(currentProject.path, checked);
},
[currentProject, setHideScrollbar]
);
if (!currentProject) {
return null;
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader className="px-6 pt-6">
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-brand-500" />
Board Background Settings
</SheetTitle>
<SheetDescription className="text-muted-foreground">
Set a custom background image for your kanban board and adjust
card/column opacity
</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-6 pb-6">
{/* Image Upload Section */}
<div className="space-y-3">
<Label>Background Image</Label>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={isProcessing}
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
{
"border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10":
isDragOver && !isProcessing,
"border-muted-foreground/25": !isDragOver && !isProcessing,
"border-muted-foreground/10 opacity-50 cursor-not-allowed":
isProcessing,
"hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5":
!isProcessing && !isDragOver,
}
)}
>
{previewImage ? (
<div className="relative p-4">
<div className="relative w-full h-48 rounded-md overflow-hidden border border-border bg-muted">
<img
src={previewImage}
alt="Background preview"
className="w-full h-full object-cover"
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
</div>
)}
</div>
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleBrowseClick}
disabled={isProcessing}
className="flex-1"
>
<Upload className="w-4 h-4 mr-2" />
Change Image
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleClear}
disabled={isProcessing}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
</div>
</div>
) : (
<div
onClick={handleBrowseClick}
className="flex flex-col items-center justify-center p-8 text-center cursor-pointer"
>
<div
className={cn(
"rounded-full p-3 mb-3",
isDragOver && !isProcessing
? "bg-brand-500/10 dark:bg-brand-500/20"
: "bg-muted"
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !isProcessing
? "Drop image here"
: "Click to upload or drag and drop"}
</p>
<p className="text-xs text-muted-foreground mt-1">
JPG, PNG, GIF, or WebP (max{" "}
{Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)
</p>
</div>
)}
</div>
</div>
{/* Opacity Controls */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Opacity</Label>
<span className="text-sm text-muted-foreground">
{cardOpacity}%
</span>
</div>
<Slider
value={[cardOpacity]}
onValueChange={handleCardOpacityChange}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Column Opacity</Label>
<span className="text-sm text-muted-foreground">
{columnOpacity}%
</span>
</div>
<Slider
value={[columnOpacity]}
onValueChange={handleColumnOpacityChange}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
{/* Column Border Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="column-border-toggle"
checked={columnBorderEnabled}
onCheckedChange={handleColumnBorderToggle}
/>
<Label htmlFor="column-border-toggle" className="cursor-pointer">
Show Column Borders
</Label>
</div>
{/* Card Glassmorphism Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="card-glassmorphism-toggle"
checked={cardGlassmorphism}
onCheckedChange={handleCardGlassmorphismToggle}
/>
<Label
htmlFor="card-glassmorphism-toggle"
className="cursor-pointer"
>
Card Glassmorphism (blur effect)
</Label>
</div>
{/* Card Border Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="card-border-toggle"
checked={cardBorderEnabled}
onCheckedChange={handleCardBorderToggle}
/>
<Label htmlFor="card-border-toggle" className="cursor-pointer">
Show Card Borders
</Label>
</div>
{/* Card Border Opacity - only show when border is enabled */}
{cardBorderEnabled && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Border Opacity</Label>
<span className="text-sm text-muted-foreground">
{cardBorderOpacity}%
</span>
</div>
<Slider
value={[cardBorderOpacity]}
onValueChange={handleCardBorderOpacityChange}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
)}
{/* Hide Scrollbar Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="hide-scrollbar-toggle"
checked={hideScrollbar}
onCheckedChange={handleHideScrollbarToggle}
/>
<Label htmlFor="hide-scrollbar-toggle" className="cursor-pointer">
Hide Board Scrollbar
</Label>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -2,8 +2,9 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { useAppStore, formatShortcut } from "@/store/app-store";
import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
import { IS_MARKETING } from "@/config/app-config";
import {
FolderOpen,
Plus,
@@ -26,22 +27,11 @@ import {
UserCircle,
MoreVertical,
Palette,
Moon,
Sun,
Terminal,
Ghost,
Snowflake,
Flame,
Sparkles as TokyoNightIcon,
Eclipse,
Trees,
Cat,
Atom,
Radio,
Monitor,
Search,
Bug,
Activity,
Recycle,
} from "lucide-react";
import {
DropdownMenu,
@@ -70,7 +60,12 @@ import {
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
import {
getElectronAPI,
Project,
TrashedProject,
RunningAgent,
} from "@/lib/electron";
import {
initializeProject,
hasAppSpec,
@@ -78,8 +73,10 @@ import {
} from "@/lib/project-init";
import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import {
DndContext,
DragEndEvent,
@@ -173,21 +170,14 @@ function SortableProjectItem({
);
}
// Theme options for project theme selector
// Theme options for project theme selector - derived from the shared config
const PROJECT_THEME_OPTIONS = [
{ value: "", label: "Use Global", icon: Monitor },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "light", label: "Light", icon: Sun },
{ value: "retro", label: "Retro", icon: Terminal },
{ value: "dracula", label: "Dracula", icon: Ghost },
{ value: "nord", label: "Nord", icon: Snowflake },
{ value: "monokai", label: "Monokai", icon: Flame },
{ value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon },
{ value: "solarized", label: "Solarized", icon: Eclipse },
{ value: "gruvbox", label: "Gruvbox", icon: Trees },
{ value: "catppuccin", label: "Catppuccin", icon: Cat },
{ value: "onedark", label: "One Dark", icon: Atom },
{ value: "synthwave", label: "Synthwave", icon: Radio },
...themeOptions.map((opt) => ({
value: opt.value,
label: opt.label,
icon: opt.Icon,
})),
] as const;
export function Sidebar() {
@@ -198,7 +188,7 @@ export function Sidebar() {
currentView,
sidebarOpen,
projectHistory,
addProject,
upsertAndSetCurrentProject,
setCurrentProject,
setCurrentView,
toggleSidebar,
@@ -211,7 +201,9 @@ export function Sidebar() {
clearProjectHistory,
setProjectTheme,
setTheme,
setPreviewTheme,
theme: globalTheme,
moveProjectToTrash,
} = useAppStore();
// Get customizable keyboard shortcuts
@@ -225,6 +217,12 @@ export function Sidebar() {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for running agents count
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
// State for new project setup dialog
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState("");
@@ -334,6 +332,49 @@ export function Sidebar() {
};
}, [setCurrentView]);
// Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
setRunningAgentsCount(result.runningAgents.length);
}
}
} catch (error) {
console.error("[Sidebar] Error fetching running agents count:", error);
}
}, []);
// Subscribe to auto-mode events to update running agents count in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) {
// If autoMode is not available, still fetch initial count
fetchRunningAgentsCount();
return;
}
// Initial fetch on mount
fetchRunningAgentsCount();
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature starts, completes, or errors, refresh the count
if (
event.type === "auto_mode_feature_complete" ||
event.type === "auto_mode_error" ||
event.type === "auto_mode_feature_start"
) {
fetchRunningAgentsCount();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount]);
// Handle creating initial spec for new project
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
@@ -414,38 +455,14 @@ export function Sidebar() {
return;
}
// Check if project already exists (by path) to preserve theme and other settings
const existingProject = projects.find((p) => p.path === path);
let project: Project;
if (existingProject) {
// Update existing project, preserving theme and other properties
project = {
...existingProject,
name, // Update name in case it changed
lastOpened: new Date().toISOString(),
};
// Update the project in the store (this will update the existing entry)
const updatedProjects = projects.map((p) =>
p.id === existingProject.id ? project : p
);
useAppStore.setState({ projects: updatedProjects });
} else {
// Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
// Then fall back to current effective theme, then global theme
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme;
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
theme: effectiveTheme,
};
addProject(project);
}
setCurrentProject(project);
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -479,7 +496,12 @@ export function Sidebar() {
});
}
}
}, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]);
}, [
trashedProjects,
upsertAndSetCurrentProject,
currentProject,
globalTheme,
]);
const handleRestoreProject = useCallback(
(projectId: string) => {
@@ -534,14 +556,14 @@ export function Sidebar() {
}
const confirmed = window.confirm(
"Clear all trashed projects from Automaker? This does not delete folders from disk."
"Clear all projects from recycle bin? This does not delete folders from disk."
);
if (!confirmed) return;
setIsEmptyingTrash(true);
try {
emptyTrash();
toast.success("Trash cleared");
toast.success("Recycle bin cleared");
setShowTrashDialog(false);
} finally {
setIsEmptyingTrash(false);
@@ -761,7 +783,9 @@ export function Sidebar() {
<div
className={cn(
"h-20 border-b border-sidebar-border shrink-0 titlebar-drag-region",
sidebarOpen ? "pt-8 px-3 lg:px-6 flex items-center justify-between" : "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
sidebarOpen
? "pt-8 px-3 lg:px-6 flex items-center justify-between"
: "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
)}
>
<div
@@ -785,14 +809,24 @@ export function Sidebar() {
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Auto<span className="text-brand-500">maker</span>
{IS_MARKETING ? (
<>
https://<span className="text-brand-500">automaker</span>.app
</>
) : (
<>
Auto<span className="text-brand-500">maker</span>
</>
)}
</span>
</div>
{/* Bug Report Button */}
<button
onClick={() => {
const api = getElectronAPI();
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
api.openExternalLink(
"https://github.com/AutoMaker-Org/automaker/issues"
);
}}
className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all"
title="Report Bug / Feature Request"
@@ -830,10 +864,10 @@ export function Sidebar() {
<button
onClick={() => setShowTrashDialog(true)}
className="group flex items-center justify-center px-3 h-[42px] rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-primary hover:bg-destructive/10 border border-sidebar-border"
title="Trash"
title="Recycle Bin"
data-testid="trash-button"
>
<Trash2 className="size-4 shrink-0" />
<Recycle className="size-4 shrink-0" />
{trashedProjects.length > 0 && (
<span className="absolute -top-[2px] -right-[2px] flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full text-brand-500">
{trashedProjects.length > 9 ? "9+" : trashedProjects.length}
@@ -934,7 +968,14 @@ export function Sidebar() {
{/* Project Options Menu - theme and history */}
{currentProject && (
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
// Clear preview theme when the menu closes
if (!open) {
setPreviewTheme(null);
}
}}
>
<DropdownMenuTrigger asChild>
<button
className="hidden lg:flex items-center justify-center w-8 h-[42px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag"
@@ -957,8 +998,12 @@ export function Sidebar() {
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-48"
className="w-56"
data-testid="project-theme-menu"
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
}}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Select theme for this project
@@ -968,9 +1013,14 @@ export function Sidebar() {
value={currentProject.theme || ""}
onValueChange={(value) => {
if (currentProject) {
// Clear preview theme when a theme is selected
setPreviewTheme(null);
// If selecting an actual theme (not "Use Global"), also update global
if (value !== "") {
setTheme(value as any);
} else {
// Restore to global theme when "Use Global" is selected
setTheme(globalTheme);
}
setProjectTheme(
currentProject.id,
@@ -981,22 +1031,54 @@ export function Sidebar() {
>
{PROJECT_THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const themeValue =
option.value === "" ? globalTheme : option.value;
return (
<DropdownMenuRadioItem
<div
key={option.value}
value={option.value}
data-testid={`project-theme-${
option.value || "global"
}`}
onPointerEnter={() => {
// Preview the theme on hover
setPreviewTheme(themeValue as any);
}}
onPointerLeave={(e) => {
// Clear preview theme when leaving this item
// Only clear if we're not moving to another theme item
const relatedTarget =
e.relatedTarget as HTMLElement;
if (
!relatedTarget ||
!relatedTarget.closest(
'[data-testid^="project-theme-"]'
)
) {
setPreviewTheme(null);
}
}}
>
<Icon className="w-4 h-4 mr-2" />
<span>{option.label}</span>
{option.value === "" && (
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${
option.value || "global"
}`}
onFocus={() => {
// Preview the theme on keyboard navigation
setPreviewTheme(themeValue as any);
}}
onBlur={() => {
// Clear preview theme when losing focus
// If moving to another item, its onFocus will set it again
setPreviewTheme(null);
}}
>
<Icon className="w-4 h-4 mr-2" />
<span>{option.label}</span>
{option.value === "" && (
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
)}
</DropdownMenuRadioItem>
</div>
);
})}
</DropdownMenuRadioGroup>
@@ -1039,6 +1121,17 @@ export function Sidebar() {
</DropdownMenuItem>
</>
)}
{/* Move to Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteProjectDialog(true)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"
data-testid="move-project-to-trash"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Move to Trash</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -1163,14 +1256,25 @@ export function Sidebar() {
{isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "text-brand-500"
: "group-hover:text-brand-400"
<div className="relative">
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-semibold rounded-full bg-brand-500 text-white"
data-testid="running-agents-count-collapsed"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</span>
)}
/>
</div>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
@@ -1179,6 +1283,18 @@ export function Sidebar() {
>
Running Agents
</span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
"hidden lg:flex items-center justify-center min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full bg-brand-500 text-white",
isActiveRoute("running-agents") && "bg-brand-600"
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</span>
)}
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Running Agents
@@ -1242,7 +1358,7 @@ export function Sidebar() {
<Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}>
<DialogContent className="bg-popover border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Trash</DialogTitle>
<DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your
system Trash.
@@ -1250,7 +1366,9 @@ export function Sidebar() {
</DialogHeader>
{trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Trash is empty.</p>
<p className="text-sm text-muted-foreground">
Recycle bin is empty.
</p>
) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => (
@@ -1318,7 +1436,7 @@ export function Sidebar() {
disabled={isEmptyingTrash}
data-testid="empty-trash"
>
{isEmptyingTrash ? "Clearing..." : "Empty Trash"}
{isEmptyingTrash ? "Clearing..." : "Empty Recycle Bin"}
</Button>
)}
</DialogFooter>
@@ -1421,6 +1539,14 @@ export function Sidebar() {
</button>
</div>
)}
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</aside>
);
}

View File

@@ -0,0 +1,453 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
FolderPlus,
FolderOpen,
Rocket,
ExternalLink,
Check,
Loader2,
Link,
Folder,
} from "lucide-react";
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
import { getElectronAPI } from "@/lib/electron";
import { getHttpApiClient } from "@/lib/http-api-client";
import { cn } from "@/lib/utils";
import { useFileBrowser } from "@/contexts/file-browser-context";
interface ValidationErrors {
projectName?: boolean;
workspaceDir?: boolean;
templateSelection?: boolean;
customUrl?: boolean;
}
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
onCreateFromTemplate: (
template: StarterTemplate,
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromCustomUrl: (
repoUrl: string,
projectName: string,
parentDir: string
) => Promise<void>;
isCreating: boolean;
}
export function NewProjectModal({
open,
onOpenChange,
onCreateBlankProject,
onCreateFromTemplate,
onCreateFromCustomUrl,
isCreating,
}: NewProjectModalProps) {
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({});
const { openFileBrowser } = useFileBrowser();
// Fetch workspace directory when modal opens
useEffect(() => {
if (open) {
setIsLoadingWorkspace(true);
const httpClient = getHttpApiClient();
httpClient.workspace.getConfig()
.then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
}
})
.catch((error) => {
console.error("Failed to get workspace config:", error);
})
.finally(() => {
setIsLoadingWorkspace(false);
});
}
}, [open]);
// Reset form when modal closes
useEffect(() => {
if (!open) {
setProjectName("");
setSelectedTemplate(null);
setUseCustomUrl(false);
setCustomUrl("");
setActiveTab("blank");
setErrors({});
}
}, [open]);
// Clear specific errors when user fixes them
useEffect(() => {
if (projectName && errors.projectName) {
setErrors((prev) => ({ ...prev, projectName: false }));
}
}, [projectName, errors.projectName]);
useEffect(() => {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
setErrors((prev) => ({ ...prev, templateSelection: false }));
}
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
useEffect(() => {
if (customUrl && errors.customUrl) {
setErrors((prev) => ({ ...prev, customUrl: false }));
}
}, [customUrl, errors.customUrl]);
const validateAndCreate = async () => {
const newErrors: ValidationErrors = {};
// Check project name
if (!projectName.trim()) {
newErrors.projectName = true;
}
// Check workspace dir
if (!workspaceDir) {
newErrors.workspaceDir = true;
}
// Check template selection (only for template tab)
if (activeTab === "template") {
if (useCustomUrl) {
if (!customUrl.trim()) {
newErrors.customUrl = true;
}
} else if (!selectedTemplate) {
newErrors.templateSelection = true;
}
}
// If there are errors, show them and don't proceed
if (Object.values(newErrors).some(Boolean)) {
setErrors(newErrors);
return;
}
// Clear errors and proceed
setErrors({});
if (activeTab === "blank") {
await onCreateBlankProject(projectName, workspaceDir);
} else if (useCustomUrl && customUrl) {
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
} else if (selectedTemplate) {
await onCreateFromTemplate(selectedTemplate, projectName, workspaceDir);
}
};
const handleOpenRepo = (url: string) => {
const api = getElectronAPI();
api.openExternalLink(url);
};
const handleSelectTemplate = (template: StarterTemplate) => {
setSelectedTemplate(template);
setUseCustomUrl(false);
setCustomUrl("");
};
const handleToggleCustomUrl = () => {
setUseCustomUrl(!useCustomUrl);
if (!useCustomUrl) {
setSelectedTemplate(null);
}
};
const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Project Directory",
description: "Choose the parent directory where your project will be created",
});
if (selectedPath) {
setWorkspaceDir(selectedPath);
// Clear any workspace error when a valid directory is selected
if (errors.workspaceDir) {
setErrors((prev) => ({ ...prev, workspaceDir: false }));
}
}
};
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="bg-card border-border max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
data-testid="new-project-modal"
>
<DialogHeader className="pb-2">
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template.
</DialogDescription>
</DialogHeader>
{/* Project Name Input - Always visible at top */}
<div className="space-y-3 pb-4 border-b border-border">
<div className="space-y-2">
<Label htmlFor="project-name" className={cn("text-foreground", errors.projectName && "text-red-500")}>
Project Name {errors.projectName && <span className="text-red-500">*</span>}
</Label>
<Input
id="project-name"
placeholder="my-awesome-project"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className={cn(
"bg-input text-foreground placeholder:text-muted-foreground",
errors.projectName
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-border"
)}
data-testid="project-name-input"
autoFocus
/>
{errors.projectName && (
<p className="text-xs text-red-500">Project name is required</p>
)}
</div>
{/* Workspace Directory Display */}
<div className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
"Loading workspace..."
) : workspaceDir ? (
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">{projectPath || "..."}</code></>
) : (
<span className="text-red-500">No workspace configured</span>
)}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleBrowseDirectory}
disabled={isLoadingWorkspace}
className="shrink-0 h-7 px-2 text-xs"
data-testid="browse-directory-button"
>
<FolderOpen className="w-3.5 h-3.5 mr-1" />
Browse
</Button>
</div>
</div>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="blank" className="gap-2">
<FolderPlus className="w-4 h-4" />
Blank Project
</TabsTrigger>
<TabsTrigger value="template" className="gap-2">
<Rocket className="w-4 h-4" />
Starter Kit
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto py-4">
<TabsContent value="blank" className="mt-0">
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
Create an empty project with the standard .automaker directory
structure. Perfect for starting from scratch or importing an
existing codebase.
</p>
</div>
</TabsContent>
<TabsContent value="template" className="mt-0">
<div className="space-y-4">
{/* Error message for template selection */}
{errors.templateSelection && (
<p className="text-sm text-red-500">Please select a template or enter a custom GitHub URL</p>
)}
{/* Preset Templates */}
<div className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}>
{starterTemplates.map((template) => (
<div
key={template.id}
className={cn(
"p-4 rounded-lg border cursor-pointer transition-all",
selectedTemplate?.id === template.id && !useCustomUrl
? "border-brand-500 bg-brand-500/10"
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
)}
onClick={() => handleSelectTemplate(template)}
data-testid={`template-${template.id}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-foreground">
{template.name}
</h4>
{selectedTemplate?.id === template.id && !useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
{template.description}
</p>
{/* Tech Stack */}
<div className="flex flex-wrap gap-1.5 mb-3">
{template.techStack.slice(0, 6).map((tech) => (
<Badge
key={tech}
variant="secondary"
className="text-xs"
>
{tech}
</Badge>
))}
{template.techStack.length > 6 && (
<Badge variant="secondary" className="text-xs">
+{template.techStack.length - 6} more
</Badge>
)}
</div>
{/* Key Features */}
<div className="text-xs text-muted-foreground">
<span className="font-medium">Features: </span>
{template.features.slice(0, 3).join(" · ")}
{template.features.length > 3 &&
` · +${template.features.length - 3} more`}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
handleOpenRepo(template.repoUrl);
}}
>
<ExternalLink className="w-4 h-4 mr-1" />
View
</Button>
</div>
</div>
))}
{/* Custom URL Option */}
<div
className={cn(
"p-4 rounded-lg border cursor-pointer transition-all",
useCustomUrl
? "border-brand-500 bg-brand-500/10"
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
)}
onClick={handleToggleCustomUrl}
>
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
</div>
<p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point.
</p>
{useCustomUrl && (
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
<Input
placeholder="https://github.com/username/repository"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className={cn(
"bg-input text-foreground placeholder:text-muted-foreground",
errors.customUrl
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-border"
)}
data-testid="custom-url-input"
/>
{errors.customUrl && (
<p className="text-xs text-red-500">GitHub URL is required</p>
)}
</div>
)}
</div>
</div>
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="border-t border-border pt-4">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-muted-foreground hover:text-foreground hover:bg-accent"
>
Cancel
</Button>
<HotkeyButton
onClick={validateAndCreate}
disabled={isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-create-project"
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{activeTab === "template" ? "Cloning..." : "Creating..."}
</>
) : (
<>Create Project</>
)}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -24,6 +24,7 @@ import {
AgentModel,
ThinkingLevel,
AIProfile,
defaultBackgroundSettings,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, modelSupportsThinking } from "@/lib/utils";
@@ -58,6 +59,7 @@ import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card";
import { AgentOutputModal } from "./agent-output-modal";
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import {
Plus,
RefreshCw,
@@ -86,6 +88,7 @@ import {
Square,
Maximize2,
Shuffle,
ImageIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -206,6 +209,7 @@ export function BoardView() {
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardBackgroundByProject,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -230,6 +234,8 @@ export function BoardView() {
);
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -400,7 +406,8 @@ export function BoardView() {
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
const isProjectSwitch =
previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
@@ -556,7 +563,8 @@ export function BoardView() {
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
const eventProjectId =
("projectId" in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed
@@ -585,15 +593,16 @@ export function BoardView() {
loadFeatures();
// Check for authentication errors and show a more helpful message
const isAuthError = event.errorType === "authentication" ||
(event.error && (
event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")
));
const isAuthError =
event.errorType === "authentication" ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
if (isAuthError) {
toast.error("Authentication Failed", {
description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
description:
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
duration: 10000,
});
} else {
@@ -867,7 +876,11 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
@@ -877,7 +890,11 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -1198,7 +1215,11 @@ export function BoardView() {
description: feature.description,
});
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
// Clear justFinishedAt timestamp when manually verifying
persistFeatureUpdate(feature.id, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1264,9 +1285,11 @@ export function BoardView() {
}
// Move feature back to in_progress before sending follow-up
// Clear justFinishedAt timestamp since user is now interacting with it
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
justFinishedAt: undefined,
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
@@ -1626,7 +1649,7 @@ export function BoardView() {
return;
}
const featuresToStart = backlogFeatures.slice(0, availableSlots);
const featuresToStart = backlogFeatures.slice(0, 1);
for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp
@@ -1835,202 +1858,282 @@ export function BoardView() {
)}
</div>
{/* Kanban Card Detail Level Toggle */}
{/* Board Background & Detail Level Controls */}
{isMounted && (
<TooltipProvider>
<div
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
data-testid="kanban-detail-toggle"
>
<div className="flex items-center gap-2 ml-4">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
<Button
variant="outline"
size="sm"
onClick={() => setShowBoardBackgroundModal(true)}
className="h-8 px-2"
data-testid="board-background-button"
>
<Minimize2 className="w-4 h-4" />
</button>
<ImageIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="kanban-detail-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
>
<Minimize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
)}
</div>
{/* Kanban Columns */}
<div className="flex-1 overflow-x-auto px-4 pb-4">
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
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}
headerAction={
column.id === "verified" && columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setShowDeleteAllVerifiedDialog(true)}
data-testid="delete-all-verified-button"
>
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
{(() => {
// Get background settings for current project
const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings;
// Build background image style if image exists
const backgroundImageStyle = backgroundSettings.imagePath
? {
backgroundImage: `url(${
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(
currentProject?.path || ""
)}${
backgroundSettings.imageVersion
? `&v=${backgroundSettings.imageVersion}`
: ""
})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
: {};
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
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}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
setShowDeleteAllVerifiedDialog(true)
}
data-testid="delete-all-verified-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
</HotkeyButton>
)}
</div>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey = index === 9 ? "0" : String(index + 1);
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
</HotkeyButton>
)}
</div>
) : undefined
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
onForceStop={() => handleForceStopFeature(feature)}
onManualVerify={() => handleManualVerify(feature)}
onMoveBackToInProgress={() =>
handleMoveBackToInProgress(feature)
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
onForceStop={() =>
handleForceStopFeature(feature)
}
onManualVerify={() =>
handleManualVerify(feature)
}
onMoveBackToInProgress={() =>
handleMoveBackToInProgress(feature)
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
/>
);
})}
</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>
<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>
);
})()}
</div>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Add Feature Dialog */}
<Dialog
open={showAddDialog}

View File

@@ -18,6 +18,7 @@ import {
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
interface InterviewMessage {
id: string;
@@ -65,6 +66,7 @@ const INTERVIEW_QUESTIONS = [
export function InterviewView() {
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
useAppStore();
const { openFileBrowser } = useFileBrowser();
const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@@ -286,11 +288,13 @@ export function InterviewView() {
};
const handleSelectDirectory = async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
const selectedPath = await openFileBrowser({
title: "Select Base Directory",
description: "Choose the parent directory where your new project will be created",
});
if (!result.canceled && result.filePaths[0]) {
setProjectPath(result.filePaths[0]);
if (selectedPath) {
setProjectPath(selectedPath);
}
};

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, memo } from "react";
import { useState, useEffect, useMemo, memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
@@ -28,7 +28,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Feature, useAppStore } from "@/store/app-store";
import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store";
import {
GripVertical,
Edit,
@@ -56,6 +56,7 @@ import {
GitMerge,
ChevronDown,
ChevronUp,
Brain,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -73,6 +74,21 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === "none") return "";
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
};
return labels[level];
}
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
@@ -94,6 +110,14 @@ interface KanbanCardProps {
contextContent?: string;
/** Feature summary from agent completion */
summary?: string;
/** Opacity percentage (0-100) */
opacity?: number;
/** Whether to use glassmorphism (backdrop-blur) effect */
glassmorphism?: boolean;
/** Whether to show card borders */
cardBorderEnabled?: boolean;
/** Card border opacity percentage (0-100) */
cardBorderOpacity?: number;
}
export const KanbanCard = memo(function KanbanCard({
@@ -115,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
@@ -132,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({
kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === "detailed";
// Helper to check if "just finished" badge should be shown (within 2 minutes)
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
feature.status !== "waiting_approval" ||
feature.error
) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
return currentTime - finishedTime < twoMinutes;
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
// Update current time periodically to check if badge should be hidden
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
const timeRemaining = twoMinutes - (currentTime - finishedTime);
if (timeRemaining <= 0) {
// Already past 2 minutes
return;
}
// Update time every second to check if 2 minutes have passed
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => clearInterval(interval);
}, [feature.justFinishedAt, feature.status, currentTime]);
// Load context file for in_progress, waiting_approval, and verified features
useEffect(() => {
const loadContext = async () => {
@@ -168,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({
} else {
// Fallback to direct file read for backward compatibility
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
@@ -225,17 +291,48 @@ export const KanbanCard = memo(function KanbanCard({
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
return (
// Calculate border style based on enabled state and opacity
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
} else if (cardBorderOpacity !== 100) {
// Apply border opacity using color-mix to blend the border color with transparent
// The --border variable uses oklch format, so we use color-mix in oklch space
// Ensure border width is set (1px is the default Tailwind border width)
(borderStyle as Record<string, string>).borderWidth = "1px";
(
borderStyle as Record<string, string>
).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardElement = (
<Card
ref={setNodeRef}
style={style}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
"cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
// Apply border class when border is enabled and opacity is 100%
// When opacity is not 100%, we use inline styles for border color
// Skip border classes when animated border is active (isCurrentAutoTask)
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity === 100 &&
"border-border",
// When border is enabled but opacity is not 100%, we still need border width
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity !== 100 &&
"border",
// Remove default background when using opacity overlay
!isDragging && "bg-transparent",
// Remove default backdrop-blur-sm from Card component when glassmorphism is disabled
!glassmorphism && "backdrop-blur-[0px]!",
isDragging && "scale-105 shadow-lg",
// Error state border (only when not in progress)
feature.error &&
!isCurrentAutoTask &&
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
@@ -246,6 +343,16 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity - only affects background, not content */}
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Skip Tests indicator badge */}
{feature.skipTests && !feature.error && (
<div
@@ -276,6 +383,21 @@ export const KanbanCard = memo(function KanbanCard({
<span>Errored</span>
</div>
)}
{/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */}
{isJustFinished && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
"bg-green-500/20 border border-green-500/50 text-green-400 animate-pulse"
)}
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
<span>Fresh Baked</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
@@ -285,19 +407,23 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
// Position below other badges if present, otherwise use normal position
feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
<span className="truncate max-w-[80px]">
{feature.branchName?.replace("feature/", "")}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">{feature.branchName}</p>
<p className="font-mono text-xs break-all">
{feature.branchName}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -306,14 +432,19 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
(feature.skipTests || feature.error) && "pt-10",
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
)}
>
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
<span className="text-xs text-running-indicator font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
@@ -437,7 +568,9 @@ export const KanbanCard = memo(function KanbanCard({
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -448,6 +581,28 @@ export const KanbanCard = memo(function KanbanCard({
</div>
)}
{/* Model/Preset Info for Backlog Cards - Show in Detailed mode */}
{showAgentInfo && feature.status === "backlog" && (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1 text-cyan-400">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel)}
</span>
</div>
)}
</div>
</div>
)}
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
{/* Detailed mode: Show all agent info */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
@@ -509,7 +664,8 @@ export const KanbanCard = memo(function KanbanCard({
todo.status === "completed" &&
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-foreground-secondary"
todo.status === "pending" &&
"text-foreground-secondary"
)}
>
{todo.content}
@@ -822,9 +978,13 @@ export const KanbanCard = memo(function KanbanCard({
<Sparkles className="w-5 h-5 text-green-400" />
Implementation Summary
</DialogTitle>
<DialogDescription className="text-sm" title={feature.description || feature.summary || ""}>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
>
{(() => {
const displayText = feature.description || feature.summary || "No description";
const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
@@ -860,10 +1020,15 @@ export const KanbanCard = memo(function KanbanCard({
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the feature back to the backlog.
This will discard all changes made by the agent and move the
feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
Branch{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{feature.branchName}
</code>{" "}
will be deleted.
</span>
)}
<span className="block mt-2 text-red-400 font-medium">
@@ -895,4 +1060,11 @@ export const KanbanCard = memo(function KanbanCard({
</Dialog>
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
});

View File

@@ -12,6 +12,9 @@ interface KanbanColumnProps {
count: number;
children: ReactNode;
headerAction?: ReactNode;
opacity?: number; // Opacity percentage (0-100) - only affects background
showBorder?: boolean; // Whether to show column border
hideScrollbar?: boolean; // Whether to hide the column scrollbar
}
export const KanbanColumn = memo(function KanbanColumn({
@@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
<div
ref={setNodeRef}
className={cn(
"flex flex-col h-full rounded-lg bg-card backdrop-blur-sm border border-border transition-colors w-72",
isOver && "bg-accent"
"relative flex flex-col h-full rounded-lg transition-colors w-72",
showBorder && "border border-border"
)}
data-testid={`kanban-column-${id}`}
>
{/* Column Header */}
<div className="flex items-center gap-2 p-3 border-b border-border">
{/* Background layer with opacity - only this layer is affected by opacity */}
<div
className={cn(
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
isOver ? "bg-accent" : "bg-card"
)}
style={{ opacity: opacity / 100 }}
/>
{/* Column Header - positioned above the background */}
<div
className={cn(
"relative z-10 flex items-center gap-2 p-3",
showBorder && "border-b border-border"
)}
>
<div className={cn("w-3 h-3 rounded-full", color)} />
<h3 className="font-medium text-sm flex-1">{title}</h3>
{headerAction}
@@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
</span>
</div>
{/* Column Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{/* Column Content - positioned above the background */}
<div
className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
)}
>
{children}
</div>
</div>

View File

@@ -29,7 +29,8 @@ export type Theme =
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave";
| "synthwave"
| "red";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";

View File

@@ -2,9 +2,6 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
@@ -13,14 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAppStore } from "@/store/app-store";
import { useAppStore, type ThemeMode } from "@/store/app-store";
import { getElectronAPI, type Project } from "@/lib/electron";
import { initializeProject } from "@/lib/project-init";
import {
@@ -41,14 +31,22 @@ import {
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
import { NewProjectModal } from "@/components/new-project-modal";
import { getHttpApiClient } from "@/lib/http-api-client";
import type { StarterTemplate } from "@/lib/templates";
export function WelcomeView() {
const { projects, addProject, setCurrentProject, setCurrentView } =
useAppStore();
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
const {
projects,
trashedProjects,
currentProject,
upsertAndSetCurrentProject,
addProject,
setCurrentProject,
setCurrentView,
theme: globalTheme,
} = useAppStore();
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isOpening, setIsOpening] = useState(false);
const [showInitDialog, setShowInitDialog] = useState(false);
@@ -108,35 +106,14 @@ export function WelcomeView() {
return;
}
// Check if project already exists (by path) to preserve theme and other settings
const existingProject = projects.find((p) => p.path === path);
let project: Project;
if (existingProject) {
// Update existing project, preserving theme and other properties
project = {
...existingProject,
name, // Update name in case it changed
lastOpened: new Date().toISOString(),
};
// Update the project in the store (this will update the existing entry)
const updatedProjects = projects.map((p) =>
p.id === existingProject.id ? project : p
);
// We need to manually update projects since addProject would create a duplicate
useAppStore.setState({ projects: updatedProjects });
} else {
// Create new project
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
};
addProject(project);
}
setCurrentProject(project);
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
// Show initialization dialog if files were created
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
@@ -171,7 +148,13 @@ export function WelcomeView() {
setIsOpening(false);
}
},
[projects, addProject, setCurrentProject, analyzeProject]
[
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
analyzeProject,
]
);
const handleOpenProject = useCallback(async () => {
@@ -191,7 +174,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
const name =
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
await initializeAndOpenProject(path, name);
}
}
@@ -203,7 +187,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
const name =
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
await initializeAndOpenProject(path, name);
}
}
@@ -231,31 +216,24 @@ export function WelcomeView() {
);
const handleNewProject = () => {
setNewProjectName("");
setNewProjectPath("");
setShowNewProjectDialog(true);
setShowNewProjectModal(true);
};
const handleInteractiveMode = () => {
setCurrentView("interview");
};
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;
/**
* Create a blank project with just .automaker directory structure
*/
const handleCreateBlankProject = async (
projectName: string,
parentDir: string
) => {
setIsCreating(true);
try {
const api = getElectronAPI();
const projectPath = `${newProjectPath}/${newProjectName}`;
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.mkdir(projectPath);
@@ -274,7 +252,7 @@ export function WelcomeView() {
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${newProjectName}</project_name>
<project_name>${projectName}</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
@@ -297,24 +275,24 @@ export function WelcomeView() {
const project = {
id: `project-${Date.now()}`,
name: newProjectName,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectDialog(false);
setShowNewProjectModal(false);
toast.success("Project created", {
description: `Created ${newProjectName} with .automaker directory`,
description: `Created ${projectName} with .automaker directory`,
});
// Set init status to show the dialog
setInitStatus({
isNewProject: true,
createdFiles: initResult.createdFiles || [],
projectName: newProjectName,
projectName: projectName,
projectPath: projectPath,
});
setShowInitDialog(true);
@@ -328,6 +306,210 @@ export function WelcomeView() {
}
};
/**
* Create a project from a GitHub starter template
*/
const handleCreateFromTemplate = async (
template: StarterTemplate,
projectName: string,
parentDir: string
) => {
setIsCreating(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
// Clone the template repository
const cloneResult = await httpClient.templates.clone(
template.repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone template", {
description: cloneResult.error || "Unknown error occurred",
});
return;
}
const projectPath = cloneResult.projectPath;
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with template-specific info
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was created from the "${template.name}" starter template.
${template.description}
</overview>
<technology_stack>
${template.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")}
</technology_stack>
<core_capabilities>
${template.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")}
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success("Project created from template", {
description: `Created ${projectName} from ${template.name}`,
});
// Set init status to show the dialog
setInitStatus({
isNewProject: true,
createdFiles: initResult.createdFiles || [],
projectName: projectName,
projectPath: projectPath,
});
setShowInitDialog(true);
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
console.error("Failed to create project from template:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreating(false);
}
};
/**
* Create a project from a custom GitHub URL
*/
const handleCreateFromCustomUrl = async (
repoUrl: string,
projectName: string,
parentDir: string
) => {
setIsCreating(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
// Clone the repository
const cloneResult = await httpClient.templates.clone(
repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone repository", {
description: cloneResult.error || "Unknown error occurred",
});
return;
}
const projectPath = cloneResult.projectPath;
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with basic info
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was cloned from ${repoUrl}.
The AI agent will analyze the project structure.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success("Project created from repository", {
description: `Created ${projectName} from ${repoUrl}`,
});
// Set init status to show the dialog
setInitStatus({
isNewProject: true,
createdFiles: initResult.createdFiles || [],
projectName: projectName,
projectPath: projectPath,
});
setShowInitDialog(true);
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
console.error("Failed to create project from custom URL:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreating(false);
}
};
const recentProjects = [...projects]
.sort((a, b) => {
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
@@ -508,82 +690,15 @@ export function WelcomeView() {
</div>
</div>
{/* New Project Dialog */}
<Dialog
open={showNewProjectDialog}
onOpenChange={setShowNewProjectDialog}
>
<DialogContent
className="bg-card border-border"
data-testid="new-project-dialog"
>
<DialogHeader>
<DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogDescription className="text-muted-foreground">
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" className="text-foreground">
Project Name
</Label>
<Input
id="project-name"
placeholder="my-awesome-project"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
className="bg-input border-border text-foreground placeholder:text-muted-foreground"
data-testid="project-name-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-path" className="text-foreground">
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 bg-input border-border text-foreground placeholder:text-muted-foreground"
data-testid="project-path-input"
/>
<Button
variant="secondary"
onClick={handleSelectDirectory}
className="bg-secondary hover:bg-secondary/80 text-foreground border border-border"
data-testid="browse-directory"
>
Browse
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowNewProjectDialog(false)}
className="text-muted-foreground hover:text-foreground hover:bg-accent"
>
Cancel
</Button>
<HotkeyButton
onClick={handleCreateProject}
disabled={!newProjectName || !newProjectPath || isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showNewProjectDialog}
data-testid="confirm-create-project"
>
{isCreating ? "Creating..." : "Create Project"}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreating}
/>
{/* Project Initialization Dialog */}
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>

View File

@@ -0,0 +1,6 @@
/**
* Marketing mode flag
* When set to true, displays "https://automaker.app" with "maker" in theme color
*/
export const IS_MARKETING = process.env.NEXT_PUBLIC_IS_MARKETING === "true";

View File

@@ -5,6 +5,7 @@ import {
Eclipse,
Flame,
Ghost,
Heart,
Moon,
Radio,
Snowflake,
@@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
Icon: Radio,
testId: "synthwave-mode-button",
},
{
value: "red",
label: "Red",
Icon: Heart,
testId: "red-mode-button",
},
];

View File

@@ -3,8 +3,13 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserOptions {
title?: string;
description?: string;
}
interface FileBrowserContextValue {
openFileBrowser: () => Promise<string | null>;
openFileBrowser: (options?: FileBrowserOptions) => Promise<string | null>;
}
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
@@ -12,9 +17,11 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
const [dialogOptions, setDialogOptions] = useState<FileBrowserOptions>({});
const openFileBrowser = useCallback((): Promise<string | null> => {
const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
@@ -26,6 +33,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}, [resolver]);
const handleOpenChange = useCallback((open: boolean) => {
@@ -34,6 +42,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
setResolver(null);
}
setIsOpen(open);
if (!open) {
setDialogOptions({});
}
}, [resolver]);
return (
@@ -43,6 +54,8 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
open={isOpen}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
title={dialogOptions.title}
description={dialogOptions.description}
/>
</FileBrowserContext.Provider>
);
@@ -57,12 +70,15 @@ export function useFileBrowser() {
}
// Global reference for non-React code (like HttpApiClient)
let globalFileBrowserFn: (() => Promise<string | null>) | null = null;
let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise<string | null>) | null = null;
export function setGlobalFileBrowser(fn: () => Promise<string | null>) {
export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise<string | null>) {
globalFileBrowserFn = fn;
}
export function getGlobalFileBrowser() {
return globalFileBrowserFn;
}
// Export the options type for consumers
export type { FileBrowserOptions };

View File

@@ -33,7 +33,6 @@ import type {
} from "@/types/electron";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== "undefined") {
@@ -43,7 +42,6 @@ const getServerUrl = (): string => {
return "http://localhost:3008";
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
@@ -76,7 +74,10 @@ export class HttpApiClient implements ElectronAPI {
}
private connectWebSocket(): void {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
return;
}
@@ -103,7 +104,10 @@ export class HttpApiClient implements ElectronAPI {
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
console.error("[HttpApiClient] Failed to parse WebSocket message:", error);
console.error(
"[HttpApiClient] Failed to parse WebSocket message:",
error
);
}
};
@@ -130,7 +134,10 @@ export class HttpApiClient implements ElectronAPI {
}
}
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
private subscribeToEvent(
type: EventType,
callback: EventCallback
): () => void {
if (!this.eventCallbacks.has(type)) {
this.eventCallbacks.set(type, new Set());
}
@@ -196,7 +203,9 @@ export class HttpApiClient implements ElectronAPI {
return result.status === "ok" ? "pong" : "error";
}
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
async openExternalLink(
url: string
): Promise<{ success: boolean; error?: string }> {
// Open in new tab
window.open(url, "_blank", "noopener,noreferrer");
return { success: true };
@@ -301,7 +310,9 @@ export class HttpApiClient implements ElectronAPI {
async getPath(name: string): Promise<string> {
// Server provides data directory
if (name === "userData") {
const result = await this.get<{ dataDir: string }>("/api/health/detailed");
const result = await this.get<{ dataDir: string }>(
"/api/health/detailed"
);
return result.dataDir || "/data";
}
return `/data/${name}`;
@@ -313,7 +324,32 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath?: string
): Promise<SaveImageResult> {
return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
return this.post("/api/fs/save-image", {
data,
filename,
mimeType,
projectPath,
});
}
async saveBoardBackground(
data: string,
filename: string,
mimeType: string,
projectPath: string
): Promise<{ success: boolean; path?: string; error?: string }> {
return this.post("/api/fs/save-board-background", {
data,
filename,
mimeType,
projectPath,
});
}
async deleteBoardBackground(
projectPath: string
): Promise<{ success: boolean; error?: string }> {
return this.post("/api/fs/delete-board-background", { projectPath });
}
// CLI checks - server-side
@@ -444,14 +480,19 @@ export class HttpApiClient implements ElectronAPI {
output?: string;
}> => this.post("/api/setup/auth-claude"),
authCodex: (apiKey?: string): Promise<{
authCodex: (
apiKey?: string
): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}> => this.post("/api/setup/auth-codex", { apiKey }),
storeApiKey: (provider: string, apiKey: string): Promise<{
storeApiKey: (
provider: string,
apiKey: string
): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
@@ -463,7 +504,9 @@ export class HttpApiClient implements ElectronAPI {
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
configureCodexMcp: (projectPath: string): Promise<{
configureCodexMcp: (
projectPath: string
): Promise<{
success: boolean;
configPath?: string;
error?: string;
@@ -496,8 +539,11 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/features/get", { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post("/api/features/update", { projectPath, featureId, updates }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
) => this.post("/api/features/update", { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
@@ -514,8 +560,16 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/run-feature", {
projectPath,
featureId,
useWorktrees,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string) =>
@@ -539,7 +593,10 @@ export class HttpApiClient implements ElectronAPI {
commitFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent("auto-mode:event", callback as EventCallback);
return this.subscribeToEvent(
"auto-mode:event",
callback as EventCallback
);
},
};
@@ -558,7 +615,11 @@ export class HttpApiClient implements ElectronAPI {
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }),
this.post("/api/worktree/file-diff", {
projectPath,
featureId,
filePath,
}),
};
// Git API
@@ -576,20 +637,30 @@ export class HttpApiClient implements ElectronAPI {
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent("suggestions:event", callback as EventCallback);
return this.subscribeToEvent(
"suggestions:event",
callback as EventCallback
);
},
};
// Spec Regeneration API
specRegeneration: SpecRegenerationAPI = {
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
create: (
projectPath: string,
projectOverview: string,
generateFeatures?: boolean
) =>
this.post("/api/spec-regeneration/create", {
projectPath,
projectOverview,
generateFeatures,
}),
generate: (projectPath: string, projectDefinition: string) =>
this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
this.post("/api/spec-regeneration/generate", {
projectPath,
projectDefinition,
}),
generateFeatures: (projectPath: string) =>
this.post("/api/spec-regeneration/generate-features", { projectPath }),
stop: () => this.post("/api/spec-regeneration/stop"),
@@ -636,7 +707,10 @@ export class HttpApiClient implements ElectronAPI {
// Agent API
agent = {
start: (sessionId: string, workingDirectory?: string): Promise<{
start: (
sessionId: string,
workingDirectory?: string
): Promise<{
success: boolean;
messages?: Message[];
error?: string;
@@ -648,9 +722,16 @@ export class HttpApiClient implements ElectronAPI {
workingDirectory?: string,
imagePaths?: string[]
): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }),
this.post("/api/agent/send", {
sessionId,
message,
workingDirectory,
imagePaths,
}),
getHistory: (sessionId: string): Promise<{
getHistory: (
sessionId: string
): Promise<{
success: boolean;
messages?: Message[];
isRunning?: boolean;
@@ -668,9 +749,26 @@ export class HttpApiClient implements ElectronAPI {
},
};
// Templates API
templates = {
clone: (
repoUrl: string,
projectName: string,
parentDir: string
): Promise<{
success: boolean;
projectPath?: string;
projectName?: string;
error?: string;
}> =>
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
};
// Sessions API
sessions = {
list: (includeArchived?: boolean): Promise<{
list: (
includeArchived?: boolean
): Promise<{
success: boolean;
sessions?: SessionListItem[];
error?: string;
@@ -700,13 +798,19 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; error?: string }> =>
this.put(`/api/sessions/${sessionId}`, { name, tags }),
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
archive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/archive`, {}),
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
unarchive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
delete: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
this.httpDelete(`/api/sessions/${sessionId}`),
};
}

View File

@@ -0,0 +1,62 @@
/**
* Starter Kit Templates
*
* Define GitHub templates that users can clone when creating new projects.
*/
export interface StarterTemplate {
id: string;
name: string;
description: string;
repoUrl: string;
techStack: string[];
features: string[];
category: "fullstack" | "frontend" | "backend" | "ai" | "other";
author: string;
}
export const starterTemplates: StarterTemplate[] = [
{
id: "agentic-jumpstart",
name: "Agentic Jumpstart",
description: "A starter template for building agentic AI applications with a pre-configured development environment including database setup, Docker support, and TypeScript configuration.",
repoUrl: "https://github.com/webdevcody/agentic-jumpstart-starter-kit",
techStack: ["TypeScript", "Vite", "Drizzle ORM", "Docker", "PostCSS"],
features: [
"Pre-configured VS Code settings",
"Docker Compose setup",
"Database migrations with Drizzle",
"Type-safe development",
"Environment setup with .env.example"
],
category: "ai",
author: "webdevcody"
},
{
id: "full-stack-campus",
name: "Full Stack Campus",
description: "A feature-driven development template for building community platforms. Includes authentication, Stripe payments, file uploads, and real-time features using TanStack Start.",
repoUrl: "https://github.com/webdevcody/full-stack-campus",
techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"],
features: [
"Community posts with comments and reactions",
"User profiles and portfolios",
"Calendar event management",
"Direct messaging",
"Member discovery directory",
"Real-time notifications",
"Tiered subscriptions (free/basic/pro)",
"File uploads with presigned URLs"
],
category: "fullstack",
author: "webdevcody"
}
];
export function getTemplateById(id: string): StarterTemplate | undefined {
return starterTemplates.find(t => t.id === id);
}
export function getTemplatesByCategory(category: StarterTemplate["category"]): StarterTemplate[] {
return starterTemplates.filter(t => t.category === category);
}

View File

@@ -27,7 +27,8 @@ export type ThemeMode =
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave";
| "synthwave"
| "red";
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
@@ -39,23 +40,39 @@ export interface ApiKeys {
// Keyboard Shortcut with optional modifiers
export interface ShortcutKey {
key: string; // The main key (e.g., "K", "N", "1")
shift?: boolean; // Shift key modifier
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
alt?: boolean; // Alt/Option key modifier
key: string; // The main key (e.g., "K", "N", "1")
shift?: boolean; // Shift key modifier
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
alt?: boolean; // Alt/Option key modifier
}
// Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string): ShortcutKey {
const parts = shortcut.split("+").map(p => p.trim());
const parts = shortcut.split("+").map((p) => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] };
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
for (let i = 0; i < parts.length - 1; i++) {
const modifier = parts[i].toLowerCase();
if (modifier === "shift") result.shift = true;
else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true;
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true;
else if (
modifier === "cmd" ||
modifier === "ctrl" ||
modifier === "win" ||
modifier === "super" ||
modifier === "⌘" ||
modifier === "^" ||
modifier === "⊞" ||
modifier === "◆"
)
result.cmdCtrl = true;
else if (
modifier === "alt" ||
modifier === "opt" ||
modifier === "option" ||
modifier === "⌥"
)
result.alt = true;
}
return result;
@@ -67,36 +84,49 @@ export function formatShortcut(shortcut: string, forDisplay = false): string {
const parts: string[] = [];
// Prefer User-Agent Client Hints when available; fall back to legacy
const platform: 'darwin' | 'win32' | 'linux' = (() => {
if (typeof navigator === 'undefined') return 'linux';
const platform: "darwin" | "win32" | "linux" = (() => {
if (typeof navigator === "undefined") return "linux";
const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } })
.userAgentData?.platform?.toLowerCase?.();
const uaPlatform = (
navigator as Navigator & { userAgentData?: { platform?: string } }
).userAgentData?.platform?.toLowerCase?.();
const legacyPlatform = navigator.platform?.toLowerCase?.();
const platformString = uaPlatform || legacyPlatform || '';
const platformString = uaPlatform || legacyPlatform || "";
if (platformString.includes('mac')) return 'darwin';
if (platformString.includes('win')) return 'win32';
return 'linux';
if (platformString.includes("mac")) return "darwin";
if (platformString.includes("win")) return "win32";
return "linux";
})();
// Primary modifier - OS-specific
if (parsed.cmdCtrl) {
if (forDisplay) {
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
parts.push(
platform === "darwin" ? "⌘" : platform === "win32" ? "⊞" : "◆"
);
} else {
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
parts.push(
platform === "darwin" ? "Cmd" : platform === "win32" ? "Win" : "Super"
);
}
}
// Alt/Option
if (parsed.alt) {
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt'));
parts.push(
forDisplay
? platform === "darwin"
? "⌥"
: "Alt"
: platform === "darwin"
? "Opt"
: "Alt"
);
}
// Shift
if (parsed.shift) {
parts.push(forDisplay ? '⇧' : 'Shift');
parts.push(forDisplay ? "⇧" : "Shift");
}
parts.push(parsed.key.toUpperCase());
@@ -139,22 +169,22 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
context: "C",
settings: "S",
profiles: "M",
// UI
toggleSidebar: "`",
// Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
// This is intentional as they are context-specific and only active in their respective views
addFeature: "N", // Only active in board view
addContextFile: "N", // Only active in context view
startNext: "G", // Only active in board view
newSession: "N", // Only active in agent view
openProject: "O", // Global shortcut
projectPicker: "P", // Global shortcut
cyclePrevProject: "Q", // Global shortcut
cycleNextProject: "E", // Global shortcut
addProfile: "N", // Only active in profiles view
addFeature: "N", // Only active in board view
addContextFile: "N", // Only active in context view
startNext: "G", // Only active in board view
newSession: "N", // Only active in agent view
openProject: "O", // Global shortcut
projectPicker: "P", // Global shortcut
cyclePrevProject: "Q", // Global shortcut
cycleNextProject: "E", // Global shortcut
addProfile: "N", // Only active in profiles view
};
export interface ImageAttachment {
@@ -245,6 +275,7 @@ export interface Feature {
// Worktree info - set when a feature is being worked on in an isolated git worktree
worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
}
// File tree node for project analysis
@@ -301,10 +332,13 @@ export interface AppState {
chatHistoryOpen: boolean;
// Auto Mode (per-project state, keyed by project ID)
autoModeByProject: Record<string, {
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
}>;
autoModeByProject: Record<
string,
{
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
}
>;
autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks
@@ -332,8 +366,49 @@ export interface AppState {
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
// Board Background Settings (per-project, keyed by project path)
boardBackgroundByProject: Record<
string,
{
imagePath: string | null; // Path to background image in .automaker directory
imageVersion?: number; // Timestamp to bust browser cache when image is updated
cardOpacity: number; // Opacity of cards (0-100)
columnOpacity: number; // Opacity of columns (0-100)
columnBorderEnabled: boolean; // Whether to show column borders
cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards
cardBorderEnabled: boolean; // Whether to show card borders
cardBorderOpacity: number; // Opacity of card borders (0-100)
hideScrollbar: boolean; // Whether to hide the board scrollbar
}
>;
// Theme Preview (for hover preview in theme selectors)
previewTheme: ThemeMode | null;
}
// Default background settings for board backgrounds
export const defaultBackgroundSettings: {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
} = {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
export interface AutoModeActivity {
id: string;
featureId: string;
@@ -364,6 +439,11 @@ export interface AppActions {
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
setCurrentProject: (project: Project | null) => void;
upsertAndSetCurrentProject: (
path: string,
name: string,
theme?: ThemeMode
) => Project; // Upsert project by path and set as current
reorderProjects: (oldIndex: number, newIndex: number) => void;
cyclePrevProject: () => void; // Cycle back through project history (Q)
cycleNextProject: () => void; // Cycle forward through project history (E)
@@ -377,7 +457,8 @@ export interface AppActions {
// Theme actions
setTheme: (theme: ThemeMode) => void;
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project or global)
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set)
setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear)
// Feature actions
setFeatures: (features: Feature[]) => void;
@@ -413,7 +494,10 @@ export interface AppActions {
addRunningTask: (projectId: string, taskId: string) => void;
removeRunningTask: (projectId: string, taskId: string) => void;
clearRunningTasks: (projectId: string) => void;
getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] };
getAutoModeState: (projectId: string) => {
isRunning: boolean;
runningTasks: string[];
};
addAutoModeActivity: (
activity: Omit<AutoModeActivity, "id" | "timestamp">
) => void;
@@ -452,9 +536,33 @@ export interface AppActions {
clearAnalysis: () => void;
// Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
setLastSelectedSession: (
projectPath: string,
sessionId: string | null
) => void;
getLastSelectedSession: (projectPath: string) => string | null;
// Board Background actions
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
setCardOpacity: (projectPath: string, opacity: number) => void;
setColumnOpacity: (projectPath: string, opacity: number) => void;
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
getBoardBackground: (projectPath: string) => {
imagePath: string | null;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
};
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
setHideScrollbar: (projectPath: string, hide: boolean) => void;
clearBoardBackground: (projectPath: string) => void;
// Reset
reset: () => void;
}
@@ -464,7 +572,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
{
id: "profile-heavy-task",
name: "Heavy Task",
description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
description:
"Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
model: "opus",
thinkingLevel: "ultrathink",
provider: "claude",
@@ -474,7 +583,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
{
id: "profile-balanced",
name: "Balanced",
description: "Claude Sonnet with medium thinking for typical development tasks.",
description:
"Claude Sonnet with medium thinking for typical development tasks.",
model: "sonnet",
thinkingLevel: "medium",
provider: "claude",
@@ -546,6 +656,8 @@ const initialState: AppState = {
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -671,7 +783,9 @@ export const useAppStore = create<AppState & AppActions>()(
// Add to project history (MRU order)
const currentHistory = get().projectHistory;
// Remove this project if it's already in history
const filteredHistory = currentHistory.filter((id) => id !== project.id);
const filteredHistory = currentHistory.filter(
(id) => id !== project.id
);
// Add to the front (most recent)
const newHistory = [project.id, ...filteredHistory];
// Reset history index to 0 (current project)
@@ -681,6 +795,58 @@ export const useAppStore = create<AppState & AppActions>()(
}
},
upsertAndSetCurrentProject: (path, name, theme) => {
const {
projects,
trashedProjects,
currentProject,
theme: globalTheme,
} = get();
const existingProject = projects.find((p) => p.path === path);
let project: Project;
if (existingProject) {
// Update existing project, preserving theme and other properties
project = {
...existingProject,
name, // Update name in case it changed
lastOpened: new Date().toISOString(),
};
// Update the project in the store
const updatedProjects = projects.map((p) =>
p.id === existingProject.id ? project : p
);
set({ projects: updatedProjects });
} else {
// Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
// Then fall back to provided theme, then current project theme, then global theme
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
theme ||
trashedProject?.theme ||
currentProject?.theme ||
globalTheme;
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
theme: effectiveTheme,
};
// Add the new project to the store
set({
projects: [
...projects,
{ ...project, lastOpened: new Date().toISOString() },
],
});
}
// Set as current project (this will also update history and view)
get().setCurrentProject(project);
return project;
},
cyclePrevProject: () => {
const { projectHistory, projectHistoryIndex, projects } = get();
@@ -711,7 +877,7 @@ export const useAppStore = create<AppState & AppActions>()(
currentProject: targetProject,
projectHistory: validHistory,
projectHistoryIndex: newIndex,
currentView: "board"
currentView: "board",
});
}
},
@@ -736,9 +902,8 @@ export const useAppStore = create<AppState & AppActions>()(
if (currentIndex === -1) currentIndex = 0;
// Move to the previous index (going forward = lower index), wrapping around
const newIndex = currentIndex <= 0
? validHistory.length - 1
: currentIndex - 1;
const newIndex =
currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1;
const targetProjectId = validHistory[newIndex];
const targetProject = projects.find((p) => p.id === targetProjectId);
@@ -748,7 +913,7 @@ export const useAppStore = create<AppState & AppActions>()(
currentProject: targetProject,
projectHistory: validHistory,
projectHistoryIndex: newIndex,
currentView: "board"
currentView: "board",
});
}
},
@@ -800,6 +965,11 @@ export const useAppStore = create<AppState & AppActions>()(
},
getEffectiveTheme: () => {
// If preview theme is set, use it (for hover preview)
const previewTheme = get().previewTheme;
if (previewTheme) {
return previewTheme;
}
const currentProject = get().currentProject;
// If current project has a theme set, use it
if (currentProject?.theme) {
@@ -809,6 +979,8 @@ export const useAppStore = create<AppState & AppActions>()(
return get().theme;
},
setPreviewTheme: (theme) => set({ previewTheme: theme }),
// Feature actions
setFeatures: (features) => set({ features }),
@@ -960,7 +1132,10 @@ export const useAppStore = create<AppState & AppActions>()(
// Auto Mode actions (per-project)
setAutoModeRunning: (projectId, running) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
set({
autoModeByProject: {
...current,
@@ -971,7 +1146,10 @@ export const useAppStore = create<AppState & AppActions>()(
addRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
if (!projectState.runningTasks.includes(taskId)) {
set({
autoModeByProject: {
@@ -987,13 +1165,18 @@ export const useAppStore = create<AppState & AppActions>()(
removeRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
set({
autoModeByProject: {
...current,
[projectId]: {
...projectState,
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
runningTasks: projectState.runningTasks.filter(
(id) => id !== taskId
),
},
},
});
@@ -1001,7 +1184,10 @@ export const useAppStore = create<AppState & AppActions>()(
clearRunningTasks: (projectId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
set({
autoModeByProject: {
...current,
@@ -1116,7 +1302,9 @@ export const useAppStore = create<AppState & AppActions>()(
const current = get().lastSelectedSessionByProject;
if (sessionId === null) {
// Remove the entry for this project
const { [projectPath]: _, ...rest } = current;
const rest = Object.fromEntries(
Object.entries(current).filter(([key]) => key !== projectPath)
);
set({ lastSelectedSessionByProject: rest });
} else {
set({
@@ -1131,6 +1319,151 @@ export const useAppStore = create<AppState & AppActions>()(
getLastSelectedSession: (projectPath) => {
return get().lastSelectedSessionByProject[projectPath] || null;
},
// Board Background actions
setBoardBackground: (projectPath, imagePath) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
imagePath,
// Update imageVersion timestamp to bust browser cache when image changes
imageVersion: imagePath ? Date.now() : undefined,
},
},
});
},
setCardOpacity: (projectPath, opacity) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
cardOpacity: opacity,
},
},
});
},
setColumnOpacity: (projectPath, opacity) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
columnOpacity: opacity,
},
},
});
},
getBoardBackground: (projectPath) => {
const settings = get().boardBackgroundByProject[projectPath];
return settings || defaultBackgroundSettings;
},
setColumnBorderEnabled: (projectPath, enabled) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
columnBorderEnabled: enabled,
},
},
});
},
setCardGlassmorphism: (projectPath, enabled) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
cardGlassmorphism: enabled,
},
},
});
},
setCardBorderEnabled: (projectPath, enabled) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
cardBorderEnabled: enabled,
},
},
});
},
setCardBorderOpacity: (projectPath, opacity) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
cardBorderOpacity: opacity,
},
},
});
},
setHideScrollbar: (projectPath, hide) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
hideScrollbar: hide,
},
},
});
},
clearBoardBackground: (projectPath) => {
const current = get().boardBackgroundByProject;
const existing = current[projectPath] || defaultBackgroundSettings;
set({
boardBackgroundByProject: {
...current,
[projectPath]: {
...existing,
imagePath: null, // Only clear the image, preserve other settings
imageVersion: undefined, // Clear version when clearing image
},
},
});
},
// Reset
reset: () => set(initialState),
}),
@@ -1178,6 +1511,8 @@ export const useAppStore = create<AppState & AppActions>()(
aiProfiles: state.aiProfiles,
chatSessions: state.chatSessions,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
// Board background settings
boardBackgroundByProject: state.boardBackgroundByProject,
}),
}
)

View File

@@ -357,6 +357,50 @@
.download-subtitle a:hover {
text-decoration: underline;
}
/* Video Demo Section */
.video-demo {
margin-top: 3rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
padding: 0 2rem;
}
.video-container {
position: relative;
margin-left: -2rem;
margin-right: -2rem;
width: calc(100% + 4rem);
padding-bottom: 66.67%; /* Taller aspect ratio to show more height */
background: rgba(30, 41, 59, 0.5);
border-radius: 1rem;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.video-container video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
@media (max-width: 768px) {
.video-demo {
margin-top: 2rem;
padding: 0 1rem;
}
.video-container {
margin-left: -1rem;
margin-right: -1rem;
width: calc(100% + 2rem);
}
}
</style>
</head>
<body>
@@ -382,6 +426,15 @@
<a href="https://github.com/AutoMaker-Org/automaker#getting-started" class="btn btn-secondary" target="_blank">Get Started</a>
</div>
<div class="video-demo">
<div class="video-container">
<video controls autoplay muted loop playsinline>
<source src="https://releases.automaker.app/demo.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
</div>
<div class="download-section" id="downloadSection" style="display: none;">
<p class="download-label">Download for your platform:</p>
<div class="download-buttons">
@@ -492,7 +545,7 @@
<script>
(function() {
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.dev/releases.json';
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.app/releases.json';
async function loadLatestRelease() {
try {

View File

@@ -321,7 +321,7 @@
<script>
(function() {
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.dev/releases.json';
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.app/releases.json';
const platformIcons = {
windows: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/></svg>',

View File

@@ -29,8 +29,10 @@ import { createModelsRoutes } from "./routes/models.js";
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { createWorkspaceRoutes } from "./routes/workspace.js";
import { createTemplatesRoutes } from "./routes/templates.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
// Load environment variables
dotenv.config();
@@ -86,6 +88,7 @@ const events: EventEmitter = createEventEmitter();
// Create services
const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events);
// Initialize services
(async () => {
@@ -103,15 +106,16 @@ app.use("/api/fs", createFsRoutes(events));
app.use("/api/agent", createAgentRoutes(agentService, events));
app.use("/api/sessions", createSessionsRoutes(agentService));
app.use("/api/features", createFeaturesRoutes(featureLoader));
app.use("/api/auto-mode", createAutoModeRoutes(events));
app.use("/api/auto-mode", createAutoModeRoutes(autoModeService));
app.use("/api/worktree", createWorktreeRoutes());
app.use("/api/git", createGitRoutes());
app.use("/api/setup", createSetupRoutes());
app.use("/api/suggestions", createSuggestionsRoutes(events));
app.use("/api/models", createModelsRoutes());
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
app.use("/api/running-agents", createRunningAgentsRoutes());
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
app.use("/api/workspace", createWorkspaceRoutes());
app.use("/api/templates", createTemplatesRoutes());
// Create HTTP server
const server = createServer(app);

View File

@@ -5,12 +5,10 @@
*/
import { Router, type Request, type Response } from "express";
import type { EventEmitter } from "../lib/events.js";
import { AutoModeService } from "../services/auto-mode-service.js";
import type { AutoModeService } from "../services/auto-mode-service.js";
export function createAutoModeRoutes(events: EventEmitter): Router {
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
const autoModeService = new AutoModeService(events);
// Start auto mode loop
router.post("/start", async (req: Request, res: Response) => {

View File

@@ -500,6 +500,86 @@ export function createFsRoutes(_events: EventEmitter): Router {
}
});
// Save board background image to .automaker/board directory
router.post("/save-board-background", async (req: Request, res: Response) => {
try {
const { data, filename, mimeType, projectPath } = req.body as {
data: string;
filename: string;
mimeType: string;
projectPath: string;
};
if (!data || !filename || !projectPath) {
res.status(400).json({
success: false,
error: "data, filename, and projectPath are required",
});
return;
}
// Create .automaker/board directory if it doesn't exist
const boardDir = path.join(projectPath, ".automaker", "board");
await fs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Use a fixed filename for the board background (overwrite previous)
const ext = path.extname(filename) || ".png";
const uniqueFilename = `background${ext}`;
const filePath = path.join(boardDir, uniqueFilename);
// Write file
await fs.writeFile(filePath, buffer);
// Add project path to allowed paths if not already
addAllowedPath(projectPath);
// Return the relative path for storage
const relativePath = `.automaker/board/${uniqueFilename}`;
res.json({ success: true, path: relativePath });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Delete board background image
router.post("/delete-board-background", async (req: Request, res: Response) => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({
success: false,
error: "projectPath is required",
});
return;
}
const boardDir = path.join(projectPath, ".automaker", "board");
try {
// Try to remove all files in the board directory
const files = await fs.readdir(boardDir);
for (const file of files) {
if (file.startsWith("background")) {
await fs.unlink(path.join(boardDir, file));
}
}
} catch {
// Directory may not exist, that's fine
}
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Browse directories for file picker
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
router.post("/browse", async (req: Request, res: Response) => {

View File

@@ -3,32 +3,22 @@
*/
import { Router, type Request, type Response } from "express";
import path from "path";
import type { AutoModeService } from "../services/auto-mode-service.js";
interface RunningAgent {
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}
// In-memory tracking of running agents (shared with auto-mode service via reference)
const runningAgentsMap = new Map<string, RunningAgent>();
let autoLoopRunning = false;
export function createRunningAgentsRoutes(): Router {
export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
const router = Router();
// Get all running agents
router.get("/", async (_req: Request, res: Response) => {
try {
const runningAgents = Array.from(runningAgentsMap.values());
const runningAgents = autoModeService.getRunningAgents();
const status = autoModeService.getStatus();
res.json({
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning,
autoLoopRunning: status.autoLoopRunning,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -38,33 +28,3 @@ export function createRunningAgentsRoutes(): Router {
return router;
}
// Export functions to update running agents from other services
export function registerRunningAgent(
featureId: string,
projectPath: string,
isAutoMode: boolean
): void {
runningAgentsMap.set(featureId, {
featureId,
projectPath,
projectName: path.basename(projectPath),
isAutoMode,
});
}
export function unregisterRunningAgent(featureId: string): void {
runningAgentsMap.delete(featureId);
}
export function setAutoLoopRunning(running: boolean): void {
autoLoopRunning = running;
}
export function getRunningAgentsCount(): number {
return runningAgentsMap.size;
}
export function isAgentRunning(featureId: string): boolean {
return runningAgentsMap.has(featureId);
}

View File

@@ -0,0 +1,181 @@
/**
* Templates routes
* Provides API for cloning GitHub starter templates
*/
import { Router, type Request, type Response } from "express";
import { spawn } from "child_process";
import path from "path";
import fs from "fs/promises";
import { addAllowedPath } from "../lib/security.js";
export function createTemplatesRoutes(): Router {
const router = Router();
/**
* Clone a GitHub template to a new project directory
* POST /api/templates/clone
* Body: { repoUrl: string, projectName: string, parentDir: string }
*/
router.post("/clone", async (req: Request, res: Response) => {
try {
const { repoUrl, projectName, parentDir } = req.body as {
repoUrl: string;
projectName: string;
parentDir: string;
};
// Validate inputs
if (!repoUrl || !projectName || !parentDir) {
res.status(400).json({
success: false,
error: "repoUrl, projectName, and parentDir are required",
});
return;
}
// Validate repo URL is a valid GitHub URL
const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
if (!githubUrlPattern.test(repoUrl)) {
res.status(400).json({
success: false,
error: "Invalid GitHub repository URL",
});
return;
}
// Sanitize project name (allow alphanumeric, dash, underscore)
const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-");
if (sanitizedName !== projectName) {
console.log(
`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`
);
}
// Build full project path
const projectPath = path.join(parentDir, sanitizedName);
const resolvedParent = path.resolve(parentDir);
const resolvedProject = path.resolve(projectPath);
const relativePath = path.relative(resolvedParent, resolvedProject);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return res.status(400).json({
success: false,
error: "Invalid project name; potential path traversal attempt.",
});
}
// Check if directory already exists
try {
await fs.access(projectPath);
res.status(400).json({
success: false,
error: `Directory "${sanitizedName}" already exists in ${parentDir}`,
});
return;
} catch {
// Directory doesn't exist, which is what we want
}
// Ensure parent directory exists
try {
await fs.mkdir(parentDir, { recursive: true });
} catch (error) {
console.error("[Templates] Failed to create parent directory:", error);
res.status(500).json({
success: false,
error: "Failed to create parent directory",
});
return;
}
console.log(`[Templates] Cloning ${repoUrl} to ${projectPath}`);
// Clone the repository
const cloneResult = await new Promise<{
success: boolean;
error?: string;
}>((resolve) => {
const gitProcess = spawn("git", ["clone", repoUrl, projectPath], {
cwd: parentDir,
});
let stderr = "";
gitProcess.stderr.on("data", (data) => {
stderr += data.toString();
});
gitProcess.on("close", (code) => {
if (code === 0) {
resolve({ success: true });
} else {
resolve({
success: false,
error: stderr || `Git clone failed with code ${code}`,
});
}
});
gitProcess.on("error", (error) => {
resolve({
success: false,
error: `Failed to spawn git: ${error.message}`,
});
});
});
if (!cloneResult.success) {
res.status(500).json({
success: false,
error: cloneResult.error || "Failed to clone repository",
});
return;
}
// Remove .git directory to start fresh
try {
const gitDir = path.join(projectPath, ".git");
await fs.rm(gitDir, { recursive: true, force: true });
console.log("[Templates] Removed .git directory");
} catch (error) {
console.warn("[Templates] Could not remove .git directory:", error);
// Continue anyway - not critical
}
// Initialize a fresh git repository
await new Promise<void>((resolve) => {
const gitInit = spawn("git", ["init"], {
cwd: projectPath,
});
gitInit.on("close", () => {
console.log("[Templates] Initialized fresh git repository");
resolve();
});
gitInit.on("error", () => {
console.warn("[Templates] Could not initialize git");
resolve();
});
});
// Add to allowed paths
addAllowedPath(projectPath);
console.log(`[Templates] Successfully cloned template to ${projectPath}`);
res.json({
success: true,
projectPath,
projectName: sanitizedName,
});
} catch (error) {
console.error("[Templates] Clone error:", error);
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -9,7 +9,11 @@
* - Verification and merge workflows
*/
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
import {
query,
AbortError,
type Options,
} from "@anthropic-ai/claude-agent-sdk";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
@@ -20,11 +24,13 @@ const execAsync = promisify(exec);
interface Feature {
id: string;
title: string;
category: string;
description: string;
steps?: string[];
status: string;
priority?: number;
spec?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
[key: string]: unknown; // Allow additional fields
}
interface RunningFeature {
@@ -85,7 +91,11 @@ export class AutoModeService {
}
private async runAutoLoop(): Promise<void> {
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
while (
this.autoLoopRunning &&
this.autoLoopAbortController &&
!this.autoLoopAbortController.signal.aborted
) {
try {
// Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
@@ -94,7 +104,9 @@ export class AutoModeService {
}
// Load pending features
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
const pendingFeatures = await this.loadPendingFeatures(
this.config!.projectPath
);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_complete", {
@@ -105,7 +117,9 @@ export class AutoModeService {
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id)
);
if (nextFeature) {
// Start feature execution in background
@@ -164,7 +178,11 @@ export class AutoModeService {
// Setup worktree if enabled
if (useWorktrees) {
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
const workDir = worktreePath || projectPath;
@@ -183,7 +201,11 @@ export class AutoModeService {
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
feature: {
id: featureId,
title: "Loading...",
description: "Feature is starting",
},
});
try {
@@ -199,20 +221,38 @@ export class AutoModeService {
// Build the prompt
const prompt = this.buildFeaturePrompt(feature);
// Run the agent
await this.runAgent(workDir, featureId, prompt, abortController);
// Extract image paths from feature
const imagePaths = this.extractImagePaths(feature.imagePaths, workDir);
// Run the agent with image paths
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
imagePaths
);
// Mark as waiting_approval for user review
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
await this.updateFeatureStatus(
projectPath,
featureId,
"waiting_approval"
);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`,
message: `Feature completed in ${Math.round(
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
)}s`,
projectPath,
});
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
if (
error instanceof AbortError ||
(error as Error)?.name === "AbortError"
) {
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: false,
@@ -221,9 +261,10 @@ export class AutoModeService {
});
} else {
const errorMessage = (error as Error).message || "Unknown error";
const isAuthError = errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
const isAuthError =
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "backlog");
@@ -280,7 +321,12 @@ export class AutoModeService {
if (hasContext) {
// Load previous context and continue
const context = await fs.readFile(contextPath, "utf-8");
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
return this.executeFeatureWithContext(
projectPath,
featureId,
context,
useWorktrees
);
}
// No context, start fresh
@@ -303,7 +349,12 @@ export class AutoModeService {
const abortController = new AbortController();
// Check if worktree exists
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
@@ -366,14 +417,28 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: feature || { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) },
feature: feature || {
id: featureId,
title: "Follow-up",
description: prompt.substring(0, 100),
},
});
try {
await this.runAgent(workDir, featureId, fullPrompt, abortController, imagePaths);
await this.runAgent(
workDir,
featureId,
fullPrompt,
abortController,
imagePaths
);
// Mark as waiting_approval for user review
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
await this.updateFeatureStatus(
projectPath,
featureId,
"waiting_approval"
);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
@@ -397,8 +462,16 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Verify a feature's implementation
*/
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
async verifyFeature(
projectPath: string,
featureId: string
): Promise<boolean> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
@@ -417,7 +490,8 @@ Address the follow-up instructions above. Review the previous work and make the
];
let allPassed = true;
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
const results: Array<{ check: string; passed: boolean; output?: string }> =
[];
for (const check of verificationChecks) {
try {
@@ -425,7 +499,11 @@ Address the follow-up instructions above. Review the previous work and make the
cwd: workDir,
timeout: 120000,
});
results.push({ check: check.name, passed: true, output: stdout || stderr });
results.push({
check: check.name,
passed: true,
output: stdout || stderr,
});
} catch (error) {
allPassed = false;
results.push({
@@ -442,7 +520,9 @@ Address the follow-up instructions above. Review the previous work and make the
passes: allPassed,
message: allPassed
? "All verification checks passed"
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
: `Verification failed: ${
results.find((r) => !r.passed)?.check || "Unknown"
}`,
});
return allPassed;
@@ -451,8 +531,16 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Commit feature changes
*/
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
async commitFeature(
projectPath: string,
featureId: string
): Promise<string | null> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
@@ -464,7 +552,9 @@ Address the follow-up instructions above. Review the previous work and make the
try {
// Check for changes
const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir });
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: workDir,
});
if (!status.trim()) {
return null; // No changes
}
@@ -472,7 +562,9 @@ Address the follow-up instructions above. Review the previous work and make the
// Load feature for commit message
const feature = await this.loadFeature(projectPath, featureId);
const commitMessage = feature
? `feat: ${feature.title}\n\nImplemented by Automaker auto-mode`
? `feat: ${this.extractTitleFromDescription(
feature.description
)}\n\nImplemented by Automaker auto-mode`
: `feat: Feature ${featureId}`;
// Stage and commit
@@ -482,7 +574,9 @@ Address the follow-up instructions above. Review the previous work and make the
});
// Get commit hash
const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir });
const { stdout: hash } = await execAsync("git rev-parse HEAD", {
cwd: workDir,
});
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
@@ -500,7 +594,10 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Check if context exists for a feature
*/
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
async contextExists(
projectPath: string,
featureId: string
): Promise<boolean> {
const contextPath = path.join(
projectPath,
".automaker",
@@ -527,7 +624,11 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId: analysisFeatureId,
projectPath,
feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" },
feature: {
id: analysisFeatureId,
title: "Project Analysis",
description: "Analyzing project structure",
},
});
const prompt = `Analyze this project and provide a summary of:
@@ -570,7 +671,11 @@ Format your response as a structured markdown document.`;
}
// Save analysis
const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md");
const analysisPath = path.join(
projectPath,
".automaker",
"project-analysis.md"
);
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
@@ -606,6 +711,23 @@ Format your response as a structured markdown document.`;
};
}
/**
* Get detailed info about all running agents
*/
getRunningAgents(): Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}> {
return Array.from(this.runningFeatures.values()).map((rf) => ({
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
}));
}
// Private helpers
private async setupWorktree(
@@ -647,7 +769,10 @@ Format your response as a structured markdown document.`;
return worktreePath;
}
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
private async loadFeature(
projectPath: string,
featureId: string
): Promise<Feature | null> {
const featurePath = path.join(
projectPath,
".automaker",
@@ -682,6 +807,14 @@ Format your response as a structured markdown document.`;
const feature = JSON.parse(data);
feature.status = status;
feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
// Badge will show for 2 minutes after this timestamp
if (status === "waiting_approval") {
feature.justFinishedAt = new Date().toISOString();
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
}
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch {
// Feature file may not exist
@@ -697,7 +830,11 @@ Format your response as a structured markdown document.`;
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, "feature.json");
const featurePath = path.join(
featuresDir,
entry.name,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
@@ -717,21 +854,59 @@ Format your response as a structured markdown document.`;
}
}
/**
* Extract a title from feature description (first line or truncated)
*/
private extractTitleFromDescription(description: string): string {
if (!description || !description.trim()) {
return "Untitled Feature";
}
// Get first line, or first 60 characters if no newline
const firstLine = description.split("\n")[0].trim();
if (firstLine.length <= 60) {
return firstLine;
}
// Truncate to 60 characters and add ellipsis
return firstLine.substring(0, 57) + "...";
}
/**
* Extract image paths from feature's imagePaths array
* Handles both string paths and objects with path property
*/
private extractImagePaths(
imagePaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined,
projectPath: string
): string[] {
if (!imagePaths || imagePaths.length === 0) {
return [];
}
return imagePaths
.map((imgPath) => {
const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path;
// Resolve relative paths to absolute paths
return path.isAbsolute(pathStr)
? pathStr
: path.join(projectPath, pathStr);
})
.filter((p) => p); // Filter out any empty paths
}
private buildFeaturePrompt(feature: Feature): string {
const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task
**Feature ID:** ${feature.id}
**Title:** ${feature.title}
**Title:** ${title}
**Description:** ${feature.description}
`;
if (feature.spec) {
prompt += `
**Specification:**
${feature.spec}
`;
}
prompt += `
## Instructions
@@ -758,14 +933,7 @@ When done, summarize what you implemented and any notes for the developer.`;
model: "claude-opus-4-5-20251101",
maxTurns: 50,
cwd: workDir,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
],
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
@@ -778,12 +946,20 @@ When done, summarize what you implemented and any notes for the developer.`;
let finalPrompt = prompt;
if (imagePaths && imagePaths.length > 0) {
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`;
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths
.map((p) => `- ${p}`)
.join("\n")}`;
}
const stream = query({ prompt: finalPrompt, options });
let responseText = "";
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
const outputPath = path.join(
workDir,
".automaker",
"features",
featureId,
"agent-output.md"
);
for await (const msg of stream) {
if (msg.type === "assistant" && msg.message.content) {
@@ -792,12 +968,14 @@ When done, summarize what you implemented and any notes for the developer.`;
responseText = block.text;
// Check for authentication errors in the response
if (block.text.includes("Invalid API key") ||
block.text.includes("authentication_failed") ||
block.text.includes("Fix external API key")) {
if (
block.text.includes("Invalid API key") ||
block.text.includes("authentication_failed") ||
block.text.includes("Fix external API key")
) {
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
);
}
@@ -813,18 +991,21 @@ When done, summarize what you implemented and any notes for the developer.`;
});
}
}
} else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") {
} else if (
msg.type === "assistant" &&
(msg as { error?: string }).error === "authentication_failed"
) {
// Handle authentication error from the SDK
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
);
} else if (msg.type === "result" && msg.subtype === "success") {
// Check if result indicates an error
if (msg.is_error && msg.result?.includes("Invalid API key")) {
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
);
}
responseText = msg.result || responseText;