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.
This commit is contained in:
Cody Seibert
2025-12-12 22:05:16 -05:00
committed by Kacper
parent 80cbabeeb0
commit ebd928e3b6
14 changed files with 1700 additions and 387 deletions

View File

@@ -12,6 +12,7 @@
@custom-variant catppuccin (&:is(.catppuccin *)); @custom-variant catppuccin (&:is(.catppuccin *));
@custom-variant onedark (&:is(.onedark *)); @custom-variant onedark (&:is(.onedark *));
@custom-variant synthwave (&:is(.synthwave *)); @custom-variant synthwave (&:is(.synthwave *));
@custom-variant red (&:is(.red *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
@@ -1072,6 +1073,75 @@
--running-indicator-text: oklch(0.75 0.26 350); --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 { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;

View File

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

View File

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

View File

@@ -26,18 +26,6 @@ import {
UserCircle, UserCircle,
MoreVertical, MoreVertical,
Palette, Palette,
Moon,
Sun,
Terminal,
Ghost,
Snowflake,
Flame,
Sparkles as TokyoNightIcon,
Eclipse,
Trees,
Cat,
Atom,
Radio,
Monitor, Monitor,
Search, Search,
Bug, Bug,
@@ -71,7 +59,12 @@ import {
useKeyboardShortcutsConfig, useKeyboardShortcutsConfig,
KeyboardShortcut, KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts"; } from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron"; import {
getElectronAPI,
Project,
TrashedProject,
RunningAgent,
} from "@/lib/electron";
import { import {
initializeProject, initializeProject,
hasAppSpec, hasAppSpec,
@@ -79,6 +72,7 @@ import {
} from "@/lib/project-init"; } from "@/lib/project-init";
import { toast } from "sonner"; import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react"; import { Sparkles, Loader2 } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron"; import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; 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 = [ const PROJECT_THEME_OPTIONS = [
{ value: "", label: "Use Global", icon: Monitor }, { value: "", label: "Use Global", icon: Monitor },
{ value: "dark", label: "Dark", icon: Moon }, ...themeOptions.map((opt) => ({
{ value: "light", label: "Light", icon: Sun }, value: opt.value,
{ value: "retro", label: "Retro", icon: Terminal }, label: opt.label,
{ value: "dracula", label: "Dracula", icon: Ghost }, icon: opt.Icon,
{ 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 },
] as const; ] as const;
export function Sidebar() { export function Sidebar() {
@@ -213,6 +200,7 @@ export function Sidebar() {
clearProjectHistory, clearProjectHistory,
setProjectTheme, setProjectTheme,
setTheme, setTheme,
setPreviewTheme,
theme: globalTheme, theme: globalTheme,
moveProjectToTrash, moveProjectToTrash,
} = useAppStore(); } = useAppStore();
@@ -389,7 +377,10 @@ export function Sidebar() {
} }
} }
} catch (error) { } catch (error) {
console.error("[Sidebar] Error fetching running agents count:", error); console.error(
"[Sidebar] Error fetching running agents count:",
error
);
} }
}; };
fetchRunningAgentsCount(); fetchRunningAgentsCount();
@@ -501,7 +492,8 @@ export function Sidebar() {
// Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) // 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 // Then fall back to current effective theme, then global theme
const trashedProject = trashedProjects.find((p) => p.path === path); const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme; const effectiveTheme =
trashedProject?.theme || currentProject?.theme || globalTheme;
project = { project = {
id: `project-${Date.now()}`, id: `project-${Date.now()}`,
name, name,
@@ -546,7 +538,14 @@ export function Sidebar() {
}); });
} }
} }
}, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]); }, [
projects,
trashedProjects,
addProject,
setCurrentProject,
currentProject,
globalTheme,
]);
const handleRestoreProject = useCallback( const handleRestoreProject = useCallback(
(projectId: string) => { (projectId: string) => {
@@ -828,7 +827,9 @@ export function Sidebar() {
<div <div
className={cn( className={cn(
"h-20 border-b border-sidebar-border shrink-0 titlebar-drag-region", "h-20 border-b border-sidebar-border shrink-0 titlebar-drag-region",
sidebarOpen ? "pt-8 px-3 lg:px-6 flex items-center justify-between" : "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2" sidebarOpen
? "pt-8 px-3 lg:px-6 flex items-center justify-between"
: "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
)} )}
> >
<div <div
@@ -859,7 +860,9 @@ export function Sidebar() {
<button <button
onClick={() => { onClick={() => {
const api = getElectronAPI(); 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" 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" title="Report Bug / Feature Request"
@@ -1001,7 +1004,14 @@ export function Sidebar() {
{/* Project Options Menu - theme and history */} {/* Project Options Menu - theme and history */}
{currentProject && ( {currentProject && (
<DropdownMenu> <DropdownMenu
onOpenChange={(open) => {
// Clear preview theme when the menu closes
if (!open) {
setPreviewTheme(null);
}
}}
>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
className="hidden lg:flex items-center justify-center w-8 h-[42px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag" className="hidden lg:flex items-center justify-center w-8 h-[42px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag"
@@ -1024,8 +1034,12 @@ export function Sidebar() {
)} )}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent <DropdownMenuSubContent
className="w-48" className="w-56"
data-testid="project-theme-menu" data-testid="project-theme-menu"
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
}}
> >
<DropdownMenuLabel className="text-xs text-muted-foreground"> <DropdownMenuLabel className="text-xs text-muted-foreground">
Select theme for this project Select theme for this project
@@ -1035,9 +1049,14 @@ export function Sidebar() {
value={currentProject.theme || ""} value={currentProject.theme || ""}
onValueChange={(value) => { onValueChange={(value) => {
if (currentProject) { if (currentProject) {
// Clear preview theme when a theme is selected
setPreviewTheme(null);
// If selecting an actual theme (not "Use Global"), also update global // If selecting an actual theme (not "Use Global"), also update global
if (value !== "") { if (value !== "") {
setTheme(value as any); setTheme(value as any);
} else {
// Restore to global theme when "Use Global" is selected
setTheme(globalTheme);
} }
setProjectTheme( setProjectTheme(
currentProject.id, currentProject.id,
@@ -1048,22 +1067,54 @@ export function Sidebar() {
> >
{PROJECT_THEME_OPTIONS.map((option) => { {PROJECT_THEME_OPTIONS.map((option) => {
const Icon = option.icon; const Icon = option.icon;
const themeValue =
option.value === "" ? globalTheme : option.value;
return ( return (
<DropdownMenuRadioItem <div
key={option.value} key={option.value}
value={option.value} onPointerEnter={() => {
data-testid={`project-theme-${ // Preview the theme on hover
option.value || "global" setPreviewTheme(themeValue as any);
}`} }}
onPointerLeave={(e) => {
// Clear preview theme when leaving this item
// Only clear if we're not moving to another theme item
const relatedTarget =
e.relatedTarget as HTMLElement;
if (
!relatedTarget ||
!relatedTarget.closest(
'[data-testid^="project-theme-"]'
)
) {
setPreviewTheme(null);
}
}}
> >
<Icon className="w-4 h-4 mr-2" /> <DropdownMenuRadioItem
<span>{option.label}</span> value={option.value}
{option.value === "" && ( data-testid={`project-theme-${
<span className="text-[10px] text-muted-foreground ml-1 capitalize"> option.value || "global"
({globalTheme}) }`}
</span> onFocus={() => {
)} // Preview the theme on keyboard navigation
</DropdownMenuRadioItem> setPreviewTheme(themeValue as any);
}}
onBlur={() => {
// Clear preview theme when losing focus
// If moving to another item, its onFocus will set it again
setPreviewTheme(null);
}}
>
<Icon className="w-4 h-4 mr-2" />
<span>{option.label}</span>
{option.value === "" && (
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
)}
</DropdownMenuRadioItem>
</div>
); );
})} })}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
@@ -1241,14 +1292,25 @@ export function Sidebar() {
{isActiveRoute("running-agents") && ( {isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div> <div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)} )}
<Activity <div className="relative">
className={cn( <Activity
"w-4 h-4 shrink-0 transition-colors", className={cn(
isActiveRoute("running-agents") "w-4 h-4 shrink-0 transition-colors",
? "text-brand-500" isActiveRoute("running-agents")
: "group-hover:text-brand-400" ? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-semibold rounded-full bg-brand-500 text-white"
data-testid="running-agents-count-collapsed"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</span>
)} )}
/> </div>
<span <span
className={cn( className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left", "ml-2.5 font-medium text-sm flex-1 text-left",
@@ -1257,6 +1319,18 @@ export function Sidebar() {
> >
Running Agents Running Agents
</span> </span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
"hidden lg:flex items-center justify-center min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full bg-brand-500 text-white",
isActiveRoute("running-agents") && "bg-brand-600"
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
</span>
)}
{!sidebarOpen && ( {!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border"> <span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
Running Agents Running Agents
@@ -1328,7 +1402,9 @@ export function Sidebar() {
</DialogHeader> </DialogHeader>
{trashedProjects.length === 0 ? ( {trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p> <p className="text-sm text-muted-foreground">
Recycle bin is empty.
</p>
) : ( ) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => ( {trashedProjects.map((project) => (

View File

@@ -58,6 +58,7 @@ import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card"; import { KanbanCard } from "./kanban-card";
import { AgentOutputModal } from "./agent-output-modal"; import { AgentOutputModal } from "./agent-output-modal";
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { import {
Plus, Plus,
RefreshCw, RefreshCw,
@@ -86,6 +87,7 @@ import {
Square, Square,
Maximize2, Maximize2,
Shuffle, Shuffle,
ImageIcon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
@@ -213,6 +215,7 @@ export function BoardView() {
aiProfiles, aiProfiles,
kanbanCardDetailLevel, kanbanCardDetailLevel,
setKanbanCardDetailLevel, setKanbanCardDetailLevel,
boardBackgroundByProject,
} = useAppStore(); } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null); const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -237,6 +240,8 @@ export function BoardView() {
); );
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
useState(false); useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]); const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false); const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null); const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -407,7 +412,8 @@ export function BoardView() {
const currentPath = currentProject.path; const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current; 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) // Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features; const cachedFeatures = useAppStore.getState().features;
@@ -563,7 +569,8 @@ export function BoardView() {
const unsubscribe = api.autoMode.onEvent((event) => { const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project // Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected 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") { if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed // Reload features when a feature is completed
@@ -592,15 +599,16 @@ export function BoardView() {
loadFeatures(); loadFeatures();
// Check for authentication errors and show a more helpful message // Check for authentication errors and show a more helpful message
const isAuthError = event.errorType === "authentication" || const isAuthError =
(event.error && ( event.errorType === "authentication" ||
event.error.includes("Authentication failed") || (event.error &&
event.error.includes("Invalid API key") (event.error.includes("Authentication failed") ||
)); event.error.includes("Invalid API key")));
if (isAuthError) { if (isAuthError) {
toast.error("Authentication Failed", { 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, duration: 10000,
}); });
} else { } else {
@@ -874,8 +882,11 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first // features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") { if (targetStatus === "verified") {
moveFeature(featureId, "verified"); moveFeature(featureId, "verified");
// Clear justFinished flag when manually verifying via drag // Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, { status: "verified", justFinished: false }); persistFeatureUpdate(featureId, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", { toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice( description: `Manually verified: ${draggedFeature.description.slice(
0, 0,
@@ -885,8 +896,11 @@ export function BoardView() {
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog // Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog"); moveFeature(featureId, "backlog");
// Clear justFinished flag when moving back to backlog // Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", justFinished: false }); persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", { toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
0, 0,
@@ -1207,8 +1221,11 @@ export function BoardView() {
description: feature.description, description: feature.description,
}); });
moveFeature(feature.id, "verified"); moveFeature(feature.id, "verified");
// Clear justFinished flag when manually verifying // Clear justFinishedAt timestamp when manually verifying
persistFeatureUpdate(feature.id, { status: "verified", justFinished: false }); persistFeatureUpdate(feature.id, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", { toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${ description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : "" feature.description.length > 50 ? "..." : ""
@@ -1274,11 +1291,11 @@ export function BoardView() {
} }
// Move feature back to in_progress before sending follow-up // 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 = { const updates = {
status: "in_progress" as const, status: "in_progress" as const,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
justFinished: false, justFinishedAt: undefined,
}; };
updateFeature(featureId, updates); updateFeature(featureId, updates);
persistFeatureUpdate(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) => { map.waiting_approval.sort((a, b) => {
// Features with justFinished=true should appear first // Helper to check if feature is "just finished" (within 2 minutes)
if (a.justFinished && !b.justFinished) return -1; const isJustFinished = (feature: Feature) => {
if (!a.justFinished && b.justFinished) return 1; 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 return 0; // Keep original order for features with same justFinished status
}); });
@@ -1646,7 +1674,7 @@ export function BoardView() {
return; return;
} }
const featuresToStart = backlogFeatures.slice(0, availableSlots); const featuresToStart = backlogFeatures.slice(0, 1);
for (const feature of featuresToStart) { for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp // Update the feature status with startedAt timestamp
@@ -1855,202 +1883,296 @@ export function BoardView() {
)} )}
</div> </div>
{/* Kanban Card Detail Level Toggle */} {/* Board Background & Detail Level Controls */}
{isMounted && ( {isMounted && (
<TooltipProvider> <TooltipProvider>
<div <div className="flex items-center gap-2 ml-4">
className="flex items-center rounded-lg bg-secondary border border-border ml-4" {/* Board Background Button */}
data-testid="kanban-detail-toggle"
>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
onClick={() => setKanbanCardDetailLevel("minimal")} variant="outline"
className={cn( size="sm"
"p-2 rounded-l-lg transition-colors", onClick={() => setShowBoardBackgroundModal(true)}
kanbanCardDetailLevel === "minimal" className="h-8 px-2"
? "bg-brand-500/20 text-brand-500" data-testid="board-background-button"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
> >
<Minimize2 className="w-4 h-4" /> <ImageIcon className="w-4 h-4" />
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Minimal - Title & category only</p> <p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="kanban-detail-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
>
<Minimize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent>
</Tooltip>
</div>
</div> </div>
</TooltipProvider> </TooltipProvider>
)} )}
</div> </div>
{/* Kanban Columns */} {/* Kanban Columns */}
<div className="flex-1 overflow-x-auto px-4 pb-4"> {(() => {
<DndContext // Get background settings for current project
sensors={sensors} const backgroundSettings = currentProject
collisionDetection={collisionDetectionStrategy} ? boardBackgroundByProject[currentProject.path] || {
onDragStart={handleDragStart} imagePath: null,
onDragEnd={handleDragEnd} cardOpacity: 100,
> columnOpacity: 100,
<div className="flex gap-4 h-full min-w-max"> columnBorderEnabled: true,
{COLUMNS.map((column) => { cardGlassmorphism: true,
const columnFeatures = getColumnFeatures(column.id); cardBorderEnabled: true,
return ( cardBorderOpacity: 100,
<KanbanColumn hideScrollbar: false,
key={column.id} }
id={column.id} : {
title={column.title} imagePath: null,
color={column.color} cardOpacity: 100,
count={columnFeatures.length} columnOpacity: 100,
headerAction={ columnBorderEnabled: true,
column.id === "verified" && columnFeatures.length > 0 ? ( cardGlassmorphism: true,
<Button cardBorderEnabled: true,
variant="ghost" cardBorderOpacity: 100,
size="sm" hideScrollbar: false,
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10" };
onClick={() => setShowDeleteAllVerifiedDialog(true)}
data-testid="delete-all-verified-button" // Build background image style if image exists
> const backgroundImageStyle = backgroundSettings.imagePath
<Trash2 className="w-3 h-3 mr-1" /> ? {
Delete All backgroundImage: `url(${
</Button> process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
) : column.id === "backlog" ? ( }/api/fs/image?path=${encodeURIComponent(
<div className="flex items-center gap-1"> backgroundSettings.imagePath
<Button )}&projectPath=${encodeURIComponent(
variant="ghost" currentProject?.path || ""
size="sm" )})`,
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative" backgroundSize: "cover",
onClick={() => setShowSuggestionsDialog(true)} backgroundPosition: "center",
title="Feature Suggestions" backgroundRepeat: "no-repeat",
data-testid="feature-suggestions-button" }
> : {};
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && ( return (
<span <div
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center" className="flex-1 overflow-x-auto px-4 pb-4 relative"
data-testid="suggestions-count" style={backgroundImageStyle}
> >
{suggestionsCount} <DndContext
</span> sensors={sensors}
)} collisionDetection={collisionDetectionStrategy}
</Button> onDragStart={handleDragStart}
{columnFeatures.length > 0 && ( onDragEnd={handleDragEnd}
<HotkeyButton >
<div className="flex gap-4 h-full min-w-max">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
color={column.color}
count={columnFeatures.length}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
<Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10" className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleStartNextFeatures} onClick={() =>
hotkey={shortcuts.startNext} setShowDeleteAllVerifiedDialog(true)
hotkeyActive={false} }
data-testid="start-next-button" data-testid="delete-all-verified-button"
> >
<FastForward className="w-3 h-3 mr-1" /> <Trash2 className="w-3 h-3 mr-1" />
Pull Top Delete All
</HotkeyButton> </Button>
)} ) : column.id === "backlog" ? (
</div> <div className="flex items-center gap-1">
) : undefined <Button
} variant="ghost"
> size="sm"
<SortableContext className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
items={columnFeatures.map((f) => f.id)} onClick={() => setShowSuggestionsDialog(true)}
strategy={verticalListSortingStrategy} title="Feature Suggestions"
> data-testid="feature-suggestions-button"
{columnFeatures.map((feature, index) => { >
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) <Lightbulb className="w-3.5 h-3.5" />
let shortcutKey: string | undefined; {suggestionsCount > 0 && (
if (column.id === "in_progress" && index < 10) { <span
shortcutKey = index === 9 ? "0" : String(index + 1); className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
</HotkeyButton>
)}
</div>
) : undefined
} }
return ( >
<KanbanCard <SortableContext
key={feature.id} items={columnFeatures.map((f) => f.id)}
feature={feature} strategy={verticalListSortingStrategy}
onEdit={() => setEditingFeature(feature)} >
onDelete={() => handleDeleteFeature(feature.id)} {columnFeatures.map((feature, index) => {
onViewOutput={() => handleViewOutput(feature)} // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
onVerify={() => handleVerifyFeature(feature)} let shortcutKey: string | undefined;
onResume={() => handleResumeFeature(feature)} if (column.id === "in_progress" && index < 10) {
onForceStop={() => handleForceStopFeature(feature)} shortcutKey =
onManualVerify={() => handleManualVerify(feature)} index === 9 ? "0" : String(index + 1);
onMoveBackToInProgress={() =>
handleMoveBackToInProgress(feature)
} }
onFollowUp={() => handleOpenFollowUp(feature)} return (
onCommit={() => handleCommitFeature(feature)} <KanbanCard
onRevert={() => handleRevertFeature(feature)} key={feature.id}
onMerge={() => handleMergeFeature(feature)} feature={feature}
hasContext={featuresWithContext.has(feature.id)} onEdit={() => setEditingFeature(feature)}
isCurrentAutoTask={runningAutoTasks.includes( onDelete={() => handleDeleteFeature(feature.id)}
feature.id onViewOutput={() => handleViewOutput(feature)}
)} onVerify={() => handleVerifyFeature(feature)}
shortcutKey={shortcutKey} onResume={() => handleResumeFeature(feature)}
/> onForceStop={() =>
); handleForceStopFeature(feature)
})} }
</SortableContext> onManualVerify={() =>
</KanbanColumn> handleManualVerify(feature)
); }
})} onMoveBackToInProgress={() =>
</div> handleMoveBackToInProgress(feature)
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
<DragOverlay> <DragOverlay>
{activeFeature && ( {activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl"> <Card className="w-72 opacity-90 rotate-3 shadow-xl">
<CardHeader className="p-3"> <CardHeader className="p-3">
<CardTitle className="text-sm"> <CardTitle className="text-sm">
{activeFeature.description} {activeFeature.description}
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
{activeFeature.category} {activeFeature.category}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
)} )}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
</div> </div>
);
})()}
</div> </div>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Add Feature Dialog */} {/* Add Feature Dialog */}
<Dialog <Dialog
open={showAddDialog} open={showAddDialog}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, memo } from "react"; import { useState, useEffect, useMemo, memo } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -110,6 +110,14 @@ interface KanbanCardProps {
contextContent?: string; contextContent?: string;
/** Feature summary from agent completion */ /** Feature summary from agent completion */
summary?: string; 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({ export const KanbanCard = memo(function KanbanCard({
@@ -131,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({
shortcutKey, shortcutKey,
contextContent, contextContent,
summary, summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) { }: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false); const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null); const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore(); const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree // Check if feature has worktree
@@ -148,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({
kanbanCardDetailLevel === "detailed"; kanbanCardDetailLevel === "detailed";
const showAgentInfo = 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 // Load context file for in_progress, waiting_approval, and verified features
useEffect(() => { useEffect(() => {
const loadContext = async () => { const loadContext = async () => {
@@ -184,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({
} else { } else {
// Fallback to direct file read for backward compatibility // Fallback to direct file read for backward compatibility
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`; 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) { if (result.success && result.content) {
const info = parseAgentContext(result.content); const info = parseAgentContext(result.content);
setAgentInfo(info); setAgentInfo(info);
} }
} }
} catch { } catch {
@@ -241,15 +291,42 @@ export const KanbanCard = memo(function KanbanCard({
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, 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<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
} else if (cardBorderOpacity !== 100) {
// Apply border opacity using color-mix to blend the border color with transparent
// The --border variable uses oklch format, so we use color-mix in oklch space
// Ensure border width is set (1px is the default Tailwind border width)
(borderStyle as Record<string, string>).borderWidth = "1px";
(
borderStyle as Record<string, string>
).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
return ( return (
<Card <Card
ref={setNodeRef} ref={setNodeRef}
style={style} style={borderStyle}
className={cn( className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none", "cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg", // Apply border class when border is enabled and opacity is 100%
// When opacity is not 100%, we use inline styles for border color
cardBorderEnabled && cardBorderOpacity === 100 && "border-border",
// When border is enabled but opacity is not 100%, we still need border width
cardBorderEnabled && cardBorderOpacity !== 100 && "border",
// Remove default background when using opacity overlay
!isDragging && "bg-transparent",
// Remove default backdrop-blur-sm from Card component when glassmorphism is disabled
!glassmorphism && "backdrop-blur-[0px]!",
isDragging && "scale-105 shadow-lg",
// Special border styles for running/error states override the border opacity
// These need to be applied with higher specificity
isCurrentAutoTask && isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse", "border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
feature.error && feature.error &&
@@ -262,6 +339,16 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes} {...attributes}
{...(isDraggable ? listeners : {})} {...(isDraggable ? listeners : {})}
> >
{/* Background overlay with opacity - only affects background, not content */}
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Skip Tests indicator badge */} {/* Skip Tests indicator badge */}
{feature.skipTests && !feature.error && ( {feature.skipTests && !feature.error && (
<div <div
@@ -292,8 +379,8 @@ export const KanbanCard = memo(function KanbanCard({
<span>Errored</span> <span>Errored</span>
</div> </div>
)} )}
{/* Just Finished indicator badge - shows when agent just completed work */} {/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */}
{feature.justFinished && feature.status === "waiting_approval" && !feature.error && ( {isJustFinished && (
<div <div
className={cn( className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10", "absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
@@ -304,7 +391,7 @@ export const KanbanCard = memo(function KanbanCard({
title="Agent just finished working on this feature" title="Agent just finished working on this feature"
> >
<Sparkles className="w-3 h-3" /> <Sparkles className="w-3 h-3" />
<span>Done</span> <span>Fresh Baked</span>
</div> </div>
)} )}
{/* Branch badge - show when feature has a worktree */} {/* 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", "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", "bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below other badges if present, otherwise use normal position // 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-8 left-2"
: "top-2 left-2" : "top-2 left-2"
)} )}
data-testid={`branch-badge-${feature.id}`} data-testid={`branch-badge-${feature.id}`}
> >
<GitBranch className="w-3 h-3 shrink-0" /> <GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span> <span className="truncate max-w-[80px]">
{feature.branchName?.replace("feature/", "")}
</span>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]"> <TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">{feature.branchName}</p> <p className="font-mono text-xs break-all">
{feature.branchName}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -337,9 +428,11 @@ export const KanbanCard = memo(function KanbanCard({
className={cn( className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout "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 // 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 // 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 && ( {isCurrentAutoTask && (
@@ -471,7 +564,9 @@ export const KanbanCard = memo(function KanbanCard({
) : ( ) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" /> <Circle className="w-3 h-3 mt-0.5 shrink-0" />
)} )}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span> <span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
</div> </div>
))} ))}
{feature.steps.length > 3 && ( {feature.steps.length > 3 && (
@@ -565,7 +660,8 @@ export const KanbanCard = memo(function KanbanCard({
todo.status === "completed" && todo.status === "completed" &&
"text-muted-foreground line-through", "text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400", todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-foreground-secondary" todo.status === "pending" &&
"text-foreground-secondary"
)} )}
> >
{todo.content} {todo.content}
@@ -878,9 +974,13 @@ export const KanbanCard = memo(function KanbanCard({
<Sparkles className="w-5 h-5 text-green-400" /> <Sparkles className="w-5 h-5 text-green-400" />
Implementation Summary Implementation Summary
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm" title={feature.description || feature.summary || ""}> <DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
>
{(() => { {(() => {
const displayText = feature.description || feature.summary || "No description"; const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100 return displayText.length > 100
? `${displayText.slice(0, 100)}...` ? `${displayText.slice(0, 100)}...`
: displayText; : displayText;
@@ -916,10 +1016,15 @@ export const KanbanCard = memo(function KanbanCard({
Revert Changes Revert Changes
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
This will discard all changes made by the agent and move the feature back to the backlog. This will discard all changes made by the agent and move the
feature back to the backlog.
{feature.branchName && ( {feature.branchName && (
<span className="block mt-2 font-medium"> <span className="block mt-2 font-medium">
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted. Branch{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{feature.branchName}
</code>{" "}
will be deleted.
</span> </span>
)} )}
<span className="block mt-2 text-red-400 font-medium"> <span className="block mt-2 text-red-400 font-medium">

View File

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

View File

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

View File

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

View File

@@ -316,6 +316,26 @@ export class HttpApiClient implements ElectronAPI {
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 // CLI checks - server-side
async checkClaudeCli(): Promise<{ async checkClaudeCli(): Promise<{
success: boolean; success: boolean;

View File

@@ -27,7 +27,8 @@ export type ThemeMode =
| "gruvbox" | "gruvbox"
| "catppuccin" | "catppuccin"
| "onedark" | "onedark"
| "synthwave"; | "synthwave"
| "red";
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed"; export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
@@ -39,23 +40,39 @@ export interface ApiKeys {
// Keyboard Shortcut with optional modifiers // Keyboard Shortcut with optional modifiers
export interface ShortcutKey { export interface ShortcutKey {
key: string; // The main key (e.g., "K", "N", "1") key: string; // The main key (e.g., "K", "N", "1")
shift?: boolean; // Shift key modifier shift?: boolean; // Shift key modifier
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
alt?: boolean; // Alt/Option key modifier alt?: boolean; // Alt/Option key modifier
} }
// Helper to parse shortcut string to ShortcutKey object // Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string): ShortcutKey { 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] }; const result: ShortcutKey = { key: parts[parts.length - 1] };
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl // Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
for (let i = 0; i < parts.length - 1; i++) { for (let i = 0; i < parts.length - 1; i++) {
const modifier = parts[i].toLowerCase(); const modifier = parts[i].toLowerCase();
if (modifier === "shift") result.shift = true; 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 (
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true; 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; return result;
@@ -67,36 +84,49 @@ export function formatShortcut(shortcut: string, forDisplay = false): string {
const parts: string[] = []; const parts: string[] = [];
// Prefer User-Agent Client Hints when available; fall back to legacy // Prefer User-Agent Client Hints when available; fall back to legacy
const platform: 'darwin' | 'win32' | 'linux' = (() => { const platform: "darwin" | "win32" | "linux" = (() => {
if (typeof navigator === 'undefined') return 'linux'; if (typeof navigator === "undefined") return "linux";
const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } }) const uaPlatform = (
.userAgentData?.platform?.toLowerCase?.(); navigator as Navigator & { userAgentData?: { platform?: string } }
).userAgentData?.platform?.toLowerCase?.();
const legacyPlatform = navigator.platform?.toLowerCase?.(); const legacyPlatform = navigator.platform?.toLowerCase?.();
const platformString = uaPlatform || legacyPlatform || ''; const platformString = uaPlatform || legacyPlatform || "";
if (platformString.includes('mac')) return 'darwin'; if (platformString.includes("mac")) return "darwin";
if (platformString.includes('win')) return 'win32'; if (platformString.includes("win")) return "win32";
return 'linux'; return "linux";
})(); })();
// Primary modifier - OS-specific // Primary modifier - OS-specific
if (parsed.cmdCtrl) { if (parsed.cmdCtrl) {
if (forDisplay) { if (forDisplay) {
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆'); parts.push(
platform === "darwin" ? "⌘" : platform === "win32" ? "⊞" : "◆"
);
} else { } else {
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super'); parts.push(
platform === "darwin" ? "Cmd" : platform === "win32" ? "Win" : "Super"
);
} }
} }
// Alt/Option // Alt/Option
if (parsed.alt) { if (parsed.alt) {
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt')); parts.push(
forDisplay
? platform === "darwin"
? "⌥"
: "Alt"
: platform === "darwin"
? "Opt"
: "Alt"
);
} }
// Shift // Shift
if (parsed.shift) { if (parsed.shift) {
parts.push(forDisplay ? '⇧' : 'Shift'); parts.push(forDisplay ? "⇧" : "Shift");
} }
parts.push(parsed.key.toUpperCase()); parts.push(parsed.key.toUpperCase());
@@ -139,22 +169,22 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
context: "C", context: "C",
settings: "S", settings: "S",
profiles: "M", profiles: "M",
// UI // UI
toggleSidebar: "`", toggleSidebar: "`",
// Actions // Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile) // 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 // This is intentional as they are context-specific and only active in their respective views
addFeature: "N", // Only active in board view addFeature: "N", // Only active in board view
addContextFile: "N", // Only active in context view addContextFile: "N", // Only active in context view
startNext: "G", // Only active in board view startNext: "G", // Only active in board view
newSession: "N", // Only active in agent view newSession: "N", // Only active in agent view
openProject: "O", // Global shortcut openProject: "O", // Global shortcut
projectPicker: "P", // Global shortcut projectPicker: "P", // Global shortcut
cyclePrevProject: "Q", // Global shortcut cyclePrevProject: "Q", // Global shortcut
cycleNextProject: "E", // Global shortcut cycleNextProject: "E", // Global shortcut
addProfile: "N", // Only active in profiles view addProfile: "N", // Only active in profiles view
}; };
export interface ImageAttachment { 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 // Worktree info - set when a feature is being worked on in an isolated git worktree
worktreePath?: string; // Path to the worktree directory worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch 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 // File tree node for project analysis
@@ -303,10 +333,13 @@ export interface AppState {
chatHistoryOpen: boolean; chatHistoryOpen: boolean;
// Auto Mode (per-project state, keyed by project ID) // Auto Mode (per-project state, keyed by project ID)
autoModeByProject: Record<string, { autoModeByProject: Record<
isRunning: boolean; string,
runningTasks: string[]; // Feature IDs being worked on {
}>; isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
}
>;
autoModeActivityLog: AutoModeActivity[]; autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks maxConcurrency: number; // Maximum number of concurrent agent tasks
@@ -336,11 +369,22 @@ export interface AppState {
isAnalyzing: boolean; isAnalyzing: boolean;
// Board Background Settings (per-project, keyed by project path) // Board Background Settings (per-project, keyed by project path)
boardBackgroundByProject: Record<string, { boardBackgroundByProject: Record<
imagePath: string | null; // Path to background image in .automaker directory string,
cardOpacity: number; // Opacity of cards (0-100) {
columnOpacity: number; // Opacity of columns (0-100) 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 { export interface AutoModeActivity {
@@ -386,7 +430,8 @@ export interface AppActions {
// Theme actions // Theme actions
setTheme: (theme: ThemeMode) => void; setTheme: (theme: ThemeMode) => void;
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear) 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 // Feature actions
setFeatures: (features: Feature[]) => void; setFeatures: (features: Feature[]) => void;
@@ -422,7 +467,10 @@ export interface AppActions {
addRunningTask: (projectId: string, taskId: string) => void; addRunningTask: (projectId: string, taskId: string) => void;
removeRunningTask: (projectId: string, taskId: string) => void; removeRunningTask: (projectId: string, taskId: string) => void;
clearRunningTasks: (projectId: string) => void; clearRunningTasks: (projectId: string) => void;
getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] }; getAutoModeState: (projectId: string) => {
isRunning: boolean;
runningTasks: string[];
};
addAutoModeActivity: ( addAutoModeActivity: (
activity: Omit<AutoModeActivity, "id" | "timestamp"> activity: Omit<AutoModeActivity, "id" | "timestamp">
) => void; ) => void;
@@ -462,14 +510,31 @@ export interface AppActions {
clearAnalysis: () => void; clearAnalysis: () => void;
// Agent Session actions // Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void; setLastSelectedSession: (
projectPath: string,
sessionId: string | null
) => void;
getLastSelectedSession: (projectPath: string) => string | null; getLastSelectedSession: (projectPath: string) => string | null;
// Board Background actions // Board Background actions
setBoardBackground: (projectPath: string, imagePath: string | null) => void; setBoardBackground: (projectPath: string, imagePath: string | null) => void;
setCardOpacity: (projectPath: string, opacity: number) => void; setCardOpacity: (projectPath: string, opacity: number) => void;
setColumnOpacity: (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; clearBoardBackground: (projectPath: string) => void;
// Reset // Reset
@@ -481,7 +546,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
{ {
id: "profile-heavy-task", id: "profile-heavy-task",
name: "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", model: "opus",
thinkingLevel: "ultrathink", thinkingLevel: "ultrathink",
provider: "claude", provider: "claude",
@@ -491,7 +557,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
{ {
id: "profile-balanced", id: "profile-balanced",
name: "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", model: "sonnet",
thinkingLevel: "medium", thinkingLevel: "medium",
provider: "claude", provider: "claude",
@@ -574,6 +641,7 @@ const initialState: AppState = {
projectAnalysis: null, projectAnalysis: null,
isAnalyzing: false, isAnalyzing: false,
boardBackgroundByProject: {}, boardBackgroundByProject: {},
previewTheme: null,
}; };
export const useAppStore = create<AppState & AppActions>()( export const useAppStore = create<AppState & AppActions>()(
@@ -699,7 +767,9 @@ export const useAppStore = create<AppState & AppActions>()(
// Add to project history (MRU order) // Add to project history (MRU order)
const currentHistory = get().projectHistory; const currentHistory = get().projectHistory;
// Remove this project if it's already in history // 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) // Add to the front (most recent)
const newHistory = [project.id, ...filteredHistory]; const newHistory = [project.id, ...filteredHistory];
// Reset history index to 0 (current project) // Reset history index to 0 (current project)
@@ -739,7 +809,7 @@ export const useAppStore = create<AppState & AppActions>()(
currentProject: targetProject, currentProject: targetProject,
projectHistory: validHistory, projectHistory: validHistory,
projectHistoryIndex: newIndex, projectHistoryIndex: newIndex,
currentView: "board" currentView: "board",
}); });
} }
}, },
@@ -764,9 +834,8 @@ export const useAppStore = create<AppState & AppActions>()(
if (currentIndex === -1) currentIndex = 0; if (currentIndex === -1) currentIndex = 0;
// Move to the previous index (going forward = lower index), wrapping around // Move to the previous index (going forward = lower index), wrapping around
const newIndex = currentIndex <= 0 const newIndex =
? validHistory.length - 1 currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1;
: currentIndex - 1;
const targetProjectId = validHistory[newIndex]; const targetProjectId = validHistory[newIndex];
const targetProject = projects.find((p) => p.id === targetProjectId); const targetProject = projects.find((p) => p.id === targetProjectId);
@@ -776,7 +845,7 @@ export const useAppStore = create<AppState & AppActions>()(
currentProject: targetProject, currentProject: targetProject,
projectHistory: validHistory, projectHistory: validHistory,
projectHistoryIndex: newIndex, projectHistoryIndex: newIndex,
currentView: "board" currentView: "board",
}); });
} }
}, },
@@ -828,6 +897,11 @@ export const useAppStore = create<AppState & AppActions>()(
}, },
getEffectiveTheme: () => { getEffectiveTheme: () => {
// If preview theme is set, use it (for hover preview)
const previewTheme = get().previewTheme;
if (previewTheme) {
return previewTheme;
}
const currentProject = get().currentProject; const currentProject = get().currentProject;
// If current project has a theme set, use it // If current project has a theme set, use it
if (currentProject?.theme) { if (currentProject?.theme) {
@@ -837,6 +911,8 @@ export const useAppStore = create<AppState & AppActions>()(
return get().theme; return get().theme;
}, },
setPreviewTheme: (theme) => set({ previewTheme: theme }),
// Feature actions // Feature actions
setFeatures: (features) => set({ features }), setFeatures: (features) => set({ features }),
@@ -988,7 +1064,10 @@ export const useAppStore = create<AppState & AppActions>()(
// Auto Mode actions (per-project) // Auto Mode actions (per-project)
setAutoModeRunning: (projectId, running) => { setAutoModeRunning: (projectId, running) => {
const current = get().autoModeByProject; const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
set({ set({
autoModeByProject: { autoModeByProject: {
...current, ...current,
@@ -999,7 +1078,10 @@ export const useAppStore = create<AppState & AppActions>()(
addRunningTask: (projectId, taskId) => { addRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject; const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
if (!projectState.runningTasks.includes(taskId)) { if (!projectState.runningTasks.includes(taskId)) {
set({ set({
autoModeByProject: { autoModeByProject: {
@@ -1015,13 +1097,18 @@ export const useAppStore = create<AppState & AppActions>()(
removeRunningTask: (projectId, taskId) => { removeRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject; const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
set({ set({
autoModeByProject: { autoModeByProject: {
...current, ...current,
[projectId]: { [projectId]: {
...projectState, ...projectState,
runningTasks: projectState.runningTasks.filter((id) => id !== taskId), runningTasks: projectState.runningTasks.filter(
(id) => id !== taskId
),
}, },
}, },
}); });
@@ -1029,7 +1116,10 @@ export const useAppStore = create<AppState & AppActions>()(
clearRunningTasks: (projectId) => { clearRunningTasks: (projectId) => {
const current = get().autoModeByProject; const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] }; const projectState = current[projectId] || {
isRunning: false,
runningTasks: [],
};
set({ set({
autoModeByProject: { autoModeByProject: {
...current, ...current,
@@ -1170,7 +1260,16 @@ export const useAppStore = create<AppState & AppActions>()(
// Board Background actions // Board Background actions
setBoardBackground: (projectPath, imagePath) => { setBoardBackground: (projectPath, imagePath) => {
const current = get().boardBackgroundByProject; 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({ set({
boardBackgroundByProject: { boardBackgroundByProject: {
...current, ...current,
@@ -1184,7 +1283,16 @@ export const useAppStore = create<AppState & AppActions>()(
setCardOpacity: (projectPath, opacity) => { setCardOpacity: (projectPath, opacity) => {
const current = get().boardBackgroundByProject; 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({ set({
boardBackgroundByProject: { boardBackgroundByProject: {
...current, ...current,
@@ -1198,7 +1306,16 @@ export const useAppStore = create<AppState & AppActions>()(
setColumnOpacity: (projectPath, opacity) => { setColumnOpacity: (projectPath, opacity) => {
const current = get().boardBackgroundByProject; 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({ set({
boardBackgroundByProject: { boardBackgroundByProject: {
...current, ...current,
@@ -1212,18 +1329,153 @@ export const useAppStore = create<AppState & AppActions>()(
getBoardBackground: (projectPath) => { getBoardBackground: (projectPath) => {
const settings = get().boardBackgroundByProject[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 current = get().boardBackgroundByProject;
const existing = current[projectPath] || {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
set({ set({
boardBackgroundByProject: { boardBackgroundByProject: {
...current, ...current,
[projectPath]: { [projectPath]: {
imagePath: null, ...existing,
cardOpacity: 100, columnBorderEnabled: enabled,
columnOpacity: 100, },
},
});
},
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
}, },
}, },
}); });

View File

@@ -32,6 +32,7 @@ import { createWorkspaceRoutes } from "./routes/workspace.js";
import { createTemplatesRoutes } from "./routes/templates.js"; import { createTemplatesRoutes } from "./routes/templates.js";
import { AgentService } from "./services/agent-service.js"; import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js"; import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
@@ -87,6 +88,7 @@ const events: EventEmitter = createEventEmitter();
// Create services // Create services
const agentService = new AgentService(DATA_DIR, events); const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events);
// Initialize services // Initialize services
(async () => { (async () => {
@@ -104,14 +106,14 @@ app.use("/api/fs", createFsRoutes(events));
app.use("/api/agent", createAgentRoutes(agentService, events)); app.use("/api/agent", createAgentRoutes(agentService, events));
app.use("/api/sessions", createSessionsRoutes(agentService)); app.use("/api/sessions", createSessionsRoutes(agentService));
app.use("/api/features", createFeaturesRoutes(featureLoader)); 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/worktree", createWorktreeRoutes());
app.use("/api/git", createGitRoutes()); app.use("/api/git", createGitRoutes());
app.use("/api/setup", createSetupRoutes()); app.use("/api/setup", createSetupRoutes());
app.use("/api/suggestions", createSuggestionsRoutes(events)); app.use("/api/suggestions", createSuggestionsRoutes(events));
app.use("/api/models", createModelsRoutes()); app.use("/api/models", createModelsRoutes());
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events)); 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/workspace", createWorkspaceRoutes());
app.use("/api/templates", createTemplatesRoutes()); app.use("/api/templates", createTemplatesRoutes());

View File

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

View File

@@ -92,7 +92,11 @@ export class AutoModeService {
} }
private async runAutoLoop(): Promise<void> { private async runAutoLoop(): Promise<void> {
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) { while (
this.autoLoopRunning &&
this.autoLoopAbortController &&
!this.autoLoopAbortController.signal.aborted
) {
try { try {
// Check if we have capacity // Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
@@ -101,7 +105,9 @@ export class AutoModeService {
} }
// Load pending features // Load pending features
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); const pendingFeatures = await this.loadPendingFeatures(
this.config!.projectPath
);
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_complete", { this.emitAutoModeEvent("auto_mode_complete", {
@@ -112,7 +118,9 @@ export class AutoModeService {
} }
// Find a feature not currently running // 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) { if (nextFeature) {
// Start feature execution in background // Start feature execution in background
@@ -171,7 +179,11 @@ export class AutoModeService {
// Setup worktree if enabled // Setup worktree if enabled
if (useWorktrees) { if (useWorktrees) {
worktreePath = await this.setupWorktree(projectPath, featureId, branchName); worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
} }
const workDir = worktreePath || projectPath; const workDir = worktreePath || projectPath;
@@ -190,7 +202,11 @@ export class AutoModeService {
this.emitAutoModeEvent("auto_mode_feature_start", { this.emitAutoModeEvent("auto_mode_feature_start", {
featureId, featureId,
projectPath, projectPath,
feature: { id: featureId, title: "Loading...", description: "Feature is starting" }, feature: {
id: featureId,
title: "Loading...",
description: "Feature is starting",
},
}); });
try { try {
@@ -219,12 +235,18 @@ export class AutoModeService {
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model);
// Mark as waiting_approval for user review // 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", { this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId, featureId,
passes: true, 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, projectPath,
}); });
} catch (error) { } catch (error) {
@@ -293,7 +315,12 @@ export class AutoModeService {
if (hasContext) { if (hasContext) {
// Load previous context and continue // Load previous context and continue
const context = await fs.readFile(contextPath, "utf-8"); 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 // No context, start fresh
@@ -316,7 +343,12 @@ export class AutoModeService {
const abortController = new AbortController(); const abortController = new AbortController();
// Check if worktree exists // Check if worktree exists
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath; let workDir = projectPath;
try { try {
@@ -379,7 +411,11 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent("auto_mode_feature_start", { this.emitAutoModeEvent("auto_mode_feature_start", {
featureId, featureId,
projectPath, 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 { 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); await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model);
// Mark as waiting_approval for user review // 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", { this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId, featureId,
@@ -496,8 +536,16 @@ Address the follow-up instructions above. Review the previous work and make the
/** /**
* Verify a feature's implementation * Verify a feature's implementation
*/ */
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> { async verifyFeature(
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); projectPath: string,
featureId: string
): Promise<boolean> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath; let workDir = projectPath;
try { try {
@@ -516,7 +564,8 @@ Address the follow-up instructions above. Review the previous work and make the
]; ];
let allPassed = true; 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) { for (const check of verificationChecks) {
try { try {
@@ -524,7 +573,11 @@ Address the follow-up instructions above. Review the previous work and make the
cwd: workDir, cwd: workDir,
timeout: 120000, timeout: 120000,
}); });
results.push({ check: check.name, passed: true, output: stdout || stderr }); results.push({
check: check.name,
passed: true,
output: stdout || stderr,
});
} catch (error) { } catch (error) {
allPassed = false; allPassed = false;
results.push({ results.push({
@@ -541,7 +594,9 @@ Address the follow-up instructions above. Review the previous work and make the
passes: allPassed, passes: allPassed,
message: allPassed message: allPassed
? "All verification checks passed" ? "All verification checks passed"
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`, : `Verification failed: ${
results.find((r) => !r.passed)?.check || "Unknown"
}`,
}); });
return allPassed; return allPassed;
@@ -550,8 +605,16 @@ Address the follow-up instructions above. Review the previous work and make the
/** /**
* Commit feature changes * Commit feature changes
*/ */
async commitFeature(projectPath: string, featureId: string): Promise<string | null> { async commitFeature(
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId); projectPath: string,
featureId: string
): Promise<string | null> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath; let workDir = projectPath;
try { try {
@@ -563,7 +626,9 @@ Address the follow-up instructions above. Review the previous work and make the
try { try {
// Check for changes // 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()) { if (!status.trim()) {
return null; // No changes return null; // No changes
} }
@@ -581,7 +646,9 @@ Address the follow-up instructions above. Review the previous work and make the
}); });
// Get commit hash // 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", { this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId, 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 * Check if context exists for a feature
*/ */
async contextExists(projectPath: string, featureId: string): Promise<boolean> { async contextExists(
projectPath: string,
featureId: string
): Promise<boolean> {
const contextPath = path.join( const contextPath = path.join(
projectPath, projectPath,
".automaker", ".automaker",
@@ -626,7 +696,11 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent("auto_mode_feature_start", { this.emitAutoModeEvent("auto_mode_feature_start", {
featureId: analysisFeatureId, featureId: analysisFeatureId,
projectPath, 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: const prompt = `Analyze this project and provide a summary of:
@@ -673,7 +747,11 @@ Format your response as a structured markdown document.`;
} }
// Save analysis // 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.mkdir(path.dirname(analysisPath), { recursive: true });
await fs.writeFile(analysisPath, analysisResult); await fs.writeFile(analysisPath, analysisResult);
@@ -767,7 +845,10 @@ Format your response as a structured markdown document.`;
return worktreePath; return worktreePath;
} }
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> { private async loadFeature(
projectPath: string,
featureId: string
): Promise<Feature | null> {
const featurePath = path.join( const featurePath = path.join(
projectPath, projectPath,
".automaker", ".automaker",
@@ -802,12 +883,13 @@ Format your response as a structured markdown document.`;
const feature = JSON.parse(data); const feature = JSON.parse(data);
feature.status = status; feature.status = status;
feature.updatedAt = new Date().toISOString(); 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") { if (status === "waiting_approval") {
feature.justFinished = true; feature.justFinishedAt = new Date().toISOString();
} else { } else {
// Clear the flag when moving to other statuses // Clear the timestamp when moving to other statuses
feature.justFinished = false; feature.justFinishedAt = undefined;
} }
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch { } catch {
@@ -824,7 +906,11 @@ Format your response as a structured markdown document.`;
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, "feature.json"); const featurePath = path.join(
featuresDir,
entry.name,
"feature.json"
);
try { try {
const data = await fs.readFile(featurePath, "utf-8"); const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data); const feature = JSON.parse(data);
@@ -940,7 +1026,13 @@ When done, summarize what you implemented and any notes for the developer.`;
// Execute via provider // Execute via provider
const stream = provider.executeQuery(options); const stream = provider.executeQuery(options);
let responseText = ""; 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) { for await (const msg of stream) {
if (msg.type === "assistant" && msg.message?.content) { if (msg.type === "assistant" && msg.message?.content) {