From ebd928e3b68d4b932ccf95b29013b0d005f61915 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 12 Dec 2025 22:05:16 -0500 Subject: [PATCH] feat: add red theme and board background modal - Introduced a new red theme with custom color variables for a bold aesthetic. - Updated the theme management to include the new red theme option. - Added a BoardBackgroundModal component for managing board background settings, including image uploads and opacity controls. - Enhanced KanbanCard and KanbanColumn components to support new background settings such as opacity and border visibility. - Updated API client to handle saving and deleting board backgrounds. - Refactored theme application logic to accommodate the new preview theme functionality. --- apps/app/src/app/globals.css | 70 +++ apps/app/src/app/page.tsx | 23 +- .../dialogs/board-background-modal.tsx | 533 ++++++++++++++++++ apps/app/src/components/layout/sidebar.tsx | 184 ++++-- apps/app/src/components/views/board-view.tsx | 512 ++++++++++------- apps/app/src/components/views/kanban-card.tsx | 149 ++++- .../src/components/views/kanban-column.tsx | 38 +- .../views/settings-view/shared/types.ts | 3 +- apps/app/src/config/theme-options.ts | 7 + apps/app/src/lib/http-api-client.ts | 20 + apps/app/src/store/app-store.ts | 384 ++++++++++--- apps/server/src/index.ts | 6 +- apps/server/src/routes/auto-mode.ts | 6 +- apps/server/src/services/auto-mode-service.ts | 152 ++++- 14 files changed, 1700 insertions(+), 387 deletions(-) create mode 100644 apps/app/src/components/dialogs/board-background-modal.tsx diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css index 2f7dc659..7036229e 100644 --- a/apps/app/src/app/globals.css +++ b/apps/app/src/app/globals.css @@ -12,6 +12,7 @@ @custom-variant catppuccin (&:is(.catppuccin *)); @custom-variant onedark (&:is(.onedark *)); @custom-variant synthwave (&:is(.synthwave *)); +@custom-variant red (&:is(.red *)); @theme inline { --color-background: var(--background); @@ -1072,6 +1073,75 @@ --running-indicator-text: oklch(0.75 0.26 350); } +/* Red Theme - Bold crimson/red aesthetic */ +.red { + --background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */ + --background-50: oklch(0.12 0.03 15 / 0.5); + --background-80: oklch(0.12 0.03 15 / 0.8); + + --foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */ + --foreground-secondary: oklch(0.7 0.02 15); + --foreground-muted: oklch(0.5 0.03 15); + + --card: oklch(0.18 0.04 15); /* Slightly lighter dark red */ + --card-foreground: oklch(0.95 0.01 15); + --popover: oklch(0.15 0.035 15); + --popover-foreground: oklch(0.95 0.01 15); + + --primary: oklch(0.55 0.25 25); /* Vibrant crimson red */ + --primary-foreground: oklch(0.98 0 0); + + --brand-400: oklch(0.6 0.23 25); + --brand-500: oklch(0.55 0.25 25); /* Crimson */ + --brand-600: oklch(0.5 0.27 25); + + --secondary: oklch(0.22 0.05 15); + --secondary-foreground: oklch(0.95 0.01 15); + + --muted: oklch(0.22 0.05 15); + --muted-foreground: oklch(0.5 0.03 15); + + --accent: oklch(0.28 0.06 15); + --accent-foreground: oklch(0.95 0.01 15); + + --destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */ + + --border: oklch(0.35 0.08 15); + --border-glass: oklch(0.55 0.25 25 / 0.3); + + --input: oklch(0.18 0.04 15); + --ring: oklch(0.55 0.25 25); + + --chart-1: oklch(0.55 0.25 25); /* Crimson */ + --chart-2: oklch(0.7 0.2 50); /* Orange */ + --chart-3: oklch(0.8 0.18 80); /* Gold */ + --chart-4: oklch(0.6 0.22 0); /* Pure red */ + --chart-5: oklch(0.65 0.2 350); /* Pink-red */ + + --sidebar: oklch(0.1 0.025 15); + --sidebar-foreground: oklch(0.95 0.01 15); + --sidebar-primary: oklch(0.55 0.25 25); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.22 0.05 15); + --sidebar-accent-foreground: oklch(0.95 0.01 15); + --sidebar-border: oklch(0.35 0.08 15); + --sidebar-ring: oklch(0.55 0.25 25); + + /* Action button colors - Red theme */ + --action-view: oklch(0.55 0.25 25); /* Crimson */ + --action-view-hover: oklch(0.5 0.27 25); + --action-followup: oklch(0.7 0.2 50); /* Orange */ + --action-followup-hover: oklch(0.65 0.22 50); + --action-commit: oklch(0.6 0.2 140); /* Green for positive actions */ + --action-commit-hover: oklch(0.55 0.22 140); + --action-verify: oklch(0.6 0.2 140); /* Green */ + --action-verify-hover: oklch(0.55 0.22 140); + + /* Running indicator - Crimson */ + --running-indicator: oklch(0.55 0.25 25); + --running-indicator-text: oklch(0.6 0.23 25); +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 14e200be..0397f513 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -15,7 +15,11 @@ import { RunningAgentsView } from "@/components/views/running-agents-view"; import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; -import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context"; +import { + FileBrowserProvider, + useFileBrowser, + setGlobalFileBrowser, +} from "@/contexts/file-browser-context"; function HomeContent() { const { @@ -24,6 +28,8 @@ function HomeContent() { setIpcConnected, theme, currentProject, + previewTheme, + getEffectiveTheme, } = useAppStore(); const { isFirstRun, setupComplete } = useSetupStore(); const [isMounted, setIsMounted] = useState(false); @@ -72,9 +78,9 @@ function HomeContent() { }; }, [handleStreamerPanelShortcut]); - // Compute the effective theme: project theme takes priority over global theme - // This is reactive because it depends on currentProject and theme from the store - const effectiveTheme = currentProject?.theme || theme; + // Compute the effective theme: previewTheme takes priority, then project theme, then global theme + // This is reactive because it depends on previewTheme, currentProject, and theme from the store + const effectiveTheme = getEffectiveTheme(); // Prevent hydration issues useEffect(() => { @@ -122,7 +128,7 @@ function HomeContent() { testConnection(); }, [setIpcConnected]); - // Apply theme class to document (uses effective theme - project-specific or global) + // Apply theme class to document (uses effective theme - preview, project-specific, or global) useEffect(() => { const root = document.documentElement; root.classList.remove( @@ -137,7 +143,8 @@ function HomeContent() { "gruvbox", "catppuccin", "onedark", - "synthwave" + "synthwave", + "red" ); if (effectiveTheme === "dark") { @@ -162,6 +169,8 @@ function HomeContent() { root.classList.add("onedark"); } else if (effectiveTheme === "synthwave") { root.classList.add("synthwave"); + } else if (effectiveTheme === "red") { + root.classList.add("red"); } else if (effectiveTheme === "light") { root.classList.add("light"); } else if (effectiveTheme === "system") { @@ -173,7 +182,7 @@ function HomeContent() { root.classList.add("light"); } } - }, [effectiveTheme]); + }, [effectiveTheme, previewTheme, currentProject, theme]); const renderView = () => { switch (currentView) { diff --git a/apps/app/src/components/dialogs/board-background-modal.tsx b/apps/app/src/components/dialogs/board-background-modal.tsx new file mode 100644 index 00000000..f22ac280 --- /dev/null +++ b/apps/app/src/components/dialogs/board-background-modal.tsx @@ -0,0 +1,533 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { useAppStore } from "@/store/app-store"; +import { getHttpApiClient } from "@/lib/http-api-client"; +import { toast } from "sonner"; + +const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]; +const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +interface BoardBackgroundModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BoardBackgroundModal({ + open, + onOpenChange, +}: BoardBackgroundModalProps) { + const { + currentProject, + boardBackgroundByProject, + setBoardBackground, + setCardOpacity, + setColumnOpacity, + setColumnBorderEnabled, + setCardGlassmorphism, + setCardBorderEnabled, + setCardBorderOpacity, + setHideScrollbar, + clearBoardBackground, + } = useAppStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const fileInputRef = useRef(null); + const [previewImage, setPreviewImage] = useState(null); + + // Get current background settings (live from store) + const backgroundSettings = currentProject + ? boardBackgroundByProject[currentProject.path] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + : { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + + const cardOpacity = backgroundSettings.cardOpacity; + const columnOpacity = backgroundSettings.columnOpacity; + const columnBorderEnabled = backgroundSettings.columnBorderEnabled; + const cardGlassmorphism = backgroundSettings.cardGlassmorphism; + const cardBorderEnabled = backgroundSettings.cardBorderEnabled; + const cardBorderOpacity = backgroundSettings.cardBorderOpacity; + const hideScrollbar = backgroundSettings.hideScrollbar; + + // Update preview image when background settings change + useEffect(() => { + if (currentProject && backgroundSettings.imagePath) { + const serverUrl = + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent(currentProject.path)}`; + setPreviewImage(imagePath); + } else { + setPreviewImage(null); + } + }, [currentProject, backgroundSettings.imagePath]); + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + } else { + reject(new Error("Failed to read file as base64")); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + }; + + const processFile = useCallback( + async (file: File) => { + if (!currentProject) { + toast.error("No project selected"); + return; + } + + // Validate file type + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + toast.error( + "Unsupported file type. Please use JPG, PNG, GIF, or WebP." + ); + return; + } + + // Validate file size + if (file.size > DEFAULT_MAX_FILE_SIZE) { + const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024); + toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); + return; + } + + setIsProcessing(true); + try { + const base64 = await fileToBase64(file); + + // Set preview immediately + setPreviewImage(base64); + + // Save to server + const httpClient = getHttpApiClient(); + const result = await httpClient.saveBoardBackground( + base64, + file.name, + file.type, + currentProject.path + ); + + if (result.success && result.path) { + // Update store with the relative path (live update) + setBoardBackground(currentProject.path, result.path); + toast.success("Background image saved"); + } else { + toast.error(result.error || "Failed to save background image"); + setPreviewImage(null); + } + } catch (error) { + console.error("Failed to process image:", error); + toast.error("Failed to process image"); + setPreviewImage(null); + } finally { + setIsProcessing(false); + } + }, + [currentProject, setBoardBackground] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + processFile(files[0]); + } + }, + [processFile] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFile(files[0]); + } + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, + [processFile] + ); + + const handleBrowseClick = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const handleClear = useCallback(async () => { + if (!currentProject) return; + + try { + setIsProcessing(true); + const httpClient = getHttpApiClient(); + const result = await httpClient.deleteBoardBackground( + currentProject.path + ); + + if (result.success) { + clearBoardBackground(currentProject.path); + setPreviewImage(null); + toast.success("Background image cleared"); + } else { + toast.error(result.error || "Failed to clear background image"); + } + } catch (error) { + console.error("Failed to clear background:", error); + toast.error("Failed to clear background"); + } finally { + setIsProcessing(false); + } + }, [currentProject, clearBoardBackground]); + + // Live update opacity when sliders change + const handleCardOpacityChange = useCallback( + (value: number[]) => { + if (!currentProject) return; + setCardOpacity(currentProject.path, value[0]); + }, + [currentProject, setCardOpacity] + ); + + const handleColumnOpacityChange = useCallback( + (value: number[]) => { + if (!currentProject) return; + setColumnOpacity(currentProject.path, value[0]); + }, + [currentProject, setColumnOpacity] + ); + + const handleColumnBorderToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setColumnBorderEnabled(currentProject.path, checked); + }, + [currentProject, setColumnBorderEnabled] + ); + + const handleCardGlassmorphismToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setCardGlassmorphism(currentProject.path, checked); + }, + [currentProject, setCardGlassmorphism] + ); + + const handleCardBorderToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setCardBorderEnabled(currentProject.path, checked); + }, + [currentProject, setCardBorderEnabled] + ); + + const handleCardBorderOpacityChange = useCallback( + (value: number[]) => { + if (!currentProject) return; + setCardBorderOpacity(currentProject.path, value[0]); + }, + [currentProject, setCardBorderOpacity] + ); + + const handleHideScrollbarToggle = useCallback( + (checked: boolean) => { + if (!currentProject) return; + setHideScrollbar(currentProject.path, checked); + }, + [currentProject, setHideScrollbar] + ); + + if (!currentProject) { + return null; + } + + return ( + + + + + + Board Background Settings + + + Set a custom background image for your kanban board and adjust + card/column opacity + + + +
+ {/* Image Upload Section */} +
+ + + {/* Hidden file input */} + + + {/* Drop zone */} +
+ {previewImage ? ( +
+
+ Background preview + {isProcessing && ( +
+ +
+ )} +
+
+ + +
+
+ ) : ( +
+
+ {isProcessing ? ( + + ) : ( + + )} +
+

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

+

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

+
+ )} +
+
+ + {/* Opacity Controls */} +
+
+
+ + + {cardOpacity}% + +
+ +
+ +
+
+ + + {columnOpacity}% + +
+ +
+ + {/* Column Border Toggle */} +
+ + +
+ + {/* Card Glassmorphism Toggle */} +
+ + +
+ + {/* Card Border Toggle */} +
+ + +
+ + {/* Card Border Opacity - only show when border is enabled */} + {cardBorderEnabled && ( +
+
+ + + {cardBorderOpacity}% + +
+ +
+ )} + + {/* Hide Scrollbar Toggle */} +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx index e659b282..82e46044 100644 --- a/apps/app/src/components/layout/sidebar.tsx +++ b/apps/app/src/components/layout/sidebar.tsx @@ -26,18 +26,6 @@ import { UserCircle, MoreVertical, Palette, - Moon, - Sun, - Terminal, - Ghost, - Snowflake, - Flame, - Sparkles as TokyoNightIcon, - Eclipse, - Trees, - Cat, - Atom, - Radio, Monitor, Search, Bug, @@ -71,7 +59,12 @@ import { useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron"; +import { + getElectronAPI, + Project, + TrashedProject, + RunningAgent, +} from "@/lib/electron"; import { initializeProject, hasAppSpec, @@ -79,6 +72,7 @@ import { } from "@/lib/project-init"; import { toast } from "sonner"; import { Sparkles, Loader2 } from "lucide-react"; +import { themeOptions } from "@/config/theme-options"; import { Checkbox } from "@/components/ui/checkbox"; import type { SpecRegenerationEvent } from "@/types/electron"; import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; @@ -175,21 +169,14 @@ function SortableProjectItem({ ); } -// Theme options for project theme selector +// Theme options for project theme selector - derived from the shared config const PROJECT_THEME_OPTIONS = [ { value: "", label: "Use Global", icon: Monitor }, - { value: "dark", label: "Dark", icon: Moon }, - { value: "light", label: "Light", icon: Sun }, - { value: "retro", label: "Retro", icon: Terminal }, - { value: "dracula", label: "Dracula", icon: Ghost }, - { value: "nord", label: "Nord", icon: Snowflake }, - { value: "monokai", label: "Monokai", icon: Flame }, - { value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon }, - { value: "solarized", label: "Solarized", icon: Eclipse }, - { value: "gruvbox", label: "Gruvbox", icon: Trees }, - { value: "catppuccin", label: "Catppuccin", icon: Cat }, - { value: "onedark", label: "One Dark", icon: Atom }, - { value: "synthwave", label: "Synthwave", icon: Radio }, + ...themeOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + })), ] as const; export function Sidebar() { @@ -213,6 +200,7 @@ export function Sidebar() { clearProjectHistory, setProjectTheme, setTheme, + setPreviewTheme, theme: globalTheme, moveProjectToTrash, } = useAppStore(); @@ -389,7 +377,10 @@ export function Sidebar() { } } } catch (error) { - console.error("[Sidebar] Error fetching running agents count:", error); + console.error( + "[Sidebar] Error fetching running agents count:", + error + ); } }; fetchRunningAgentsCount(); @@ -501,7 +492,8 @@ export function Sidebar() { // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) // Then fall back to current effective theme, then global theme const trashedProject = trashedProjects.find((p) => p.path === path); - const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme; + const effectiveTheme = + trashedProject?.theme || currentProject?.theme || globalTheme; project = { id: `project-${Date.now()}`, name, @@ -546,7 +538,14 @@ export function Sidebar() { }); } } - }, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]); + }, [ + projects, + trashedProjects, + addProject, + setCurrentProject, + currentProject, + globalTheme, + ]); const handleRestoreProject = useCallback( (projectId: string) => { @@ -828,7 +827,9 @@ export function Sidebar() {
{ const api = getElectronAPI(); - api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues"); + api.openExternalLink( + "https://github.com/AutoMaker-Org/automaker/issues" + ); }} className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all" title="Report Bug / Feature Request" @@ -1001,7 +1004,14 @@ export function Sidebar() { {/* Project Options Menu - theme and history */} {currentProject && ( - + { + // Clear preview theme when the menu closes + if (!open) { + setPreviewTheme(null); + } + }} + >
); })} @@ -1241,14 +1292,25 @@ export function Sidebar() { {isActiveRoute("running-agents") && (
)} - + + {/* Running agents count badge - shown in collapsed state */} + {!sidebarOpen && runningAgentsCount > 0 && ( + + {runningAgentsCount > 99 ? "99" : runningAgentsCount} + )} - /> +
Running Agents + {/* Running agents count badge - shown in expanded state */} + {sidebarOpen && runningAgentsCount > 0 && ( + + {runningAgentsCount > 99 ? "99" : runningAgentsCount} + + )} {!sidebarOpen && ( Running Agents @@ -1328,7 +1402,9 @@ export function Sidebar() { {trashedProjects.length === 0 ? ( -

Recycle bin is empty.

+

+ Recycle bin is empty. +

) : (
{trashedProjects.map((project) => ( diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index ce2d0e87..d671c603 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -58,6 +58,7 @@ import { KanbanColumn } from "./kanban-column"; import { KanbanCard } from "./kanban-card"; import { AgentOutputModal } from "./agent-output-modal"; import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; +import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { Plus, RefreshCw, @@ -86,6 +87,7 @@ import { Square, Maximize2, Shuffle, + ImageIcon, } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; @@ -213,6 +215,7 @@ export function BoardView() { aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, + boardBackgroundByProject, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [activeFeature, setActiveFeature] = useState(null); @@ -237,6 +240,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); @@ -407,7 +412,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; @@ -563,7 +569,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 @@ -592,15 +599,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 { @@ -874,8 +882,11 @@ export function BoardView() { // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); - // Clear justFinished flag when manually verifying via drag - persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); + // Clear justFinishedAt timestamp when manually verifying via drag + persistFeatureUpdate(featureId, { + status: "verified", + justFinishedAt: undefined, + }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, @@ -885,8 +896,11 @@ export function BoardView() { } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - // Clear justFinished flag when moving back to backlog - persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); + // Clear justFinishedAt timestamp when moving back to backlog + persistFeatureUpdate(featureId, { + status: "backlog", + justFinishedAt: undefined, + }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -1207,8 +1221,11 @@ export function BoardView() { description: feature.description, }); moveFeature(feature.id, "verified"); - // Clear justFinished flag when manually verifying - persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); + // Clear justFinishedAt timestamp when manually verifying + persistFeatureUpdate(feature.id, { + status: "verified", + justFinishedAt: undefined, + }); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" @@ -1274,11 +1291,11 @@ export function BoardView() { } // Move feature back to in_progress before sending follow-up - // Clear justFinished flag since user is now interacting with it + // Clear justFinishedAt timestamp since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), - justFinished: false, + justFinishedAt: undefined, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); @@ -1537,11 +1554,22 @@ export function BoardView() { } }); - // Sort waiting_approval column: justFinished features go to the top + // Sort waiting_approval column: justFinished features (within 2 minutes) go to the top map.waiting_approval.sort((a, b) => { - // Features with justFinished=true should appear first - if (a.justFinished && !b.justFinished) return -1; - if (!a.justFinished && b.justFinished) return 1; + // Helper to check if feature is "just finished" (within 2 minutes) + const isJustFinished = (feature: Feature) => { + if (!feature.justFinishedAt) return false; + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const now = Date.now(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + return now - finishedTime < twoMinutes; + }; + + const aJustFinished = isJustFinished(a); + const bJustFinished = isJustFinished(b); + // Features with justFinishedAt within 2 minutes should appear first + if (aJustFinished && !bJustFinished) return -1; + if (!aJustFinished && bJustFinished) return 1; return 0; // Keep original order for features with same justFinished status }); @@ -1646,7 +1674,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 @@ -1855,202 +1883,296 @@ export function BoardView() { )}
- {/* Kanban Card Detail Level Toggle */} + {/* Board Background & Detail Level Controls */} {isMounted && ( -
+
+ {/* Board Background Button */} - + + -

Minimal - Title & category only

-
-
- - - - - -

Standard - Steps & progress

-
-
- - - - - -

Detailed - Model, tools & tasks

+

Board Background Settings

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

Minimal - Title & category only

+
+
+ + + + + +

Standard - Steps & progress

+
+
+ + + + + +

Detailed - Model, tools & tasks

+
+
+
)}
{/* Kanban Columns */} -
- -
- {COLUMNS.map((column) => { - const columnFeatures = getColumnFeatures(column.id); - return ( - 0 ? ( - - ) : column.id === "backlog" ? ( -
- - {columnFeatures.length > 0 && ( - { + // Get background settings for current project + const backgroundSettings = currentProject + ? boardBackgroundByProject[currentProject.path] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + : { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + + // Build background image style if image exists + const backgroundImageStyle = backgroundSettings.imagePath + ? { + backgroundImage: `url(${ + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008" + }/api/fs/image?path=${encodeURIComponent( + backgroundSettings.imagePath + )}&projectPath=${encodeURIComponent( + currentProject?.path || "" + )})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + : {}; + + return ( +
+ +
+ {COLUMNS.map((column) => { + const columnFeatures = getColumnFeatures(column.id); + return ( + 0 ? ( +
- ) : undefined - } - > - f.id)} - strategy={verticalListSortingStrategy} - > - {columnFeatures.map((feature, index) => { - // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) - let shortcutKey: string | undefined; - if (column.id === "in_progress" && index < 10) { - shortcutKey = index === 9 ? "0" : String(index + 1); + + Delete All + + ) : column.id === "backlog" ? ( +
+ + {columnFeatures.length > 0 && ( + + + Pull Top + + )} +
+ ) : undefined } - return ( - setEditingFeature(feature)} - onDelete={() => handleDeleteFeature(feature.id)} - onViewOutput={() => handleViewOutput(feature)} - onVerify={() => handleVerifyFeature(feature)} - onResume={() => handleResumeFeature(feature)} - onForceStop={() => handleForceStopFeature(feature)} - onManualVerify={() => handleManualVerify(feature)} - onMoveBackToInProgress={() => - handleMoveBackToInProgress(feature) + > + f.id)} + strategy={verticalListSortingStrategy} + > + {columnFeatures.map((feature, index) => { + // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) + let shortcutKey: string | undefined; + if (column.id === "in_progress" && index < 10) { + shortcutKey = + index === 9 ? "0" : String(index + 1); } - onFollowUp={() => handleOpenFollowUp(feature)} - onCommit={() => handleCommitFeature(feature)} - onRevert={() => handleRevertFeature(feature)} - onMerge={() => handleMergeFeature(feature)} - hasContext={featuresWithContext.has(feature.id)} - isCurrentAutoTask={runningAutoTasks.includes( - feature.id - )} - shortcutKey={shortcutKey} - /> - ); - })} - - - ); - })} -
+ return ( + setEditingFeature(feature)} + onDelete={() => handleDeleteFeature(feature.id)} + onViewOutput={() => handleViewOutput(feature)} + onVerify={() => handleVerifyFeature(feature)} + onResume={() => handleResumeFeature(feature)} + onForceStop={() => + handleForceStopFeature(feature) + } + onManualVerify={() => + handleManualVerify(feature) + } + onMoveBackToInProgress={() => + handleMoveBackToInProgress(feature) + } + onFollowUp={() => handleOpenFollowUp(feature)} + onCommit={() => handleCommitFeature(feature)} + onRevert={() => handleRevertFeature(feature)} + onMerge={() => handleMergeFeature(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes( + feature.id + )} + shortcutKey={shortcutKey} + opacity={backgroundSettings.cardOpacity} + glassmorphism={ + backgroundSettings.cardGlassmorphism + } + cardBorderEnabled={ + backgroundSettings.cardBorderEnabled + } + cardBorderOpacity={ + backgroundSettings.cardBorderOpacity + } + /> + ); + })} + + + ); + })} +
- - {activeFeature && ( - - - - {activeFeature.description} - - - {activeFeature.category} - - - - )} - - -
+ + {activeFeature && ( + + + + {activeFeature.description} + + + {activeFeature.category} + + + + )} + +
+
+ ); + })()} + {/* Board Background Modal */} + + {/* Add Feature Dialog */} (null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [currentTime, setCurrentTime] = useState(() => Date.now()); const { kanbanCardDetailLevel } = useAppStore(); // Check if feature has worktree @@ -148,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({ kanbanCardDetailLevel === "detailed"; const showAgentInfo = kanbanCardDetailLevel === "detailed"; + // Helper to check if "just finished" badge should be shown (within 2 minutes) + const isJustFinished = useMemo(() => { + if ( + !feature.justFinishedAt || + feature.status !== "waiting_approval" || + feature.error + ) { + return false; + } + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + return currentTime - finishedTime < twoMinutes; + }, [feature.justFinishedAt, feature.status, feature.error, currentTime]); + + // Update current time periodically to check if badge should be hidden + useEffect(() => { + if (!feature.justFinishedAt || feature.status !== "waiting_approval") { + return; + } + + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds + const timeRemaining = twoMinutes - (currentTime - finishedTime); + + if (timeRemaining <= 0) { + // Already past 2 minutes + return; + } + + // Update time every second to check if 2 minutes have passed + const interval = setInterval(() => { + setCurrentTime(Date.now()); + }, 1000); + + return () => clearInterval(interval); + }, [feature.justFinishedAt, feature.status, currentTime]); + // Load context file for in_progress, waiting_approval, and verified features useEffect(() => { const loadContext = async () => { @@ -184,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({ } else { // Fallback to direct file read for backward compatibility const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`; - const result = await api.readFile(contextPath); + const result = await api.readFile(contextPath); - if (result.success && result.content) { - const info = parseAgentContext(result.content); - setAgentInfo(info); + if (result.success && result.content) { + const info = parseAgentContext(result.content); + setAgentInfo(info); } } } catch { @@ -241,15 +291,42 @@ export const KanbanCard = memo(function KanbanCard({ const style = { transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : undefined, }; + // Calculate border style based on enabled state and opacity + const borderStyle: React.CSSProperties = { ...style }; + if (!cardBorderEnabled) { + (borderStyle as Record).borderWidth = "0px"; + (borderStyle as Record).borderColor = "transparent"; + } else if (cardBorderOpacity !== 100) { + // Apply border opacity using color-mix to blend the border color with transparent + // The --border variable uses oklch format, so we use color-mix in oklch space + // Ensure border width is set (1px is the default Tailwind border width) + (borderStyle as Record).borderWidth = "1px"; + ( + borderStyle as Record + ).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`; + } + return ( + {/* Background overlay with opacity - only affects background, not content */} + {!isDragging && ( +
+ )} {/* Skip Tests indicator badge */} {feature.skipTests && !feature.error && (
Errored
)} - {/* Just Finished indicator badge - shows when agent just completed work */} - {feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( + {/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */} + {isJustFinished && (
- Done + Fresh Baked
)} {/* Branch badge - show when feature has a worktree */} @@ -317,18 +404,22 @@ export const KanbanCard = memo(function KanbanCard({ "absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default", "bg-purple-500/20 border border-purple-500/50 text-purple-400", // Position below other badges if present, otherwise use normal position - feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval") + feature.error || feature.skipTests || isJustFinished ? "top-8 left-2" : "top-2 left-2" )} data-testid={`branch-badge-${feature.id}`} > - {feature.branchName?.replace("feature/", "")} + + {feature.branchName?.replace("feature/", "")} +
-

{feature.branchName}

+

+ {feature.branchName} +

@@ -337,9 +428,11 @@ export const KanbanCard = memo(function KanbanCard({ className={cn( "p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout // Add extra top padding when badges are present to prevent text overlap - (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-10", + (feature.skipTests || feature.error || isJustFinished) && "pt-10", // Add even more top padding when both badges and branch are shown - hasWorktree && (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14" + hasWorktree && + (feature.skipTests || feature.error || isJustFinished) && + "pt-14" )} > {isCurrentAutoTask && ( @@ -471,7 +564,9 @@ export const KanbanCard = memo(function KanbanCard({ ) : ( )} - {step} + + {step} + ))} {feature.steps.length > 3 && ( @@ -565,7 +660,8 @@ export const KanbanCard = memo(function KanbanCard({ todo.status === "completed" && "text-muted-foreground line-through", todo.status === "in_progress" && "text-amber-400", - todo.status === "pending" && "text-foreground-secondary" + todo.status === "pending" && + "text-foreground-secondary" )} > {todo.content} @@ -878,9 +974,13 @@ export const KanbanCard = memo(function KanbanCard({ Implementation Summary - + {(() => { - const displayText = feature.description || feature.summary || "No description"; + const displayText = + feature.description || feature.summary || "No description"; return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText; @@ -916,10 +1016,15 @@ export const KanbanCard = memo(function KanbanCard({ Revert Changes - This will discard all changes made by the agent and move the feature back to the backlog. + This will discard all changes made by the agent and move the + feature back to the backlog. {feature.branchName && ( - Branch {feature.branchName} will be deleted. + Branch{" "} + + {feature.branchName} + {" "} + will be deleted. )} diff --git a/apps/app/src/components/views/kanban-column.tsx b/apps/app/src/components/views/kanban-column.tsx index cbffc051..e9a76a79 100644 --- a/apps/app/src/components/views/kanban-column.tsx +++ b/apps/app/src/components/views/kanban-column.tsx @@ -12,6 +12,9 @@ interface KanbanColumnProps { count: number; children: ReactNode; headerAction?: ReactNode; + opacity?: number; // Opacity percentage (0-100) - only affects background + showBorder?: boolean; // Whether to show column border + hideScrollbar?: boolean; // Whether to hide the column scrollbar } export const KanbanColumn = memo(function KanbanColumn({ @@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({ count, children, headerAction, + opacity = 100, + showBorder = true, + hideScrollbar = false, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); @@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
- {/* Column Header */} -
+ {/* Background layer with opacity - only this layer is affected by opacity */} +
+ + {/* Column Header - positioned above the background */} +

{title}

{headerAction} @@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
- {/* Column Content */} -
+ {/* Column Content - positioned above the background */} +
{children}
diff --git a/apps/app/src/components/views/settings-view/shared/types.ts b/apps/app/src/components/views/settings-view/shared/types.ts index e28966a6..5ad91dcc 100644 --- a/apps/app/src/components/views/settings-view/shared/types.ts +++ b/apps/app/src/components/views/settings-view/shared/types.ts @@ -29,7 +29,8 @@ export type Theme = | "gruvbox" | "catppuccin" | "onedark" - | "synthwave"; + | "synthwave" + | "red"; export type KanbanDetailLevel = "minimal" | "standard" | "detailed"; diff --git a/apps/app/src/config/theme-options.ts b/apps/app/src/config/theme-options.ts index ac8bc567..ec0a028d 100644 --- a/apps/app/src/config/theme-options.ts +++ b/apps/app/src/config/theme-options.ts @@ -5,6 +5,7 @@ import { Eclipse, Flame, Ghost, + Heart, Moon, Radio, Snowflake, @@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray = [ Icon: Radio, testId: "synthwave-mode-button", }, + { + value: "red", + label: "Red", + Icon: Heart, + testId: "red-mode-button", + }, ]; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 04c84bcd..ba0f4c6b 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -316,6 +316,26 @@ export class HttpApiClient implements ElectronAPI { return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath }); } + async saveBoardBackground( + data: string, + filename: string, + mimeType: string, + projectPath: string + ): Promise<{ success: boolean; path?: string; error?: string }> { + return this.post("/api/fs/save-board-background", { + data, + filename, + mimeType, + projectPath, + }); + } + + async deleteBoardBackground( + projectPath: string + ): Promise<{ success: boolean; error?: string }> { + return this.post("/api/fs/delete-board-background", { projectPath }); + } + // CLI checks - server-side async checkClaudeCli(): Promise<{ success: boolean; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 640ab47f..573027e4 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 { @@ -246,7 +276,7 @@ export interface Feature { // Worktree info - set when a feature is being worked on in an isolated git worktree worktreePath?: string; // Path to the worktree directory branchName?: string; // Name of the feature branch - justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval + justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes) } // File tree node for project analysis @@ -303,10 +333,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 @@ -336,11 +369,22 @@ export interface AppState { isAnalyzing: boolean; // Board Background Settings (per-project, keyed by project path) - boardBackgroundByProject: Record; + boardBackgroundByProject: Record< + string, + { + imagePath: string | null; // Path to background image in .automaker directory + cardOpacity: number; // Opacity of cards (0-100) + columnOpacity: number; // Opacity of columns (0-100) + columnBorderEnabled: boolean; // Whether to show column borders + cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards + cardBorderEnabled: boolean; // Whether to show card borders + cardBorderOpacity: number; // Opacity of card borders (0-100) + hideScrollbar: boolean; // Whether to hide the board scrollbar + } + >; + + // Theme Preview (for hover preview in theme selectors) + previewTheme: ThemeMode | null; } export interface AutoModeActivity { @@ -386,7 +430,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; @@ -422,7 +467,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; @@ -462,14 +510,31 @@ export interface AppActions { clearAnalysis: () => void; // Agent Session actions - setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; + setLastSelectedSession: ( + projectPath: string, + sessionId: string | null + ) => void; getLastSelectedSession: (projectPath: string) => string | null; // Board Background actions setBoardBackground: (projectPath: string, imagePath: string | null) => void; setCardOpacity: (projectPath: string, opacity: number) => void; setColumnOpacity: (projectPath: string, opacity: number) => void; - getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number }; + setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void; + getBoardBackground: (projectPath: string) => { + imagePath: string | null; + cardOpacity: number; + columnOpacity: number; + columnBorderEnabled: boolean; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; + hideScrollbar: boolean; + }; + setCardGlassmorphism: (projectPath: string, enabled: boolean) => void; + setCardBorderEnabled: (projectPath: string, enabled: boolean) => void; + setCardBorderOpacity: (projectPath: string, opacity: number) => void; + setHideScrollbar: (projectPath: string, hide: boolean) => void; clearBoardBackground: (projectPath: string) => void; // Reset @@ -481,7 +546,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", @@ -491,7 +557,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", @@ -574,6 +641,7 @@ const initialState: AppState = { projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, + previewTheme: null, }; export const useAppStore = create()( @@ -699,7 +767,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) @@ -739,7 +809,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -764,9 +834,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); @@ -776,7 +845,7 @@ export const useAppStore = create()( currentProject: targetProject, projectHistory: validHistory, projectHistoryIndex: newIndex, - currentView: "board" + currentView: "board", }); } }, @@ -828,6 +897,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) { @@ -837,6 +911,8 @@ export const useAppStore = create()( return get().theme; }, + setPreviewTheme: (theme) => set({ previewTheme: theme }), + // Feature actions setFeatures: (features) => set({ features }), @@ -988,7 +1064,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, @@ -999,7 +1078,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: { @@ -1015,13 +1097,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 + ), }, }, }); @@ -1029,7 +1116,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, @@ -1170,7 +1260,16 @@ export const useAppStore = create()( // Board Background actions setBoardBackground: (projectPath, imagePath) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1184,7 +1283,16 @@ export const useAppStore = create()( setCardOpacity: (projectPath, opacity) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1198,7 +1306,16 @@ export const useAppStore = create()( setColumnOpacity: (projectPath, opacity) => { const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, @@ -1212,18 +1329,153 @@ export const useAppStore = create()( getBoardBackground: (projectPath) => { const settings = get().boardBackgroundByProject[projectPath]; - return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 }; + return ( + settings || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + } + ); }, - clearBoardBackground: (projectPath) => { + setColumnBorderEnabled: (projectPath, enabled) => { const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; set({ boardBackgroundByProject: { ...current, [projectPath]: { - imagePath: null, - cardOpacity: 100, - columnOpacity: 100, + ...existing, + columnBorderEnabled: enabled, + }, + }, + }); + }, + + setCardGlassmorphism: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardGlassmorphism: enabled, + }, + }, + }); + }, + + setCardBorderEnabled: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderEnabled: enabled, + }, + }, + }); + }, + + setCardBorderOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderOpacity: opacity, + }, + }, + }); + }, + + setHideScrollbar: (projectPath, hide) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + hideScrollbar: hide, + }, + }, + }); + }, + + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath: null, // Only clear the image, preserve other settings }, }, }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fa485bd5..de7c7240 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -32,6 +32,7 @@ import { createWorkspaceRoutes } from "./routes/workspace.js"; import { createTemplatesRoutes } from "./routes/templates.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; +import { AutoModeService } from "./services/auto-mode-service.js"; // Load environment variables dotenv.config(); @@ -87,6 +88,7 @@ const events: EventEmitter = createEventEmitter(); // Create services const agentService = new AgentService(DATA_DIR, events); const featureLoader = new FeatureLoader(); +const autoModeService = new AutoModeService(events); // Initialize services (async () => { @@ -104,14 +106,14 @@ app.use("/api/fs", createFsRoutes(events)); app.use("/api/agent", createAgentRoutes(agentService, events)); app.use("/api/sessions", createSessionsRoutes(agentService)); app.use("/api/features", createFeaturesRoutes(featureLoader)); -app.use("/api/auto-mode", createAutoModeRoutes(events)); +app.use("/api/auto-mode", createAutoModeRoutes(autoModeService)); app.use("/api/worktree", createWorktreeRoutes()); app.use("/api/git", createGitRoutes()); app.use("/api/setup", createSetupRoutes()); app.use("/api/suggestions", createSuggestionsRoutes(events)); app.use("/api/models", createModelsRoutes()); app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); -app.use("/api/running-agents", createRunningAgentsRoutes()); +app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService)); app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); diff --git a/apps/server/src/routes/auto-mode.ts b/apps/server/src/routes/auto-mode.ts index 408b0d96..cd6bfcb0 100644 --- a/apps/server/src/routes/auto-mode.ts +++ b/apps/server/src/routes/auto-mode.ts @@ -5,12 +5,10 @@ */ import { Router, type Request, type Response } from "express"; -import type { EventEmitter } from "../lib/events.js"; -import { AutoModeService } from "../services/auto-mode-service.js"; +import type { AutoModeService } from "../services/auto-mode-service.js"; -export function createAutoModeRoutes(events: EventEmitter): Router { +export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); - const autoModeService = new AutoModeService(events); // Start auto mode loop router.post("/start", async (req: Request, res: Response) => { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index ffb8b171..c43fad2e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -92,7 +92,11 @@ export class AutoModeService { } private async runAutoLoop(): Promise { - while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) { + while ( + this.autoLoopRunning && + this.autoLoopAbortController && + !this.autoLoopAbortController.signal.aborted + ) { try { // Check if we have capacity if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { @@ -101,7 +105,9 @@ export class AutoModeService { } // Load pending features - const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); + const pendingFeatures = await this.loadPendingFeatures( + this.config!.projectPath + ); if (pendingFeatures.length === 0) { this.emitAutoModeEvent("auto_mode_complete", { @@ -112,7 +118,9 @@ export class AutoModeService { } // Find a feature not currently running - const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); + const nextFeature = pendingFeatures.find( + (f) => !this.runningFeatures.has(f.id) + ); if (nextFeature) { // Start feature execution in background @@ -171,7 +179,11 @@ export class AutoModeService { // Setup worktree if enabled if (useWorktrees) { - worktreePath = await this.setupWorktree(projectPath, featureId, branchName); + worktreePath = await this.setupWorktree( + projectPath, + featureId, + branchName + ); } const workDir = worktreePath || projectPath; @@ -190,7 +202,11 @@ export class AutoModeService { this.emitAutoModeEvent("auto_mode_feature_start", { featureId, projectPath, - feature: { id: featureId, title: "Loading...", description: "Feature is starting" }, + feature: { + id: featureId, + title: "Loading...", + description: "Feature is starting", + }, }); try { @@ -219,12 +235,18 @@ export class AutoModeService { await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); // Mark as waiting_approval for user review - await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); + await this.updateFeatureStatus( + projectPath, + featureId, + "waiting_approval" + ); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, passes: true, - message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`, + message: `Feature completed in ${Math.round( + (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 + )}s`, projectPath, }); } catch (error) { @@ -293,7 +315,12 @@ export class AutoModeService { if (hasContext) { // Load previous context and continue const context = await fs.readFile(contextPath, "utf-8"); - return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); + return this.executeFeatureWithContext( + projectPath, + featureId, + context, + useWorktrees + ); } // No context, start fresh @@ -316,7 +343,12 @@ export class AutoModeService { const abortController = new AbortController(); // Check if worktree exists - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -379,7 +411,11 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent("auto_mode_feature_start", { featureId, projectPath, - feature: feature || { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) }, + feature: feature || { + id: featureId, + title: "Follow-up", + description: prompt.substring(0, 100), + }, }); try { @@ -472,7 +508,11 @@ Address the follow-up instructions above. Review the previous work and make the await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model); // Mark as waiting_approval for user review - await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); + await this.updateFeatureStatus( + projectPath, + featureId, + "waiting_approval" + ); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -496,8 +536,16 @@ Address the follow-up instructions above. Review the previous work and make the /** * Verify a feature's implementation */ - async verifyFeature(projectPath: string, featureId: string): Promise { - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + async verifyFeature( + projectPath: string, + featureId: string + ): Promise { + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -516,7 +564,8 @@ Address the follow-up instructions above. Review the previous work and make the ]; let allPassed = true; - const results: Array<{ check: string; passed: boolean; output?: string }> = []; + const results: Array<{ check: string; passed: boolean; output?: string }> = + []; for (const check of verificationChecks) { try { @@ -524,7 +573,11 @@ Address the follow-up instructions above. Review the previous work and make the cwd: workDir, timeout: 120000, }); - results.push({ check: check.name, passed: true, output: stdout || stderr }); + results.push({ + check: check.name, + passed: true, + output: stdout || stderr, + }); } catch (error) { allPassed = false; results.push({ @@ -541,7 +594,9 @@ Address the follow-up instructions above. Review the previous work and make the passes: allPassed, message: allPassed ? "All verification checks passed" - : `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`, + : `Verification failed: ${ + results.find((r) => !r.passed)?.check || "Unknown" + }`, }); return allPassed; @@ -550,8 +605,16 @@ Address the follow-up instructions above. Review the previous work and make the /** * Commit feature changes */ - async commitFeature(projectPath: string, featureId: string): Promise { - const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); + async commitFeature( + projectPath: string, + featureId: string + ): Promise { + const worktreePath = path.join( + projectPath, + ".automaker", + "worktrees", + featureId + ); let workDir = projectPath; try { @@ -563,7 +626,9 @@ Address the follow-up instructions above. Review the previous work and make the try { // Check for changes - const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir }); + const { stdout: status } = await execAsync("git status --porcelain", { + cwd: workDir, + }); if (!status.trim()) { return null; // No changes } @@ -581,7 +646,9 @@ Address the follow-up instructions above. Review the previous work and make the }); // Get commit hash - const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir }); + const { stdout: hash } = await execAsync("git rev-parse HEAD", { + cwd: workDir, + }); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -599,7 +666,10 @@ Address the follow-up instructions above. Review the previous work and make the /** * Check if context exists for a feature */ - async contextExists(projectPath: string, featureId: string): Promise { + async contextExists( + projectPath: string, + featureId: string + ): Promise { const contextPath = path.join( projectPath, ".automaker", @@ -626,7 +696,11 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent("auto_mode_feature_start", { featureId: analysisFeatureId, projectPath, - feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" }, + feature: { + id: analysisFeatureId, + title: "Project Analysis", + description: "Analyzing project structure", + }, }); const prompt = `Analyze this project and provide a summary of: @@ -673,7 +747,11 @@ Format your response as a structured markdown document.`; } // Save analysis - const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md"); + const analysisPath = path.join( + projectPath, + ".automaker", + "project-analysis.md" + ); await fs.mkdir(path.dirname(analysisPath), { recursive: true }); await fs.writeFile(analysisPath, analysisResult); @@ -767,7 +845,10 @@ Format your response as a structured markdown document.`; return worktreePath; } - private async loadFeature(projectPath: string, featureId: string): Promise { + private async loadFeature( + projectPath: string, + featureId: string + ): Promise { const featurePath = path.join( projectPath, ".automaker", @@ -802,12 +883,13 @@ Format your response as a structured markdown document.`; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); - // Set justFinished flag when moving to waiting_approval (agent just completed) + // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) + // Badge will show for 2 minutes after this timestamp if (status === "waiting_approval") { - feature.justFinished = true; + feature.justFinishedAt = new Date().toISOString(); } else { - // Clear the flag when moving to other statuses - feature.justFinished = false; + // Clear the timestamp when moving to other statuses + feature.justFinishedAt = undefined; } await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch { @@ -824,7 +906,11 @@ Format your response as a structured markdown document.`; for (const entry of entries) { if (entry.isDirectory()) { - const featurePath = path.join(featuresDir, entry.name, "feature.json"); + const featurePath = path.join( + featuresDir, + entry.name, + "feature.json" + ); try { const data = await fs.readFile(featurePath, "utf-8"); const feature = JSON.parse(data); @@ -940,7 +1026,13 @@ When done, summarize what you implemented and any notes for the developer.`; // Execute via provider const stream = provider.executeQuery(options); let responseText = ""; - const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md"); + const outputPath = path.join( + workDir, + ".automaker", + "features", + featureId, + "agent-output.md" + ); for await (const msg of stream) { if (msg.type === "assistant" && msg.message?.content) {