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