From be4aadb63264768f3c5002f12bcce8223c770453 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 17:14:31 -0500 Subject: [PATCH 01/15] adding new project from template --- apps/app/src/components/new-project-modal.tsx | 424 ++++++++++++++++++ .../app/src/components/views/welcome-view.tsx | 333 +++++++++----- apps/app/src/lib/http-api-client.ts | 10 + apps/app/src/lib/templates.ts | 62 +++ apps/server/src/index.ts | 2 + apps/server/src/routes/templates.ts | 171 +++++++ 6 files changed, 892 insertions(+), 110 deletions(-) create mode 100644 apps/app/src/components/new-project-modal.tsx create mode 100644 apps/app/src/lib/templates.ts create mode 100644 apps/server/src/routes/templates.ts diff --git a/apps/app/src/components/new-project-modal.tsx b/apps/app/src/components/new-project-modal.tsx new file mode 100644 index 00000000..562363c4 --- /dev/null +++ b/apps/app/src/components/new-project-modal.tsx @@ -0,0 +1,424 @@ +"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, + 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"; + +interface ValidationErrors { + projectName?: boolean; + workspaceDir?: boolean; + templateSelection?: boolean; + customUrl?: boolean; +} + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateBlankProject: (projectName: string, parentDir: string) => Promise; + onCreateFromTemplate: ( + template: StarterTemplate, + projectName: string, + parentDir: string + ) => Promise; + onCreateFromCustomUrl: ( + repoUrl: string, + projectName: string, + parentDir: string + ) => Promise; + 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(""); + const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [useCustomUrl, setUseCustomUrl] = useState(false); + const [customUrl, setCustomUrl] = useState(""); + const [errors, setErrors] = useState({}); + + // 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 projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; + + return ( + + + + Create New Project + + Start with a blank project or choose from a starter template. + + + + {/* Project Name Input - Always visible at top */} +
+
+ + 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 && ( +

Project name is required

+ )} +
+ + {/* Workspace Directory Display */} +
+ + + {isLoadingWorkspace ? ( + "Loading workspace..." + ) : workspaceDir ? ( + <>Will be created at: {projectPath || "..."} + ) : ( + No workspace configured - please configure WORKSPACE_DIR + )} + +
+
+ + setActiveTab(v as "blank" | "template")} + className="flex-1 flex flex-col overflow-hidden" + > + + + + Blank Project + + + + Starter Kit + + + +
+ +
+

+ Create an empty project with the standard .automaker directory + structure. Perfect for starting from scratch or importing an + existing codebase. +

+
+
+ + +
+ {/* Error message for template selection */} + {errors.templateSelection && ( +

Please select a template or enter a custom GitHub URL

+ )} + + {/* Preset Templates */} +
+ {starterTemplates.map((template) => ( +
handleSelectTemplate(template)} + data-testid={`template-${template.id}`} + > +
+
+
+

+ {template.name} +

+ {selectedTemplate?.id === template.id && !useCustomUrl && ( + + )} +
+

+ {template.description} +

+ + {/* Tech Stack */} +
+ {template.techStack.slice(0, 6).map((tech) => ( + + {tech} + + ))} + {template.techStack.length > 6 && ( + + +{template.techStack.length - 6} more + + )} +
+ + {/* Key Features */} +
+ Features: + {template.features.slice(0, 3).join(" · ")} + {template.features.length > 3 && + ` · +${template.features.length - 3} more`} +
+
+ + +
+
+ ))} + + {/* Custom URL Option */} +
+
+ +

Custom GitHub URL

+ {useCustomUrl && } +
+

+ Clone any public GitHub repository as a starting point. +

