mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -12,6 +12,7 @@
|
||||
@custom-variant catppuccin (&:is(.catppuccin *));
|
||||
@custom-variant onedark (&:is(.onedark *));
|
||||
@custom-variant synthwave (&:is(.synthwave *));
|
||||
@custom-variant red (&:is(.red *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@@ -1072,6 +1073,75 @@
|
||||
--running-indicator-text: oklch(0.75 0.26 350);
|
||||
}
|
||||
|
||||
/* Red Theme - Bold crimson/red aesthetic */
|
||||
.red {
|
||||
--background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */
|
||||
--background-50: oklch(0.12 0.03 15 / 0.5);
|
||||
--background-80: oklch(0.12 0.03 15 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */
|
||||
--foreground-secondary: oklch(0.7 0.02 15);
|
||||
--foreground-muted: oklch(0.5 0.03 15);
|
||||
|
||||
--card: oklch(0.18 0.04 15); /* Slightly lighter dark red */
|
||||
--card-foreground: oklch(0.95 0.01 15);
|
||||
--popover: oklch(0.15 0.035 15);
|
||||
--popover-foreground: oklch(0.95 0.01 15);
|
||||
|
||||
--primary: oklch(0.55 0.25 25); /* Vibrant crimson red */
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
|
||||
--brand-400: oklch(0.6 0.23 25);
|
||||
--brand-500: oklch(0.55 0.25 25); /* Crimson */
|
||||
--brand-600: oklch(0.5 0.27 25);
|
||||
|
||||
--secondary: oklch(0.22 0.05 15);
|
||||
--secondary-foreground: oklch(0.95 0.01 15);
|
||||
|
||||
--muted: oklch(0.22 0.05 15);
|
||||
--muted-foreground: oklch(0.5 0.03 15);
|
||||
|
||||
--accent: oklch(0.28 0.06 15);
|
||||
--accent-foreground: oklch(0.95 0.01 15);
|
||||
|
||||
--destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */
|
||||
|
||||
--border: oklch(0.35 0.08 15);
|
||||
--border-glass: oklch(0.55 0.25 25 / 0.3);
|
||||
|
||||
--input: oklch(0.18 0.04 15);
|
||||
--ring: oklch(0.55 0.25 25);
|
||||
|
||||
--chart-1: oklch(0.55 0.25 25); /* Crimson */
|
||||
--chart-2: oklch(0.7 0.2 50); /* Orange */
|
||||
--chart-3: oklch(0.8 0.18 80); /* Gold */
|
||||
--chart-4: oklch(0.6 0.22 0); /* Pure red */
|
||||
--chart-5: oklch(0.65 0.2 350); /* Pink-red */
|
||||
|
||||
--sidebar: oklch(0.1 0.025 15);
|
||||
--sidebar-foreground: oklch(0.95 0.01 15);
|
||||
--sidebar-primary: oklch(0.55 0.25 25);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.05 15);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 15);
|
||||
--sidebar-border: oklch(0.35 0.08 15);
|
||||
--sidebar-ring: oklch(0.55 0.25 25);
|
||||
|
||||
/* Action button colors - Red theme */
|
||||
--action-view: oklch(0.55 0.25 25); /* Crimson */
|
||||
--action-view-hover: oklch(0.5 0.27 25);
|
||||
--action-followup: oklch(0.7 0.2 50); /* Orange */
|
||||
--action-followup-hover: oklch(0.65 0.22 50);
|
||||
--action-commit: oklch(0.6 0.2 140); /* Green for positive actions */
|
||||
--action-commit-hover: oklch(0.55 0.22 140);
|
||||
--action-verify: oklch(0.6 0.2 140); /* Green */
|
||||
--action-verify-hover: oklch(0.55 0.22 140);
|
||||
|
||||
/* Running indicator - Crimson */
|
||||
--running-indicator: oklch(0.55 0.25 25);
|
||||
--running-indicator-text: oklch(0.6 0.23 25);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -15,7 +15,11 @@ import { RunningAgentsView } from "@/components/views/running-agents-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
setGlobalFileBrowser,
|
||||
} from "@/contexts/file-browser-context";
|
||||
|
||||
function HomeContent() {
|
||||
const {
|
||||
@@ -24,6 +28,8 @@ function HomeContent() {
|
||||
setIpcConnected,
|
||||
theme,
|
||||
currentProject,
|
||||
previewTheme,
|
||||
getEffectiveTheme,
|
||||
} = useAppStore();
|
||||
const { isFirstRun, setupComplete } = useSetupStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -72,9 +78,9 @@ function HomeContent() {
|
||||
};
|
||||
}, [handleStreamerPanelShortcut]);
|
||||
|
||||
// Compute the effective theme: project theme takes priority over global theme
|
||||
// This is reactive because it depends on currentProject and theme from the store
|
||||
const effectiveTheme = currentProject?.theme || theme;
|
||||
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
|
||||
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
@@ -122,7 +128,7 @@ function HomeContent() {
|
||||
testConnection();
|
||||
}, [setIpcConnected]);
|
||||
|
||||
// Apply theme class to document (uses effective theme - project-specific or global)
|
||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove(
|
||||
@@ -137,7 +143,8 @@ function HomeContent() {
|
||||
"gruvbox",
|
||||
"catppuccin",
|
||||
"onedark",
|
||||
"synthwave"
|
||||
"synthwave",
|
||||
"red"
|
||||
);
|
||||
|
||||
if (effectiveTheme === "dark") {
|
||||
@@ -162,6 +169,8 @@ function HomeContent() {
|
||||
root.classList.add("onedark");
|
||||
} else if (effectiveTheme === "synthwave") {
|
||||
root.classList.add("synthwave");
|
||||
} else if (effectiveTheme === "red") {
|
||||
root.classList.add("red");
|
||||
} else if (effectiveTheme === "light") {
|
||||
root.classList.add("light");
|
||||
} else if (effectiveTheme === "system") {
|
||||
@@ -173,7 +182,7 @@ function HomeContent() {
|
||||
root.classList.add("light");
|
||||
}
|
||||
}
|
||||
}, [effectiveTheme]);
|
||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
|
||||
533
apps/app/src/components/dialogs/board-background-modal.tsx
Normal file
533
apps/app/src/components/dialogs/board-background-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -26,18 +26,6 @@ import {
|
||||
UserCircle,
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Moon,
|
||||
Sun,
|
||||
Terminal,
|
||||
Ghost,
|
||||
Snowflake,
|
||||
Flame,
|
||||
Sparkles as TokyoNightIcon,
|
||||
Eclipse,
|
||||
Trees,
|
||||
Cat,
|
||||
Atom,
|
||||
Radio,
|
||||
Monitor,
|
||||
Search,
|
||||
Bug,
|
||||
@@ -71,7 +59,12 @@ import {
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import { getElectronAPI, Project, TrashedProject, RunningAgent } from "@/lib/electron";
|
||||
import {
|
||||
getElectronAPI,
|
||||
Project,
|
||||
TrashedProject,
|
||||
RunningAgent,
|
||||
} from "@/lib/electron";
|
||||
import {
|
||||
initializeProject,
|
||||
hasAppSpec,
|
||||
@@ -79,6 +72,7 @@ import {
|
||||
} from "@/lib/project-init";
|
||||
import { toast } from "sonner";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { themeOptions } from "@/config/theme-options";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
||||
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
|
||||
@@ -175,21 +169,14 @@ function SortableProjectItem({
|
||||
);
|
||||
}
|
||||
|
||||
// Theme options for project theme selector
|
||||
// Theme options for project theme selector - derived from the shared config
|
||||
const PROJECT_THEME_OPTIONS = [
|
||||
{ value: "", label: "Use Global", icon: Monitor },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "retro", label: "Retro", icon: Terminal },
|
||||
{ value: "dracula", label: "Dracula", icon: Ghost },
|
||||
{ value: "nord", label: "Nord", icon: Snowflake },
|
||||
{ value: "monokai", label: "Monokai", icon: Flame },
|
||||
{ value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon },
|
||||
{ value: "solarized", label: "Solarized", icon: Eclipse },
|
||||
{ value: "gruvbox", label: "Gruvbox", icon: Trees },
|
||||
{ value: "catppuccin", label: "Catppuccin", icon: Cat },
|
||||
{ value: "onedark", label: "One Dark", icon: Atom },
|
||||
{ value: "synthwave", label: "Synthwave", icon: Radio },
|
||||
...themeOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
icon: opt.Icon,
|
||||
})),
|
||||
] as const;
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -213,6 +200,7 @@ export function Sidebar() {
|
||||
clearProjectHistory,
|
||||
setProjectTheme,
|
||||
setTheme,
|
||||
setPreviewTheme,
|
||||
theme: globalTheme,
|
||||
moveProjectToTrash,
|
||||
} = useAppStore();
|
||||
@@ -389,7 +377,10 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Sidebar] Error fetching running agents count:", error);
|
||||
console.error(
|
||||
"[Sidebar] Error fetching running agents count:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
fetchRunningAgentsCount();
|
||||
@@ -501,7 +492,8 @@ export function Sidebar() {
|
||||
// Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
|
||||
// Then fall back to current effective theme, then global theme
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme;
|
||||
const effectiveTheme =
|
||||
trashedProject?.theme || currentProject?.theme || globalTheme;
|
||||
project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name,
|
||||
@@ -546,7 +538,14 @@ export function Sidebar() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]);
|
||||
}, [
|
||||
projects,
|
||||
trashedProjects,
|
||||
addProject,
|
||||
setCurrentProject,
|
||||
currentProject,
|
||||
globalTheme,
|
||||
]);
|
||||
|
||||
const handleRestoreProject = useCallback(
|
||||
(projectId: string) => {
|
||||
@@ -828,7 +827,9 @@ export function Sidebar() {
|
||||
<div
|
||||
className={cn(
|
||||
"h-20 border-b border-sidebar-border shrink-0 titlebar-drag-region",
|
||||
sidebarOpen ? "pt-8 px-3 lg:px-6 flex items-center justify-between" : "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
|
||||
sidebarOpen
|
||||
? "pt-8 px-3 lg:px-6 flex items-center justify-between"
|
||||
: "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -859,7 +860,9 @@ export function Sidebar() {
|
||||
<button
|
||||
onClick={() => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
|
||||
api.openExternalLink(
|
||||
"https://github.com/AutoMaker-Org/automaker/issues"
|
||||
);
|
||||
}}
|
||||
className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all"
|
||||
title="Report Bug / Feature Request"
|
||||
@@ -1001,7 +1004,14 @@ export function Sidebar() {
|
||||
|
||||
{/* Project Options Menu - theme and history */}
|
||||
{currentProject && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// Clear preview theme when the menu closes
|
||||
if (!open) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="hidden lg:flex items-center justify-center w-8 h-[42px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag"
|
||||
@@ -1024,8 +1034,12 @@ export function Sidebar() {
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="w-48"
|
||||
className="w-56"
|
||||
data-testid="project-theme-menu"
|
||||
onPointerLeave={() => {
|
||||
// Clear preview theme when leaving the dropdown
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Select theme for this project
|
||||
@@ -1035,9 +1049,14 @@ export function Sidebar() {
|
||||
value={currentProject.theme || ""}
|
||||
onValueChange={(value) => {
|
||||
if (currentProject) {
|
||||
// Clear preview theme when a theme is selected
|
||||
setPreviewTheme(null);
|
||||
// If selecting an actual theme (not "Use Global"), also update global
|
||||
if (value !== "") {
|
||||
setTheme(value as any);
|
||||
} else {
|
||||
// Restore to global theme when "Use Global" is selected
|
||||
setTheme(globalTheme);
|
||||
}
|
||||
setProjectTheme(
|
||||
currentProject.id,
|
||||
@@ -1048,22 +1067,54 @@ export function Sidebar() {
|
||||
>
|
||||
{PROJECT_THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const themeValue =
|
||||
option.value === "" ? globalTheme : option.value;
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
<div
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${
|
||||
option.value || "global"
|
||||
}`}
|
||||
onPointerEnter={() => {
|
||||
// Preview the theme on hover
|
||||
setPreviewTheme(themeValue as any);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
// Clear preview theme when leaving this item
|
||||
// Only clear if we're not moving to another theme item
|
||||
const relatedTarget =
|
||||
e.relatedTarget as HTMLElement;
|
||||
if (
|
||||
!relatedTarget ||
|
||||
!relatedTarget.closest(
|
||||
'[data-testid^="project-theme-"]'
|
||||
)
|
||||
) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
<span>{option.label}</span>
|
||||
{option.value === "" && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
|
||||
({globalTheme})
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${
|
||||
option.value || "global"
|
||||
}`}
|
||||
onFocus={() => {
|
||||
// Preview the theme on keyboard navigation
|
||||
setPreviewTheme(themeValue as any);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Clear preview theme when losing focus
|
||||
// If moving to another item, its onFocus will set it again
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
<span>{option.label}</span>
|
||||
{option.value === "" && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
|
||||
({globalTheme})
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
@@ -1241,14 +1292,25 @@ export function Sidebar() {
|
||||
{isActiveRoute("running-agents") && (
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
|
||||
)}
|
||||
<Activity
|
||||
className={cn(
|
||||
"w-4 h-4 shrink-0 transition-colors",
|
||||
isActiveRoute("running-agents")
|
||||
? "text-brand-500"
|
||||
: "group-hover:text-brand-400"
|
||||
<div className="relative">
|
||||
<Activity
|
||||
className={cn(
|
||||
"w-4 h-4 shrink-0 transition-colors",
|
||||
isActiveRoute("running-agents")
|
||||
? "text-brand-500"
|
||||
: "group-hover:text-brand-400"
|
||||
)}
|
||||
/>
|
||||
{/* Running agents count badge - shown in collapsed state */}
|
||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-semibold rounded-full bg-brand-500 text-white"
|
||||
data-testid="running-agents-count-collapsed"
|
||||
>
|
||||
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2.5 font-medium text-sm flex-1 text-left",
|
||||
@@ -1257,6 +1319,18 @@ export function Sidebar() {
|
||||
>
|
||||
Running Agents
|
||||
</span>
|
||||
{/* Running agents count badge - shown in expanded state */}
|
||||
{sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden lg:flex items-center justify-center min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full bg-brand-500 text-white",
|
||||
isActiveRoute("running-agents") && "bg-brand-600"
|
||||
)}
|
||||
data-testid="running-agents-count"
|
||||
>
|
||||
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarOpen && (
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
|
||||
Running Agents
|
||||
@@ -1328,7 +1402,9 @@ export function Sidebar() {
|
||||
</DialogHeader>
|
||||
|
||||
{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">
|
||||
{trashedProjects.map((project) => (
|
||||
|
||||
@@ -58,6 +58,7 @@ import { KanbanColumn } from "./kanban-column";
|
||||
import { KanbanCard } from "./kanban-card";
|
||||
import { AgentOutputModal } from "./agent-output-modal";
|
||||
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
Square,
|
||||
Maximize2,
|
||||
Shuffle,
|
||||
ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
@@ -213,6 +215,7 @@ export function BoardView() {
|
||||
aiProfiles,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
boardBackgroundByProject,
|
||||
} = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
@@ -237,6 +240,8 @@ export function BoardView() {
|
||||
);
|
||||
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
|
||||
useState(false);
|
||||
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
|
||||
useState(false);
|
||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||
@@ -407,7 +412,8 @@ export function BoardView() {
|
||||
|
||||
const currentPath = currentProject.path;
|
||||
const previousPath = prevProjectPathRef.current;
|
||||
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
|
||||
const isProjectSwitch =
|
||||
previousPath !== null && currentPath !== previousPath;
|
||||
|
||||
// Get cached features from store (without adding to dependencies)
|
||||
const cachedFeatures = useAppStore.getState().features;
|
||||
@@ -563,7 +569,8 @@ export function BoardView() {
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Use event's projectPath or projectId if available, otherwise use current project
|
||||
// Board view only reacts to events for the currently selected project
|
||||
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
|
||||
const eventProjectId =
|
||||
("projectId" in event && event.projectId) || projectId;
|
||||
|
||||
if (event.type === "auto_mode_feature_complete") {
|
||||
// Reload features when a feature is completed
|
||||
@@ -592,15 +599,16 @@ export function BoardView() {
|
||||
loadFeatures();
|
||||
|
||||
// Check for authentication errors and show a more helpful message
|
||||
const isAuthError = event.errorType === "authentication" ||
|
||||
(event.error && (
|
||||
event.error.includes("Authentication failed") ||
|
||||
event.error.includes("Invalid API key")
|
||||
));
|
||||
const isAuthError =
|
||||
event.errorType === "authentication" ||
|
||||
(event.error &&
|
||||
(event.error.includes("Authentication failed") ||
|
||||
event.error.includes("Invalid API key")));
|
||||
|
||||
if (isAuthError) {
|
||||
toast.error("Authentication Failed", {
|
||||
description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||
description:
|
||||
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
@@ -874,8 +882,11 @@ export function BoardView() {
|
||||
// features often have skipTests=true, and we want status-based handling first
|
||||
if (targetStatus === "verified") {
|
||||
moveFeature(featureId, "verified");
|
||||
// Clear justFinished flag when manually verifying via drag
|
||||
persistFeatureUpdate(featureId, { status: "verified", justFinished: false });
|
||||
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "verified",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Manually verified: ${draggedFeature.description.slice(
|
||||
0,
|
||||
@@ -885,8 +896,11 @@ export function BoardView() {
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear justFinished flag when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", justFinished: false });
|
||||
// Clear justFinishedAt timestamp when moving back to backlog
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "backlog",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
@@ -1207,8 +1221,11 @@ export function BoardView() {
|
||||
description: feature.description,
|
||||
});
|
||||
moveFeature(feature.id, "verified");
|
||||
// Clear justFinished flag when manually verifying
|
||||
persistFeatureUpdate(feature.id, { status: "verified", justFinished: false });
|
||||
// Clear justFinishedAt timestamp when manually verifying
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: "verified",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Marked as verified: ${feature.description.slice(0, 50)}${
|
||||
feature.description.length > 50 ? "..." : ""
|
||||
@@ -1274,11 +1291,11 @@ export function BoardView() {
|
||||
}
|
||||
|
||||
// Move feature back to in_progress before sending follow-up
|
||||
// Clear justFinished flag since user is now interacting with it
|
||||
// Clear justFinishedAt timestamp since user is now interacting with it
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
justFinished: false,
|
||||
justFinishedAt: undefined,
|
||||
};
|
||||
updateFeature(featureId, updates);
|
||||
persistFeatureUpdate(featureId, updates);
|
||||
@@ -1537,11 +1554,22 @@ export function BoardView() {
|
||||
}
|
||||
});
|
||||
|
||||
// Sort waiting_approval column: justFinished features go to the top
|
||||
// Sort waiting_approval column: justFinished features (within 2 minutes) go to the top
|
||||
map.waiting_approval.sort((a, b) => {
|
||||
// Features with justFinished=true should appear first
|
||||
if (a.justFinished && !b.justFinished) return -1;
|
||||
if (!a.justFinished && b.justFinished) return 1;
|
||||
// Helper to check if feature is "just finished" (within 2 minutes)
|
||||
const isJustFinished = (feature: Feature) => {
|
||||
if (!feature.justFinishedAt) return false;
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const now = Date.now();
|
||||
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
|
||||
return now - finishedTime < twoMinutes;
|
||||
};
|
||||
|
||||
const aJustFinished = isJustFinished(a);
|
||||
const bJustFinished = isJustFinished(b);
|
||||
// Features with justFinishedAt within 2 minutes should appear first
|
||||
if (aJustFinished && !bJustFinished) return -1;
|
||||
if (!aJustFinished && bJustFinished) return 1;
|
||||
return 0; // Keep original order for features with same justFinished status
|
||||
});
|
||||
|
||||
@@ -1646,7 +1674,7 @@ export function BoardView() {
|
||||
return;
|
||||
}
|
||||
|
||||
const featuresToStart = backlogFeatures.slice(0, availableSlots);
|
||||
const featuresToStart = backlogFeatures.slice(0, 1);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Update the feature status with startedAt timestamp
|
||||
@@ -1855,202 +1883,296 @@ export function BoardView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kanban Card Detail Level Toggle */}
|
||||
{/* Board Background & Detail Level Controls */}
|
||||
{isMounted && (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
|
||||
data-testid="kanban-detail-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("minimal")}
|
||||
className={cn(
|
||||
"p-2 rounded-l-lg transition-colors",
|
||||
kanbanCardDetailLevel === "minimal"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-minimal"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBoardBackgroundModal(true)}
|
||||
className="h-8 px-2"
|
||||
data-testid="board-background-button"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Minimal - Title & category only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("standard")}
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
kanbanCardDetailLevel === "standard"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-standard"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Standard - Steps & progress</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("detailed")}
|
||||
className={cn(
|
||||
"p-2 rounded-r-lg transition-colors",
|
||||
kanbanCardDetailLevel === "detailed"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-detailed"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Detailed - Model, tools & tasks</p>
|
||||
<p>Board Background Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kanban Card Detail Level Toggle */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="kanban-detail-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("minimal")}
|
||||
className={cn(
|
||||
"p-2 rounded-l-lg transition-colors",
|
||||
kanbanCardDetailLevel === "minimal"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-minimal"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Minimal - Title & category only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("standard")}
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
kanbanCardDetailLevel === "standard"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-standard"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Standard - Steps & progress</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel("detailed")}
|
||||
className={cn(
|
||||
"p-2 rounded-r-lg transition-colors",
|
||||
kanbanCardDetailLevel === "detailed"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-detailed"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Detailed - Model, tools & tasks</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{/* Kanban Columns */}
|
||||
<div className="flex-1 overflow-x-auto px-4 pb-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 h-full min-w-max">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
count={columnFeatures.length}
|
||||
headerAction={
|
||||
column.id === "verified" && columnFeatures.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setShowDeleteAllVerifiedDialog(true)}
|
||||
data-testid="delete-all-verified-button"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Delete All
|
||||
</Button>
|
||||
) : column.id === "backlog" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
||||
onClick={() => setShowSuggestionsDialog(true)}
|
||||
title="Feature Suggestions"
|
||||
data-testid="feature-suggestions-button"
|
||||
>
|
||||
<Lightbulb className="w-3.5 h-3.5" />
|
||||
{suggestionsCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
||||
data-testid="suggestions-count"
|
||||
>
|
||||
{suggestionsCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{columnFeatures.length > 0 && (
|
||||
<HotkeyButton
|
||||
{(() => {
|
||||
// Get background settings for current project
|
||||
const backgroundSettings = currentProject
|
||||
? boardBackgroundByProject[currentProject.path] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
}
|
||||
: {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
|
||||
// Build background image style if image exists
|
||||
const backgroundImageStyle = backgroundSettings.imagePath
|
||||
? {
|
||||
backgroundImage: `url(${
|
||||
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
|
||||
}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(
|
||||
currentProject?.path || ""
|
||||
)})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 h-full min-w-max">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
count={columnFeatures.length}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
headerAction={
|
||||
column.id === "verified" &&
|
||||
columnFeatures.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||
onClick={handleStartNextFeatures}
|
||||
hotkey={shortcuts.startNext}
|
||||
hotkeyActive={false}
|
||||
data-testid="start-next-button"
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
setShowDeleteAllVerifiedDialog(true)
|
||||
}
|
||||
data-testid="delete-all-verified-button"
|
||||
>
|
||||
<FastForward className="w-3 h-3 mr-1" />
|
||||
Pull Top
|
||||
</HotkeyButton>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{columnFeatures.map((feature, index) => {
|
||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === "in_progress" && index < 10) {
|
||||
shortcutKey = index === 9 ? "0" : String(index + 1);
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Delete All
|
||||
</Button>
|
||||
) : column.id === "backlog" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
||||
onClick={() => setShowSuggestionsDialog(true)}
|
||||
title="Feature Suggestions"
|
||||
data-testid="feature-suggestions-button"
|
||||
>
|
||||
<Lightbulb className="w-3.5 h-3.5" />
|
||||
{suggestionsCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
||||
data-testid="suggestions-count"
|
||||
>
|
||||
{suggestionsCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{columnFeatures.length > 0 && (
|
||||
<HotkeyButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||
onClick={handleStartNextFeatures}
|
||||
hotkey={shortcuts.startNext}
|
||||
hotkeyActive={false}
|
||||
data-testid="start-next-button"
|
||||
>
|
||||
<FastForward className="w-3 h-3 mr-1" />
|
||||
Pull Top
|
||||
</HotkeyButton>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
return (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => setEditingFeature(feature)}
|
||||
onDelete={() => handleDeleteFeature(feature.id)}
|
||||
onViewOutput={() => handleViewOutput(feature)}
|
||||
onVerify={() => handleVerifyFeature(feature)}
|
||||
onResume={() => handleResumeFeature(feature)}
|
||||
onForceStop={() => handleForceStopFeature(feature)}
|
||||
onManualVerify={() => handleManualVerify(feature)}
|
||||
onMoveBackToInProgress={() =>
|
||||
handleMoveBackToInProgress(feature)
|
||||
>
|
||||
<SortableContext
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{columnFeatures.map((feature, index) => {
|
||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === "in_progress" && index < 10) {
|
||||
shortcutKey =
|
||||
index === 9 ? "0" : String(index + 1);
|
||||
}
|
||||
onFollowUp={() => handleOpenFollowUp(feature)}
|
||||
onCommit={() => handleCommitFeature(feature)}
|
||||
onRevert={() => handleRevertFeature(feature)}
|
||||
onMerge={() => handleMergeFeature(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(
|
||||
feature.id
|
||||
)}
|
||||
shortcutKey={shortcutKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</KanbanColumn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => setEditingFeature(feature)}
|
||||
onDelete={() => handleDeleteFeature(feature.id)}
|
||||
onViewOutput={() => handleViewOutput(feature)}
|
||||
onVerify={() => handleVerifyFeature(feature)}
|
||||
onResume={() => handleResumeFeature(feature)}
|
||||
onForceStop={() =>
|
||||
handleForceStopFeature(feature)
|
||||
}
|
||||
onManualVerify={() =>
|
||||
handleManualVerify(feature)
|
||||
}
|
||||
onMoveBackToInProgress={() =>
|
||||
handleMoveBackToInProgress(feature)
|
||||
}
|
||||
onFollowUp={() => handleOpenFollowUp(feature)}
|
||||
onCommit={() => handleCommitFeature(feature)}
|
||||
onRevert={() => handleRevertFeature(feature)}
|
||||
onMerge={() => handleMergeFeature(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(
|
||||
feature.id
|
||||
)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={
|
||||
backgroundSettings.cardGlassmorphism
|
||||
}
|
||||
cardBorderEnabled={
|
||||
backgroundSettings.cardBorderEnabled
|
||||
}
|
||||
cardBorderOpacity={
|
||||
backgroundSettings.cardBorderOpacity
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</KanbanColumn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeFeature && (
|
||||
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm">
|
||||
{activeFeature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{activeFeature.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeFeature && (
|
||||
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm">
|
||||
{activeFeature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{activeFeature.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Board Background Modal */}
|
||||
<BoardBackgroundModal
|
||||
open={showBoardBackgroundModal}
|
||||
onOpenChange={setShowBoardBackgroundModal}
|
||||
/>
|
||||
|
||||
{/* Add Feature Dialog */}
|
||||
<Dialog
|
||||
open={showAddDialog}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, memo } from "react";
|
||||
import { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -110,6 +110,14 @@ interface KanbanCardProps {
|
||||
contextContent?: string;
|
||||
/** Feature summary from agent completion */
|
||||
summary?: string;
|
||||
/** Opacity percentage (0-100) */
|
||||
opacity?: number;
|
||||
/** Whether to use glassmorphism (backdrop-blur) effect */
|
||||
glassmorphism?: boolean;
|
||||
/** Whether to show card borders */
|
||||
cardBorderEnabled?: boolean;
|
||||
/** Card border opacity percentage (0-100) */
|
||||
cardBorderOpacity?: number;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -131,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
shortcutKey,
|
||||
contextContent,
|
||||
summary,
|
||||
opacity = 100,
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
}: KanbanCardProps) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
|
||||
// Check if feature has worktree
|
||||
@@ -148,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
kanbanCardDetailLevel === "detailed";
|
||||
const showAgentInfo = kanbanCardDetailLevel === "detailed";
|
||||
|
||||
// Helper to check if "just finished" badge should be shown (within 2 minutes)
|
||||
const isJustFinished = useMemo(() => {
|
||||
if (
|
||||
!feature.justFinishedAt ||
|
||||
feature.status !== "waiting_approval" ||
|
||||
feature.error
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
|
||||
return currentTime - finishedTime < twoMinutes;
|
||||
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
|
||||
|
||||
// Update current time periodically to check if badge should be hidden
|
||||
useEffect(() => {
|
||||
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
|
||||
return;
|
||||
}
|
||||
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
|
||||
const timeRemaining = twoMinutes - (currentTime - finishedTime);
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
// Already past 2 minutes
|
||||
return;
|
||||
}
|
||||
|
||||
// Update time every second to check if 2 minutes have passed
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
|
||||
// Load context file for in_progress, waiting_approval, and verified features
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -184,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
} else {
|
||||
// Fallback to direct file read for backward compatibility
|
||||
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
|
||||
const result = await api.readFile(contextPath);
|
||||
const result = await api.readFile(contextPath);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const info = parseAgentContext(result.content);
|
||||
setAgentInfo(info);
|
||||
if (result.success && result.content) {
|
||||
const info = parseAgentContext(result.content);
|
||||
setAgentInfo(info);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -241,15 +291,42 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
// Calculate border style based on enabled state and opacity
|
||||
const borderStyle: React.CSSProperties = { ...style };
|
||||
if (!cardBorderEnabled) {
|
||||
(borderStyle as Record<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 (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
style={borderStyle}
|
||||
className={cn(
|
||||
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
|
||||
isDragging && "opacity-50 scale-105 shadow-lg",
|
||||
"cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
|
||||
// Apply border class when border is enabled and opacity is 100%
|
||||
// When opacity is not 100%, we use inline styles for border color
|
||||
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 &&
|
||||
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
|
||||
feature.error &&
|
||||
@@ -262,6 +339,16 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* Background overlay with opacity - only affects background, not content */}
|
||||
{!isDragging && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-xl bg-card -z-10",
|
||||
glassmorphism && "backdrop-blur-sm"
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
)}
|
||||
{/* Skip Tests indicator badge */}
|
||||
{feature.skipTests && !feature.error && (
|
||||
<div
|
||||
@@ -292,8 +379,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<span>Errored</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Just Finished indicator badge - shows when agent just completed work */}
|
||||
{feature.justFinished && feature.status === "waiting_approval" && !feature.error && (
|
||||
{/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */}
|
||||
{isJustFinished && (
|
||||
<div
|
||||
className={cn(
|
||||
"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"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>Done</span>
|
||||
<span>Fresh Baked</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Branch badge - show when feature has a worktree */}
|
||||
@@ -317,18 +404,22 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
|
||||
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
||||
// Position below other badges if present, otherwise use normal position
|
||||
feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval")
|
||||
feature.error || feature.skipTests || isJustFinished
|
||||
? "top-8 left-2"
|
||||
: "top-2 left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
>
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
|
||||
<span className="truncate max-w-[80px]">
|
||||
{feature.branchName?.replace("feature/", "")}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
||||
<p className="font-mono text-xs break-all">{feature.branchName}</p>
|
||||
<p className="font-mono text-xs break-all">
|
||||
{feature.branchName}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -337,9 +428,11 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
className={cn(
|
||||
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
|
||||
// Add extra top padding when badges are present to prevent text overlap
|
||||
(feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-10",
|
||||
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
|
||||
// Add even more top padding when both badges and branch are shown
|
||||
hasWorktree && (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14"
|
||||
hasWorktree &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-14"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
@@ -471,7 +564,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
|
||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.steps.length > 3 && (
|
||||
@@ -565,7 +660,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
todo.status === "completed" &&
|
||||
"text-muted-foreground line-through",
|
||||
todo.status === "in_progress" && "text-amber-400",
|
||||
todo.status === "pending" && "text-foreground-secondary"
|
||||
todo.status === "pending" &&
|
||||
"text-foreground-secondary"
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
@@ -878,9 +974,13 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<Sparkles className="w-5 h-5 text-green-400" />
|
||||
Implementation Summary
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm" title={feature.description || feature.summary || ""}>
|
||||
<DialogDescription
|
||||
className="text-sm"
|
||||
title={feature.description || feature.summary || ""}
|
||||
>
|
||||
{(() => {
|
||||
const displayText = feature.description || feature.summary || "No description";
|
||||
const displayText =
|
||||
feature.description || feature.summary || "No description";
|
||||
return displayText.length > 100
|
||||
? `${displayText.slice(0, 100)}...`
|
||||
: displayText;
|
||||
@@ -916,10 +1016,15 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
Revert Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will discard all changes made by the agent and move the feature back to the backlog.
|
||||
This will discard all changes made by the agent and move the
|
||||
feature back to the backlog.
|
||||
{feature.branchName && (
|
||||
<span className="block mt-2 font-medium">
|
||||
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
|
||||
Branch{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
{feature.branchName}
|
||||
</code>{" "}
|
||||
will be deleted.
|
||||
</span>
|
||||
)}
|
||||
<span className="block mt-2 text-red-400 font-medium">
|
||||
|
||||
@@ -12,6 +12,9 @@ interface KanbanColumnProps {
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
opacity?: number; // Opacity percentage (0-100) - only affects background
|
||||
showBorder?: boolean; // Whether to show column border
|
||||
hideScrollbar?: boolean; // Whether to hide the column scrollbar
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
@@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
@@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex flex-col h-full rounded-lg bg-card backdrop-blur-sm border border-border transition-colors w-72",
|
||||
isOver && "bg-accent"
|
||||
"relative flex flex-col h-full rounded-lg transition-colors w-72",
|
||||
showBorder && "border border-border"
|
||||
)}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center gap-2 p-3 border-b border-border">
|
||||
{/* Background layer with opacity - only this layer is affected by opacity */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
|
||||
isOver ? "bg-accent" : "bg-card"
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
|
||||
{/* Column Header - positioned above the background */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex items-center gap-2 p-3",
|
||||
showBorder && "border-b border-border"
|
||||
)}
|
||||
>
|
||||
<div className={cn("w-3 h-3 rounded-full", color)} />
|
||||
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
||||
{headerAction}
|
||||
@@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{/* Column Content - positioned above the background */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
|
||||
hideScrollbar &&
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,8 @@ export type Theme =
|
||||
| "gruvbox"
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave";
|
||||
| "synthwave"
|
||||
| "red";
|
||||
|
||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Eclipse,
|
||||
Flame,
|
||||
Ghost,
|
||||
Heart,
|
||||
Moon,
|
||||
Radio,
|
||||
Snowflake,
|
||||
@@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
|
||||
Icon: Radio,
|
||||
testId: "synthwave-mode-button",
|
||||
},
|
||||
{
|
||||
value: "red",
|
||||
label: "Red",
|
||||
Icon: Heart,
|
||||
testId: "red-mode-button",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -316,6 +316,26 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
|
||||
}
|
||||
|
||||
async saveBoardBackground(
|
||||
data: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
projectPath: string
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> {
|
||||
return this.post("/api/fs/save-board-background", {
|
||||
data,
|
||||
filename,
|
||||
mimeType,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBoardBackground(
|
||||
projectPath: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return this.post("/api/fs/delete-board-background", { projectPath });
|
||||
}
|
||||
|
||||
// CLI checks - server-side
|
||||
async checkClaudeCli(): Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -27,7 +27,8 @@ export type ThemeMode =
|
||||
| "gruvbox"
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave";
|
||||
| "synthwave"
|
||||
| "red";
|
||||
|
||||
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
@@ -39,23 +40,39 @@ export interface ApiKeys {
|
||||
|
||||
// Keyboard Shortcut with optional modifiers
|
||||
export interface ShortcutKey {
|
||||
key: string; // The main key (e.g., "K", "N", "1")
|
||||
shift?: boolean; // Shift key modifier
|
||||
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
||||
alt?: boolean; // Alt/Option key modifier
|
||||
key: string; // The main key (e.g., "K", "N", "1")
|
||||
shift?: boolean; // Shift key modifier
|
||||
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
||||
alt?: boolean; // Alt/Option key modifier
|
||||
}
|
||||
|
||||
// Helper to parse shortcut string to ShortcutKey object
|
||||
export function parseShortcut(shortcut: string): ShortcutKey {
|
||||
const parts = shortcut.split("+").map(p => p.trim());
|
||||
const parts = shortcut.split("+").map((p) => p.trim());
|
||||
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
||||
|
||||
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const modifier = parts[i].toLowerCase();
|
||||
if (modifier === "shift") result.shift = true;
|
||||
else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true;
|
||||
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true;
|
||||
else if (
|
||||
modifier === "cmd" ||
|
||||
modifier === "ctrl" ||
|
||||
modifier === "win" ||
|
||||
modifier === "super" ||
|
||||
modifier === "⌘" ||
|
||||
modifier === "^" ||
|
||||
modifier === "⊞" ||
|
||||
modifier === "◆"
|
||||
)
|
||||
result.cmdCtrl = true;
|
||||
else if (
|
||||
modifier === "alt" ||
|
||||
modifier === "opt" ||
|
||||
modifier === "option" ||
|
||||
modifier === "⌥"
|
||||
)
|
||||
result.alt = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -67,36 +84,49 @@ export function formatShortcut(shortcut: string, forDisplay = false): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Prefer User-Agent Client Hints when available; fall back to legacy
|
||||
const platform: 'darwin' | 'win32' | 'linux' = (() => {
|
||||
if (typeof navigator === 'undefined') return 'linux';
|
||||
const platform: "darwin" | "win32" | "linux" = (() => {
|
||||
if (typeof navigator === "undefined") return "linux";
|
||||
|
||||
const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } })
|
||||
.userAgentData?.platform?.toLowerCase?.();
|
||||
const uaPlatform = (
|
||||
navigator as Navigator & { userAgentData?: { platform?: string } }
|
||||
).userAgentData?.platform?.toLowerCase?.();
|
||||
const legacyPlatform = navigator.platform?.toLowerCase?.();
|
||||
const platformString = uaPlatform || legacyPlatform || '';
|
||||
const platformString = uaPlatform || legacyPlatform || "";
|
||||
|
||||
if (platformString.includes('mac')) return 'darwin';
|
||||
if (platformString.includes('win')) return 'win32';
|
||||
return 'linux';
|
||||
if (platformString.includes("mac")) return "darwin";
|
||||
if (platformString.includes("win")) return "win32";
|
||||
return "linux";
|
||||
})();
|
||||
|
||||
// Primary modifier - OS-specific
|
||||
if (parsed.cmdCtrl) {
|
||||
if (forDisplay) {
|
||||
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
|
||||
parts.push(
|
||||
platform === "darwin" ? "⌘" : platform === "win32" ? "⊞" : "◆"
|
||||
);
|
||||
} else {
|
||||
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
|
||||
parts.push(
|
||||
platform === "darwin" ? "Cmd" : platform === "win32" ? "Win" : "Super"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Alt/Option
|
||||
if (parsed.alt) {
|
||||
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt'));
|
||||
parts.push(
|
||||
forDisplay
|
||||
? platform === "darwin"
|
||||
? "⌥"
|
||||
: "Alt"
|
||||
: platform === "darwin"
|
||||
? "Opt"
|
||||
: "Alt"
|
||||
);
|
||||
}
|
||||
|
||||
// Shift
|
||||
if (parsed.shift) {
|
||||
parts.push(forDisplay ? '⇧' : 'Shift');
|
||||
parts.push(forDisplay ? "⇧" : "Shift");
|
||||
}
|
||||
|
||||
parts.push(parsed.key.toUpperCase());
|
||||
@@ -139,22 +169,22 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||
context: "C",
|
||||
settings: "S",
|
||||
profiles: "M",
|
||||
|
||||
|
||||
// UI
|
||||
toggleSidebar: "`",
|
||||
|
||||
|
||||
// Actions
|
||||
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
|
||||
// This is intentional as they are context-specific and only active in their respective views
|
||||
addFeature: "N", // Only active in board view
|
||||
addContextFile: "N", // Only active in context view
|
||||
startNext: "G", // Only active in board view
|
||||
newSession: "N", // Only active in agent view
|
||||
openProject: "O", // Global shortcut
|
||||
projectPicker: "P", // Global shortcut
|
||||
cyclePrevProject: "Q", // Global shortcut
|
||||
cycleNextProject: "E", // Global shortcut
|
||||
addProfile: "N", // Only active in profiles view
|
||||
addFeature: "N", // Only active in board view
|
||||
addContextFile: "N", // Only active in context view
|
||||
startNext: "G", // Only active in board view
|
||||
newSession: "N", // Only active in agent view
|
||||
openProject: "O", // Global shortcut
|
||||
projectPicker: "P", // Global shortcut
|
||||
cyclePrevProject: "Q", // Global shortcut
|
||||
cycleNextProject: "E", // Global shortcut
|
||||
addProfile: "N", // Only active in profiles view
|
||||
};
|
||||
|
||||
export interface ImageAttachment {
|
||||
@@ -246,7 +276,7 @@ export interface Feature {
|
||||
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
||||
worktreePath?: string; // Path to the worktree directory
|
||||
branchName?: string; // Name of the feature branch
|
||||
justFinished?: boolean; // Set to true when agent just finished and moved to waiting_approval
|
||||
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
|
||||
}
|
||||
|
||||
// File tree node for project analysis
|
||||
@@ -303,10 +333,13 @@ export interface AppState {
|
||||
chatHistoryOpen: boolean;
|
||||
|
||||
// Auto Mode (per-project state, keyed by project ID)
|
||||
autoModeByProject: Record<string, {
|
||||
isRunning: boolean;
|
||||
runningTasks: string[]; // Feature IDs being worked on
|
||||
}>;
|
||||
autoModeByProject: Record<
|
||||
string,
|
||||
{
|
||||
isRunning: boolean;
|
||||
runningTasks: string[]; // Feature IDs being worked on
|
||||
}
|
||||
>;
|
||||
autoModeActivityLog: AutoModeActivity[];
|
||||
maxConcurrency: number; // Maximum number of concurrent agent tasks
|
||||
|
||||
@@ -336,11 +369,22 @@ export interface AppState {
|
||||
isAnalyzing: boolean;
|
||||
|
||||
// Board Background Settings (per-project, keyed by project path)
|
||||
boardBackgroundByProject: Record<string, {
|
||||
imagePath: string | null; // Path to background image in .automaker directory
|
||||
cardOpacity: number; // Opacity of cards (0-100)
|
||||
columnOpacity: number; // Opacity of columns (0-100)
|
||||
}>;
|
||||
boardBackgroundByProject: Record<
|
||||
string,
|
||||
{
|
||||
imagePath: string | null; // Path to background image in .automaker directory
|
||||
cardOpacity: number; // Opacity of cards (0-100)
|
||||
columnOpacity: number; // Opacity of columns (0-100)
|
||||
columnBorderEnabled: boolean; // Whether to show column borders
|
||||
cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards
|
||||
cardBorderEnabled: boolean; // Whether to show card borders
|
||||
cardBorderOpacity: number; // Opacity of card borders (0-100)
|
||||
hideScrollbar: boolean; // Whether to hide the board scrollbar
|
||||
}
|
||||
>;
|
||||
|
||||
// Theme Preview (for hover preview in theme selectors)
|
||||
previewTheme: ThemeMode | null;
|
||||
}
|
||||
|
||||
export interface AutoModeActivity {
|
||||
@@ -386,7 +430,8 @@ export interface AppActions {
|
||||
// Theme actions
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
|
||||
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project or global)
|
||||
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set)
|
||||
setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear)
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features: Feature[]) => void;
|
||||
@@ -422,7 +467,10 @@ export interface AppActions {
|
||||
addRunningTask: (projectId: string, taskId: string) => void;
|
||||
removeRunningTask: (projectId: string, taskId: string) => void;
|
||||
clearRunningTasks: (projectId: string) => void;
|
||||
getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] };
|
||||
getAutoModeState: (projectId: string) => {
|
||||
isRunning: boolean;
|
||||
runningTasks: string[];
|
||||
};
|
||||
addAutoModeActivity: (
|
||||
activity: Omit<AutoModeActivity, "id" | "timestamp">
|
||||
) => void;
|
||||
@@ -462,14 +510,31 @@ export interface AppActions {
|
||||
clearAnalysis: () => void;
|
||||
|
||||
// Agent Session actions
|
||||
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
|
||||
setLastSelectedSession: (
|
||||
projectPath: string,
|
||||
sessionId: string | null
|
||||
) => void;
|
||||
getLastSelectedSession: (projectPath: string) => string | null;
|
||||
|
||||
// Board Background actions
|
||||
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
|
||||
setCardOpacity: (projectPath: string, opacity: number) => void;
|
||||
setColumnOpacity: (projectPath: string, opacity: number) => void;
|
||||
getBoardBackground: (projectPath: string) => { imagePath: string | null; cardOpacity: number; columnOpacity: number };
|
||||
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||
getBoardBackground: (projectPath: string) => {
|
||||
imagePath: string | null;
|
||||
cardOpacity: number;
|
||||
columnOpacity: number;
|
||||
columnBorderEnabled: boolean;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
hideScrollbar: boolean;
|
||||
};
|
||||
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
|
||||
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
|
||||
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
||||
clearBoardBackground: (projectPath: string) => void;
|
||||
|
||||
// Reset
|
||||
@@ -481,7 +546,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
||||
{
|
||||
id: "profile-heavy-task",
|
||||
name: "Heavy Task",
|
||||
description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
||||
description:
|
||||
"Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
||||
model: "opus",
|
||||
thinkingLevel: "ultrathink",
|
||||
provider: "claude",
|
||||
@@ -491,7 +557,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
||||
{
|
||||
id: "profile-balanced",
|
||||
name: "Balanced",
|
||||
description: "Claude Sonnet with medium thinking for typical development tasks.",
|
||||
description:
|
||||
"Claude Sonnet with medium thinking for typical development tasks.",
|
||||
model: "sonnet",
|
||||
thinkingLevel: "medium",
|
||||
provider: "claude",
|
||||
@@ -574,6 +641,7 @@ const initialState: AppState = {
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
boardBackgroundByProject: {},
|
||||
previewTheme: null,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
@@ -699,7 +767,9 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Add to project history (MRU order)
|
||||
const currentHistory = get().projectHistory;
|
||||
// Remove this project if it's already in history
|
||||
const filteredHistory = currentHistory.filter((id) => id !== project.id);
|
||||
const filteredHistory = currentHistory.filter(
|
||||
(id) => id !== project.id
|
||||
);
|
||||
// Add to the front (most recent)
|
||||
const newHistory = [project.id, ...filteredHistory];
|
||||
// Reset history index to 0 (current project)
|
||||
@@ -739,7 +809,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
currentProject: targetProject,
|
||||
projectHistory: validHistory,
|
||||
projectHistoryIndex: newIndex,
|
||||
currentView: "board"
|
||||
currentView: "board",
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -764,9 +834,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
if (currentIndex === -1) currentIndex = 0;
|
||||
|
||||
// Move to the previous index (going forward = lower index), wrapping around
|
||||
const newIndex = currentIndex <= 0
|
||||
? validHistory.length - 1
|
||||
: currentIndex - 1;
|
||||
const newIndex =
|
||||
currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1;
|
||||
const targetProjectId = validHistory[newIndex];
|
||||
const targetProject = projects.find((p) => p.id === targetProjectId);
|
||||
|
||||
@@ -776,7 +845,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
currentProject: targetProject,
|
||||
projectHistory: validHistory,
|
||||
projectHistoryIndex: newIndex,
|
||||
currentView: "board"
|
||||
currentView: "board",
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -828,6 +897,11 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
},
|
||||
|
||||
getEffectiveTheme: () => {
|
||||
// If preview theme is set, use it (for hover preview)
|
||||
const previewTheme = get().previewTheme;
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
const currentProject = get().currentProject;
|
||||
// If current project has a theme set, use it
|
||||
if (currentProject?.theme) {
|
||||
@@ -837,6 +911,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
return get().theme;
|
||||
},
|
||||
|
||||
setPreviewTheme: (theme) => set({ previewTheme: theme }),
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features) => set({ features }),
|
||||
|
||||
@@ -988,7 +1064,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Auto Mode actions (per-project)
|
||||
setAutoModeRunning: (projectId, running) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
||||
const projectState = current[projectId] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
};
|
||||
set({
|
||||
autoModeByProject: {
|
||||
...current,
|
||||
@@ -999,7 +1078,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
addRunningTask: (projectId, taskId) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
||||
const projectState = current[projectId] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
};
|
||||
if (!projectState.runningTasks.includes(taskId)) {
|
||||
set({
|
||||
autoModeByProject: {
|
||||
@@ -1015,13 +1097,18 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
removeRunningTask: (projectId, taskId) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
||||
const projectState = current[projectId] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
};
|
||||
set({
|
||||
autoModeByProject: {
|
||||
...current,
|
||||
[projectId]: {
|
||||
...projectState,
|
||||
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
|
||||
runningTasks: projectState.runningTasks.filter(
|
||||
(id) => id !== taskId
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1029,7 +1116,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
clearRunningTasks: (projectId) => {
|
||||
const current = get().autoModeByProject;
|
||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
||||
const projectState = current[projectId] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
};
|
||||
set({
|
||||
autoModeByProject: {
|
||||
...current,
|
||||
@@ -1170,7 +1260,16 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Board Background actions
|
||||
setBoardBackground: (projectPath, imagePath) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 };
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
@@ -1184,7 +1283,16 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
setCardOpacity: (projectPath, opacity) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 };
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
@@ -1198,7 +1306,16 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
setColumnOpacity: (projectPath, opacity) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || { imagePath: null, cardOpacity: 100, columnOpacity: 100 };
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
@@ -1212,18 +1329,153 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
getBoardBackground: (projectPath) => {
|
||||
const settings = get().boardBackgroundByProject[projectPath];
|
||||
return settings || { imagePath: null, cardOpacity: 100, columnOpacity: 100 };
|
||||
return (
|
||||
settings || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
clearBoardBackground: (projectPath) => {
|
||||
setColumnBorderEnabled: (projectPath, enabled) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
[projectPath]: {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
...existing,
|
||||
columnBorderEnabled: enabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setCardGlassmorphism: (projectPath, enabled) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
[projectPath]: {
|
||||
...existing,
|
||||
cardGlassmorphism: enabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setCardBorderEnabled: (projectPath, enabled) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
[projectPath]: {
|
||||
...existing,
|
||||
cardBorderEnabled: enabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setCardBorderOpacity: (projectPath, opacity) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
[projectPath]: {
|
||||
...existing,
|
||||
cardBorderOpacity: opacity,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
setHideScrollbar: (projectPath, hide) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
[projectPath]: {
|
||||
...existing,
|
||||
hideScrollbar: hide,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearBoardBackground: (projectPath) => {
|
||||
const current = get().boardBackgroundByProject;
|
||||
const existing = current[projectPath] || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
set({
|
||||
boardBackgroundByProject: {
|
||||
...current,
|
||||
[projectPath]: {
|
||||
...existing,
|
||||
imagePath: null, // Only clear the image, preserve other settings
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -87,6 +88,7 @@ const events: EventEmitter = createEventEmitter();
|
||||
// Create services
|
||||
const agentService = new AgentService(DATA_DIR, events);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -104,14 +106,14 @@ app.use("/api/fs", createFsRoutes(events));
|
||||
app.use("/api/agent", createAgentRoutes(agentService, events));
|
||||
app.use("/api/sessions", createSessionsRoutes(agentService));
|
||||
app.use("/api/features", createFeaturesRoutes(featureLoader));
|
||||
app.use("/api/auto-mode", createAutoModeRoutes(events));
|
||||
app.use("/api/auto-mode", createAutoModeRoutes(autoModeService));
|
||||
app.use("/api/worktree", createWorktreeRoutes());
|
||||
app.use("/api/git", createGitRoutes());
|
||||
app.use("/api/setup", createSetupRoutes());
|
||||
app.use("/api/suggestions", createSuggestionsRoutes(events));
|
||||
app.use("/api/models", createModelsRoutes());
|
||||
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes());
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
import { AutoModeService } from "../services/auto-mode-service.js";
|
||||
import type { AutoModeService } from "../services/auto-mode-service.js";
|
||||
|
||||
export function createAutoModeRoutes(events: EventEmitter): Router {
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
const autoModeService = new AutoModeService(events);
|
||||
|
||||
// Start auto mode loop
|
||||
router.post("/start", async (req: Request, res: Response) => {
|
||||
|
||||
@@ -92,7 +92,11 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
private async runAutoLoop(): Promise<void> {
|
||||
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
|
||||
while (
|
||||
this.autoLoopRunning &&
|
||||
this.autoLoopAbortController &&
|
||||
!this.autoLoopAbortController.signal.aborted
|
||||
) {
|
||||
try {
|
||||
// Check if we have capacity
|
||||
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
|
||||
@@ -101,7 +105,9 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
// Load pending features
|
||||
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
|
||||
const pendingFeatures = await this.loadPendingFeatures(
|
||||
this.config!.projectPath
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
this.emitAutoModeEvent("auto_mode_complete", {
|
||||
@@ -112,7 +118,9 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
// Find a feature not currently running
|
||||
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
|
||||
const nextFeature = pendingFeatures.find(
|
||||
(f) => !this.runningFeatures.has(f.id)
|
||||
);
|
||||
|
||||
if (nextFeature) {
|
||||
// Start feature execution in background
|
||||
@@ -171,7 +179,11 @@ export class AutoModeService {
|
||||
|
||||
// Setup worktree if enabled
|
||||
if (useWorktrees) {
|
||||
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
|
||||
worktreePath = await this.setupWorktree(
|
||||
projectPath,
|
||||
featureId,
|
||||
branchName
|
||||
);
|
||||
}
|
||||
|
||||
const workDir = worktreePath || projectPath;
|
||||
@@ -190,7 +202,11 @@ export class AutoModeService {
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: "Loading...",
|
||||
description: "Feature is starting",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -219,12 +235,18 @@ export class AutoModeService {
|
||||
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model);
|
||||
|
||||
// Mark as waiting_approval for user review
|
||||
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
||||
await this.updateFeatureStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
"waiting_approval"
|
||||
);
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`,
|
||||
message: `Feature completed in ${Math.round(
|
||||
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
|
||||
)}s`,
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -293,7 +315,12 @@ export class AutoModeService {
|
||||
if (hasContext) {
|
||||
// Load previous context and continue
|
||||
const context = await fs.readFile(contextPath, "utf-8");
|
||||
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
return this.executeFeatureWithContext(
|
||||
projectPath,
|
||||
featureId,
|
||||
context,
|
||||
useWorktrees
|
||||
);
|
||||
}
|
||||
|
||||
// No context, start fresh
|
||||
@@ -316,7 +343,12 @@ export class AutoModeService {
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Check if worktree exists
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
let workDir = projectPath;
|
||||
|
||||
try {
|
||||
@@ -379,7 +411,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: feature || { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) },
|
||||
feature: feature || {
|
||||
id: featureId,
|
||||
title: "Follow-up",
|
||||
description: prompt.substring(0, 100),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -472,7 +508,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
await this.runAgent(workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, model);
|
||||
|
||||
// Mark as waiting_approval for user review
|
||||
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
||||
await this.updateFeatureStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
"waiting_approval"
|
||||
);
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
@@ -496,8 +536,16 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
/**
|
||||
* Verify a feature's implementation
|
||||
*/
|
||||
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
async verifyFeature(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
let workDir = projectPath;
|
||||
|
||||
try {
|
||||
@@ -516,7 +564,8 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
];
|
||||
|
||||
let allPassed = true;
|
||||
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
|
||||
const results: Array<{ check: string; passed: boolean; output?: string }> =
|
||||
[];
|
||||
|
||||
for (const check of verificationChecks) {
|
||||
try {
|
||||
@@ -524,7 +573,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
cwd: workDir,
|
||||
timeout: 120000,
|
||||
});
|
||||
results.push({ check: check.name, passed: true, output: stdout || stderr });
|
||||
results.push({
|
||||
check: check.name,
|
||||
passed: true,
|
||||
output: stdout || stderr,
|
||||
});
|
||||
} catch (error) {
|
||||
allPassed = false;
|
||||
results.push({
|
||||
@@ -541,7 +594,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
passes: allPassed,
|
||||
message: allPassed
|
||||
? "All verification checks passed"
|
||||
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
|
||||
: `Verification failed: ${
|
||||
results.find((r) => !r.passed)?.check || "Unknown"
|
||||
}`,
|
||||
});
|
||||
|
||||
return allPassed;
|
||||
@@ -550,8 +605,16 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
/**
|
||||
* Commit feature changes
|
||||
*/
|
||||
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
async commitFeature(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
let workDir = projectPath;
|
||||
|
||||
try {
|
||||
@@ -563,7 +626,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
|
||||
try {
|
||||
// Check for changes
|
||||
const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir });
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: workDir,
|
||||
});
|
||||
if (!status.trim()) {
|
||||
return null; // No changes
|
||||
}
|
||||
@@ -581,7 +646,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
});
|
||||
|
||||
// Get commit hash
|
||||
const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir });
|
||||
const { stdout: hash } = await execAsync("git rev-parse HEAD", {
|
||||
cwd: workDir,
|
||||
});
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
@@ -599,7 +666,10 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
/**
|
||||
* Check if context exists for a feature
|
||||
*/
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
async contextExists(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<boolean> {
|
||||
const contextPath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
@@ -626,7 +696,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId: analysisFeatureId,
|
||||
projectPath,
|
||||
feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" },
|
||||
feature: {
|
||||
id: analysisFeatureId,
|
||||
title: "Project Analysis",
|
||||
description: "Analyzing project structure",
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = `Analyze this project and provide a summary of:
|
||||
@@ -673,7 +747,11 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
|
||||
// Save analysis
|
||||
const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md");
|
||||
const analysisPath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"project-analysis.md"
|
||||
);
|
||||
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
|
||||
await fs.writeFile(analysisPath, analysisResult);
|
||||
|
||||
@@ -767,7 +845,10 @@ Format your response as a structured markdown document.`;
|
||||
return worktreePath;
|
||||
}
|
||||
|
||||
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
|
||||
private async loadFeature(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<Feature | null> {
|
||||
const featurePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
@@ -802,12 +883,13 @@ Format your response as a structured markdown document.`;
|
||||
const feature = JSON.parse(data);
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
// Set justFinished flag when moving to waiting_approval (agent just completed)
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === "waiting_approval") {
|
||||
feature.justFinished = true;
|
||||
feature.justFinishedAt = new Date().toISOString();
|
||||
} else {
|
||||
// Clear the flag when moving to other statuses
|
||||
feature.justFinished = false;
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
} catch {
|
||||
@@ -824,7 +906,11 @@ Format your response as a structured markdown document.`;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const featurePath = path.join(featuresDir, entry.name, "feature.json");
|
||||
const featurePath = path.join(
|
||||
featuresDir,
|
||||
entry.name,
|
||||
"feature.json"
|
||||
);
|
||||
try {
|
||||
const data = await fs.readFile(featurePath, "utf-8");
|
||||
const feature = JSON.parse(data);
|
||||
@@ -940,7 +1026,13 @@ When done, summarize what you implemented and any notes for the developer.`;
|
||||
// Execute via provider
|
||||
const stream = provider.executeQuery(options);
|
||||
let responseText = "";
|
||||
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
|
||||
const outputPath = path.join(
|
||||
workDir,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"agent-output.md"
|
||||
);
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
|
||||
Reference in New Issue
Block a user