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/.gitignore b/.gitignore index 59cf700e..dba6edc7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ node_modules/ # Build outputs dist/ .next/ -node_modules .automaker/images/ .automaker/ /.automaker/* diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 2f7dc659..1e522a2b 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; @@ -1327,6 +1397,39 @@ .text-running-indicator { color: var(--running-indicator-text); } + + /* Animated border for in-progress cards */ + @keyframes border-rotate { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + } + + .animated-border-wrapper { + position: relative; + border-radius: 0.75rem; + padding: 2px; + background: linear-gradient( + 90deg, + var(--running-indicator), + color-mix(in oklch, var(--running-indicator), transparent 50%), + var(--running-indicator), + color-mix(in oklch, var(--running-indicator), transparent 50%), + var(--running-indicator) + ); + background-size: 200% 100%; + animation: border-rotate 3s ease infinite; + } + + .animated-border-wrapper > * { + border-radius: calc(0.75rem - 2px); + } } /* Retro Overrides for Utilities */ 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..ad1207eb --- /dev/null +++ b/apps/app/src/components/dialogs/board-background-modal.tsx @@ -0,0 +1,520 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { useAppStore, defaultBackgroundSettings } from "@/store/app-store"; +import { getHttpApiClient } from "@/lib/http-api-client"; +import { toast } from "sonner"; + +const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]; +const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +interface BoardBackgroundModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BoardBackgroundModal({ + open, + onOpenChange, +}: BoardBackgroundModalProps) { + const { + currentProject, + boardBackgroundByProject, + setBoardBackground, + setCardOpacity, + setColumnOpacity, + setColumnBorderEnabled, + setCardGlassmorphism, + setCardBorderEnabled, + setCardBorderOpacity, + setHideScrollbar, + clearBoardBackground, + } = useAppStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const fileInputRef = useRef(null); + const [previewImage, setPreviewImage] = useState(null); + + // Get current background settings (live from store) + const backgroundSettings = + (currentProject && boardBackgroundByProject[currentProject.path]) || + defaultBackgroundSettings; + + const cardOpacity = backgroundSettings.cardOpacity; + const columnOpacity = backgroundSettings.columnOpacity; + const columnBorderEnabled = backgroundSettings.columnBorderEnabled; + const cardGlassmorphism = backgroundSettings.cardGlassmorphism; + const cardBorderEnabled = backgroundSettings.cardBorderEnabled; + const cardBorderOpacity = backgroundSettings.cardBorderOpacity; + const hideScrollbar = backgroundSettings.hideScrollbar; + const imageVersion = backgroundSettings.imageVersion; + + // Update preview image when background settings change + useEffect(() => { + if (currentProject && backgroundSettings.imagePath) { + const serverUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + // Add cache-busting query parameter to force browser to reload image + const cacheBuster = imageVersion + ? `&v=${imageVersion}` + : `&v=${Date.now()}`; + const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`; + setPreviewImage(imagePath); + } else { + setPreviewImage(null); + } + }, [currentProject, backgroundSettings.imagePath, imageVersion]); + + const fileToBase64 = (file: File): Promise => { + 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 222e54e2..f1efa237 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -2,8 +2,9 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { useAppStore, formatShortcut } from "@/store/app-store"; +import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store"; import { CoursePromoBadge } from "@/components/ui/course-promo-badge"; +import { IS_MARKETING } from "@/config/app-config"; import { FolderOpen, Plus, @@ -26,22 +27,11 @@ import { UserCircle, MoreVertical, Palette, - Moon, - Sun, - Terminal, - Ghost, - Snowflake, - Flame, - Sparkles as TokyoNightIcon, - Eclipse, - Trees, - Cat, - Atom, - Radio, Monitor, Search, Bug, Activity, + Recycle, } from "lucide-react"; import { DropdownMenu, @@ -70,7 +60,12 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project, TrashedProject } from "@/lib/electron"; +import { + getElectronAPI, + Project, + TrashedProject, + RunningAgent, +} from "@/lib/electron"; import { initializeProject, hasAppSpec, @@ -78,8 +73,10 @@ import { } from "@/lib/project-init"; import { toast } from "sonner"; import { Sparkles, Loader2 } from "lucide-react"; +import { themeOptions } from "@/config/theme-options"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; +import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; import { DndContext, DragEndEvent, @@ -173,21 +170,14 @@ function SortableProjectItem({ ); } -// Theme options for project theme selector +// Theme options for project theme selector - derived from the shared config const PROJECT_THEME_OPTIONS = [ { value: "", label: "Use Global", icon: Monitor }, - { value: "dark", label: "Dark", icon: Moon }, - { value: "light", label: "Light", icon: Sun }, - { value: "retro", label: "Retro", icon: Terminal }, - { value: "dracula", label: "Dracula", icon: Ghost }, - { value: "nord", label: "Nord", icon: Snowflake }, - { value: "monokai", label: "Monokai", icon: Flame }, - { value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon }, - { value: "solarized", label: "Solarized", icon: Eclipse }, - { value: "gruvbox", label: "Gruvbox", icon: Trees }, - { value: "catppuccin", label: "Catppuccin", icon: Cat }, - { value: "onedark", label: "One Dark", icon: Atom }, - { value: "synthwave", label: "Synthwave", icon: Radio }, + ...themeOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + })), ] as const; export function Sidebar() { @@ -198,7 +188,7 @@ export function Sidebar() { currentView, sidebarOpen, projectHistory, - addProject, + upsertAndSetCurrentProject, setCurrentProject, setCurrentView, toggleSidebar, @@ -211,7 +201,9 @@ export function Sidebar() { clearProjectHistory, setProjectTheme, setTheme, + setPreviewTheme, theme: globalTheme, + moveProjectToTrash, } = useAppStore(); // Get customizable keyboard shortcuts @@ -225,6 +217,12 @@ export function Sidebar() { const [activeTrashId, setActiveTrashId] = useState(null); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); + // State for delete project confirmation dialog + const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); + + // State for running agents count + const [runningAgentsCount, setRunningAgentsCount] = useState(0); + // State for new project setup dialog const [showSetupDialog, setShowSetupDialog] = useState(false); const [setupProjectPath, setSetupProjectPath] = useState(""); @@ -334,6 +332,49 @@ export function Sidebar() { }; }, [setCurrentView]); + // Fetch running agents count function - used for initial load and event-driven updates + const fetchRunningAgentsCount = useCallback(async () => { + try { + const api = getElectronAPI(); + if (api.runningAgents) { + const result = await api.runningAgents.getAll(); + if (result.success && result.runningAgents) { + setRunningAgentsCount(result.runningAgents.length); + } + } + } catch (error) { + console.error("[Sidebar] Error fetching running agents count:", error); + } + }, []); + + // Subscribe to auto-mode events to update running agents count in real-time + useEffect(() => { + const api = getElectronAPI(); + if (!api.autoMode) { + // If autoMode is not available, still fetch initial count + fetchRunningAgentsCount(); + return; + } + + // Initial fetch on mount + fetchRunningAgentsCount(); + + const unsubscribe = api.autoMode.onEvent((event) => { + // When a feature starts, completes, or errors, refresh the count + if ( + event.type === "auto_mode_feature_complete" || + event.type === "auto_mode_error" || + event.type === "auto_mode_feature_start" + ) { + fetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, [fetchRunningAgentsCount]); + // Handle creating initial spec for new project const handleCreateInitialSpec = useCallback(async () => { if (!setupProjectPath || !projectOverview.trim()) return; @@ -414,38 +455,14 @@ export function Sidebar() { return; } - // Check if project already exists (by path) to preserve theme and other settings - const existingProject = projects.find((p) => p.path === path); - - let project: Project; - if (existingProject) { - // Update existing project, preserving theme and other properties - project = { - ...existingProject, - name, // Update name in case it changed - lastOpened: new Date().toISOString(), - }; - // Update the project in the store (this will update the existing entry) - const updatedProjects = projects.map((p) => - p.id === existingProject.id ? project : p - ); - useAppStore.setState({ projects: updatedProjects }); - } else { - // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) - // Then fall back to current effective theme, then global theme - const trashedProject = trashedProjects.find((p) => p.path === path); - const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme; - project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - theme: effectiveTheme, - }; - addProject(project); - } - - setCurrentProject(project); + // Upsert project and set as current (handles both create and update cases) + // Theme preservation is handled by the store action + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + const project = upsertAndSetCurrentProject(path, name, effectiveTheme); // Check if app_spec.txt exists const specExists = await hasAppSpec(path); @@ -479,7 +496,12 @@ export function Sidebar() { }); } } - }, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]); + }, [ + trashedProjects, + upsertAndSetCurrentProject, + currentProject, + globalTheme, + ]); const handleRestoreProject = useCallback( (projectId: string) => { @@ -534,14 +556,14 @@ export function Sidebar() { } const confirmed = window.confirm( - "Clear all trashed projects from Automaker? This does not delete folders from disk." + "Clear all projects from recycle bin? This does not delete folders from disk." ); if (!confirmed) return; setIsEmptyingTrash(true); try { emptyTrash(); - toast.success("Trash cleared"); + toast.success("Recycle bin cleared"); setShowTrashDialog(false); } finally { setIsEmptyingTrash(false); @@ -761,7 +783,9 @@ export function Sidebar() {
- Automaker + {IS_MARKETING ? ( + <> + https://automaker.app + + ) : ( + <> + Automaker + + )}
{/* Bug Report Button */}
); })} @@ -1039,6 +1121,17 @@ export function Sidebar() { )} + + {/* Move to Trash Section */} + + setShowDeleteProjectDialog(true)} + className="text-destructive focus:text-destructive focus:bg-destructive/10" + data-testid="move-project-to-trash" + > + + Move to Trash + )} @@ -1163,14 +1256,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 @@ -1242,7 +1358,7 @@ export function Sidebar() { - Trash + Recycle Bin Restore projects to the sidebar or delete their folders using your system Trash. @@ -1250,7 +1366,9 @@ export function Sidebar() { {trashedProjects.length === 0 ? ( -

Trash is empty.

+

+ Recycle bin is empty. +

) : (
{trashedProjects.map((project) => ( @@ -1318,7 +1436,7 @@ export function Sidebar() { disabled={isEmptyingTrash} data-testid="empty-trash" > - {isEmptyingTrash ? "Clearing..." : "Empty Trash"} + {isEmptyingTrash ? "Clearing..." : "Empty Recycle Bin"} )} @@ -1421,6 +1539,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 new file mode 100644 index 00000000..fd1429de --- /dev/null +++ b/apps/app/src/components/new-project-modal.tsx @@ -0,0 +1,453 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { + FolderPlus, + FolderOpen, + Rocket, + ExternalLink, + Check, + Loader2, + Link, + Folder, +} from "lucide-react"; +import { starterTemplates, type StarterTemplate } from "@/lib/templates"; +import { getElectronAPI } from "@/lib/electron"; +import { getHttpApiClient } from "@/lib/http-api-client"; +import { cn } from "@/lib/utils"; +import { useFileBrowser } from "@/contexts/file-browser-context"; + +interface ValidationErrors { + projectName?: boolean; + workspaceDir?: boolean; + templateSelection?: boolean; + customUrl?: boolean; +} + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateBlankProject: (projectName: string, parentDir: string) => Promise; + 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({}); + const { openFileBrowser } = useFileBrowser(); + + // Fetch workspace directory when modal opens + useEffect(() => { + if (open) { + setIsLoadingWorkspace(true); + const httpClient = getHttpApiClient(); + httpClient.workspace.getConfig() + .then((result) => { + if (result.success && result.workspaceDir) { + setWorkspaceDir(result.workspaceDir); + } + }) + .catch((error) => { + console.error("Failed to get workspace config:", error); + }) + .finally(() => { + setIsLoadingWorkspace(false); + }); + } + }, [open]); + + // Reset form when modal closes + useEffect(() => { + if (!open) { + setProjectName(""); + setSelectedTemplate(null); + setUseCustomUrl(false); + setCustomUrl(""); + setActiveTab("blank"); + setErrors({}); + } + }, [open]); + + // Clear specific errors when user fixes them + useEffect(() => { + if (projectName && errors.projectName) { + setErrors((prev) => ({ ...prev, projectName: false })); + } + }, [projectName, errors.projectName]); + + useEffect(() => { + if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) { + setErrors((prev) => ({ ...prev, templateSelection: false })); + } + }, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]); + + useEffect(() => { + if (customUrl && errors.customUrl) { + setErrors((prev) => ({ ...prev, customUrl: false })); + } + }, [customUrl, errors.customUrl]); + + const validateAndCreate = async () => { + const newErrors: ValidationErrors = {}; + + // Check project name + if (!projectName.trim()) { + newErrors.projectName = true; + } + + // Check workspace dir + if (!workspaceDir) { + newErrors.workspaceDir = true; + } + + // Check template selection (only for template tab) + if (activeTab === "template") { + if (useCustomUrl) { + if (!customUrl.trim()) { + newErrors.customUrl = true; + } + } else if (!selectedTemplate) { + newErrors.templateSelection = true; + } + } + + // If there are errors, show them and don't proceed + if (Object.values(newErrors).some(Boolean)) { + setErrors(newErrors); + return; + } + + // Clear errors and proceed + setErrors({}); + + if (activeTab === "blank") { + await onCreateBlankProject(projectName, workspaceDir); + } else if (useCustomUrl && customUrl) { + await onCreateFromCustomUrl(customUrl, projectName, workspaceDir); + } else if (selectedTemplate) { + await onCreateFromTemplate(selectedTemplate, projectName, workspaceDir); + } + }; + + const handleOpenRepo = (url: string) => { + const api = getElectronAPI(); + api.openExternalLink(url); + }; + + const handleSelectTemplate = (template: StarterTemplate) => { + setSelectedTemplate(template); + setUseCustomUrl(false); + setCustomUrl(""); + }; + + const handleToggleCustomUrl = () => { + setUseCustomUrl(!useCustomUrl); + if (!useCustomUrl) { + setSelectedTemplate(null); + } + }; + + const handleBrowseDirectory = async () => { + const selectedPath = await openFileBrowser({ + title: "Select Base Project Directory", + description: "Choose the parent directory where your project will be created", + }); + if (selectedPath) { + setWorkspaceDir(selectedPath); + // Clear any workspace error when a valid directory is selected + if (errors.workspaceDir) { + setErrors((prev) => ({ ...prev, workspaceDir: false })); + } + } + }; + + const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; + + return ( + + + + 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 + )} + + +
+
+ + 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/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 23d332a9..12df34e5 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -24,6 +24,7 @@ import { AgentModel, ThinkingLevel, AIProfile, + defaultBackgroundSettings, } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; import { cn, modelSupportsThinking } from "@/lib/utils"; @@ -58,6 +59,7 @@ import { KanbanColumn } from "./kanban-column"; import { KanbanCard } from "./kanban-card"; import { AgentOutputModal } from "./agent-output-modal"; import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; +import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { Plus, RefreshCw, @@ -86,6 +88,7 @@ import { Square, Maximize2, Shuffle, + ImageIcon, } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; @@ -206,6 +209,7 @@ export function BoardView() { aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, + boardBackgroundByProject, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [activeFeature, setActiveFeature] = useState(null); @@ -230,6 +234,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 +406,8 @@ export function BoardView() { const currentPath = currentProject.path; const previousPath = prevProjectPathRef.current; - const isProjectSwitch = previousPath !== null && currentPath !== previousPath; + const isProjectSwitch = + previousPath !== null && currentPath !== previousPath; // Get cached features from store (without adding to dependencies) const cachedFeatures = useAppStore.getState().features; @@ -556,7 +563,8 @@ export function BoardView() { const unsubscribe = api.autoMode.onEvent((event) => { // Use event's projectPath or projectId if available, otherwise use current project // Board view only reacts to events for the currently selected project - const eventProjectId = ('projectId' in event && event.projectId) || projectId; + const eventProjectId = + ("projectId" in event && event.projectId) || projectId; if (event.type === "auto_mode_feature_complete") { // Reload features when a feature is completed @@ -585,15 +593,16 @@ export function BoardView() { loadFeatures(); // Check for authentication errors and show a more helpful message - const isAuthError = event.errorType === "authentication" || - (event.error && ( - event.error.includes("Authentication failed") || - event.error.includes("Invalid API key") - )); + const isAuthError = + event.errorType === "authentication" || + (event.error && + (event.error.includes("Authentication failed") || + event.error.includes("Invalid API key"))); if (isAuthError) { toast.error("Authentication Failed", { - description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.", + description: + "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.", duration: 10000, }); } else { @@ -867,7 +876,11 @@ export function BoardView() { // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); - persistFeatureUpdate(featureId, { status: "verified" }); + // Clear justFinishedAt timestamp when manually verifying via drag + persistFeatureUpdate(featureId, { + status: "verified", + justFinishedAt: undefined, + }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, @@ -877,7 +890,11 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - persistFeatureUpdate(featureId, { status: "backlog" }); + // Clear justFinishedAt timestamp when moving back to backlog + persistFeatureUpdate(featureId, { + status: "backlog", + justFinishedAt: undefined, + }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -1198,7 +1215,11 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - persistFeatureUpdate(feature.id, { status: "verified" }); + // Clear justFinishedAt timestamp when manually verifying + persistFeatureUpdate(feature.id, { + status: "verified", + justFinishedAt: undefined, + }); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" @@ -1264,9 +1285,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up + // Clear justFinishedAt timestamp since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), + justFinishedAt: undefined, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1626,7 +1649,7 @@ export function BoardView() { return; } - const featuresToStart = backlogFeatures.slice(0, availableSlots); + const featuresToStart = backlogFeatures.slice(0, 1); for (const feature of featuresToStart) { // Update the feature status with startedAt timestamp @@ -1835,202 +1858,282 @@ export function BoardView() { )} - {/* 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]) || + defaultBackgroundSettings; + + // Build background image style if image exists + const backgroundImageStyle = backgroundSettings.imagePath + ? { + backgroundImage: `url(${ + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008" + }/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent( + currentProject?.path || "" + )}${ + backgroundSettings.imageVersion + ? `&v=${backgroundSettings.imageVersion}` + : "" + })`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + : {}; + + return ( +
+ +
+ {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 */} ([]); 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 a0e06ad1..a49ca9b6 100644 --- a/apps/app/src/components/views/kanban-card.tsx +++ b/apps/app/src/components/views/kanban-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, memo } from "react"; +import { useState, useEffect, useMemo, memo } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; @@ -28,7 +28,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Feature, useAppStore } from "@/store/app-store"; +import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store"; import { GripVertical, Edit, @@ -56,6 +56,7 @@ import { GitMerge, ChevronDown, ChevronUp, + Brain, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; @@ -73,6 +74,21 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; +/** + * Formats thinking level for compact display + */ +function formatThinkingLevel(level: ThinkingLevel | undefined): string { + if (!level || level === "none") return ""; + const labels: Record = { + none: "", + low: "Low", + medium: "Med", + high: "High", + ultrathink: "Ultra", + }; + return labels[level]; +} + interface KanbanCardProps { feature: Feature; onEdit: () => void; @@ -94,6 +110,14 @@ interface KanbanCardProps { contextContent?: string; /** Feature summary from agent completion */ summary?: string; + /** Opacity percentage (0-100) */ + opacity?: number; + /** Whether to use glassmorphism (backdrop-blur) effect */ + glassmorphism?: boolean; + /** Whether to show card borders */ + cardBorderEnabled?: boolean; + /** Card border opacity percentage (0-100) */ + cardBorderOpacity?: number; } export const KanbanCard = memo(function KanbanCard({ @@ -115,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({ shortcutKey, contextContent, summary, + opacity = 100, + glassmorphism = true, + cardBorderEnabled = true, + cardBorderOpacity = 100, }: KanbanCardProps) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [currentTime, setCurrentTime] = useState(() => Date.now()); const { kanbanCardDetailLevel } = useAppStore(); // Check if feature has worktree @@ -132,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({ kanbanCardDetailLevel === "detailed"; const showAgentInfo = kanbanCardDetailLevel === "detailed"; + // Helper to check if "just finished" badge should be shown (within 2 minutes) + const isJustFinished = useMemo(() => { + if ( + !feature.justFinishedAt || + feature.status !== "waiting_approval" || + feature.error + ) { + return false; + } + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + return currentTime - finishedTime < twoMinutes; + }, [feature.justFinishedAt, feature.status, feature.error, currentTime]); + + // Update current time periodically to check if badge should be hidden + useEffect(() => { + if (!feature.justFinishedAt || feature.status !== "waiting_approval") { + return; + } + + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + const timeRemaining = twoMinutes - (currentTime - finishedTime); + + if (timeRemaining <= 0) { + // Already past 2 minutes + return; + } + + // Update time every second to check if 2 minutes have passed + const interval = setInterval(() => { + setCurrentTime(Date.now()); + }, 1000); + + return () => clearInterval(interval); + }, [feature.justFinishedAt, feature.status, currentTime]); + // Load context file for in_progress, waiting_approval, and verified features useEffect(() => { const loadContext = async () => { @@ -168,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({ } else { // Fallback to direct file read for backward compatibility const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`; - const result = await api.readFile(contextPath); + const result = await api.readFile(contextPath); - if (result.success && result.content) { - const info = parseAgentContext(result.content); - setAgentInfo(info); + if (result.success && result.content) { + const info = parseAgentContext(result.content); + setAgentInfo(info); } } } catch { @@ -225,17 +291,48 @@ export const KanbanCard = memo(function KanbanCard({ const style = { transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : undefined, }; - return ( + // Calculate border style based on enabled state and opacity + const borderStyle: React.CSSProperties = { ...style }; + if (!cardBorderEnabled) { + (borderStyle as Record).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)`; + } + + const cardElement = ( + {/* 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 (for 2 minutes) */} + {isJustFinished && ( +
+ + Fresh Baked +
+ )} {/* Branch badge - show when feature has a worktree */} {hasWorktree && !isCurrentAutoTask && ( @@ -285,19 +407,23 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default", "bg-purple-500/20 border border-purple-500/50 text-purple-400", - // Position below error badge if present, otherwise use normal position - feature.error || feature.skipTests + // Position below other badges if present, otherwise use normal position + feature.error || feature.skipTests || isJustFinished ? "top-8 left-2" : "top-2 left-2" )} data-testid={`branch-badge-${feature.id}`} > - {feature.branchName?.replace("feature/", "")} + + {feature.branchName?.replace("feature/", "")} +
-

{feature.branchName}

+

+ {feature.branchName} +

@@ -306,14 +432,19 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout // Add extra top padding when badges are present to prevent text overlap - (feature.skipTests || feature.error) && "pt-10", + (feature.skipTests || feature.error || isJustFinished) && "pt-10", // Add even more top padding when both badges and branch are shown - hasWorktree && (feature.skipTests || feature.error) && "pt-14" + hasWorktree && + (feature.skipTests || feature.error || isJustFinished) && + "pt-14" )} > {isCurrentAutoTask && (
+ + {formatModelName(feature.model ?? DEFAULT_MODEL)} + {feature.startedAt && ( )} - {step} + + {step} +
))} {feature.steps.length > 3 && ( @@ -448,6 +581,28 @@ export const KanbanCard = memo(function KanbanCard({ )} + {/* 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 && ( @@ -509,7 +664,8 @@ export const KanbanCard = memo(function KanbanCard({ todo.status === "completed" && "text-muted-foreground line-through", todo.status === "in_progress" && "text-amber-400", - todo.status === "pending" && "text-foreground-secondary" + todo.status === "pending" && + "text-foreground-secondary" )} > {todo.content} @@ -822,9 +978,13 @@ export const KanbanCard = memo(function KanbanCard({ 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; @@ -860,10 +1020,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. )} @@ -895,4 +1060,11 @@ export const KanbanCard = memo(function KanbanCard({
); + + // Wrap with animated border when in progress + if (isCurrentAutoTask) { + return
{cardElement}
; + } + + return cardElement; }); 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/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx index 9128c179..21c93112 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,14 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, type ThemeMode } from "@/store/app-store"; import { getElectronAPI, type Project } from "@/lib/electron"; import { initializeProject } from "@/lib/project-init"; import { @@ -41,14 +31,22 @@ import { } from "@/components/ui/dropdown-menu"; import { toast } from "sonner"; import { WorkspacePickerModal } from "@/components/workspace-picker-modal"; +import { NewProjectModal } from "@/components/new-project-modal"; import { getHttpApiClient } from "@/lib/http-api-client"; +import type { StarterTemplate } from "@/lib/templates"; export function WelcomeView() { - const { projects, addProject, setCurrentProject, setCurrentView } = - useAppStore(); - const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); - const [newProjectName, setNewProjectName] = useState(""); - const [newProjectPath, setNewProjectPath] = useState(""); + const { + projects, + trashedProjects, + currentProject, + upsertAndSetCurrentProject, + addProject, + setCurrentProject, + setCurrentView, + theme: globalTheme, + } = useAppStore(); + const [showNewProjectModal, setShowNewProjectModal] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isOpening, setIsOpening] = useState(false); const [showInitDialog, setShowInitDialog] = useState(false); @@ -108,35 +106,14 @@ export function WelcomeView() { return; } - // Check if project already exists (by path) to preserve theme and other settings - const existingProject = projects.find((p) => p.path === path); - - let project: Project; - if (existingProject) { - // Update existing project, preserving theme and other properties - project = { - ...existingProject, - name, // Update name in case it changed - lastOpened: new Date().toISOString(), - }; - // Update the project in the store (this will update the existing entry) - const updatedProjects = projects.map((p) => - p.id === existingProject.id ? project : p - ); - // We need to manually update projects since addProject would create a duplicate - useAppStore.setState({ projects: updatedProjects }); - } else { - // Create new project - project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - }; - addProject(project); - } - - setCurrentProject(project); + // Upsert project and set as current (handles both create and update cases) + // Theme preservation is handled by the store action + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + const project = upsertAndSetCurrentProject(path, name, effectiveTheme); // Show initialization dialog if files were created if (initResult.createdFiles && initResult.createdFiles.length > 0) { @@ -171,7 +148,13 @@ export function WelcomeView() { setIsOpening(false); } }, - [projects, addProject, setCurrentProject, analyzeProject] + [ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + analyzeProject, + ] ); const handleOpenProject = useCallback(async () => { @@ -191,7 +174,8 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; // Extract folder name from path (works on both Windows and Mac/Linux) - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = + path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; await initializeAndOpenProject(path, name); } } @@ -203,7 +187,8 @@ export function WelcomeView() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; - const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = + path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; await initializeAndOpenProject(path, name); } } @@ -231,31 +216,24 @@ export function WelcomeView() { ); const handleNewProject = () => { - setNewProjectName(""); - setNewProjectPath(""); - setShowNewProjectDialog(true); + setShowNewProjectModal(true); }; const handleInteractiveMode = () => { setCurrentView("interview"); }; - const handleSelectDirectory = async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); - - if (!result.canceled && result.filePaths[0]) { - setNewProjectPath(result.filePaths[0]); - } - }; - - const handleCreateProject = async () => { - if (!newProjectName || !newProjectPath) return; - + /** + * Create a blank project with just .automaker directory structure + */ + const handleCreateBlankProject = async ( + projectName: string, + parentDir: string + ) => { setIsCreating(true); try { const api = getElectronAPI(); - const projectPath = `${newProjectPath}/${newProjectName}`; + const projectPath = `${parentDir}/${projectName}`; // Create project directory await api.mkdir(projectPath); @@ -274,7 +252,7 @@ export function WelcomeView() { await api.writeFile( `${projectPath}/.automaker/app_spec.txt`, ` - ${newProjectName} + ${projectName} Describe your project here. This file will be analyzed by an AI agent @@ -297,24 +275,24 @@ export function WelcomeView() { const project = { id: `project-${Date.now()}`, - name: newProjectName, + name: projectName, path: projectPath, lastOpened: new Date().toISOString(), }; addProject(project); setCurrentProject(project); - setShowNewProjectDialog(false); + setShowNewProjectModal(false); toast.success("Project created", { - description: `Created ${newProjectName} with .automaker directory`, + description: `Created ${projectName} with .automaker directory`, }); // Set init status to show the dialog setInitStatus({ isNewProject: true, createdFiles: initResult.createdFiles || [], - projectName: newProjectName, + projectName: projectName, projectPath: projectPath, }); setShowInitDialog(true); @@ -328,6 +306,210 @@ export function WelcomeView() { } }; + /** + * Create a project from a GitHub starter template + */ + const handleCreateFromTemplate = async ( + template: StarterTemplate, + projectName: string, + parentDir: string + ) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const api = getElectronAPI(); + + // Clone the template repository + const cloneResult = await httpClient.templates.clone( + template.repoUrl, + projectName, + parentDir + ); + + if (!cloneResult.success || !cloneResult.projectPath) { + toast.error("Failed to clone template", { + description: cloneResult.error || "Unknown error occurred", + }); + return; + } + + const projectPath = cloneResult.projectPath; + + // Initialize .automaker directory with all necessary files + const initResult = await initializeProject(projectPath); + + if (!initResult.success) { + toast.error("Failed to initialize project", { + description: initResult.error || "Unknown error occurred", + }); + return; + } + + // Update the app_spec.txt with template-specific info + await api.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${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 +690,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/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/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/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/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 76313ee1..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,32 @@ 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( + data: string, + filename: string, + mimeType: string, + projectPath: string + ): Promise<{ success: boolean; path?: string; error?: string }> { + return this.post("/api/fs/save-board-background", { + data, + filename, + mimeType, + projectPath, + }); + } + + async deleteBoardBackground( + projectPath: string + ): Promise<{ success: boolean; error?: string }> { + return this.post("/api/fs/delete-board-background", { projectPath }); } // CLI checks - server-side @@ -444,14 +480,19 @@ export class HttpApiClient implements ElectronAPI { output?: string; }> => this.post("/api/setup/auth-claude"), - authCodex: (apiKey?: string): Promise<{ + authCodex: ( + apiKey?: string + ): Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string; }> => this.post("/api/setup/auth-codex", { apiKey }), - storeApiKey: (provider: string, apiKey: string): Promise<{ + storeApiKey: ( + provider: string, + apiKey: string + ): Promise<{ success: boolean; error?: string; }> => this.post("/api/setup/store-api-key", { provider, apiKey }), @@ -463,7 +504,9 @@ export class HttpApiClient implements ElectronAPI { hasGoogleKey: boolean; }> => this.get("/api/setup/api-keys"), - configureCodexMcp: (projectPath: string): Promise<{ + configureCodexMcp: ( + projectPath: string + ): Promise<{ success: boolean; configPath?: string; error?: string; @@ -496,8 +539,11 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/features/get", { projectPath, featureId }), create: (projectPath: string, feature: Feature) => this.post("/api/features/create", { projectPath, feature }), - update: (projectPath: string, featureId: string, updates: Partial) => - 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) => @@ -514,8 +560,16 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/auto-mode/stop-feature", { featureId }), status: (projectPath?: string) => this.post("/api/auto-mode/status", { projectPath }), - runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => - this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }), + runFeature: ( + projectPath: string, + featureId: string, + useWorktrees?: boolean + ) => + this.post("/api/auto-mode/run-feature", { + projectPath, + featureId, + useWorktrees, + }), verifyFeature: (projectPath: string, featureId: string) => this.post("/api/auto-mode/verify-feature", { projectPath, featureId }), resumeFeature: (projectPath: string, featureId: string) => @@ -539,7 +593,10 @@ export class HttpApiClient implements ElectronAPI { commitFeature: (projectPath: string, featureId: string) => this.post("/api/auto-mode/commit-feature", { projectPath, featureId }), onEvent: (callback: (event: AutoModeEvent) => void) => { - return this.subscribeToEvent("auto-mode:event", callback as EventCallback); + return this.subscribeToEvent( + "auto-mode:event", + callback as EventCallback + ); }, }; @@ -558,7 +615,11 @@ export class HttpApiClient implements ElectronAPI { getDiffs: (projectPath: string, featureId: string) => this.post("/api/worktree/diffs", { projectPath, featureId }), getFileDiff: (projectPath: string, featureId: string, filePath: string) => - this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }), + this.post("/api/worktree/file-diff", { + projectPath, + featureId, + filePath, + }), }; // Git API @@ -576,20 +637,30 @@ export class HttpApiClient implements ElectronAPI { stop: () => this.post("/api/suggestions/stop"), status: () => this.get("/api/suggestions/status"), onEvent: (callback: (event: SuggestionsEvent) => void) => { - return this.subscribeToEvent("suggestions:event", callback as EventCallback); + return this.subscribeToEvent( + "suggestions:event", + callback as EventCallback + ); }, }; // Spec Regeneration API specRegeneration: SpecRegenerationAPI = { - create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) => + create: ( + projectPath: string, + projectOverview: string, + generateFeatures?: boolean + ) => this.post("/api/spec-regeneration/create", { projectPath, projectOverview, generateFeatures, }), generate: (projectPath: string, projectDefinition: string) => - this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }), + this.post("/api/spec-regeneration/generate", { + projectPath, + projectDefinition, + }), generateFeatures: (projectPath: string) => this.post("/api/spec-regeneration/generate-features", { projectPath }), stop: () => this.post("/api/spec-regeneration/stop"), @@ -636,7 +707,10 @@ export class HttpApiClient implements ElectronAPI { // Agent API agent = { - start: (sessionId: string, workingDirectory?: string): Promise<{ + start: ( + sessionId: string, + workingDirectory?: string + ): Promise<{ success: boolean; messages?: Message[]; error?: string; @@ -648,9 +722,16 @@ export class HttpApiClient implements ElectronAPI { workingDirectory?: string, imagePaths?: string[] ): Promise<{ success: boolean; error?: string }> => - this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }), + this.post("/api/agent/send", { + sessionId, + message, + workingDirectory, + imagePaths, + }), - getHistory: (sessionId: string): Promise<{ + getHistory: ( + sessionId: string + ): Promise<{ success: boolean; messages?: Message[]; isRunning?: boolean; @@ -668,9 +749,26 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Templates API + templates = { + clone: ( + repoUrl: string, + projectName: string, + parentDir: string + ): Promise<{ + success: boolean; + projectPath?: string; + projectName?: string; + error?: string; + }> => + this.post("/api/templates/clone", { repoUrl, projectName, parentDir }), + }; + // Sessions API sessions = { - list: (includeArchived?: boolean): Promise<{ + list: ( + includeArchived?: boolean + ): Promise<{ success: boolean; sessions?: SessionListItem[]; error?: string; @@ -700,13 +798,19 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; error?: string }> => this.put(`/api/sessions/${sessionId}`, { name, tags }), - archive: (sessionId: string): Promise<{ success: boolean; error?: string }> => + archive: ( + sessionId: string + ): Promise<{ success: boolean; error?: string }> => this.post(`/api/sessions/${sessionId}/archive`, {}), - unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> => + unarchive: ( + sessionId: string + ): Promise<{ success: boolean; error?: string }> => this.post(`/api/sessions/${sessionId}/unarchive`, {}), - delete: (sessionId: string): Promise<{ success: boolean; error?: string }> => + delete: ( + sessionId: string + ): Promise<{ success: boolean; error?: string }> => this.httpDelete(`/api/sessions/${sessionId}`), }; } 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/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 8b83e7e5..acc02f85 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,6 +275,7 @@ export interface Feature { // Worktree info - set when a feature is being worked on in an isolated git worktree worktreePath?: string; // Path to the worktree directory branchName?: string; // Name of the feature branch + justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes) } // File tree node for project analysis @@ -301,10 +332,13 @@ export interface AppState { chatHistoryOpen: boolean; // Auto Mode (per-project state, keyed by project ID) - autoModeByProject: Record; + autoModeByProject: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; // Feature IDs being worked on + } + >; autoModeActivityLog: AutoModeActivity[]; maxConcurrency: number; // Maximum number of concurrent agent tasks @@ -332,8 +366,49 @@ export interface AppState { // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; + + // Board Background Settings (per-project, keyed by project path) + boardBackgroundByProject: Record< + string, + { + imagePath: string | null; // Path to background image in .automaker directory + imageVersion?: number; // Timestamp to bust browser cache when image is updated + cardOpacity: number; // Opacity of cards (0-100) + columnOpacity: number; // Opacity of columns (0-100) + columnBorderEnabled: boolean; // Whether to show column borders + cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards + cardBorderEnabled: boolean; // Whether to show card borders + cardBorderOpacity: number; // Opacity of card borders (0-100) + hideScrollbar: boolean; // Whether to hide the board scrollbar + } + >; + + // Theme Preview (for hover preview in theme selectors) + previewTheme: ThemeMode | null; } +// Default background settings for board backgrounds +export const defaultBackgroundSettings: { + imagePath: string | null; + imageVersion?: number; + cardOpacity: number; + columnOpacity: number; + columnBorderEnabled: boolean; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; + hideScrollbar: boolean; +} = { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, +}; + export interface AutoModeActivity { id: string; featureId: string; @@ -364,6 +439,11 @@ export interface AppActions { deleteTrashedProject: (projectId: string) => void; emptyTrash: () => void; setCurrentProject: (project: Project | null) => void; + upsertAndSetCurrentProject: ( + path: string, + name: string, + theme?: ThemeMode + ) => Project; // Upsert project by path and set as current reorderProjects: (oldIndex: number, newIndex: number) => void; cyclePrevProject: () => void; // Cycle back through project history (Q) cycleNextProject: () => void; // Cycle forward through project history (E) @@ -377,7 +457,8 @@ export interface AppActions { // Theme actions setTheme: (theme: ThemeMode) => void; setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear) - getEffectiveTheme: () => ThemeMode; // Get the effective theme (project or global) + getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set) + setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear) // Feature actions setFeatures: (features: Feature[]) => void; @@ -413,7 +494,10 @@ export interface AppActions { addRunningTask: (projectId: string, taskId: string) => void; removeRunningTask: (projectId: string, taskId: string) => void; clearRunningTasks: (projectId: string) => void; - getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] }; + getAutoModeState: (projectId: string) => { + isRunning: boolean; + runningTasks: string[]; + }; addAutoModeActivity: ( activity: Omit ) => void; @@ -452,9 +536,33 @@ export interface AppActions { clearAnalysis: () => void; // Agent Session actions - setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; + setLastSelectedSession: ( + projectPath: string, + sessionId: string | null + ) => void; getLastSelectedSession: (projectPath: string) => string | null; + // Board Background actions + setBoardBackground: (projectPath: string, imagePath: string | null) => void; + setCardOpacity: (projectPath: string, opacity: number) => void; + setColumnOpacity: (projectPath: string, opacity: number) => void; + setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void; + getBoardBackground: (projectPath: string) => { + imagePath: string | null; + cardOpacity: number; + columnOpacity: number; + columnBorderEnabled: boolean; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; + hideScrollbar: boolean; + }; + setCardGlassmorphism: (projectPath: string, enabled: boolean) => void; + setCardBorderEnabled: (projectPath: string, enabled: boolean) => void; + setCardBorderOpacity: (projectPath: string, opacity: number) => void; + setHideScrollbar: (projectPath: string, hide: boolean) => void; + clearBoardBackground: (projectPath: string) => void; + // Reset reset: () => void; } @@ -464,7 +572,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ { id: "profile-heavy-task", name: "Heavy Task", - description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.", + description: + "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.", model: "opus", thinkingLevel: "ultrathink", provider: "claude", @@ -474,7 +583,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ { id: "profile-balanced", name: "Balanced", - description: "Claude Sonnet with medium thinking for typical development tasks.", + description: + "Claude Sonnet with medium thinking for typical development tasks.", model: "sonnet", thinkingLevel: "medium", provider: "claude", @@ -546,6 +656,8 @@ const initialState: AppState = { aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, + boardBackgroundByProject: {}, + previewTheme: null, }; export const useAppStore = create()( @@ -671,7 +783,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) @@ -681,6 +795,58 @@ export const useAppStore = create()( } }, + upsertAndSetCurrentProject: (path, name, theme) => { + const { + projects, + trashedProjects, + currentProject, + theme: globalTheme, + } = get(); + const existingProject = projects.find((p) => p.path === path); + let project: Project; + + if (existingProject) { + // Update existing project, preserving theme and other properties + project = { + ...existingProject, + name, // Update name in case it changed + lastOpened: new Date().toISOString(), + }; + // Update the project in the store + const updatedProjects = projects.map((p) => + p.id === existingProject.id ? project : p + ); + set({ projects: updatedProjects }); + } else { + // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) + // Then fall back to provided theme, then current project theme, then global theme + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + theme || + trashedProject?.theme || + currentProject?.theme || + globalTheme; + project = { + id: `project-${Date.now()}`, + name, + path, + lastOpened: new Date().toISOString(), + theme: effectiveTheme, + }; + // Add the new project to the store + set({ + projects: [ + ...projects, + { ...project, lastOpened: new Date().toISOString() }, + ], + }); + } + + // Set as current project (this will also update history and view) + get().setCurrentProject(project); + return project; + }, + cyclePrevProject: () => { const { projectHistory, projectHistoryIndex, projects } = get(); @@ -711,7 +877,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -736,9 +902,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); @@ -748,7 +913,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -800,6 +965,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) { @@ -809,6 +979,8 @@ export const useAppStore = create()( return get().theme; }, + setPreviewTheme: (theme) => set({ previewTheme: theme }), + // Feature actions setFeatures: (features) => set({ features }), @@ -960,7 +1132,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, @@ -971,7 +1146,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: { @@ -987,13 +1165,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 + ), }, }, }); @@ -1001,7 +1184,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, @@ -1116,7 +1302,9 @@ export const useAppStore = create()( const current = get().lastSelectedSessionByProject; if (sessionId === null) { // Remove the entry for this project - const { [projectPath]: _, ...rest } = current; + const rest = Object.fromEntries( + Object.entries(current).filter(([key]) => key !== projectPath) + ); set({ lastSelectedSessionByProject: rest }); } else { set({ @@ -1131,6 +1319,151 @@ export const useAppStore = create()( getLastSelectedSession: (projectPath) => { return get().lastSelectedSessionByProject[projectPath] || null; }, + + // Board Background actions + setBoardBackground: (projectPath, imagePath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath, + // Update imageVersion timestamp to bust browser cache when image changes + imageVersion: imagePath ? Date.now() : undefined, + }, + }, + }); + }, + + setCardOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardOpacity: opacity, + }, + }, + }); + }, + + setColumnOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnOpacity: opacity, + }, + }, + }); + }, + + getBoardBackground: (projectPath) => { + const settings = get().boardBackgroundByProject[projectPath]; + return settings || defaultBackgroundSettings; + }, + + setColumnBorderEnabled: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnBorderEnabled: enabled, + }, + }, + }); + }, + + setCardGlassmorphism: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardGlassmorphism: enabled, + }, + }, + }); + }, + + setCardBorderEnabled: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderEnabled: enabled, + }, + }, + }); + }, + + setCardBorderOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderOpacity: opacity, + }, + }, + }); + }, + + setHideScrollbar: (projectPath, hide) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + hideScrollbar: hide, + }, + }, + }); + }, + + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath: null, // Only clear the image, preserve other settings + imageVersion: undefined, // Clear version when clearing image + }, + }, + }); + }, + // Reset reset: () => set(initialState), }), @@ -1178,6 +1511,8 @@ export const useAppStore = create()( aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, lastSelectedSessionByProject: state.lastSelectedSessionByProject, + // Board background settings + boardBackgroundByProject: state.boardBackgroundByProject, }), } ) diff --git a/apps/marketing/public/index.html b/apps/marketing/public/index.html index 3f9a6336..372bd8fe 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
+
+
+ +
+
+