+ + {useCustomUrl && ( +
e.stopPropagation()} className="space-y-1"> + 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 && ( +

GitHub URL is required

+ )} +
+ )} +
+
+
+
+
+
+ + + + + {isCreating ? ( + <> + + {activeTab === "template" ? "Cloning..." : "Creating..."} + + ) : ( + <>Create Project + )} + + +
+
+ ); +} diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 9128c179..bd161a9e 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -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,13 +10,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { useAppStore } from "@/store/app-store"; import { getElectronAPI, type Project } from "@/lib/electron"; import { initializeProject } from "@/lib/project-init"; @@ -41,14 +31,14 @@ 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 [showNewProjectModal, setShowNewProjectModal] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isOpening, setIsOpening] = useState(false); const [showInitDialog, setShowInitDialog] = useState(false); @@ -231,31 +221,21 @@ 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 +254,7 @@ export function WelcomeView() { await api.writeFile( `${projectPath}/.automaker/app_spec.txt`, ` - ${newProjectName} + ${projectName} Describe your project here. This file will be analyzed by an AI agent @@ -297,24 +277,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 +308,206 @@ 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`, + ` + ${projectName} + + + This project was created from the "${template.name}" starter template. + ${template.description} + + + + ${template.techStack.map((tech) => `${tech}`).join("\n ")} + + + + ${template.features.map((feature) => `${feature}`).join("\n ")} + + + + + +` + ); + + 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`, + ` + ${projectName} + + + This project was cloned from ${repoUrl}. + The AI agent will analyze the project structure. + + + + + + + + + + + + + +` + ); + + 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 +688,15 @@ export function WelcomeView() { - {/* New Project Dialog */} - - - - - Create New Project - - - Set up a new project directory with initial configuration files. - - -
-
- - setNewProjectName(e.target.value)} - className="bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="project-name-input" - /> -
-
- -
- setNewProjectPath(e.target.value)} - className="flex-1 bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="project-path-input" - /> - -
-
-
- - - - {isCreating ? "Creating..." : "Create Project"} - - -
-
+ {/* New Project Modal */} + {/* Project Initialization Dialog */} diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index d11d6044..0cb6b56b 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -702,6 +702,16 @@ 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<{ diff --git a/apps/app/src/lib/templates.ts b/apps/app/src/lib/templates.ts new file mode 100644 index 00000000..b445895a --- /dev/null +++ b/apps/app/src/lib/templates.ts @@ -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); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2c4821b2..4801c5ce 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -29,6 +29,7 @@ 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"; @@ -112,6 +113,7 @@ app.use("/api/models", createModelsRoutes()); app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); app.use("/api/running-agents", createRunningAgentsRoutes()); app.use("/api/workspace", createWorkspaceRoutes()); +app.use("/api/templates", createTemplatesRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/templates.ts b/apps/server/src/routes/templates.ts new file mode 100644 index 00000000..3dd27bd2 --- /dev/null +++ b/apps/server/src/routes/templates.ts @@ -0,0 +1,171 @@ +/** + * 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); + + // 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((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; +} From ca4809ca06e7d3a8a52e9ac7ce68ae931ceb74d2 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 20:51:01 -0500 Subject: [PATCH 02/15] various fixes --- .github/scripts/upload-to-r2.js | 106 ++++++++++-------- .github/workflows/release.yml | 2 +- apps/app/src/components/layout/sidebar.tsx | 102 +++++++++++++++-- apps/app/src/components/new-project-modal.tsx | 37 +++++- apps/app/src/components/views/board-view.tsx | 19 +++- .../src/components/views/interview-view.tsx | 12 +- apps/app/src/components/views/kanban-card.tsx | 66 ++++++++++- .../app/src/components/views/welcome-view.tsx | 19 +++- .../app/src/contexts/file-browser-context.tsx | 24 +++- apps/app/src/store/app-store.ts | 81 +++++++++++++ apps/server/src/routes/fs.ts | 80 +++++++++++++ apps/server/src/routes/running-agents.ts | 50 +-------- apps/server/src/services/auto-mode-service.ts | 24 ++++ 13 files changed, 496 insertions(+), 126 deletions(-) diff --git a/.github/scripts/upload-to-r2.js b/.github/scripts/upload-to-r2.js index 336069cb..67940265 100644 --- a/.github/scripts/upload-to-r2.js +++ b/.github/scripts/upload-to-r2.js @@ -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); }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5135a73b..11abdcd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 222e54e2..e659b282 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -42,6 +42,7 @@ import { Search, Bug, Activity, + Recycle, } from "lucide-react"; import { DropdownMenu, @@ -70,7 +71,7 @@ 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, @@ -80,6 +81,7 @@ import { toast } from "sonner"; import { Sparkles, Loader2 } from "lucide-react"; 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, @@ -212,6 +214,7 @@ export function Sidebar() { setProjectTheme, setTheme, theme: globalTheme, + moveProjectToTrash, } = useAppStore(); // Get customizable keyboard shortcuts @@ -225,6 +228,12 @@ export function Sidebar() { const [activeTrashId, setActiveTrashId] = useState(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 +343,64 @@ export function Sidebar() { }; }, [setCurrentView]); + // Fetch running agents count and update every 2 seconds + useEffect(() => { + const fetchRunningAgentsCount = 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); + } + }; + + // Initial fetch + fetchRunningAgentsCount(); + + // Set up interval to refresh every 2 seconds + const interval = setInterval(fetchRunningAgentsCount, 2000); + + return () => clearInterval(interval); + }, []); + + // Subscribe to auto-mode events to update running agents count in real-time + useEffect(() => { + const api = getElectronAPI(); + if (!api.autoMode) return; + + 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_started" + ) { + const fetchRunningAgentsCount = async () => { + try { + 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); + } + }; + fetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { if (!setupProjectPath || !projectOverview.trim()) return; @@ -534,14 +601,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); @@ -830,10 +897,10 @@ export function Sidebar() { )} @@ -1421,6 +1499,14 @@ export function Sidebar() { )} + + {/* Delete Project Confirmation Dialog */} + ); } diff --git a/apps/app/src/components/new-project-modal.tsx b/apps/app/src/components/new-project-modal.tsx index 562363c4..fd1429de 100644 --- a/apps/app/src/components/new-project-modal.tsx +++ b/apps/app/src/components/new-project-modal.tsx @@ -17,6 +17,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { FolderPlus, + FolderOpen, Rocket, ExternalLink, Check, @@ -28,6 +29,7 @@ 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; @@ -69,6 +71,7 @@ export function NewProjectModal({ const [useCustomUrl, setUseCustomUrl] = useState(false); const [customUrl, setCustomUrl] = useState(""); const [errors, setErrors] = useState({}); + const { openFileBrowser } = useFileBrowser(); // Fetch workspace directory when modal opens useEffect(() => { @@ -181,6 +184,20 @@ export function NewProjectModal({ } }; + 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 ( @@ -226,16 +243,28 @@ export function NewProjectModal({ "flex items-center gap-2 text-sm", errors.workspaceDir ? "text-red-500" : "text-muted-foreground" )}> - - + + {isLoadingWorkspace ? ( "Loading workspace..." ) : workspaceDir ? ( - <>Will be created at: {projectPath || "..."} + <>Will be created at: {projectPath || "..."} ) : ( - No workspace configured - please configure WORKSPACE_DIR + No workspace configured )} + diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 23d332a9..35e8d0a0 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -867,7 +867,8 @@ 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 justFinished flag when manually verifying via drag + persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, @@ -877,7 +878,8 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - persistFeatureUpdate(featureId, { status: "backlog" }); + // Clear justFinished flag when moving back to backlog + persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -1198,7 +1200,8 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - persistFeatureUpdate(feature.id, { status: "verified" }); + // Clear justFinished flag when manually verifying + persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" @@ -1264,9 +1267,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up + // Clear justFinished flag since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), + justFinished: false, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1525,6 +1530,14 @@ export function BoardView() { } }); + // Sort waiting_approval column: justFinished features go to the top + map.waiting_approval.sort((a, b) => { + // Features with justFinished=true should appear first + if (a.justFinished && !b.justFinished) return -1; + if (!a.justFinished && b.justFinished) return 1; + return 0; // Keep original order for features with same justFinished status + }); + return map; }, [features, runningAutoTasks, searchQuery]); diff --git a/apps/app/src/components/views/interview-view.tsx b/apps/app/src/components/views/interview-view.tsx index 4e4d1b26..78110faa 100644 --- a/apps/app/src/components/views/interview-view.tsx +++ b/apps/app/src/components/views/interview-view.tsx @@ -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([]); 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); } }; diff --git a/apps/app/src/components/views/kanban-card.tsx b/apps/app/src/components/views/kanban-card.tsx index 41a35729..69603bd3 100644 --- a/apps/app/src/components/views/kanban-card.tsx +++ b/apps/app/src/components/views/kanban-card.tsx @@ -27,7 +27,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, @@ -55,6 +55,7 @@ import { GitMerge, ChevronDown, ChevronUp, + Brain, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; @@ -72,6 +73,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 = { + none: "", + low: "Low", + medium: "Med", + high: "High", + ultrathink: "Ultra", + }; + return labels[level]; +} + interface KanbanCardProps { feature: Feature; onEdit: () => void; @@ -280,6 +296,21 @@ export const KanbanCard = memo(function KanbanCard({ Errored )} + {/* Just Finished indicator badge - shows when agent just completed work */} + {feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( +
+ + Done +
+ )} {/* Branch badge - show when feature has a worktree */} {hasWorktree && !isCurrentAutoTask && ( @@ -289,8 +320,8 @@ 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 || (feature.justFinished && feature.status === "waiting_approval") ? "top-8 left-2" : "top-2 left-2" )} @@ -310,14 +341,17 @@ 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 || (feature.justFinished && feature.status === "waiting_approval")) && "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 || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14" )} > {isCurrentAutoTask && (
+ + {formatModelName(feature.model ?? DEFAULT_MODEL)} + {feature.startedAt && ( )} + {/* Model/Preset Info for Backlog Cards - Show in Detailed mode */} + {showAgentInfo && feature.status === "backlog" && ( +
+
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+ {feature.thinkingLevel && feature.thinkingLevel !== "none" && ( +
+ + + {formatThinkingLevel(feature.thinkingLevel)} + +
+ )} +
+
+ )} + {/* Agent Info Panel - shows for in_progress, waiting_approval, verified */} {/* Detailed mode: Show all agent info */} {showAgentInfo && feature.status !== "backlog" && agentInfo && ( diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index bd161a9e..36744cb1 100644 --- a/apps/app/src/components/views/welcome-view.tsx +++ b/apps/app/src/components/views/welcome-view.tsx @@ -181,7 +181,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); } } @@ -193,7 +194,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,7 +233,10 @@ export function WelcomeView() { /** * Create a blank project with just .automaker directory structure */ - const handleCreateBlankProject = async (projectName: string, parentDir: string) => { + const handleCreateBlankProject = async ( + projectName: string, + parentDir: string + ) => { setIsCreating(true); try { const api = getElectronAPI(); @@ -359,11 +364,15 @@ export function WelcomeView() { - ${template.techStack.map((tech) => `${tech}`).join("\n ")} + ${template.techStack + .map((tech) => `${tech}`) + .join("\n ")} - ${template.features.map((feature) => `${feature}`).join("\n ")} + ${template.features + .map((feature) => `${feature}`) + .join("\n ")} diff --git a/apps/app/src/contexts/file-browser-context.tsx b/apps/app/src/contexts/file-browser-context.tsx index f54fb27f..b4c0b4ee 100644 --- a/apps/app/src/contexts/file-browser-context.tsx +++ b/apps/app/src/contexts/file-browser-context.tsx @@ -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; + openFileBrowser: (options?: FileBrowserOptions) => Promise; } const FileBrowserContext = createContext(null); @@ -12,9 +17,11 @@ const FileBrowserContext = createContext(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({}); - const openFileBrowser = useCallback((): Promise => { + const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise => { 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} /> ); @@ -57,12 +70,15 @@ export function useFileBrowser() { } // Global reference for non-React code (like HttpApiClient) -let globalFileBrowserFn: (() => Promise) | null = null; +let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise) | null = null; -export function setGlobalFileBrowser(fn: () => Promise) { +export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise) { globalFileBrowserFn = fn; } export function getGlobalFileBrowser() { return globalFileBrowserFn; } + +// Export the options type for consumers +export type { FileBrowserOptions }; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 81d50617..d185eea0 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -245,6 +245,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 + justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval } // File tree node for project analysis @@ -332,6 +333,13 @@ export interface AppState { // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; + + // Board Background Settings (per-project, keyed by project path) + boardBackgroundByProject: Record; } export interface AutoModeActivity { @@ -455,6 +463,13 @@ export interface AppActions { 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; + getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number }; + clearBoardBackground: (projectPath: string) => void; + // Reset reset: () => void; } @@ -546,6 +561,7 @@ const initialState: AppState = { aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, + boardBackgroundByProject: {}, }; export const useAppStore = create()( @@ -1131,6 +1147,69 @@ export const useAppStore = create()( 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 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath, + }, + }, + }); + }, + + setCardOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardOpacity: opacity, + }, + }, + }); + }, + + setColumnOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnOpacity: opacity, + }, + }, + }); + }, + + getBoardBackground: (projectPath) => { + const settings = get().boardBackgroundByProject[projectPath]; + return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + }, + + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + }, + }, + }); + }, + // Reset reset: () => set(initialState), }), @@ -1164,6 +1243,8 @@ export const useAppStore = create()( aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, lastSelectedSessionByProject: state.lastSelectedSessionByProject, + // Board background settings + boardBackgroundByProject: state.boardBackgroundByProject, }), } ) diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index ac492f03..7befe07f 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -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) => { diff --git a/apps/server/src/routes/running-agents.ts b/apps/server/src/routes/running-agents.ts index 57285636..116a5b00 100644 --- a/apps/server/src/routes/running-agents.ts +++ b/apps/server/src/routes/running-agents.ts @@ -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(); -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); -} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 8015de91..bdb34511 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -563,6 +563,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( @@ -639,6 +656,13 @@ Format your response as a structured markdown document.`; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); + // Set justFinished flag when moving to waiting_approval (agent just completed) + if (status === "waiting_approval") { + feature.justFinished = true; + } else { + // Clear the flag when moving to other statuses + feature.justFinished = false; + } await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch { // Feature file may not exist From 28328d7d1e5b6ce51714701056820233c65ea284 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:05:16 -0500 Subject: [PATCH 03/15] 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. --- apps/app/src/app/globals.css | 70 +++ apps/app/src/app/page.tsx | 23 +- .../dialogs/board-background-modal.tsx | 533 ++++++++++++++++++ apps/app/src/components/layout/sidebar.tsx | 184 ++++-- apps/app/src/components/views/board-view.tsx | 512 ++++++++++------- apps/app/src/components/views/kanban-card.tsx | 149 ++++- .../src/components/views/kanban-column.tsx | 38 +- .../views/settings-view/shared/types.ts | 3 +- apps/app/src/config/theme-options.ts | 7 + apps/app/src/lib/http-api-client.ts | 20 + apps/app/src/store/app-store.ts | 384 ++++++++++--- apps/server/src/index.ts | 6 +- apps/server/src/routes/auto-mode.ts | 6 +- apps/server/src/services/auto-mode-service.ts | 210 +++++-- 14 files changed, 1736 insertions(+), 409 deletions(-) create mode 100644 apps/app/src/components/dialogs/board-background-modal.tsx diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 2f7dc659..7036229e 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -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; diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 14e200be..0397f513 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -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) { diff --git a/apps/app/src/components/dialogs/board-background-modal.tsx b/apps/app/src/components/dialogs/board-background-modal.tsx new file mode 100644 index 00000000..f22ac280 --- /dev/null +++ b/apps/app/src/components/dialogs/board-background-modal.tsx @@ -0,0 +1,533 @@ +"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 } 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(null); + const [previewImage, setPreviewImage] = useState(null); + + // Get current background settings (live from store) + const backgroundSettings = currentProject + ? boardBackgroundByProject[currentProject.path] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + : { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + + 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; + + // Update preview image when background settings change + useEffect(() => { + if (currentProject && backgroundSettings.imagePath) { + const serverUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent(currentProject.path)}`; + setPreviewImage(imagePath); + } else { + setPreviewImage(null); + } + }, [currentProject, backgroundSettings.imagePath]); + + const fileToBase64 = (file: File): Promise => { + 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) => { + 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 ( + + + + + + Board Background Settings + + + Set a custom background image for your kanban board and adjust + card/column opacity + + + +
+ {/* Image Upload Section */} +
+ + + {/* Hidden file input */} + + + {/* Drop zone */} +
+ {previewImage ? ( +
+
+ Background preview + {isProcessing && ( +
+ +
+ )} +
+
+ + +
+
+ ) : ( +
+
+ {isProcessing ? ( + + ) : ( + + )} +
+

+ {isDragOver && !isProcessing + ? "Drop image here" + : "Click to upload or drag and drop"} +

+

+ JPG, PNG, GIF, or WebP (max{" "} + {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB) +

+
+ )} +
+
+ + {/* Opacity Controls */} +
+
+
+ + + {cardOpacity}% + +
+ +
+ +
+
+ + + {columnOpacity}% + +
+ +
+ + {/* Column Border Toggle */} +
+ + +
+ + {/* Card Glassmorphism Toggle */} +
+ + +
+ + {/* Card Border Toggle */} +
+ + +
+ + {/* Card Border Opacity - only show when border is enabled */} + {cardBorderEnabled && ( +
+
+ + + {cardBorderOpacity}% + +
+ +
+ )} + + {/* Hide Scrollbar Toggle */} +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index e659b282..82e46044 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -26,18 +26,6 @@ import { UserCircle, MoreVertical, Palette, - Moon, - Sun, - Terminal, - Ghost, - Snowflake, - Flame, - Sparkles as TokyoNightIcon, - Eclipse, - Trees, - Cat, - Atom, - Radio, Monitor, Search, Bug, @@ -71,7 +59,12 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron"; +import { + getElectronAPI, + Project, + TrashedProject, + RunningAgent, +} from "@/lib/electron"; import { initializeProject, hasAppSpec, @@ -79,6 +72,7 @@ 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"; @@ -175,21 +169,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() { @@ -213,6 +200,7 @@ export function Sidebar() { clearProjectHistory, setProjectTheme, setTheme, + setPreviewTheme, theme: globalTheme, moveProjectToTrash, } = useAppStore(); @@ -389,7 +377,10 @@ export function Sidebar() { } } } catch (error) { - console.error("[Sidebar] Error fetching running agents count:", error); + console.error( + "[Sidebar] Error fetching running agents count:", + error + ); } }; fetchRunningAgentsCount(); @@ -501,7 +492,8 @@ export function Sidebar() { // 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; + const effectiveTheme = + trashedProject?.theme || currentProject?.theme || globalTheme; project = { id: `project-${Date.now()}`, name, @@ -546,7 +538,14 @@ export function Sidebar() { }); } } - }, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]); + }, [ + projects, + trashedProjects, + addProject, + setCurrentProject, + currentProject, + globalTheme, + ]); const handleRestoreProject = useCallback( (projectId: string) => { @@ -828,7 +827,9 @@ export function Sidebar() {
{ 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" @@ -1001,7 +1004,14 @@ export function Sidebar() { {/* Project Options Menu - theme and history */} {currentProject && ( - + { + // Clear preview theme when the menu closes + if (!open) { + setPreviewTheme(null); + } + }} + >
); })} @@ -1241,14 +1292,25 @@ export function Sidebar() { {isActiveRoute("running-agents") && (
)} - + + {/* Running agents count badge - shown in collapsed state */} + {!sidebarOpen && runningAgentsCount > 0 && ( + + {runningAgentsCount > 99 ? "99" : runningAgentsCount} + )} - /> +
Running Agents + {/* Running agents count badge - shown in expanded state */} + {sidebarOpen && runningAgentsCount > 0 && ( + + {runningAgentsCount > 99 ? "99" : runningAgentsCount} + + )} {!sidebarOpen && ( Running Agents @@ -1328,7 +1402,9 @@ export function Sidebar() { {trashedProjects.length === 0 ? ( -

Recycle bin is empty.

+

+ Recycle bin is empty. +

) : (
{trashedProjects.map((project) => ( diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 35e8d0a0..8302cc7a 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -58,6 +58,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 +87,7 @@ import { Square, Maximize2, Shuffle, + ImageIcon, } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; @@ -206,6 +208,7 @@ export function BoardView() { aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, + boardBackgroundByProject, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [activeFeature, setActiveFeature] = useState(null); @@ -230,6 +233,8 @@ export function BoardView() { ); const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = useState(false); + const [showBoardBackgroundModal, setShowBoardBackgroundModal] = + useState(false); const [persistedCategories, setPersistedCategories] = useState([]); const [showFollowUpDialog, setShowFollowUpDialog] = useState(false); const [followUpFeature, setFollowUpFeature] = useState(null); @@ -400,7 +405,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 +562,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 +592,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,8 +875,11 @@ export function BoardView() { // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); - // Clear justFinished flag when manually verifying via drag - persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); + // 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, @@ -878,8 +889,11 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - // Clear justFinished flag when moving back to backlog - persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); + // 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, @@ -1200,8 +1214,11 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - // Clear justFinished flag when manually verifying - persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); + // 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 ? "..." : "" @@ -1267,11 +1284,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up - // Clear justFinished flag since user is now interacting with it + // Clear justFinishedAt timestamp since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), - justFinished: false, + justFinishedAt: undefined, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1530,11 +1547,22 @@ export function BoardView() { } }); - // Sort waiting_approval column: justFinished features go to the top + // Sort waiting_approval column: justFinished features (within 2 minutes) go to the top map.waiting_approval.sort((a, b) => { - // Features with justFinished=true should appear first - if (a.justFinished && !b.justFinished) return -1; - if (!a.justFinished && b.justFinished) return 1; + // Helper to check if feature is "just finished" (within 2 minutes) + const isJustFinished = (feature: Feature) => { + if (!feature.justFinishedAt) return false; + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const now = Date.now(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + return now - finishedTime < twoMinutes; + }; + + const aJustFinished = isJustFinished(a); + const bJustFinished = isJustFinished(b); + // Features with justFinishedAt within 2 minutes should appear first + if (aJustFinished && !bJustFinished) return -1; + if (!aJustFinished && bJustFinished) return 1; return 0; // Keep original order for features with same justFinished status }); @@ -1639,7 +1667,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 @@ -1848,202 +1876,296 @@ export function BoardView() { )}
- {/* Kanban Card Detail Level Toggle */} + {/* Board Background & Detail Level Controls */} {isMounted && ( -
+
+ {/* Board Background Button */} - + + -

Minimal - Title & category only

-
-
- - - - - -

Standard - Steps & progress

-
-
- - - - - -

Detailed - Model, tools & tasks

+

Board Background Settings

+ + {/* Kanban Card Detail Level Toggle */} +
+ + + + + +

Minimal - Title & category only

+
+
+ + + + + +

Standard - Steps & progress

+
+
+ + + + + +

Detailed - Model, tools & tasks

+
+
+
)}
{/* Kanban Columns */} -
- -
- {COLUMNS.map((column) => { - const columnFeatures = getColumnFeatures(column.id); - return ( - 0 ? ( - - ) : column.id === "backlog" ? ( -
- - {columnFeatures.length > 0 && ( - { + // Get background settings for current project + const backgroundSettings = currentProject + ? boardBackgroundByProject[currentProject.path] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + : { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + + // 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 || "" + )})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + : {}; + + return ( +
+ +
+ {COLUMNS.map((column) => { + const columnFeatures = getColumnFeatures(column.id); + return ( + 0 ? ( +
- ) : undefined - } - > - 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); + + Delete All + + ) : column.id === "backlog" ? ( +
+ + {columnFeatures.length > 0 && ( + + + Pull Top + + )} +
+ ) : undefined } - return ( - setEditingFeature(feature)} - onDelete={() => handleDeleteFeature(feature.id)} - onViewOutput={() => handleViewOutput(feature)} - onVerify={() => handleVerifyFeature(feature)} - onResume={() => handleResumeFeature(feature)} - onForceStop={() => handleForceStopFeature(feature)} - onManualVerify={() => handleManualVerify(feature)} - onMoveBackToInProgress={() => - handleMoveBackToInProgress(feature) + > + 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} - /> - ); - })} - - - ); - })} -
+ return ( + 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 + } + /> + ); + })} + + + ); + })} +
- - {activeFeature && ( - - - - {activeFeature.description} - - - {activeFeature.category} - - - - )} - - -
+ + {activeFeature && ( + + + + {activeFeature.description} + + + {activeFeature.category} + + + + )} + +
+
+ ); + })()}
+ {/* Board Background Modal */} + + {/* Add Feature Dialog */} (null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [currentTime, setCurrentTime] = useState(() => Date.now()); const { kanbanCardDetailLevel } = useAppStore(); // Check if feature has worktree @@ -148,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 () => { @@ -184,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 { @@ -241,15 +291,42 @@ export const KanbanCard = memo(function KanbanCard({ const style = { transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : undefined, }; + // Calculate border style based on enabled state and opacity + const borderStyle: React.CSSProperties = { ...style }; + if (!cardBorderEnabled) { + (borderStyle as Record).borderWidth = "0px"; + (borderStyle as Record).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).borderWidth = "1px"; + ( + borderStyle as Record + ).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`; + } + return ( + {/* Background overlay with opacity - only affects background, not content */} + {!isDragging && ( +
+ )} {/* Skip Tests indicator badge */} {feature.skipTests && !feature.error && (
Errored
)} - {/* Just Finished indicator badge - shows when agent just completed work */} - {feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( + {/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */} + {isJustFinished && (
- Done + Fresh Baked
)} {/* Branch badge - show when feature has a worktree */} @@ -317,18 +404,22 @@ export const KanbanCard = memo(function KanbanCard({ "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 other badges if present, otherwise use normal position - feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval") + feature.error || feature.skipTests || isJustFinished ? "top-8 left-2" : "top-2 left-2" )} data-testid={`branch-badge-${feature.id}`} > - {feature.branchName?.replace("feature/", "")} + + {feature.branchName?.replace("feature/", "")} +
-

{feature.branchName}

+

+ {feature.branchName} +

@@ -337,9 +428,11 @@ 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 || (feature.justFinished && feature.status === "waiting_approval")) && "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 || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14" + hasWorktree && + (feature.skipTests || feature.error || isJustFinished) && + "pt-14" )} > {isCurrentAutoTask && ( @@ -471,7 +564,9 @@ export const KanbanCard = memo(function KanbanCard({ ) : ( )} - {step} + + {step} + ))} {feature.steps.length > 3 && ( @@ -565,7 +660,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} @@ -878,9 +974,13 @@ export const KanbanCard = memo(function KanbanCard({ Implementation 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; @@ -916,10 +1016,15 @@ export const KanbanCard = memo(function KanbanCard({ Revert Changes - 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 && ( - Branch {feature.branchName} will be deleted. + Branch{" "} + + {feature.branchName} + {" "} + will be deleted. )} diff --git a/apps/app/src/components/views/kanban-column.tsx b/apps/app/src/components/views/kanban-column.tsx index cbffc051..e9a76a79 100644 --- a/apps/app/src/components/views/kanban-column.tsx +++ b/apps/app/src/components/views/kanban-column.tsx @@ -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({
- {/* Column Header */} -
+ {/* Background layer with opacity - only this layer is affected by opacity */} +
+ + {/* Column Header - positioned above the background */} +

{title}

{headerAction} @@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
- {/* Column Content */} -
+ {/* Column Content - positioned above the background */} +
{children}
diff --git a/apps/app/src/components/views/settings-view/shared/types.ts b/apps/app/src/components/views/settings-view/shared/types.ts index e28966a6..5ad91dcc 100644 --- a/apps/app/src/components/views/settings-view/shared/types.ts +++ b/apps/app/src/components/views/settings-view/shared/types.ts @@ -29,7 +29,8 @@ export type Theme = | "gruvbox" | "catppuccin" | "onedark" - | "synthwave"; + | "synthwave" + | "red"; export type KanbanDetailLevel = "minimal" | "standard" | "detailed"; diff --git a/apps/app/src/config/theme-options.ts b/apps/app/src/config/theme-options.ts index ac8bc567..ec0a028d 100644 --- a/apps/app/src/config/theme-options.ts +++ b/apps/app/src/config/theme-options.ts @@ -5,6 +5,7 @@ import { Eclipse, Flame, Ghost, + Heart, Moon, Radio, Snowflake, @@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray = [ Icon: Radio, testId: "synthwave-mode-button", }, + { + value: "red", + label: "Red", + Icon: Heart, + testId: "red-mode-button", + }, ]; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 04c84bcd..ba0f4c6b 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -316,6 +316,26 @@ export class HttpApiClient implements ElectronAPI { 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 async checkClaudeCli(): Promise<{ success: boolean; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 5a7037e2..53ba746f 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -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,7 +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 - justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval + justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes) } // File tree node for project analysis @@ -302,10 +332,13 @@ export interface AppState { chatHistoryOpen: boolean; // Auto Mode (per-project state, keyed by project ID) - autoModeByProject: Record; + autoModeByProject: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; // Feature IDs being worked on + } + >; autoModeActivityLog: AutoModeActivity[]; maxConcurrency: number; // Maximum number of concurrent agent tasks @@ -335,11 +368,22 @@ export interface AppState { isAnalyzing: boolean; // Board Background Settings (per-project, keyed by project path) - boardBackgroundByProject: Record; + boardBackgroundByProject: Record< + string, + { + imagePath: string | null; // Path to background image in .automaker directory + 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; } export interface AutoModeActivity { @@ -385,7 +429,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; @@ -421,7 +466,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 ) => void; @@ -460,14 +508,31 @@ 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; - getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number }; + 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 @@ -479,7 +544,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", @@ -489,7 +555,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", @@ -562,6 +629,7 @@ const initialState: AppState = { projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, + previewTheme: null, }; export const useAppStore = create()( @@ -687,7 +755,9 @@ export const useAppStore = create()( // 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) @@ -727,7 +797,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -752,9 +822,8 @@ export const useAppStore = create()( 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); @@ -764,7 +833,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -816,6 +885,11 @@ export const useAppStore = create()( }, 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) { @@ -825,6 +899,8 @@ export const useAppStore = create()( return get().theme; }, + setPreviewTheme: (theme) => set({ previewTheme: theme }), + // Feature actions setFeatures: (features) => set({ features }), @@ -976,7 +1052,10 @@ export const useAppStore = create()( // 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, @@ -987,7 +1066,10 @@ export const useAppStore = create()( 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: { @@ -1003,13 +1085,18 @@ export const useAppStore = create()( 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 + ), }, }, }); @@ -1017,7 +1104,10 @@ export const useAppStore = create()( clearRunningTasks: (projectId) => { const current = get().autoModeByProject; - const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; set({ autoModeByProject: { ...current, @@ -1151,7 +1241,16 @@ export const useAppStore = create()( // Board Background actions setBoardBackground: (projectPath, imagePath) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1165,7 +1264,16 @@ export const useAppStore = create()( setCardOpacity: (projectPath, opacity) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1179,7 +1287,16 @@ export const useAppStore = create()( setColumnOpacity: (projectPath, opacity) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1193,18 +1310,153 @@ export const useAppStore = create()( getBoardBackground: (projectPath) => { const settings = get().boardBackgroundByProject[projectPath]; - return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + return ( + settings || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + ); }, - clearBoardBackground: (projectPath) => { + setColumnBorderEnabled: (projectPath, enabled) => { 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]: { - imagePath: null, - cardOpacity: 100, - columnOpacity: 100, + ...existing, + columnBorderEnabled: enabled, + }, + }, + }); + }, + + setCardGlassmorphism: (projectPath, enabled) => { + 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, + cardGlassmorphism: enabled, + }, + }, + }); + }, + + setCardBorderEnabled: (projectPath, enabled) => { + 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, + cardBorderEnabled: enabled, + }, + }, + }); + }, + + setCardBorderOpacity: (projectPath, opacity) => { + 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, + cardBorderOpacity: opacity, + }, + }, + }); + }, + + setHideScrollbar: (projectPath, hide) => { + 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, + hideScrollbar: hide, + }, + }, + }); + }, + + clearBoardBackground: (projectPath) => { + 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: null, // Only clear the image, preserve other settings }, }, }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fa485bd5..de7c7240 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -32,6 +32,7 @@ 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(); @@ -87,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 () => { @@ -104,14 +106,14 @@ 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()); diff --git a/apps/server/src/routes/auto-mode.ts b/apps/server/src/routes/auto-mode.ts index 408b0d96..cd6bfcb0 100644 --- a/apps/server/src/routes/auto-mode.ts +++ b/apps/server/src/routes/auto-mode.ts @@ -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) => { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 4e72d6bb..3b42ca10 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -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"; @@ -85,7 +89,11 @@ export class AutoModeService { } private async runAutoLoop(): Promise { - 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 +102,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 +115,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 +176,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 +199,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 { @@ -203,16 +223,25 @@ export class AutoModeService { await this.runAgent(workDir, featureId, prompt, abortController); // 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 +250,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 +310,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 +338,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 +406,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 +451,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 { - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + async verifyFeature( + projectPath: string, + featureId: string + ): Promise { + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -417,7 +479,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 +488,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 +509,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 +520,16 @@ Address the follow-up instructions above. Review the previous work and make the /** * Commit feature changes */ - async commitFeature(projectPath: string, featureId: string): Promise { - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + async commitFeature( + projectPath: string, + featureId: string + ): Promise { + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -464,7 +541,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 } @@ -482,7 +561,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 +581,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 { + async contextExists( + projectPath: string, + featureId: string + ): Promise { const contextPath = path.join( projectPath, ".automaker", @@ -527,7 +611,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 +658,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); @@ -664,7 +756,10 @@ Format your response as a structured markdown document.`; return worktreePath; } - private async loadFeature(projectPath: string, featureId: string): Promise { + private async loadFeature( + projectPath: string, + featureId: string + ): Promise { const featurePath = path.join( projectPath, ".automaker", @@ -699,12 +794,13 @@ Format your response as a structured markdown document.`; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); - // Set justFinished flag when moving to waiting_approval (agent just completed) + // 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.justFinished = true; + feature.justFinishedAt = new Date().toISOString(); } else { - // Clear the flag when moving to other statuses - feature.justFinished = false; + // Clear the timestamp when moving to other statuses + feature.justFinishedAt = undefined; } await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch { @@ -721,7 +817,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); @@ -782,14 +882,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, @@ -802,12 +895,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) { @@ -816,12 +917,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." ); } @@ -837,18 +940,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; From b3a4fd2be1d69fbf3949572f6ef4db27e719eb8e Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:42:43 -0500 Subject: [PATCH 04/15] 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. --- apps/app/src/components/layout/sidebar.tsx | 13 +- apps/app/src/config/app-config.ts | 6 + apps/app/src/lib/http-api-client.ts | 132 ++++++++++++++++----- 3 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 apps/app/src/config/app-config.ts diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index 82e46044..8476483c 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -4,6 +4,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; import { useAppStore, formatShortcut } from "@/store/app-store"; import { CoursePromoBadge } from "@/components/ui/course-promo-badge"; +import { IS_MARKETING } from "@/config/app-config"; import { FolderOpen, Plus, @@ -366,7 +367,7 @@ export function Sidebar() { if ( event.type === "auto_mode_feature_complete" || event.type === "auto_mode_error" || - event.type === "auto_mode_feature_started" + event.type === "auto_mode_feature_start" ) { const fetchRunningAgentsCount = async () => { try { @@ -853,7 +854,15 @@ export function Sidebar() { sidebarOpen ? "hidden lg:block" : "hidden" )} > - Automaker + {IS_MARKETING ? ( + <> + https://automaker.app + + ) : ( + <> + Automaker + + )}
{/* Bug Report Button */} diff --git a/apps/app/src/config/app-config.ts b/apps/app/src/config/app-config.ts new file mode 100644 index 00000000..6755a303 --- /dev/null +++ b/apps/app/src/config/app-config.ts @@ -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"; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index ba0f4c6b..ed5377bb 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -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 { // 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,12 @@ export class HttpApiClient implements ElectronAPI { mimeType: string, projectPath?: string ): Promise { - return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath }); + return this.post("/api/fs/save-image", { + data, + filename, + mimeType, + projectPath, + }); } async saveBoardBackground( @@ -464,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 }), @@ -483,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; @@ -516,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) => - this.post("/api/features/update", { projectPath, featureId, updates }), + update: ( + projectPath: string, + featureId: string, + updates: Partial + ) => 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) => @@ -534,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) => @@ -559,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 + ); }, }; @@ -578,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 @@ -596,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"), @@ -656,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; @@ -668,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; @@ -690,17 +751,24 @@ export class HttpApiClient implements ElectronAPI { // Templates API templates = { - clone: (repoUrl: string, projectName: string, parentDir: string): Promise<{ + clone: ( + repoUrl: string, + projectName: string, + parentDir: string + ): Promise<{ success: boolean; projectPath?: string; projectName?: string; error?: string; - }> => this.post("/api/templates/clone", { repoUrl, projectName, parentDir }), + }> => + 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; @@ -730,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}`), }; } From c991d5f2f71113ae46c6e7d68f44ea017d13c0b2 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:51:39 -0500 Subject: [PATCH 05/15] 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. --- apps/marketing/public/index.html | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/marketing/public/index.html b/apps/marketing/public/index.html index 3f9a6336..2aab9346 100644 --- a/apps/marketing/public/index.html +++ b/apps/marketing/public/index.html @@ -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); + } + } @@ -382,6 +426,15 @@ Get Started
+
+
+ +
+
+